mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
Merge remote-tracking branch 'origin/main' into codex/viyatb/pr14718-fix
# Conflicts: # codex-rs/core/src/codex_tests.rs # codex-rs/core/src/config/mod.rs # codex-rs/core/src/config_loader/mod.rs
This commit is contained in:
@@ -1,17 +1,8 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
exports_files(
|
||||
[
|
||||
"templates/collaboration_mode/default.md",
|
||||
"templates/collaboration_mode/plan.md",
|
||||
],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "model_availability_nux_fixtures",
|
||||
srcs = [
|
||||
"models.json",
|
||||
"tests/cli_responses_fixture.sse",
|
||||
],
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -38,9 +29,8 @@ codex_rust_crate(
|
||||
},
|
||||
integration_compile_data_extra = [
|
||||
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
|
||||
"models.json",
|
||||
"prompt.md",
|
||||
],
|
||||
integration_test_timeout = "long",
|
||||
test_data_extra = [
|
||||
"config.schema.json",
|
||||
] + glob([
|
||||
@@ -57,6 +47,7 @@ codex_rust_crate(
|
||||
"//:AGENTS.md",
|
||||
],
|
||||
test_tags = ["no-sandbox"],
|
||||
unit_test_timeout = "long",
|
||||
extra_binaries = [
|
||||
"//codex-rs/linux-sandbox:codex-linux-sandbox",
|
||||
"//codex-rs/rmcp-client:test_stdio_server",
|
||||
|
||||
@@ -23,7 +23,6 @@ async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
bm25 = { workspace = true }
|
||||
chardetng = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-analytics = { workspace = true }
|
||||
@@ -37,7 +36,11 @@ codex-config = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-feedback = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-mcp = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
@@ -47,6 +50,7 @@ codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-response-debug-context = { workspace = true }
|
||||
codex-rollout = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
@@ -70,7 +74,6 @@ codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-
|
||||
csv = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
env-flags = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
@@ -114,13 +117,8 @@ tracing = { workspace = true, features = ["log"] }
|
||||
url = { workspace = true }
|
||||
uuid = { workspace = true, features = ["serde", "v4", "v5"] }
|
||||
which = { workspace = true }
|
||||
wildmatch = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
landlock = { workspace = true }
|
||||
seccompiler = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.9"
|
||||
|
||||
@@ -146,9 +144,7 @@ codex-shell-escalation = { workspace = true }
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-otel = { workspace = true, features = [
|
||||
"disable-default-metrics-exporter",
|
||||
] }
|
||||
codex-otel = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
|
||||
@@ -392,6 +392,9 @@
|
||||
"fast_mode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"general_analytics": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"guardian_approval": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -518,6 +521,15 @@
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_apps_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_environment_context": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_permissions_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_node_module_dirs": {
|
||||
"description": "Ordered list of directories to search for Node modules in `js_repl`.",
|
||||
"items": {
|
||||
@@ -816,10 +828,62 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModelProviderAuthInfo": {
|
||||
"additionalProperties": false,
|
||||
"description": "Configuration for obtaining a provider bearer token from a command.",
|
||||
"properties": {
|
||||
"args": {
|
||||
"default": [],
|
||||
"description": "Command arguments.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"command": {
|
||||
"description": "Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`.",
|
||||
"type": "string"
|
||||
},
|
||||
"cwd": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Working directory used when running the token command."
|
||||
},
|
||||
"refresh_interval_ms": {
|
||||
"default": 300000,
|
||||
"description": "Maximum age for the cached token before rerunning the command. Set to `0` to disable proactive refresh and only rerun after a 401 retry path.",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"timeout_ms": {
|
||||
"default": 5000,
|
||||
"description": "Maximum time to wait for the token command to exit successfully.",
|
||||
"format": "uint64",
|
||||
"minimum": 1.0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModelProviderInfo": {
|
||||
"additionalProperties": false,
|
||||
"description": "Serializable representation of a provider definition.",
|
||||
"properties": {
|
||||
"auth": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModelProviderAuthInfo"
|
||||
}
|
||||
],
|
||||
"description": "Command-backed bearer-token configuration for this provider."
|
||||
},
|
||||
"base_url": {
|
||||
"description": "Base URL for the provider's OpenAI-compatible API.",
|
||||
"type": "string"
|
||||
@@ -1264,6 +1328,7 @@
|
||||
},
|
||||
"RawMcpServerConfig": {
|
||||
"additionalProperties": false,
|
||||
"description": "Raw MCP config shape used for deserialization and JSON Schema generation.\n\nKeep `TryFrom<RawMcpServerConfig> for McpServerConfig` exhaustively destructuring this struct so new TOML fields cannot be added here without updating the validation/mapping logic that produces [`McpServerConfig`].",
|
||||
"properties": {
|
||||
"args": {
|
||||
"default": null,
|
||||
@@ -2034,6 +2099,9 @@
|
||||
"fast_mode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"general_analytics": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"guardian_approval": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2209,6 +2277,18 @@
|
||||
"default": null,
|
||||
"description": "Settings that govern if and what will be written to `~/.codex/history.jsonl`."
|
||||
},
|
||||
"include_apps_instructions": {
|
||||
"description": "Whether to inject the `<apps_instructions>` developer block.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_environment_context": {
|
||||
"description": "Whether to inject the `<environment_context>` user block.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_permissions_instructions": {
|
||||
"description": "Whether to inject the `<permissions instructions>` developer block.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"instructions": {
|
||||
"description": "System instructions.",
|
||||
"type": "string"
|
||||
@@ -2337,7 +2417,7 @@
|
||||
"$ref": "#/definitions/Notice"
|
||||
}
|
||||
],
|
||||
"description": "Collection of in-product notices (different from notifications) See [`crate::config::types::Notices`] for more details"
|
||||
"description": "Collection of in-product notices (different from notifications) See [`codex_config::types::Notice`] for more details"
|
||||
},
|
||||
"notify": {
|
||||
"default": null,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,275 +0,0 @@
|
||||
You are a coding agent running in the Codex CLI, a terminal-based coding assistant. Codex CLI is an open source project led by OpenAI. You are expected to be precise, safe, and helpful.
|
||||
|
||||
Your capabilities:
|
||||
|
||||
- Receive user prompts and other context provided by the harness, such as files in the workspace.
|
||||
- Communicate with the user by streaming thinking & responses, and by making & updating plans.
|
||||
- Emit function calls to run terminal commands and apply patches. Depending on how this specific run is configured, you can request that these function calls be escalated to the user for approval before running. More on this in the "Sandbox and approvals" section.
|
||||
|
||||
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
|
||||
|
||||
# How you work
|
||||
|
||||
## Personality
|
||||
|
||||
Your default personality and tone is concise, direct, and friendly. You communicate efficiently, always keeping the user clearly informed about ongoing actions without unnecessary detail. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
|
||||
|
||||
# AGENTS.md spec
|
||||
- Repos often contain AGENTS.md files. These files can appear anywhere within the repository.
|
||||
- These files are a way for humans to give you (the agent) instructions or tips for working within the container.
|
||||
- Some examples might be: coding conventions, info about how code is organized, or instructions for how to run or test code.
|
||||
- Instructions in AGENTS.md files:
|
||||
- The scope of an AGENTS.md file is the entire directory tree rooted at the folder that contains it.
|
||||
- For every file you touch in the final patch, you must obey instructions in any AGENTS.md file whose scope includes that file.
|
||||
- Instructions about code style, structure, naming, etc. apply only to code within the AGENTS.md file's scope, unless the file states otherwise.
|
||||
- More-deeply-nested AGENTS.md files take precedence in the case of conflicting instructions.
|
||||
- Direct system/developer/user instructions (as part of a prompt) take precedence over AGENTS.md instructions.
|
||||
- The contents of the AGENTS.md file at the root of the repo and any directories from the CWD up to the root are included with the developer message and don't need to be re-read. When working in a subdirectory of CWD, or a directory outside the CWD, check for any AGENTS.md files that may be applicable.
|
||||
|
||||
## Responsiveness
|
||||
|
||||
### Preamble messages
|
||||
|
||||
Before making tool calls, send a brief preamble to the user explaining what you’re about to do. When sending preamble messages, follow these principles and examples:
|
||||
|
||||
- **Logically group related actions**: if you’re about to run several related commands, describe them together in one preamble rather than sending a separate note for each.
|
||||
- **Keep it concise**: be no more than 1-2 sentences, focused on immediate, tangible next steps. (8–12 words for quick updates).
|
||||
- **Build on prior context**: if this is not your first tool call, use the preamble message to connect the dots with what’s been done so far and create a sense of momentum and clarity for the user to understand your next actions.
|
||||
- **Keep your tone light, friendly and curious**: add small touches of personality in preambles feel collaborative and engaging.
|
||||
- **Exception**: Avoid adding a preamble for every trivial read (e.g., `cat` a single file) unless it’s part of a larger grouped action.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- “I’ve explored the repo; now checking the API route definitions.”
|
||||
- “Next, I’ll patch the config and update the related tests.”
|
||||
- “I’m about to scaffold the CLI commands and helper functions.”
|
||||
- “Ok cool, so I’ve wrapped my head around the repo. Now digging into the API routes.”
|
||||
- “Config’s looking tidy. Next up is patching helpers to keep things in sync.”
|
||||
- “Finished poking at the DB gateway. I will now chase down error handling.”
|
||||
- “Alright, build pipeline order is interesting. Checking how it reports failures.”
|
||||
- “Spotted a clever caching util; now hunting where it gets used.”
|
||||
|
||||
## Planning
|
||||
|
||||
You have access to an `update_plan` tool which tracks steps and progress and renders them to the user. Using the tool helps demonstrate that you've understood the task and convey how you're approaching it. Plans can help to make complex, ambiguous, or multi-phase work clearer and more collaborative for the user. A good plan should break the task into meaningful, logically ordered steps that are easy to verify as you go.
|
||||
|
||||
Note that plans are not for padding out simple work with filler steps or stating the obvious. The content of your plan should not involve doing anything that you aren't capable of doing (i.e. don't try to test things that you can't test). Do not use plans for simple or single-step queries that you can just do or answer immediately.
|
||||
|
||||
Do not repeat the full contents of the plan after an `update_plan` call — the harness already displays it. Instead, summarize the change made and highlight any important context or next step.
|
||||
|
||||
Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. It may be the case that you complete all steps in your plan after a single pass of implementation. If this is the case, you can simply mark all the planned steps as completed. Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so.
|
||||
|
||||
Use a plan when:
|
||||
|
||||
- The task is non-trivial and will require multiple actions over a long time horizon.
|
||||
- There are logical phases or dependencies where sequencing matters.
|
||||
- The work has ambiguity that benefits from outlining high-level goals.
|
||||
- You want intermediate checkpoints for feedback and validation.
|
||||
- When the user asked you to do more than one thing in a single prompt
|
||||
- The user has asked you to use the plan tool (aka "TODOs")
|
||||
- You generate additional steps while working, and plan to do them before yielding to the user
|
||||
|
||||
### Examples
|
||||
|
||||
**High-quality plans**
|
||||
|
||||
Example 1:
|
||||
|
||||
1. Add CLI entry with file args
|
||||
2. Parse Markdown via CommonMark library
|
||||
3. Apply semantic HTML template
|
||||
4. Handle code blocks, images, links
|
||||
5. Add error handling for invalid files
|
||||
|
||||
Example 2:
|
||||
|
||||
1. Define CSS variables for colors
|
||||
2. Add toggle with localStorage state
|
||||
3. Refactor components to use variables
|
||||
4. Verify all views for readability
|
||||
5. Add smooth theme-change transition
|
||||
|
||||
Example 3:
|
||||
|
||||
1. Set up Node.js + WebSocket server
|
||||
2. Add join/leave broadcast events
|
||||
3. Implement messaging with timestamps
|
||||
4. Add usernames + mention highlighting
|
||||
5. Persist messages in lightweight DB
|
||||
6. Add typing indicators + unread count
|
||||
|
||||
**Low-quality plans**
|
||||
|
||||
Example 1:
|
||||
|
||||
1. Create CLI tool
|
||||
2. Add Markdown parser
|
||||
3. Convert to HTML
|
||||
|
||||
Example 2:
|
||||
|
||||
1. Add dark mode toggle
|
||||
2. Save preference
|
||||
3. Make styles look good
|
||||
|
||||
Example 3:
|
||||
|
||||
1. Create single-file HTML game
|
||||
2. Run quick sanity check
|
||||
3. Summarize usage instructions
|
||||
|
||||
If you need to write a plan, only write high quality plans, not low quality ones.
|
||||
|
||||
## Task execution
|
||||
|
||||
You are a coding agent. Please keep going until the query is completely resolved, before ending your turn and yielding back to the user. Only terminate your turn when you are sure that the problem is solved. Autonomously resolve the query to the best of your ability, using the tools available to you, before coming back to the user. Do NOT guess or make up an answer.
|
||||
|
||||
You MUST adhere to the following criteria when solving queries:
|
||||
|
||||
- Working on the repo(s) in the current environment is allowed, even if they are proprietary.
|
||||
- Analyzing code for vulnerabilities is allowed.
|
||||
- Showing user code and tool call details is allowed.
|
||||
- Use the `apply_patch` tool to edit files (NEVER try `applypatch` or `apply-patch`, only `apply_patch`): {"command":["apply_patch","*** Begin Patch\\n*** Update File: path/to/file.py\\n@@ def example():\\n- pass\\n+ return 123\\n*** End Patch"]}
|
||||
|
||||
If completing the user's task requires writing or modifying files, your code and final answer should follow these coding guidelines, though user instructions (i.e. AGENTS.md) may override these guidelines:
|
||||
|
||||
- Fix the problem at the root cause rather than applying surface-level patches, when possible.
|
||||
- Avoid unneeded complexity in your solution.
|
||||
- Do not attempt to fix unrelated bugs or broken tests. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
|
||||
- Update documentation as necessary.
|
||||
- Keep changes consistent with the style of the existing codebase. Changes should be minimal and focused on the task.
|
||||
- Use `git log` and `git blame` to search the history of the codebase if additional context is required.
|
||||
- NEVER add copyright or license headers unless specifically requested.
|
||||
- Do not waste tokens by re-reading files after calling `apply_patch` on them. The tool call will fail if it didn't work. The same goes for making folders, deleting folders, etc.
|
||||
- Do not `git commit` your changes or create new git branches unless explicitly requested.
|
||||
- Do not add inline comments within code unless explicitly requested.
|
||||
- Do not use one-letter variable names unless explicitly requested.
|
||||
- NEVER output inline citations like "【F:README.md†L5-L14】" in your outputs. The CLI is not able to render these so they will just be broken in the UI. Instead, if you output valid filepaths, users will be able to click on them to open the files in their editor.
|
||||
|
||||
## Validating your work
|
||||
|
||||
If the codebase has tests or the ability to build or run, consider using them to verify that your work is complete.
|
||||
|
||||
When testing, your philosophy should be to start as specific as possible to the code you changed so that you can catch issues efficiently, then make your way to broader tests as you build confidence. If there's no test for the code you changed, and if the adjacent patterns in the codebases show that there's a logical place for you to add a test, you may do so. However, do not add tests to codebases with no tests.
|
||||
|
||||
Similarly, once you're confident in correctness, you can suggest or use formatting commands to ensure that your code is well formatted. If there are issues you can iterate up to 3 times to get formatting right, but if you still can't manage it's better to save the user time and present them a correct solution where you call out the formatting in your final message. If the codebase does not have a formatter configured, do not add one.
|
||||
|
||||
For all of testing, running, building, and formatting, do not attempt to fix unrelated bugs. It is not your responsibility to fix them. (You may mention them to the user in your final message though.)
|
||||
|
||||
Be mindful of whether to run validation commands proactively. In the absence of behavioral guidance:
|
||||
|
||||
- When running in non-interactive approval modes like **never** or **on-failure**, proactively run tests, lint and do whatever you need to ensure you've completed the task.
|
||||
- When working in interactive approval modes like **untrusted**, or **on-request**, hold off on running tests or lint commands until the user is ready for you to finalize your output, because these commands take time to run and slow down iteration. Instead suggest what you want to do next, and let the user confirm first.
|
||||
- When working on test-related tasks, such as adding tests, fixing tests, or reproducing a bug to verify behavior, you may proactively run tests regardless of approval mode. Use your judgement to decide whether this is a test-related task.
|
||||
|
||||
## Ambition vs. precision
|
||||
|
||||
For tasks that have no prior context (i.e. the user is starting something brand new), you should feel free to be ambitious and demonstrate creativity with your implementation.
|
||||
|
||||
If you're operating in an existing codebase, you should make sure you do exactly what the user asks with surgical precision. Treat the surrounding codebase with respect, and don't overstep (i.e. changing filenames or variables unnecessarily). You should balance being sufficiently ambitious and proactive when completing tasks of this nature.
|
||||
|
||||
You should use judicious initiative to decide on the right level of detail and complexity to deliver based on the user's needs. This means showing good judgment that you're capable of doing the right extras without gold-plating. This might be demonstrated by high-value, creative touches when scope of the task is vague; while being surgical and targeted when scope is tightly specified.
|
||||
|
||||
## Sharing progress updates
|
||||
|
||||
For especially longer tasks that you work on (i.e. requiring many tool calls, or a plan with multiple steps), you should provide progress updates back to the user at reasonable intervals. These updates should be structured as a concise sentence or two (no more than 8-10 words long) recapping progress so far in plain language: this update demonstrates your understanding of what needs to be done, progress so far (i.e. files explores, subtasks complete), and where you're going next.
|
||||
|
||||
Before doing large chunks of work that may incur latency as experienced by the user (i.e. writing a new file), you should send a concise message to the user with an update indicating what you're about to do to ensure they know what you're spending time on. Don't start editing or writing large files before informing the user what you are doing and why.
|
||||
|
||||
The messages you send before tool calls should describe what is immediately about to be done next in very concise language. If there was previous work done, this preamble message should also include a note about the work done so far to bring the user along.
|
||||
|
||||
## Presenting your work and final message
|
||||
|
||||
Your final message should read naturally, like an update from a concise teammate. For casual conversation, brainstorming tasks, or quick questions from the user, respond in a friendly, conversational tone. You should ask questions, suggest ideas, and adapt to the user’s style. If you've finished a large amount of work, when describing what you've done to the user, you should follow the final answer formatting guidelines to communicate substantive changes. You don't need to add structured formatting for one-word answers, greetings, or purely conversational exchanges.
|
||||
|
||||
You can skip heavy formatting for single, simple actions or confirmations. In these cases, respond in plain sentences with any relevant next step or quick option. Reserve multi-section structured responses for results that need grouping or explanation.
|
||||
|
||||
The user is working on the same computer as you, and has access to your work. As such there's no need to show the full contents of large files you have already written unless the user explicitly asks for them. Similarly, if you've created or modified files using `apply_patch`, there's no need to tell users to "save the file" or "copy the code into a file"—just reference the file path.
|
||||
|
||||
If there's something that you think you could help with as a logical next step, concisely ask the user if they want you to do so. Good examples of this are running tests, committing changes, or building out the next logical component. If there’s something that you couldn't do (even with approval) but that the user might want to do (such as verifying changes by running the app), include those instructions succinctly.
|
||||
|
||||
Brevity is very important as a default. You should be very concise (i.e. no more than 10 lines), but can relax this requirement for tasks where additional detail and comprehensiveness is important for the user's understanding.
|
||||
|
||||
### Final answer structure and style guidelines
|
||||
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
**Section Headers**
|
||||
|
||||
- Use only when they improve clarity — they are not mandatory for every answer.
|
||||
- Choose descriptive names that fit the content
|
||||
- Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
|
||||
- Leave no blank line before the first bullet under a header.
|
||||
- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
|
||||
|
||||
**Bullets**
|
||||
|
||||
- Use `-` followed by a space for every bullet.
|
||||
- Merge related points when possible; avoid a bullet for every trivial detail.
|
||||
- Keep bullets to one line unless breaking for clarity is unavoidable.
|
||||
- Group into short lists (4–6 bullets) ordered by importance.
|
||||
- Use consistent keyword phrasing and formatting across sections.
|
||||
|
||||
**Monospace**
|
||||
|
||||
- Wrap all commands, file paths, env vars, and code identifiers in backticks (`` `...` ``).
|
||||
- Apply to inline examples and to bullet keywords if the keyword itself is a literal file/command.
|
||||
- Never mix monospace and bold markers; choose one based on whether it’s a keyword (`**`) or inline code/path (`` ` ``).
|
||||
|
||||
**File References**
|
||||
When referencing files in your response, make sure to include the relevant start line and always follow the below rules:
|
||||
* Use inline code to make file paths clickable.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
|
||||
|
||||
**Structure**
|
||||
|
||||
- Place related bullets together; don’t mix unrelated concepts in the same section.
|
||||
- Order sections from general → specific → supporting info.
|
||||
- For subsections (e.g., “Binaries” under “Rust Workspace”), introduce with a bolded keyword bullet, then list items under it.
|
||||
- Match structure to complexity:
|
||||
- Multi-part or detailed results → use clear headers and grouped bullets.
|
||||
- Simple results → minimal headers, possibly just a short list or paragraph.
|
||||
|
||||
**Tone**
|
||||
|
||||
- Keep the voice collaborative and natural, like a coding partner handing off work.
|
||||
- Be concise and factual — no filler or conversational commentary and avoid unnecessary repetition
|
||||
- Use present tense and active voice (e.g., “Runs tests” not “This will run tests”).
|
||||
- Keep descriptions self-contained; don’t refer to “above” or “below”.
|
||||
- Use parallel structure in lists for consistency.
|
||||
|
||||
**Don’t**
|
||||
|
||||
- Don’t use literal words “bold” or “monospace” in the content.
|
||||
- Don’t nest bullets or create deep hierarchies.
|
||||
- Don’t output ANSI escape codes directly — the CLI renderer applies them.
|
||||
- Don’t cram unrelated keywords into a single bullet; split for clarity.
|
||||
- Don’t let keyword lists run long — wrap or reformat for scanability.
|
||||
|
||||
Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.
|
||||
|
||||
For casual greetings, acknowledgements, or other one-off conversational messages that are not delivering substantive information or structured results, respond naturally without section headers or bullet formatting.
|
||||
|
||||
# Tool Guidelines
|
||||
|
||||
## Shell commands
|
||||
|
||||
When using the shell, you must adhere to the following guidelines:
|
||||
|
||||
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
|
||||
- Do not use python scripts to attempt to output larger chunks of a file.
|
||||
|
||||
## `update_plan`
|
||||
|
||||
A tool named `update_plan` is available to you. You can use it to keep an up‑to‑date, step‑by‑step plan for the task.
|
||||
|
||||
To create a new plan, call `update_plan` with a short list of 1‑sentence steps (no more than 5-7 words each) with a `status` for each step (`pending`, `in_progress`, or `completed`).
|
||||
|
||||
When steps have been completed, use `update_plan` to mark each finished step as `completed` and the next step you are working on as `in_progress`. There should always be exactly one `in_progress` step until everything is done. You can mark multiple items as complete in a single `update_plan` call.
|
||||
|
||||
If all steps are complete, ensure you call `update_plan` to mark all steps as `completed`.
|
||||
@@ -21,7 +21,7 @@ pub(crate) async fn resolve_agent_target(
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, target)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
crate::error::CodexErr::UnsupportedOperation(message) => {
|
||||
codex_protocol::error::CodexErr::UnsupportedOperation(message) => {
|
||||
FunctionCallError::RespondToModel(message)
|
||||
}
|
||||
other => FunctionCallError::RespondToModel(other.to_string()),
|
||||
|
||||
+213
-103
@@ -4,21 +4,22 @@ use crate::agent::registry::AgentRegistry;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::resolve_role_config;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::find_archived_thread_path_by_id_str;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::session_prefix::format_subagent_context_line;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::state_db;
|
||||
use crate::thread_manager::ThreadManagerState;
|
||||
use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
@@ -28,6 +29,7 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_rollout::state_db;
|
||||
use codex_state::DirectionalThreadSpawnEdgeStatus;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
@@ -38,12 +40,18 @@ use tokio::sync::watch;
|
||||
use tracing::warn;
|
||||
|
||||
const AGENT_NAMES: &str = include_str!("agent_names.txt");
|
||||
const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.";
|
||||
const ROOT_LAST_TASK_MESSAGE: &str = "Main thread";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum SpawnAgentForkMode {
|
||||
FullHistory,
|
||||
LastNTurns(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SpawnAgentOptions {
|
||||
pub(crate) fork_parent_spawn_call_id: Option<String>,
|
||||
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -85,6 +93,36 @@ fn agent_nickname_candidates(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str()
|
||||
{
|
||||
"system" | "developer" | "user" => true,
|
||||
"assistant" => *phase == Some(MessagePhase::FinalAnswer),
|
||||
_ => false,
|
||||
},
|
||||
RolloutItem::ResponseItem(
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other,
|
||||
) => false,
|
||||
RolloutItem::Compacted(_)
|
||||
| RolloutItem::EventMsg(_)
|
||||
| RolloutItem::SessionMeta(_)
|
||||
| RolloutItem::TurnContext(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Control-plane handle for multi-agent operations.
|
||||
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
|
||||
/// spawn new agents and the inter-agent communication layer.
|
||||
@@ -178,87 +216,78 @@ impl AgentControl {
|
||||
let notification_source = session_source.clone();
|
||||
|
||||
// The same `AgentControl` is sent to spawn the thread.
|
||||
let new_thread = match session_source {
|
||||
Some(session_source) => {
|
||||
if let Some(call_id) = options.fork_parent_spawn_call_id.as_ref() {
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
..
|
||||
}) = session_source.clone()
|
||||
else {
|
||||
return Err(CodexErr::Fatal(
|
||||
"spawn_agent fork requires a thread-spawn session source".to_string(),
|
||||
));
|
||||
};
|
||||
let parent_thread = state.get_thread(parent_thread_id).await.ok();
|
||||
if let Some(parent_thread) = parent_thread.as_ref() {
|
||||
// `record_conversation_items` only queues rollout writes asynchronously.
|
||||
// Flush/materialize the live parent before snapshotting JSONL for a fork.
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread.codex.session.flush_rollout().await;
|
||||
}
|
||||
let rollout_path = parent_thread
|
||||
.as_ref()
|
||||
.and_then(|parent_thread| parent_thread.rollout_path())
|
||||
.or(find_thread_path_by_id_str(
|
||||
config.codex_home.as_path(),
|
||||
&parent_thread_id.to_string(),
|
||||
)
|
||||
.await?)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::Fatal(format!(
|
||||
"parent thread rollout unavailable for fork: {parent_thread_id}"
|
||||
))
|
||||
})?;
|
||||
let mut forked_rollout_items: Vec<RolloutItem> =
|
||||
RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await?
|
||||
.get_rollout_items();
|
||||
let mut output = FunctionCallOutputPayload::from_text(
|
||||
FORKED_SPAWN_AGENT_OUTPUT_MESSAGE.to_string(),
|
||||
);
|
||||
output.success = Some(true);
|
||||
forked_rollout_items.push(RolloutItem::ResponseItem(
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output,
|
||||
},
|
||||
));
|
||||
let initial_history = InitialHistory::Forked(forked_rollout_items);
|
||||
state
|
||||
.fork_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
self.clone(),
|
||||
session_source,
|
||||
/*persist_extended_history*/ false,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
state
|
||||
.spawn_new_thread_with_source(
|
||||
config,
|
||||
self.clone(),
|
||||
session_source,
|
||||
/*persist_extended_history*/ false,
|
||||
/*metrics_service_name*/ None,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
let new_thread = match (session_source, options.fork_mode.as_ref()) {
|
||||
(Some(session_source), Some(_)) => {
|
||||
self.spawn_forked_thread(
|
||||
&state,
|
||||
config,
|
||||
session_source,
|
||||
&options,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => state.spawn_new_thread(config, self.clone()).await?,
|
||||
(Some(session_source), None) => {
|
||||
state
|
||||
.spawn_new_thread_with_source(
|
||||
config,
|
||||
self.clone(),
|
||||
session_source,
|
||||
/*persist_extended_history*/ false,
|
||||
/*metrics_service_name*/ None,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
(None, _) => state.spawn_new_thread(config, self.clone()).await?,
|
||||
};
|
||||
agent_metadata.agent_id = Some(new_thread.thread_id);
|
||||
reservation.commit(agent_metadata.clone());
|
||||
|
||||
if let Some(SessionSource::SubAgent(
|
||||
subagent_source @ SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
},
|
||||
)) = notification_source.as_ref()
|
||||
&& new_thread.thread.enabled(Feature::GeneralAnalytics)
|
||||
{
|
||||
let client_metadata = match state.get_thread(*parent_thread_id).await {
|
||||
Ok(parent_thread) => {
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.app_server_client_metadata()
|
||||
.await
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
parent_thread_id = %parent_thread_id,
|
||||
"skipping subagent thread analytics: failed to load parent thread metadata"
|
||||
);
|
||||
crate::codex::AppServerClientMetadata {
|
||||
client_name: None,
|
||||
client_version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let thread_config = new_thread.thread.codex.thread_config_snapshot().await;
|
||||
emit_subagent_session_started(
|
||||
&new_thread
|
||||
.thread
|
||||
.codex
|
||||
.session
|
||||
.services
|
||||
.analytics_events_client,
|
||||
client_metadata,
|
||||
new_thread.thread_id,
|
||||
thread_config,
|
||||
subagent_source.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Notify a new thread has been created. This notification will be processed by clients
|
||||
// to subscribe or drain this newly created thread.
|
||||
// TODO(jif) add helper for drain
|
||||
@@ -273,17 +302,19 @@ impl AgentControl {
|
||||
|
||||
self.send_input(new_thread.thread_id, initial_operation)
|
||||
.await?;
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| new_thread.thread_id.to_string());
|
||||
self.maybe_start_completion_watcher(
|
||||
new_thread.thread_id,
|
||||
notification_source,
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
if !new_thread.thread.enabled(Feature::MultiAgentV2) {
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| new_thread.thread_id.to_string());
|
||||
self.maybe_start_completion_watcher(
|
||||
new_thread.thread_id,
|
||||
notification_source,
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(LiveAgent {
|
||||
thread_id: new_thread.thread_id,
|
||||
@@ -292,6 +323,83 @@ impl AgentControl {
|
||||
})
|
||||
}
|
||||
|
||||
async fn spawn_forked_thread(
|
||||
&self,
|
||||
state: &Arc<ThreadManagerState>,
|
||||
config: crate::config::Config,
|
||||
session_source: SessionSource,
|
||||
options: &SpawnAgentOptions,
|
||||
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
|
||||
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
|
||||
) -> CodexResult<crate::thread_manager::NewThread> {
|
||||
if options.fork_parent_spawn_call_id.is_none() {
|
||||
return Err(CodexErr::Fatal(
|
||||
"spawn_agent fork requires a parent spawn call id".to_string(),
|
||||
));
|
||||
}
|
||||
let Some(fork_mode) = options.fork_mode.as_ref() else {
|
||||
return Err(CodexErr::Fatal(
|
||||
"spawn_agent fork requires a fork mode".to_string(),
|
||||
));
|
||||
};
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) = &session_source
|
||||
else {
|
||||
return Err(CodexErr::Fatal(
|
||||
"spawn_agent fork requires a thread-spawn session source".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let parent_thread_id = *parent_thread_id;
|
||||
let parent_thread = state.get_thread(parent_thread_id).await.ok();
|
||||
if let Some(parent_thread) = parent_thread.as_ref() {
|
||||
// `record_conversation_items` only queues rollout writes asynchronously.
|
||||
// Flush/materialize the live parent before snapshotting JSONL for a fork.
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread.codex.session.flush_rollout().await;
|
||||
}
|
||||
|
||||
let rollout_path = parent_thread
|
||||
.as_ref()
|
||||
.and_then(|parent_thread| parent_thread.rollout_path())
|
||||
.or(find_thread_path_by_id_str(
|
||||
config.codex_home.as_path(),
|
||||
&parent_thread_id.to_string(),
|
||||
)
|
||||
.await?)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::Fatal(format!(
|
||||
"parent thread rollout unavailable for fork: {parent_thread_id}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut forked_rollout_items = RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await?
|
||||
.get_rollout_items();
|
||||
if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode {
|
||||
forked_rollout_items =
|
||||
truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns);
|
||||
}
|
||||
forked_rollout_items.retain(keep_forked_rollout_item);
|
||||
|
||||
state
|
||||
.fork_thread_with_source(
|
||||
config,
|
||||
InitialHistory::Forked(forked_rollout_items),
|
||||
self.clone(),
|
||||
session_source,
|
||||
/*persist_extended_history*/ false,
|
||||
inherited_shell_snapshot,
|
||||
inherited_exec_policy,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Resume an existing agent thread from a recorded rollout file.
|
||||
pub(crate) async fn resume_agent_from_rollout(
|
||||
&self,
|
||||
@@ -445,17 +553,19 @@ impl AgentControl {
|
||||
// Resumed threads are re-registered in-memory and need the same listener
|
||||
// attachment path as freshly spawned threads.
|
||||
state.notify_thread_created(resumed_thread.thread_id);
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| resumed_thread.thread_id.to_string());
|
||||
self.maybe_start_completion_watcher(
|
||||
resumed_thread.thread_id,
|
||||
Some(notification_source.clone()),
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
if !resumed_thread.thread.enabled(Feature::MultiAgentV2) {
|
||||
let child_reference = agent_metadata
|
||||
.agent_path
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| resumed_thread.thread_id.to_string());
|
||||
self.maybe_start_completion_watcher(
|
||||
resumed_thread.thread_id,
|
||||
Some(notification_source.clone()),
|
||||
child_reference,
|
||||
agent_metadata.agent_path.clone(),
|
||||
);
|
||||
}
|
||||
self.persist_thread_spawn_edge_for_source(
|
||||
resumed_thread.thread.as_ref(),
|
||||
resumed_thread.thread_id,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::CodexThread;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use assert_matches::assert_matches;
|
||||
use chrono::Utc;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -35,15 +35,9 @@ async fn test_config_with_cli_overrides(
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> (TempDir, Config) {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.cli_overrides(cli_overrides)
|
||||
.loader_overrides(LoaderOverrides {
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(String::new()),
|
||||
..LoaderOverrides::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
@@ -62,6 +56,28 @@ fn text_input(text: &str) -> Op {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn assistant_message(text: &str, phase: Option<MessagePhase>) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: text.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase,
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_agent_call(call_id: &str) -> ResponseItem {
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "spawn_agent".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: call_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentControlHarness {
|
||||
_home: TempDir,
|
||||
config: Config,
|
||||
@@ -561,7 +577,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
parent_thread
|
||||
@@ -569,17 +585,32 @@ async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
.await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-history".to_string();
|
||||
let parent_spawn_call = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "spawn_agent".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: parent_spawn_call_id.clone(),
|
||||
};
|
||||
let trigger_message = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"parent trigger message".to_string(),
|
||||
/*trigger_turn*/ true,
|
||||
);
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[parent_spawn_call])
|
||||
.record_conversation_items(
|
||||
turn_context.as_ref(),
|
||||
&[
|
||||
assistant_message("parent commentary", Some(MessagePhase::Commentary)),
|
||||
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
|
||||
assistant_message("parent unknown phase", /*phase*/ None),
|
||||
ResponseItem::Reasoning {
|
||||
id: "parent-reasoning".to_string(),
|
||||
summary: Vec::new(),
|
||||
content: None,
|
||||
encrypted_content: None,
|
||||
},
|
||||
trigger_message.to_response_input_item().into(),
|
||||
spawn_agent_call(&parent_spawn_call_id),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
@@ -601,7 +632,8 @@ async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
|
||||
fork_mode: Some(SpawnAgentForkMode::FullHistory),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -615,10 +647,23 @@ async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
.expect("child thread should be registered");
|
||||
assert_ne!(child_thread_id, parent_thread_id);
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(history_contains_text(
|
||||
let expected_history = [
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "parent seed context".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)),
|
||||
];
|
||||
assert_eq!(
|
||||
history.raw_items(),
|
||||
"parent seed context"
|
||||
));
|
||||
&expected_history,
|
||||
"forked child history should keep only parent user messages and assistant final answers"
|
||||
);
|
||||
|
||||
let expected = (
|
||||
child_thread_id,
|
||||
@@ -649,22 +694,119 @@ async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
|
||||
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-1".to_string();
|
||||
let parent_spawn_call = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "spawn_agent".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: parent_spawn_call_id.clone(),
|
||||
};
|
||||
let parent_spawn_call_id = "spawn-call-unflushed".to_string();
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[parent_spawn_call])
|
||||
.record_conversation_items(
|
||||
turn_context.as_ref(),
|
||||
&[
|
||||
assistant_message("unflushed final answer", Some(MessagePhase::FinalAnswer)),
|
||||
spawn_agent_call(&parent_spawn_call_id),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
|
||||
fork_mode: Some(SpawnAgentForkMode::FullHistory),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should flush parent rollout before loading history")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "unflushed final answer"),
|
||||
"forked child history should include unflushed assistant final answers after flushing the parent rollout"
|
||||
);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
|
||||
parent_thread
|
||||
.inject_user_message_without_turn("old parent context".to_string())
|
||||
.await;
|
||||
let queued_communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"queued message".to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
let queued_turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(
|
||||
queued_turn_context.as_ref(),
|
||||
&[queued_communication.to_response_input_item().into()],
|
||||
)
|
||||
.await;
|
||||
|
||||
let triggered_communication = InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
Vec::new(),
|
||||
"triggered context".to_string(),
|
||||
/*trigger_turn*/ true,
|
||||
);
|
||||
let triggered_turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(
|
||||
triggered_turn_context.as_ref(),
|
||||
&[triggered_communication.to_response_input_item().into()],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.inject_user_message_without_turn("current parent task".to_string())
|
||||
.await;
|
||||
let spawn_turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-last-n".to_string();
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(
|
||||
spawn_turn_context.as_ref(),
|
||||
&[spawn_agent_call(&parent_spawn_call_id)],
|
||||
)
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
@@ -687,10 +829,11 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
|
||||
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should succeed")
|
||||
.expect("forked spawn should keep only the last two turns")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
@@ -699,100 +842,23 @@ async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
let injected_output = history.raw_items().iter().find_map(|item| match item {
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
if call_id == &parent_spawn_call_id =>
|
||||
{
|
||||
Some(output)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let injected_output =
|
||||
injected_output.expect("forked child should contain synthetic tool output");
|
||||
assert_eq!(
|
||||
injected_output.text_content(),
|
||||
Some(FORKED_SPAWN_AGENT_OUTPUT_MESSAGE)
|
||||
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "old parent context"),
|
||||
"forked child history should drop parent context outside the requested last-N turn window"
|
||||
);
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "queued message"),
|
||||
"forked child history should drop queued inter-agent messages outside the requested last-N turn window"
|
||||
);
|
||||
assert!(
|
||||
!history_contains_text(history.raw_items(), "triggered context"),
|
||||
"forked child history should filter assistant inter-agent messages even when they fall inside the requested last-N turn window"
|
||||
);
|
||||
assert!(
|
||||
history_contains_text(history.raw_items(), "current parent task"),
|
||||
"forked child history should keep the parent user message from the requested last-N turn window"
|
||||
);
|
||||
assert_eq!(injected_output.success, Some(true));
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-unflushed".to_string();
|
||||
let parent_spawn_call = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "spawn_agent".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: parent_spawn_call_id.clone(),
|
||||
};
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[parent_spawn_call])
|
||||
.await;
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_metadata(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should flush parent rollout before loading history")
|
||||
.thread_id;
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
|
||||
let mut parent_call_index = None;
|
||||
let mut injected_output_index = None;
|
||||
for (idx, item) in history.raw_items().iter().enumerate() {
|
||||
match item {
|
||||
ResponseItem::FunctionCall { call_id, .. } if call_id == &parent_spawn_call_id => {
|
||||
parent_call_index = Some(idx);
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { call_id, .. }
|
||||
if call_id == &parent_spawn_call_id =>
|
||||
{
|
||||
injected_output_index = Some(idx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let parent_call_index =
|
||||
parent_call_index.expect("forked child should include the parent spawn_agent call");
|
||||
let injected_output_index = injected_output_index
|
||||
.expect("forked child should include synthetic output for the parent spawn_agent call");
|
||||
assert!(parent_call_index < injected_output_index);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use rand::prelude::IndexedRandom;
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
use base64::Engine;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_api::AuthProvider as ApiAuthProvider;
|
||||
use codex_api::TransportError;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::rate_limits::parse_promo_message;
|
||||
use codex_api::rate_limits::parse_rate_limit_for_limit;
|
||||
use codex_login::token_data::PlanType;
|
||||
use http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::RetryLimitReachedError;
|
||||
use crate::error::UnexpectedResponseError;
|
||||
use crate::error::UsageLimitReachedError;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
|
||||
pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
|
||||
match err {
|
||||
ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded,
|
||||
ApiError::QuotaExceeded => CodexErr::QuotaExceeded,
|
||||
ApiError::UsageNotIncluded => CodexErr::UsageNotIncluded,
|
||||
ApiError::Retryable { message, delay } => CodexErr::Stream(message, delay),
|
||||
ApiError::Stream(msg) => CodexErr::Stream(msg, None),
|
||||
ApiError::ServerOverloaded => CodexErr::ServerOverloaded,
|
||||
ApiError::Api { status, message } => CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: message,
|
||||
url: None,
|
||||
cf_ray: None,
|
||||
request_id: None,
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
}),
|
||||
ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message),
|
||||
ApiError::Transport(transport) => match transport {
|
||||
TransportError::Http {
|
||||
status,
|
||||
url,
|
||||
headers,
|
||||
body,
|
||||
} => {
|
||||
let body_text = body.unwrap_or_default();
|
||||
|
||||
if status == http::StatusCode::SERVICE_UNAVAILABLE
|
||||
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&body_text)
|
||||
&& matches!(
|
||||
value
|
||||
.get("error")
|
||||
.and_then(|error| error.get("code"))
|
||||
.and_then(serde_json::Value::as_str),
|
||||
Some("server_is_overloaded" | "slow_down")
|
||||
)
|
||||
{
|
||||
return CodexErr::ServerOverloaded;
|
||||
}
|
||||
|
||||
if status == http::StatusCode::BAD_REQUEST {
|
||||
if body_text
|
||||
.contains("The image data you provided does not represent a valid image")
|
||||
{
|
||||
CodexErr::InvalidImageRequest()
|
||||
} else {
|
||||
CodexErr::InvalidRequest(body_text)
|
||||
}
|
||||
} else if status == http::StatusCode::INTERNAL_SERVER_ERROR {
|
||||
CodexErr::InternalServerError
|
||||
} else if status == http::StatusCode::TOO_MANY_REQUESTS {
|
||||
if let Ok(err) = serde_json::from_str::<UsageErrorResponse>(&body_text) {
|
||||
if err.error.error_type.as_deref() == Some("usage_limit_reached") {
|
||||
let limit_id = extract_header(headers.as_ref(), ACTIVE_LIMIT_HEADER);
|
||||
let rate_limits = headers.as_ref().and_then(|map| {
|
||||
parse_rate_limit_for_limit(map, limit_id.as_deref())
|
||||
});
|
||||
let promo_message = headers.as_ref().and_then(parse_promo_message);
|
||||
let resets_at = err
|
||||
.error
|
||||
.resets_at
|
||||
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0));
|
||||
return CodexErr::UsageLimitReached(UsageLimitReachedError {
|
||||
plan_type: err.error.plan_type,
|
||||
resets_at,
|
||||
rate_limits: rate_limits.map(Box::new),
|
||||
promo_message,
|
||||
});
|
||||
} else if err.error.error_type.as_deref() == Some("usage_not_included") {
|
||||
return CodexErr::UsageNotIncluded;
|
||||
}
|
||||
}
|
||||
|
||||
CodexErr::RetryLimit(RetryLimitReachedError {
|
||||
status,
|
||||
request_id: extract_request_tracking_id(headers.as_ref()),
|
||||
})
|
||||
} else {
|
||||
CodexErr::UnexpectedStatus(UnexpectedResponseError {
|
||||
status,
|
||||
body: body_text,
|
||||
url,
|
||||
cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER),
|
||||
request_id: extract_request_id(headers.as_ref()),
|
||||
identity_authorization_error: extract_header(
|
||||
headers.as_ref(),
|
||||
X_OPENAI_AUTHORIZATION_ERROR_HEADER,
|
||||
),
|
||||
identity_error_code: extract_x_error_json_code(headers.as_ref()),
|
||||
})
|
||||
}
|
||||
}
|
||||
TransportError::RetryLimit => CodexErr::RetryLimit(RetryLimitReachedError {
|
||||
status: http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
request_id: None,
|
||||
}),
|
||||
TransportError::Timeout => CodexErr::Timeout,
|
||||
TransportError::Network(msg) | TransportError::Build(msg) => {
|
||||
CodexErr::Stream(msg, None)
|
||||
}
|
||||
},
|
||||
ApiError::RateLimit(msg) => CodexErr::Stream(msg, None),
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIVE_LIMIT_HEADER: &str = "x-codex-active-limit";
|
||||
const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
|
||||
const CF_RAY_HEADER: &str = "cf-ray";
|
||||
const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error";
|
||||
const X_ERROR_JSON_HEADER: &str = "x-error-json";
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "api_bridge_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option<String> {
|
||||
extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER))
|
||||
}
|
||||
|
||||
fn extract_request_id(headers: Option<&HeaderMap>) -> Option<String> {
|
||||
extract_header(headers, REQUEST_ID_HEADER)
|
||||
.or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER))
|
||||
}
|
||||
|
||||
fn extract_header(headers: Option<&HeaderMap>, name: &str) -> Option<String> {
|
||||
headers.and_then(|map| {
|
||||
map.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_x_error_json_code(headers: Option<&HeaderMap>) -> Option<String> {
|
||||
let encoded = extract_header(headers, X_ERROR_JSON_HEADER)?;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.ok()?;
|
||||
let parsed = serde_json::from_slice::<Value>(&decoded).ok()?;
|
||||
parsed
|
||||
.get("error")
|
||||
.and_then(|error| error.get("code"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(crate) fn auth_provider_from_auth(
|
||||
auth: Option<CodexAuth>,
|
||||
provider: &ModelProviderInfo,
|
||||
) -> crate::error::Result<CoreAuthProvider> {
|
||||
if let Some(api_key) = provider.api_key()? {
|
||||
return Ok(CoreAuthProvider {
|
||||
token: Some(api_key),
|
||||
account_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(token) = provider.experimental_bearer_token.clone() {
|
||||
return Ok(CoreAuthProvider {
|
||||
token: Some(token),
|
||||
account_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(auth) = auth {
|
||||
let token = auth.get_token()?;
|
||||
Ok(CoreAuthProvider {
|
||||
token: Some(token),
|
||||
account_id: auth.get_account_id(),
|
||||
})
|
||||
} else {
|
||||
Ok(CoreAuthProvider {
|
||||
token: None,
|
||||
account_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UsageErrorResponse {
|
||||
error: UsageErrorBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UsageErrorBody {
|
||||
#[serde(rename = "type")]
|
||||
error_type: Option<String>,
|
||||
plan_type: Option<PlanType>,
|
||||
resets_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct CoreAuthProvider {
|
||||
token: Option<String>,
|
||||
account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CoreAuthProvider {
|
||||
pub(crate) fn auth_header_attached(&self) -> bool {
|
||||
self.token
|
||||
.as_ref()
|
||||
.is_some_and(|token| http::HeaderValue::from_str(&format!("Bearer {token}")).is_ok())
|
||||
}
|
||||
|
||||
pub(crate) fn auth_header_name(&self) -> Option<&'static str> {
|
||||
self.auth_header_attached().then_some("authorization")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_test(token: Option<&str>, account_id: Option<&str>) -> Self {
|
||||
Self {
|
||||
token: token.map(str::to_string),
|
||||
account_id: account_id.map(str::to_string),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiAuthProvider for CoreAuthProvider {
|
||||
fn bearer_token(&self) -> Option<String> {
|
||||
self.token.clone()
|
||||
}
|
||||
|
||||
fn account_id(&self) -> Option<String> {
|
||||
self.account_id.clone()
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_server_overloaded() {
|
||||
let err = map_api_error(ApiError::ServerOverloaded);
|
||||
assert!(matches!(err, CodexErr::ServerOverloaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_server_overloaded_from_503_body() {
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"code": "server_is_overloaded"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: None,
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
assert!(matches!(err, CodexErr::ServerOverloaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_maps_usage_limit_limit_name_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
ACTIVE_LIMIT_HEADER,
|
||||
http::HeaderValue::from_static("codex_other"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-other-limit-name",
|
||||
http::HeaderValue::from_static("codex_other"),
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"type": "usage_limit_reached",
|
||||
"plan_type": "pro",
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::TOO_MANY_REQUESTS,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
||||
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
usage_limit
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.limit_name.as_deref()),
|
||||
Some("codex_other")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
ACTIVE_LIMIT_HEADER,
|
||||
http::HeaderValue::from_static("codex_other"),
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"error": {
|
||||
"type": "usage_limit_reached",
|
||||
"plan_type": "pro",
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::TOO_MANY_REQUESTS,
|
||||
url: Some("http://example.com/v1/responses".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(body),
|
||||
}));
|
||||
|
||||
let CodexErr::UsageLimitReached(usage_limit) = err else {
|
||||
panic!("expected CodexErr::UsageLimitReached, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
usage_limit
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.limit_name.as_deref()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_api_error_extracts_identity_auth_details_from_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(REQUEST_ID_HEADER, http::HeaderValue::from_static("req-401"));
|
||||
headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-401"));
|
||||
headers.insert(
|
||||
X_OPENAI_AUTHORIZATION_ERROR_HEADER,
|
||||
http::HeaderValue::from_static("missing_authorization_header"),
|
||||
);
|
||||
let x_error_json =
|
||||
base64::engine::general_purpose::STANDARD.encode(r#"{"error":{"code":"token_expired"}}"#);
|
||||
headers.insert(
|
||||
X_ERROR_JSON_HEADER,
|
||||
http::HeaderValue::from_str(&x_error_json).expect("valid x-error-json header"),
|
||||
);
|
||||
|
||||
let err = map_api_error(ApiError::Transport(TransportError::Http {
|
||||
status: http::StatusCode::UNAUTHORIZED,
|
||||
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some(r#"{"detail":"Unauthorized"}"#.to_string()),
|
||||
}));
|
||||
|
||||
let CodexErr::UnexpectedStatus(err) = err else {
|
||||
panic!("expected CodexErr::UnexpectedStatus, got {err:?}");
|
||||
};
|
||||
assert_eq!(err.request_id.as_deref(), Some("req-401"));
|
||||
assert_eq!(err.cf_ray.as_deref(), Some("ray-401"));
|
||||
assert_eq!(
|
||||
err.identity_authorization_error.as_deref(),
|
||||
Some("missing_authorization_header")
|
||||
);
|
||||
assert_eq!(err.identity_error_code.as_deref(), Some("token_expired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_auth_provider_reports_when_auth_header_will_attach() {
|
||||
let auth = CoreAuthProvider {
|
||||
token: Some("access-token".to_string()),
|
||||
account_id: None,
|
||||
};
|
||||
|
||||
assert!(auth.auth_header_attached());
|
||||
assert_eq!(auth.auth_header_name(), Some("authorization"));
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::FileChange;
|
||||
use crate::protocol::FileSystemSandboxPolicy;
|
||||
use crate::safety::SafetyCheck;
|
||||
use crate::safety::assess_patch_safety;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use codex_apply_patch::ApplyPatchAction;
|
||||
use codex_apply_patch::ApplyPatchFileChange;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
use codex_protocol::protocol::FileSystemSandboxPolicy;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ use tracing::warn;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::compact::content_items_to_text;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
@@ -147,10 +148,7 @@ pub(crate) async fn monitor_action(
|
||||
.timeout(ARC_MONITOR_TIMEOUT)
|
||||
.json(&body)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth
|
||||
.as_ref()
|
||||
.and_then(crate::auth::CodexAuth::get_account_id)
|
||||
{
|
||||
if let Some(account_id) = auth.as_ref().and_then(CodexAuth::get_account_id) {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ async fn monitor_action_posts_expected_arc_request() {
|
||||
let server = MockServer::start().await;
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
turn_context.developer_instructions = Some("Developer policy".to_string());
|
||||
turn_context.user_instructions = Some("User policy".to_string());
|
||||
@@ -407,7 +407,7 @@ async fn monitor_action_rejects_legacy_response_fields() {
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
use codex_otel::AuthEnvTelemetryMetadata;
|
||||
|
||||
use crate::auth::CODEX_API_KEY_ENV_VAR;
|
||||
use crate::auth::OPENAI_API_KEY_ENV_VAR;
|
||||
use crate::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct AuthEnvTelemetry {
|
||||
pub(crate) openai_api_key_env_present: bool,
|
||||
pub(crate) codex_api_key_env_present: bool,
|
||||
pub(crate) codex_api_key_env_enabled: bool,
|
||||
pub(crate) provider_env_key_name: Option<String>,
|
||||
pub(crate) provider_env_key_present: Option<bool>,
|
||||
pub(crate) refresh_token_url_override_present: bool,
|
||||
}
|
||||
|
||||
impl AuthEnvTelemetry {
|
||||
pub(crate) fn to_otel_metadata(&self) -> AuthEnvTelemetryMetadata {
|
||||
AuthEnvTelemetryMetadata {
|
||||
openai_api_key_env_present: self.openai_api_key_env_present,
|
||||
codex_api_key_env_present: self.codex_api_key_env_present,
|
||||
codex_api_key_env_enabled: self.codex_api_key_env_enabled,
|
||||
provider_env_key_name: self.provider_env_key_name.clone(),
|
||||
provider_env_key_present: self.provider_env_key_present,
|
||||
refresh_token_url_override_present: self.refresh_token_url_override_present,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_auth_env_telemetry(
|
||||
provider: &ModelProviderInfo,
|
||||
codex_api_key_env_enabled: bool,
|
||||
) -> AuthEnvTelemetry {
|
||||
AuthEnvTelemetry {
|
||||
openai_api_key_env_present: env_var_present(OPENAI_API_KEY_ENV_VAR),
|
||||
codex_api_key_env_present: env_var_present(CODEX_API_KEY_ENV_VAR),
|
||||
codex_api_key_env_enabled,
|
||||
// Custom provider `env_key` is arbitrary config text, so emit only a safe bucket.
|
||||
provider_env_key_name: provider.env_key.as_ref().map(|_| "configured".to_string()),
|
||||
provider_env_key_present: provider.env_key.as_deref().map(env_var_present),
|
||||
refresh_token_url_override_present: env_var_present(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_var_present(name: &str) -> bool {
|
||||
match std::env::var(name) {
|
||||
Ok(value) => !value.trim().is_empty(),
|
||||
Err(std::env::VarError::NotUnicode(_)) => true,
|
||||
Err(std::env::VarError::NotPresent) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn collect_auth_env_telemetry_buckets_provider_env_key_name() {
|
||||
let provider = ModelProviderInfo {
|
||||
name: "Custom".to_string(),
|
||||
base_url: None,
|
||||
env_key: Some("sk-should-not-leak".to_string()),
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: crate::model_provider_info::WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
};
|
||||
|
||||
let telemetry =
|
||||
collect_auth_env_telemetry(&provider, /*codex_api_key_env_enabled*/ false);
|
||||
|
||||
assert_eq!(
|
||||
telemetry.provider_env_key_name,
|
||||
Some("configured".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
+146
-55
@@ -28,14 +28,9 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crate::api_bridge::CoreAuthProvider;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::map_api_error;
|
||||
use crate::auth::UnauthorizedRecovery;
|
||||
use crate::auth_env_telemetry::AuthEnvTelemetry;
|
||||
use crate::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use codex_api::CompactClient as ApiCompactClient;
|
||||
use codex_api::CompactionInput as ApiCompactionInput;
|
||||
use codex_api::MemoriesClient as ApiMemoriesClient;
|
||||
@@ -60,6 +55,12 @@ use codex_api::create_text_param_for_request;
|
||||
use codex_api::error::ApiError;
|
||||
use codex_api::requests::responses::Compression;
|
||||
use codex_api::response_create_client_metadata;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::RefreshTokenError;
|
||||
use codex_login::UnauthorizedRecovery;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::current_span_w3c_trace_context;
|
||||
|
||||
@@ -71,6 +72,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_tools::create_tools_json_for_responses_api;
|
||||
use eventsource_stream::Event;
|
||||
@@ -91,30 +93,36 @@ use tracing::instrument;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::auth::AuthMode;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::auth::RefreshTokenError;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::client_common::ResponseStream;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::flags::CODEX_RS_SSE_FIXTURE;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use crate::response_debug_context::extract_response_debug_context;
|
||||
use crate::response_debug_context::extract_response_debug_context_from_api_error;
|
||||
use crate::response_debug_context::telemetry_api_error_message;
|
||||
use crate::response_debug_context::telemetry_transport_error_message;
|
||||
use crate::util::FeedbackRequestTags;
|
||||
use crate::util::emit_feedback_auth_recovery_tags;
|
||||
use crate::util::emit_feedback_request_tags_with_auth_env;
|
||||
use codex_api::api_bridge::CoreAuthProvider;
|
||||
use codex_api::api_bridge::map_api_error;
|
||||
use codex_feedback::FeedbackRequestTags;
|
||||
use codex_feedback::emit_feedback_request_tags_with_auth_env;
|
||||
use codex_login::api_bridge::auth_provider_from_auth;
|
||||
use codex_login::auth_env_telemetry::AuthEnvTelemetry;
|
||||
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use codex_login::provider_auth::auth_manager_for_provider;
|
||||
#[cfg(test)]
|
||||
use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_response_debug_context::extract_response_debug_context;
|
||||
use codex_response_debug_context::extract_response_debug_context_from_api_error;
|
||||
use codex_response_debug_context::telemetry_api_error_message;
|
||||
use codex_response_debug_context::telemetry_transport_error_message;
|
||||
|
||||
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
|
||||
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
|
||||
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
|
||||
pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
|
||||
pub const X_CODEX_WINDOW_ID_HEADER: &str = "x-codex-window-id";
|
||||
pub const X_OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent";
|
||||
pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str =
|
||||
"x-responsesapi-include-timing-metrics";
|
||||
const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
|
||||
@@ -123,7 +131,7 @@ const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact";
|
||||
const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize";
|
||||
#[cfg(test)]
|
||||
pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
|
||||
Duration::from_millis(crate::model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS);
|
||||
Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS);
|
||||
|
||||
/// Session-scoped state shared by all [`ModelClient`] clones.
|
||||
///
|
||||
@@ -133,6 +141,7 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
|
||||
struct ModelClientState {
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
conversation_id: ThreadId,
|
||||
window_generation: AtomicU64,
|
||||
provider: ModelProviderInfo,
|
||||
auth_env_telemetry: AuthEnvTelemetry,
|
||||
session_source: SessionSource,
|
||||
@@ -261,6 +270,7 @@ impl ModelClient {
|
||||
include_timing_metrics: bool,
|
||||
beta_features_header: Option<String>,
|
||||
) -> Self {
|
||||
let auth_manager = auth_manager_for_provider(auth_manager, &provider);
|
||||
let codex_api_key_env_enabled = auth_manager
|
||||
.as_ref()
|
||||
.is_some_and(|manager| manager.codex_api_key_env_enabled());
|
||||
@@ -269,6 +279,7 @@ impl ModelClient {
|
||||
state: Arc::new(ModelClientState {
|
||||
auth_manager,
|
||||
conversation_id,
|
||||
window_generation: AtomicU64::new(0),
|
||||
provider,
|
||||
auth_env_telemetry,
|
||||
session_source,
|
||||
@@ -294,6 +305,28 @@ impl ModelClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auth_manager(&self) -> Option<Arc<AuthManager>> {
|
||||
self.state.auth_manager.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn set_window_generation(&self, window_generation: u64) {
|
||||
self.state
|
||||
.window_generation
|
||||
.store(window_generation, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
pub(crate) fn advance_window_generation(&self) {
|
||||
self.state.window_generation.fetch_add(1, Ordering::Relaxed);
|
||||
self.store_cached_websocket_session(WebsocketSession::default());
|
||||
}
|
||||
|
||||
fn current_window_id(&self) -> String {
|
||||
let conversation_id = self.state.conversation_id;
|
||||
let window_generation = self.state.window_generation.load(Ordering::Relaxed);
|
||||
format!("{conversation_id}:{window_generation}")
|
||||
}
|
||||
|
||||
fn take_cached_websocket_session(&self) -> WebsocketSession {
|
||||
let mut cached_websocket_session = self
|
||||
.state
|
||||
@@ -392,7 +425,7 @@ impl ModelClient {
|
||||
text,
|
||||
};
|
||||
|
||||
let mut extra_headers = self.build_subagent_headers();
|
||||
let mut extra_headers = self.build_responses_identity_headers();
|
||||
extra_headers.extend(build_conversation_headers(Some(
|
||||
self.state.conversation_id.to_string(),
|
||||
)));
|
||||
@@ -452,23 +485,56 @@ impl ModelClient {
|
||||
|
||||
fn build_subagent_headers(&self) -> ApiHeaderMap {
|
||||
let mut extra_headers = ApiHeaderMap::new();
|
||||
if let SessionSource::SubAgent(sub) = &self.state.session_source {
|
||||
let subagent = match sub {
|
||||
crate::protocol::SubAgentSource::Review => "review".to_string(),
|
||||
crate::protocol::SubAgentSource::Compact => "compact".to_string(),
|
||||
crate::protocol::SubAgentSource::MemoryConsolidation => {
|
||||
"memory_consolidation".to_string()
|
||||
}
|
||||
crate::protocol::SubAgentSource::ThreadSpawn { .. } => "collab_spawn".to_string(),
|
||||
crate::protocol::SubAgentSource::Other(label) => label.clone(),
|
||||
};
|
||||
if let Ok(val) = HeaderValue::from_str(&subagent) {
|
||||
extra_headers.insert("x-openai-subagent", val);
|
||||
}
|
||||
if let Some(subagent) = subagent_header_value(&self.state.session_source)
|
||||
&& let Ok(val) = HeaderValue::from_str(&subagent)
|
||||
{
|
||||
extra_headers.insert(X_OPENAI_SUBAGENT_HEADER, val);
|
||||
}
|
||||
extra_headers
|
||||
}
|
||||
|
||||
fn build_responses_identity_headers(&self) -> ApiHeaderMap {
|
||||
let mut extra_headers = self.build_subagent_headers();
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source)
|
||||
&& let Ok(val) = HeaderValue::from_str(&parent_thread_id)
|
||||
{
|
||||
extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&self.current_window_id()) {
|
||||
extra_headers.insert(X_CODEX_WINDOW_ID_HEADER, val);
|
||||
}
|
||||
extra_headers
|
||||
}
|
||||
|
||||
fn build_ws_client_metadata(
|
||||
&self,
|
||||
turn_metadata_header: Option<&str>,
|
||||
) -> HashMap<String, String> {
|
||||
let mut client_metadata = HashMap::new();
|
||||
client_metadata.insert(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
self.current_window_id(),
|
||||
);
|
||||
if let Some(subagent) = subagent_header_value(&self.state.session_source) {
|
||||
client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent);
|
||||
}
|
||||
if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) {
|
||||
client_metadata.insert(
|
||||
X_CODEX_PARENT_THREAD_ID_HEADER.to_string(),
|
||||
parent_thread_id,
|
||||
);
|
||||
}
|
||||
if let Some(turn_metadata_header) = parse_turn_metadata_header(turn_metadata_header)
|
||||
&& let Ok(turn_metadata) = turn_metadata_header.to_str()
|
||||
{
|
||||
client_metadata.insert(
|
||||
X_CODEX_TURN_METADATA_HEADER.to_string(),
|
||||
turn_metadata.to_string(),
|
||||
);
|
||||
}
|
||||
client_metadata
|
||||
}
|
||||
|
||||
/// Builds request telemetry for unary API calls (e.g., Compact endpoint).
|
||||
fn build_request_telemetry(
|
||||
session_telemetry: &SessionTelemetry,
|
||||
@@ -568,7 +634,7 @@ impl ModelClient {
|
||||
websocket_connect_timeout,
|
||||
ApiWebSocketResponsesClient::new(api_provider, api_auth).connect(
|
||||
headers,
|
||||
crate::default_client::default_headers(),
|
||||
codex_login::default_client::default_headers(),
|
||||
turn_state,
|
||||
Some(websocket_telemetry),
|
||||
),
|
||||
@@ -648,6 +714,7 @@ impl ModelClient {
|
||||
headers.insert("x-client-request-id", header_value);
|
||||
}
|
||||
headers.extend(build_conversation_headers(Some(conversation_id)));
|
||||
headers.extend(self.build_responses_identity_headers());
|
||||
headers.insert(
|
||||
OPENAI_BETA_HEADER,
|
||||
HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE),
|
||||
@@ -671,7 +738,7 @@ impl Drop for ModelClientSession {
|
||||
}
|
||||
|
||||
impl ModelClientSession {
|
||||
fn reset_websocket_session(&mut self) {
|
||||
pub(crate) fn reset_websocket_session(&mut self) {
|
||||
self.websocket_session.connection = None;
|
||||
self.websocket_session.last_request = None;
|
||||
self.websocket_session.last_response_rx = None;
|
||||
@@ -762,11 +829,15 @@ impl ModelClientSession {
|
||||
ApiResponsesOptions {
|
||||
conversation_id: Some(conversation_id),
|
||||
session_source: Some(self.client.state.session_source.clone()),
|
||||
extra_headers: build_responses_headers(
|
||||
self.client.state.beta_features_header.as_deref(),
|
||||
Some(&self.turn_state),
|
||||
turn_metadata_header.as_ref(),
|
||||
),
|
||||
extra_headers: {
|
||||
let mut headers = build_responses_headers(
|
||||
self.client.state.beta_features_header.as_deref(),
|
||||
Some(&self.turn_state),
|
||||
turn_metadata_header.as_ref(),
|
||||
);
|
||||
headers.extend(self.client.build_responses_identity_headers());
|
||||
headers
|
||||
},
|
||||
compression,
|
||||
turn_state: Some(Arc::clone(&self.turn_state)),
|
||||
}
|
||||
@@ -966,7 +1037,7 @@ impl ModelClientSession {
|
||||
))
|
||||
}
|
||||
|
||||
fn responses_request_compression(&self, auth: Option<&crate::auth::CodexAuth>) -> Compression {
|
||||
fn responses_request_compression(&self, auth: Option<&CodexAuth>) -> Compression {
|
||||
if self.client.state.enable_request_compression
|
||||
&& auth.is_some_and(CodexAuth::is_chatgpt_auth)
|
||||
&& self.client.state.provider.is_openai()
|
||||
@@ -1019,7 +1090,7 @@ impl ModelClientSession {
|
||||
let auth_manager = self.client.state.auth_manager.clone();
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
.map(super::auth::AuthManager::unauthorized_recovery);
|
||||
.map(AuthManager::unauthorized_recovery);
|
||||
let mut pending_retry = PendingUnauthorizedRetry::default();
|
||||
loop {
|
||||
let client_setup = self.client.current_client_setup().await?;
|
||||
@@ -1108,7 +1179,7 @@ impl ModelClientSession {
|
||||
|
||||
let mut auth_recovery = auth_manager
|
||||
.as_ref()
|
||||
.map(super::auth::AuthManager::unauthorized_recovery);
|
||||
.map(AuthManager::unauthorized_recovery);
|
||||
let mut pending_retry = PendingUnauthorizedRetry::default();
|
||||
loop {
|
||||
let client_setup = self.client.current_client_setup().await?;
|
||||
@@ -1130,7 +1201,7 @@ impl ModelClientSession {
|
||||
)?;
|
||||
let mut ws_payload = ResponseCreateWsRequest {
|
||||
client_metadata: response_create_client_metadata(
|
||||
build_ws_client_metadata(turn_metadata_header),
|
||||
Some(self.client.build_ws_client_metadata(turn_metadata_header)),
|
||||
request_trace.as_ref(),
|
||||
),
|
||||
..ResponseCreateWsRequest::from(&request)
|
||||
@@ -1363,14 +1434,6 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option<Head
|
||||
turn_metadata_header.and_then(|value| HeaderValue::from_str(value).ok())
|
||||
}
|
||||
|
||||
fn build_ws_client_metadata(turn_metadata_header: Option<&str>) -> Option<HashMap<String, String>> {
|
||||
let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header)?;
|
||||
let turn_metadata = turn_metadata_header.to_str().ok()?.to_string();
|
||||
let mut client_metadata = HashMap::new();
|
||||
client_metadata.insert(X_CODEX_TURN_METADATA_HEADER.to_string(), turn_metadata);
|
||||
Some(client_metadata)
|
||||
}
|
||||
|
||||
/// Builds the extra headers attached to Responses API requests.
|
||||
///
|
||||
/// These headers implement Codex-specific conventions:
|
||||
@@ -1402,6 +1465,34 @@ fn build_responses_headers(
|
||||
headers
|
||||
}
|
||||
|
||||
fn subagent_header_value(session_source: &SessionSource) -> Option<String> {
|
||||
let SessionSource::SubAgent(subagent_source) = session_source else {
|
||||
return None;
|
||||
};
|
||||
match subagent_source {
|
||||
SubAgentSource::Review => Some("review".to_string()),
|
||||
SubAgentSource::Compact => Some("compact".to_string()),
|
||||
SubAgentSource::MemoryConsolidation => Some("memory_consolidation".to_string()),
|
||||
SubAgentSource::ThreadSpawn { .. } => Some("collab_spawn".to_string()),
|
||||
SubAgentSource::Other(label) => Some(label.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_thread_id_header_value(session_source: &SessionSource) -> Option<String> {
|
||||
match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) => Some(parent_thread_id.to_string()),
|
||||
SessionSource::Cli
|
||||
| SessionSource::VSCode
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::SubAgent(_)
|
||||
| SessionSource::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_response_stream<S>(
|
||||
api_stream: S,
|
||||
session_telemetry: SessionTelemetry,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::config::types::Personality;
|
||||
use crate::error::Result;
|
||||
pub use codex_api::common::ResponseEvent;
|
||||
use codex_config::types::Personality;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -156,14 +156,6 @@ fn strip_total_output_header(output: &str) -> Option<(&str, u32)> {
|
||||
Some((remainder, total_lines))
|
||||
}
|
||||
|
||||
pub(crate) mod tools {
|
||||
pub(crate) use codex_tools::FreeformTool;
|
||||
pub(crate) use codex_tools::FreeformToolFormat;
|
||||
pub(crate) use codex_tools::ResponsesApiTool;
|
||||
pub(crate) use codex_tools::ToolSearchOutputTool;
|
||||
pub(crate) use codex_tools::ToolSpec;
|
||||
}
|
||||
|
||||
pub struct ResponseStream {
|
||||
pub(crate) rx_event: mpsc::Receiver<Result<ResponseEvent>>,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ use super::AuthRequestTelemetryContext;
|
||||
use super::ModelClient;
|
||||
use super::PendingUnauthorizedRetry;
|
||||
use super::UnauthorizedRecoveryExecution;
|
||||
use super::X_CODEX_PARENT_THREAD_ID_HEADER;
|
||||
use super::X_CODEX_TURN_METADATA_HEADER;
|
||||
use super::X_CODEX_WINDOW_ID_HEADER;
|
||||
use super::X_OPENAI_SUBAGENT_HEADER;
|
||||
use codex_api::api_bridge::CoreAuthProvider;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_model_provider_info::create_oss_provider_with_base_url;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -11,10 +19,7 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
fn test_model_client(session_source: SessionSource) -> ModelClient {
|
||||
let provider = crate::model_provider_info::create_oss_provider_with_base_url(
|
||||
"https://example.com/v1",
|
||||
crate::model_provider_info::WireApi::Responses,
|
||||
);
|
||||
let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses);
|
||||
ModelClient::new(
|
||||
/*auth_manager*/ None,
|
||||
ThreadId::new(),
|
||||
@@ -79,11 +84,49 @@ fn build_subagent_headers_sets_other_subagent_label() {
|
||||
)));
|
||||
let headers = client.build_subagent_headers();
|
||||
let value = headers
|
||||
.get("x-openai-subagent")
|
||||
.get(X_OPENAI_SUBAGENT_HEADER)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
assert_eq!(value, Some("memory_consolidation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() {
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 2,
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
}));
|
||||
|
||||
client.advance_window_generation();
|
||||
|
||||
let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#));
|
||||
let conversation_id = client.state.conversation_id;
|
||||
assert_eq!(
|
||||
client_metadata,
|
||||
std::collections::HashMap::from([
|
||||
(
|
||||
X_CODEX_WINDOW_ID_HEADER.to_string(),
|
||||
format!("{conversation_id}:1"),
|
||||
),
|
||||
(
|
||||
X_OPENAI_SUBAGENT_HEADER.to_string(),
|
||||
"collab_spawn".to_string(),
|
||||
),
|
||||
(
|
||||
X_CODEX_PARENT_THREAD_ID_HEADER.to_string(),
|
||||
parent_thread_id.to_string(),
|
||||
),
|
||||
(
|
||||
X_CODEX_TURN_METADATA_HEADER.to_string(),
|
||||
r#"{"turn_id":"turn-123"}"#.to_string(),
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn summarize_memories_returns_empty_for_empty_input() {
|
||||
let client = test_model_client(SessionSource::Cli);
|
||||
@@ -105,8 +148,8 @@ async fn summarize_memories_returns_empty_for_empty_input() {
|
||||
#[test]
|
||||
fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() {
|
||||
let auth_context = AuthRequestTelemetryContext::new(
|
||||
Some(crate::auth::AuthMode::Chatgpt),
|
||||
&crate::api_bridge::CoreAuthProvider::for_test(Some("access-token"), Some("workspace-123")),
|
||||
Some(AuthMode::Chatgpt),
|
||||
&CoreAuthProvider::for_test(Some("access-token"), Some("workspace-123")),
|
||||
PendingUnauthorizedRetry::from_recovery(UnauthorizedRecoveryExecution {
|
||||
mode: "managed",
|
||||
phase: "refresh_token",
|
||||
|
||||
+386
-140
@@ -5,17 +5,16 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::SandboxState;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::Mailbox;
|
||||
use crate::agent::MailboxReceiver;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::apps::render_apps_section;
|
||||
use crate::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
use crate::compact;
|
||||
use crate::compact::InitialContextInjection;
|
||||
@@ -25,11 +24,6 @@ use crate::compact_remote::run_inline_remote_auto_compact_task;
|
||||
use crate::config::ManagedFeatures;
|
||||
use crate::connectors;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
#[cfg(test)]
|
||||
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::manager::RefreshStrategy;
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::parse_turn_item;
|
||||
use crate::path_utils::normalize_for_native_workdir;
|
||||
use crate::realtime_conversation::RealtimeConversationManager;
|
||||
@@ -39,6 +33,7 @@ use crate::realtime_conversation::handle_start as handle_realtime_conversation_s
|
||||
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
|
||||
use crate::render_skills_section;
|
||||
use crate::rollout::session_index;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::skills_load_input_from_config;
|
||||
use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
@@ -55,6 +50,7 @@ use chrono::Utc;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SubAgentThreadStartedInput;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
@@ -69,6 +65,19 @@ use codex_hooks::HookPayload;
|
||||
use codex_hooks::HookResult;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::mcp_connection_manager::McpConnectionManager;
|
||||
use codex_mcp::mcp_connection_manager::SandboxState;
|
||||
use codex_mcp::mcp_connection_manager::ToolInfo as McpToolInfo;
|
||||
use codex_mcp::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use codex_mcp::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
|
||||
#[cfg(test)]
|
||||
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_models_manager::manager::ModelsManager;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
use codex_network_proxy::normalize_host;
|
||||
@@ -120,6 +129,8 @@ use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_rollout::state_db;
|
||||
use codex_shell_command::parse_command::parse_command;
|
||||
use codex_terminal_detection::user_agent;
|
||||
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
@@ -160,7 +171,6 @@ use tracing::trace_span;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::client::ModelClient;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::client_common::Prompt;
|
||||
@@ -173,16 +183,17 @@ use crate::config::ConstraintResult;
|
||||
use crate::config::GhostSnapshotConfig;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::config::resolve_web_search_mode_for_turn;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::context_manager::TotalTokenUsageBreakdown;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
use crate::exec::StreamOutput;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::ShellEnvironmentPolicy;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
|
||||
mod rollout_reconstruction;
|
||||
#[cfg(test)]
|
||||
@@ -260,14 +271,8 @@ use crate::injection::ToolMentionKind;
|
||||
use crate::injection::app_id_from_path;
|
||||
use crate::injection::tool_kind_for_path;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
|
||||
use crate::mcp::with_codex_apps_mcp;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
|
||||
use crate::mcp_skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
|
||||
use crate::memories;
|
||||
use crate::mentions::build_connector_slug_counts;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
@@ -279,43 +284,6 @@ use crate::plugins::PluginsManager;
|
||||
use crate::plugins::build_plugin_injections;
|
||||
use crate::plugins::render_plugins_section;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::protocol::AgentMessageContentDeltaEvent;
|
||||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||||
use crate::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::BackgroundEventEvent;
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::DeprecationNoticeEvent;
|
||||
use crate::protocol::ErrorEvent;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecApprovalRequestEvent;
|
||||
use crate::protocol::McpServerRefreshConfig;
|
||||
use crate::protocol::ModelRerouteEvent;
|
||||
use crate::protocol::ModelRerouteReason;
|
||||
use crate::protocol::NetworkApprovalContext;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::PlanDeltaEvent;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::ReasoningContentDeltaEvent;
|
||||
use crate::protocol::ReasoningRawContentDeltaEvent;
|
||||
use crate::protocol::RequestUserInputEvent;
|
||||
use crate::protocol::ReviewDecision;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::protocol::SessionNetworkProxyRuntime;
|
||||
use crate::protocol::SkillDependencies as ProtocolSkillDependencies;
|
||||
use crate::protocol::SkillErrorInfo;
|
||||
use crate::protocol::SkillInterface as ProtocolSkillInterface;
|
||||
use crate::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use crate::protocol::SkillToolDependency as ProtocolSkillToolDependency;
|
||||
use crate::protocol::StreamErrorEvent;
|
||||
use crate::protocol::Submission;
|
||||
use crate::protocol::TokenCountEvent;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::protocol::TurnDiffEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::resolve_skill_dependencies_for_turn;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::RolloutRecorderParams;
|
||||
@@ -328,9 +296,9 @@ use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::MailboxDeliveryPhase;
|
||||
use crate::state::SessionServices;
|
||||
use crate::state::SessionState;
|
||||
use crate::state_db;
|
||||
use crate::tasks::GhostSnapshotTask;
|
||||
use crate::tasks::ReviewTask;
|
||||
use crate::tasks::SessionTask;
|
||||
@@ -345,8 +313,6 @@ use crate::tools::network_approval::build_network_policy_decider;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::turn_timing::TurnTimingState;
|
||||
use crate::turn_timing::record_turn_ttfm_metric;
|
||||
@@ -356,6 +322,9 @@ use crate::util::backoff;
|
||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::mcp::auth::compute_auth_statuses;
|
||||
use codex_mcp::mcp::with_codex_apps_mcp;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_otel::metrics::names::THREAD_STARTED_METRIC;
|
||||
@@ -366,13 +335,53 @@ use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::AgentMessageContentDeltaEvent;
|
||||
use codex_protocol::protocol::AgentReasoningSectionBreakEvent;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::BackgroundEventEvent;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::DeprecationNoticeEvent;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::McpServerRefreshConfig;
|
||||
use codex_protocol::protocol::ModelRerouteEvent;
|
||||
use codex_protocol::protocol::ModelRerouteReason;
|
||||
use codex_protocol::protocol::NetworkApprovalContext;
|
||||
use codex_protocol::protocol::NonSteerableTurnKind;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::PlanDeltaEvent;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::ReasoningContentDeltaEvent;
|
||||
use codex_protocol::protocol::ReasoningRawContentDeltaEvent;
|
||||
use codex_protocol::protocol::RequestUserInputEvent;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_protocol::protocol::SessionNetworkProxyRuntime;
|
||||
use codex_protocol::protocol::SkillDependencies as ProtocolSkillDependencies;
|
||||
use codex_protocol::protocol::SkillErrorInfo;
|
||||
use codex_protocol::protocol::SkillInterface as ProtocolSkillInterface;
|
||||
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
|
||||
use codex_protocol::protocol::SkillToolDependency as ProtocolSkillToolDependency;
|
||||
use codex_protocol::protocol::StreamErrorEvent;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnDiffEvent;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::ToolsConfigParams;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
@@ -546,13 +555,13 @@ impl Codex {
|
||||
|
||||
let config = Arc::new(config);
|
||||
let refresh_strategy = match session_source {
|
||||
SessionSource::SubAgent(_) => crate::models_manager::manager::RefreshStrategy::Offline,
|
||||
_ => crate::models_manager::manager::RefreshStrategy::OnlineIfUncached,
|
||||
SessionSource::SubAgent(_) => codex_models_manager::manager::RefreshStrategy::Offline,
|
||||
_ => codex_models_manager::manager::RefreshStrategy::OnlineIfUncached,
|
||||
};
|
||||
if config.model.is_none()
|
||||
|| !matches!(
|
||||
refresh_strategy,
|
||||
crate::models_manager::manager::RefreshStrategy::Offline
|
||||
codex_models_manager::manager::RefreshStrategy::Offline
|
||||
)
|
||||
{
|
||||
let _ = models_manager.list_models(refresh_strategy).await;
|
||||
@@ -565,7 +574,9 @@ impl Codex {
|
||||
// 1. config.base_instructions override
|
||||
// 2. conversation history => session_meta.base_instructions
|
||||
// 3. base_instructions for current model
|
||||
let model_info = models_manager.get_model_info(model.as_str(), &config).await;
|
||||
let model_info = models_manager
|
||||
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||
.await;
|
||||
let base_instructions = config
|
||||
.base_instructions
|
||||
.clone()
|
||||
@@ -631,6 +642,7 @@ impl Codex {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source,
|
||||
dynamic_tools,
|
||||
persist_extended_history,
|
||||
@@ -750,13 +762,15 @@ impl Codex {
|
||||
self.session.steer_input(input, expected_turn_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_app_server_client_name(
|
||||
pub(crate) async fn set_app_server_client_info(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
app_server_client_name,
|
||||
app_server_client_version,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
@@ -898,7 +912,9 @@ impl TurnContext {
|
||||
pub(crate) async fn with_model(&self, model: String, models_manager: &ModelsManager) -> Self {
|
||||
let mut config = (*self.config).clone();
|
||||
config.model = Some(model.clone());
|
||||
let model_info = models_manager.get_model_info(model.as_str(), &config).await;
|
||||
let model_info = models_manager
|
||||
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||
.await;
|
||||
let truncation_policy = model_info.truncation_policy.into();
|
||||
let supported_reasoning_levels = model_info
|
||||
.supported_reasoning_levels
|
||||
@@ -942,7 +958,9 @@ impl TurnContext {
|
||||
.with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone())
|
||||
.with_web_search_config(self.tools_config.web_search_config.clone())
|
||||
.with_allow_login_shell(self.tools_config.allow_login_shell)
|
||||
.with_agent_roles(config.agent_roles.clone());
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&config.agent_roles,
|
||||
));
|
||||
|
||||
Self {
|
||||
sub_id: self.sub_id.clone(),
|
||||
@@ -1107,6 +1125,7 @@ pub(crate) struct SessionConfiguration {
|
||||
/// Optional service name tag for session metrics.
|
||||
metrics_service_name: Option<String>,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
/// Source of the session (cli, vscode, exec, mcp, ...)
|
||||
session_source: SessionSource,
|
||||
dynamic_tools: Vec<DynamicToolSpec>,
|
||||
@@ -1200,6 +1219,9 @@ impl SessionConfiguration {
|
||||
if let Some(app_server_client_name) = updates.app_server_client_name.clone() {
|
||||
next_configuration.app_server_client_name = Some(app_server_client_name);
|
||||
}
|
||||
if let Some(app_server_client_version) = updates.app_server_client_version.clone() {
|
||||
next_configuration.app_server_client_version = Some(app_server_client_version);
|
||||
}
|
||||
Ok(next_configuration)
|
||||
}
|
||||
}
|
||||
@@ -1217,9 +1239,26 @@ pub(crate) struct SessionSettingsUpdate {
|
||||
pub(crate) final_output_json_schema: Option<Option<Value>>,
|
||||
pub(crate) personality: Option<Personality>,
|
||||
pub(crate) app_server_client_name: Option<String>,
|
||||
pub(crate) app_server_client_version: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct AppServerClientMetadata {
|
||||
pub(crate) client_name: Option<String>,
|
||||
pub(crate) client_version: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata {
|
||||
let state = self.state.lock().await;
|
||||
AppServerClientMetadata {
|
||||
client_name: state.session_configuration.app_server_client_name.clone(),
|
||||
client_version: state
|
||||
.session_configuration
|
||||
.app_server_client_version
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `x-codex-beta-features` header value for this session.
|
||||
///
|
||||
/// `ModelClient` is session-scoped and intentionally does not depend on the full `Config`, so
|
||||
@@ -1394,13 +1433,15 @@ impl Session {
|
||||
windows_sandbox_level: session_configuration.windows_sandbox_level,
|
||||
})
|
||||
.with_unified_exec_shell_mode_for_session(
|
||||
user_shell,
|
||||
crate::tools::spec::tool_user_shell_type(user_shell),
|
||||
shell_zsh_path,
|
||||
main_execve_wrapper_exe,
|
||||
)
|
||||
.with_web_search_config(per_turn_config.web_search_config.clone())
|
||||
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
|
||||
.with_agent_roles(per_turn_config.agent_roles.clone());
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&per_turn_config.agent_roles,
|
||||
));
|
||||
|
||||
let cwd = session_configuration.cwd.clone();
|
||||
|
||||
@@ -1517,6 +1558,17 @@ impl Session {
|
||||
),
|
||||
),
|
||||
};
|
||||
let window_generation = match &initial_history {
|
||||
InitialHistory::Resumed(resumed_history) => u64::try_from(
|
||||
resumed_history
|
||||
.history
|
||||
.iter()
|
||||
.filter(|item| matches!(item, RolloutItem::Compacted(_)))
|
||||
.count(),
|
||||
)
|
||||
.unwrap_or(u64::MAX),
|
||||
InitialHistory::New | InitialHistory::Forked(_) => 0,
|
||||
};
|
||||
let state_builder = match &initial_history {
|
||||
InitialHistory::Resumed(resumed) => metadata::builder_from_items(
|
||||
resumed.history.as_slice(),
|
||||
@@ -1658,7 +1710,7 @@ impl Session {
|
||||
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
|
||||
let account_id = auth.and_then(CodexAuth::get_account_id);
|
||||
let account_email = auth.and_then(CodexAuth::get_account_email);
|
||||
let originator = crate::default_client::originator().value;
|
||||
let originator = originator().value;
|
||||
let terminal_type = user_agent();
|
||||
let session_model = session_configuration.collaboration_mode.model().to_string();
|
||||
let auth_env_telemetry = collect_auth_env_telemetry(
|
||||
@@ -1906,6 +1958,9 @@ impl Session {
|
||||
),
|
||||
environment: environment_manager.current().await?,
|
||||
};
|
||||
services
|
||||
.model_client
|
||||
.set_window_generation(window_generation);
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
@@ -1994,6 +2049,7 @@ impl Session {
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_statuses.clone(),
|
||||
&session_configuration.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event.clone(),
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
@@ -2456,7 +2512,7 @@ impl Session {
|
||||
.models_manager
|
||||
.get_model_info(
|
||||
session_configuration.collaboration_mode.model(),
|
||||
&per_turn_config,
|
||||
&per_turn_config.to_models_manager_config(),
|
||||
)
|
||||
.await;
|
||||
let plugin_outcome = self
|
||||
@@ -2638,6 +2694,8 @@ impl Session {
|
||||
msg,
|
||||
};
|
||||
self.send_event_raw(event).await;
|
||||
self.maybe_notify_parent_of_terminal_turn(turn_context, &legacy_source)
|
||||
.await;
|
||||
self.maybe_mirror_event_text_to_realtime(&legacy_source)
|
||||
.await;
|
||||
self.maybe_clear_realtime_handoff_for_event(&legacy_source)
|
||||
@@ -2653,6 +2711,73 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards terminal turn events from spawned MultiAgentV2 children to their direct parent.
|
||||
async fn maybe_notify_parent_of_terminal_turn(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
msg: &EventMsg,
|
||||
) {
|
||||
if !self.enabled(Feature::MultiAgentV2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !matches!(msg, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
agent_path: Some(child_agent_path),
|
||||
..
|
||||
}) = &turn_context.session_source
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(status) = agent_status_from_event(msg) else {
|
||||
return;
|
||||
};
|
||||
if !is_final(&status) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.forward_child_completion_to_parent(*parent_thread_id, child_agent_path, status)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Sends the standard completion envelope from a spawned MultiAgentV2 child to its parent.
|
||||
async fn forward_child_completion_to_parent(
|
||||
&self,
|
||||
parent_thread_id: ThreadId,
|
||||
child_agent_path: &codex_protocol::AgentPath,
|
||||
status: AgentStatus,
|
||||
) {
|
||||
let Some(parent_agent_path) = child_agent_path
|
||||
.as_str()
|
||||
.rsplit_once('/')
|
||||
.and_then(|(parent, _)| codex_protocol::AgentPath::try_from(parent).ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let message = format_subagent_notification_message(child_agent_path.as_str(), &status);
|
||||
let communication = InterAgentCommunication::new(
|
||||
child_agent_path.clone(),
|
||||
parent_agent_path,
|
||||
Vec::new(),
|
||||
message,
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
if let Err(err) = self
|
||||
.services
|
||||
.agent_control
|
||||
.send_inter_agent_communication(parent_thread_id, communication)
|
||||
.await
|
||||
{
|
||||
debug!("failed to notify parent thread {parent_thread_id}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) {
|
||||
let Some(text) = realtime_text_for_event(msg) else {
|
||||
return;
|
||||
@@ -3430,6 +3555,7 @@ impl Session {
|
||||
self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item)])
|
||||
.await;
|
||||
}
|
||||
self.services.model_client.advance_window_generation();
|
||||
}
|
||||
|
||||
async fn persist_rollout_response_items(&self, items: &[ResponseItem]) {
|
||||
@@ -3495,22 +3621,24 @@ impl Session {
|
||||
{
|
||||
developer_sections.push(model_switch_message.into_text());
|
||||
}
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
turn_context.approval_policy.value(),
|
||||
turn_context.config.approvals_reviewer,
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
&turn_context.cwd,
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::ExecPermissionApprovals),
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::RequestPermissionsTool),
|
||||
)
|
||||
.into_text(),
|
||||
);
|
||||
if turn_context.config.include_permissions_instructions {
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
turn_context.approval_policy.value(),
|
||||
turn_context.config.approvals_reviewer,
|
||||
self.services.exec_policy.current().as_ref(),
|
||||
&turn_context.cwd,
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::ExecPermissionApprovals),
|
||||
turn_context
|
||||
.features
|
||||
.enabled(Feature::RequestPermissionsTool),
|
||||
)
|
||||
.into_text(),
|
||||
);
|
||||
}
|
||||
let separate_guardian_developer_message =
|
||||
crate::guardian::is_guardian_reviewer_source(&session_source);
|
||||
// Keep the guardian policy prompt out of the aggregated developer bundle so it
|
||||
@@ -3560,7 +3688,7 @@ impl Session {
|
||||
);
|
||||
}
|
||||
}
|
||||
if turn_context.apps_enabled() {
|
||||
if turn_context.config.include_apps_instructions && turn_context.apps_enabled() {
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
|
||||
let accessible_and_enabled_connectors =
|
||||
connectors::list_accessible_and_enabled_connectors_from_manager(
|
||||
@@ -3603,16 +3731,18 @@ impl Session {
|
||||
.serialize_to_text(),
|
||||
);
|
||||
}
|
||||
let subagents = self
|
||||
.services
|
||||
.agent_control
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
.with_subagents(subagents)
|
||||
.serialize_to_xml(),
|
||||
);
|
||||
if turn_context.config.include_environment_context {
|
||||
let subagents = self
|
||||
.services
|
||||
.agent_control
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
.with_subagents(subagents)
|
||||
.serialize_to_xml(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut items = Vec::with_capacity(3);
|
||||
if let Some(developer_message) =
|
||||
@@ -3945,6 +4075,7 @@ impl Session {
|
||||
|
||||
let mut turn_state = active_turn.turn_state.lock().await;
|
||||
turn_state.push_pending_input(input.into());
|
||||
turn_state.accept_mailbox_delivery_for_current_turn();
|
||||
Ok(active_turn_id.clone())
|
||||
}
|
||||
|
||||
@@ -3966,6 +4097,42 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn defer_mailbox_delivery_to_next_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
let mut turn_state = turn_state.lock().await;
|
||||
if turn_state.has_pending_input() {
|
||||
return;
|
||||
}
|
||||
turn_state.set_mailbox_delivery_phase(MailboxDeliveryPhase::NextTurn);
|
||||
}
|
||||
|
||||
pub(crate) async fn accept_mailbox_delivery_for_current_turn(&self, sub_id: &str) {
|
||||
let turn_state = self.turn_state_for_sub_id(sub_id).await;
|
||||
let Some(turn_state) = turn_state else {
|
||||
return;
|
||||
};
|
||||
turn_state
|
||||
.lock()
|
||||
.await
|
||||
.set_mailbox_delivery_phase(MailboxDeliveryPhase::CurrentTurn);
|
||||
}
|
||||
|
||||
async fn turn_state_for_sub_id(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
) -> Option<Arc<tokio::sync::Mutex<crate::state::TurnState>>> {
|
||||
let active = self.active_turn.lock().await;
|
||||
active.as_ref().and_then(|active_turn| {
|
||||
active_turn
|
||||
.tasks
|
||||
.contains_key(sub_id)
|
||||
.then(|| Arc::clone(&active_turn.turn_state))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe_mailbox_seq(&self) -> watch::Receiver<u64> {
|
||||
self.mailbox.subscribe()
|
||||
}
|
||||
@@ -3991,16 +4158,22 @@ impl Session {
|
||||
}
|
||||
|
||||
pub async fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||||
let pending_input = {
|
||||
let (pending_input, accepts_mailbox_delivery) = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.take_pending_input()
|
||||
(
|
||||
ts.take_pending_input(),
|
||||
ts.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => Vec::new(),
|
||||
None => (Vec::new(), true),
|
||||
}
|
||||
};
|
||||
if !accepts_mailbox_delivery {
|
||||
return pending_input;
|
||||
}
|
||||
let mailbox_items = {
|
||||
let mut mailbox_rx = self.mailbox_rx.lock().await;
|
||||
mailbox_rx
|
||||
@@ -4040,17 +4213,26 @@ impl Session {
|
||||
}
|
||||
|
||||
pub async fn has_pending_input(&self) -> bool {
|
||||
if self.mailbox_rx.lock().await.has_pending() {
|
||||
let (has_turn_pending_input, accepts_mailbox_delivery) = {
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
(
|
||||
ts.has_pending_input(),
|
||||
ts.accepts_mailbox_delivery_for_current_turn(),
|
||||
)
|
||||
}
|
||||
None => (false, true),
|
||||
}
|
||||
};
|
||||
if has_turn_pending_input {
|
||||
return true;
|
||||
}
|
||||
let active = self.active_turn.lock().await;
|
||||
match active.as_ref() {
|
||||
Some(at) => {
|
||||
let ts = at.turn_state.lock().await;
|
||||
ts.has_pending_input()
|
||||
}
|
||||
None => false,
|
||||
if !accepts_mailbox_delivery {
|
||||
return false;
|
||||
}
|
||||
self.mailbox_rx.lock().await.has_pending()
|
||||
}
|
||||
|
||||
pub async fn list_resources(
|
||||
@@ -4175,16 +4357,12 @@ impl Session {
|
||||
) {
|
||||
let auth = self.services.auth_manager.auth().await;
|
||||
let config = self.get_config().await;
|
||||
let mcp_config = config.to_mcp_config(self.services.plugins_manager.as_ref());
|
||||
let tool_plugin_provenance = self
|
||||
.services
|
||||
.mcp_manager
|
||||
.tool_plugin_provenance(config.as_ref());
|
||||
let mcp_servers = with_codex_apps_mcp(
|
||||
mcp_servers,
|
||||
self.features.apps_enabled_for_auth(auth.as_ref()),
|
||||
auth.as_ref(),
|
||||
config.as_ref(),
|
||||
);
|
||||
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
|
||||
let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await;
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
@@ -4202,6 +4380,7 @@ impl Session {
|
||||
store_mode,
|
||||
auth_statuses,
|
||||
&turn_context.config.permissions.approval_policy,
|
||||
turn_context.sub_id.clone(),
|
||||
self.get_tx_event(),
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
@@ -4286,6 +4465,37 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn emit_subagent_session_started(
|
||||
analytics_events_client: &AnalyticsEventsClient,
|
||||
client_metadata: AppServerClientMetadata,
|
||||
thread_id: ThreadId,
|
||||
thread_config: ThreadConfigSnapshot,
|
||||
subagent_source: SubAgentSource,
|
||||
) {
|
||||
let AppServerClientMetadata {
|
||||
client_name,
|
||||
client_version,
|
||||
} = client_metadata;
|
||||
let (Some(client_name), Some(client_version)) = (client_name, client_version) else {
|
||||
tracing::warn!("skipping subagent thread analytics: missing inherited client metadata");
|
||||
return;
|
||||
};
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
analytics_events_client.track_subagent_thread_started(SubAgentThreadStartedInput {
|
||||
thread_id: thread_id.to_string(),
|
||||
product_client_id: client_name.clone(),
|
||||
client_name,
|
||||
client_version,
|
||||
model: thread_config.model,
|
||||
ephemeral: thread_config.ephemeral,
|
||||
subagent_source,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
|
||||
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
|
||||
// To break out of this loop, send Op::Shutdown.
|
||||
while let Ok(sub) = rx_sub.recv().await {
|
||||
@@ -4544,8 +4754,6 @@ mod handlers {
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
use crate::review_prompts::resolve_review_request;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::session_index;
|
||||
@@ -4554,6 +4762,8 @@ mod handlers {
|
||||
use crate::tasks::UserShellCommandMode;
|
||||
use crate::tasks::UserShellCommandTask;
|
||||
use crate::tasks::execute_user_shell_command;
|
||||
use codex_mcp::mcp::auth::compute_auth_statuses;
|
||||
use codex_mcp::mcp::collect_mcp_snapshot_from_manager;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
@@ -4653,6 +4863,7 @@ mod handlers {
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
personality,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4701,8 +4912,8 @@ mod handlers {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an inter-agent assistant envelope and, when requested, wakes the recipient by
|
||||
/// starting a regular turn if the session is currently idle.
|
||||
/// Records an inter-agent assistant envelope, then lets the shared pending-work scheduler
|
||||
/// decide whether an idle session should start a regular turn.
|
||||
pub async fn inter_agent_communication(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
@@ -4711,7 +4922,7 @@ mod handlers {
|
||||
let trigger_turn = communication.trigger_turn;
|
||||
sess.enqueue_mailbox_communication(communication);
|
||||
if trigger_turn {
|
||||
sess.ensure_task_for_pending_inputs_with_sub_id(sub_id)
|
||||
sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -4892,7 +5103,7 @@ mod handlers {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::GetHistoryEntryResponse(
|
||||
crate::protocol::GetHistoryEntryResponseEvent {
|
||||
codex_protocol::protocol::GetHistoryEntryResponseEvent {
|
||||
offset,
|
||||
log_id,
|
||||
entry: entry_opt.map(|e| codex_protocol::message_history::HistoryEntry {
|
||||
@@ -5376,7 +5587,7 @@ async fn spawn_review_thread(
|
||||
let review_model_info = sess
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&model, &config)
|
||||
.get_model_info(&model, &config.to_models_manager_config())
|
||||
.await;
|
||||
// For reviews, disable web_search and view_image regardless of global settings.
|
||||
let mut review_features = sess.features.clone();
|
||||
@@ -5397,13 +5608,15 @@ async fn spawn_review_thread(
|
||||
windows_sandbox_level: parent_turn_context.windows_sandbox_level,
|
||||
})
|
||||
.with_unified_exec_shell_mode_for_session(
|
||||
sess.services.user_shell.as_ref(),
|
||||
crate::tools::spec::tool_user_shell_type(sess.services.user_shell.as_ref()),
|
||||
sess.services.shell_zsh_path.as_ref(),
|
||||
sess.services.main_execve_wrapper_exe.as_ref(),
|
||||
)
|
||||
.with_web_search_config(/*web_search_config*/ None)
|
||||
.with_allow_login_shell(config.permissions.allow_login_shell)
|
||||
.with_agent_roles(config.agent_roles.clone());
|
||||
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
|
||||
&config.agent_roles,
|
||||
));
|
||||
|
||||
let review_prompt = resolved.prompt.clone();
|
||||
let provider = parent_turn_context.provider.clone();
|
||||
@@ -5594,16 +5807,20 @@ pub(crate) async fn run_turn(
|
||||
|
||||
let model_info = turn_context.model_info.clone();
|
||||
let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX);
|
||||
let mut prewarmed_client_session = prewarmed_client_session;
|
||||
// TODO(ccunningham): Pre-turn compaction runs before context updates and the
|
||||
// new user message are recorded. Estimate pending incoming items (context
|
||||
// diffs/full reinjection + user input) and trigger compaction preemptively
|
||||
// when they would push the thread over the compaction threshold.
|
||||
if run_pre_sampling_compact(&sess, &turn_context)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
error!("Failed to run pre-sampling compact");
|
||||
return None;
|
||||
let pre_sampling_compacted = match run_pre_sampling_compact(&sess, &turn_context).await {
|
||||
Ok(pre_sampling_compacted) => pre_sampling_compacted,
|
||||
Err(_) => {
|
||||
error!("Failed to run pre-sampling compact");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if pre_sampling_compacted && let Some(mut client_session) = prewarmed_client_session.take() {
|
||||
client_session.reset_websocket_session();
|
||||
}
|
||||
|
||||
let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref());
|
||||
@@ -5916,6 +6133,7 @@ pub(crate) async fn run_turn(
|
||||
{
|
||||
return None;
|
||||
}
|
||||
client_session.reset_websocket_session();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -5942,7 +6160,7 @@ pub(crate) async fn run_turn(
|
||||
for run in sess.hooks().preview_stop(&stop_request) {
|
||||
sess.send_event(
|
||||
&turn_context,
|
||||
EventMsg::HookStarted(crate::protocol::HookStartedEvent {
|
||||
EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
run,
|
||||
}),
|
||||
@@ -6076,9 +6294,9 @@ pub(crate) async fn run_turn(
|
||||
async fn run_pre_sampling_compact(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
) -> CodexResult<()> {
|
||||
) -> CodexResult<bool> {
|
||||
let total_usage_tokens_before_compaction = sess.get_total_token_usage().await;
|
||||
maybe_run_previous_model_inline_compact(
|
||||
let mut pre_sampling_compacted = maybe_run_previous_model_inline_compact(
|
||||
sess,
|
||||
turn_context,
|
||||
total_usage_tokens_before_compaction,
|
||||
@@ -6092,8 +6310,9 @@ async fn run_pre_sampling_compact(
|
||||
// Compact if the total usage tokens are greater than the auto compact limit
|
||||
if total_usage_tokens >= auto_compact_limit {
|
||||
run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?;
|
||||
pre_sampling_compacted = true;
|
||||
}
|
||||
Ok(())
|
||||
Ok(pre_sampling_compacted)
|
||||
}
|
||||
|
||||
/// Runs pre-sampling compaction against the previous model when switching to a smaller
|
||||
@@ -6292,10 +6511,10 @@ fn connector_inserted_in_messages(
|
||||
}
|
||||
|
||||
fn filter_codex_apps_mcp_tools(
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
mcp_tools: &HashMap<String, McpToolInfo>,
|
||||
connectors: &[connectors::AppInfo],
|
||||
config: &Config,
|
||||
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
|
||||
) -> HashMap<String, McpToolInfo> {
|
||||
let allowed: HashSet<&str> = connectors
|
||||
.iter()
|
||||
.map(|connector| connector.id.as_str())
|
||||
@@ -6316,7 +6535,7 @@ fn filter_codex_apps_mcp_tools(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Option<&str> {
|
||||
fn codex_apps_connector_id(tool: &McpToolInfo) -> Option<&str> {
|
||||
tool.connector_id.as_deref()
|
||||
}
|
||||
|
||||
@@ -6351,6 +6570,7 @@ pub(crate) fn build_prompt(
|
||||
output_schema: turn_context.final_output_json_schema.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(level = "trace",
|
||||
skip_all,
|
||||
@@ -7287,6 +7507,25 @@ async fn try_run_sampling_request(
|
||||
cancellation_token: cancellation_token.child_token(),
|
||||
};
|
||||
|
||||
let preempt_for_mailbox_mail = match &item {
|
||||
ResponseItem::Message { role, phase, .. } => {
|
||||
role == "assistant" && matches!(phase, Some(MessagePhase::Commentary))
|
||||
}
|
||||
ResponseItem::Reasoning { .. } => true,
|
||||
ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => false,
|
||||
};
|
||||
|
||||
let output_result = handle_output_item_done(&mut ctx, item, previously_active_item)
|
||||
.instrument(handle_responses)
|
||||
.await?;
|
||||
@@ -7297,6 +7536,13 @@ async fn try_run_sampling_request(
|
||||
last_agent_message = Some(agent_message);
|
||||
}
|
||||
needs_follow_up |= output_result.needs_follow_up;
|
||||
// todo: remove before stabilizing multi-agent v2
|
||||
if preempt_for_mailbox_mail && sess.mailbox_rx.lock().await.has_pending() {
|
||||
break Ok(SamplingRequestResult {
|
||||
needs_follow_up: true,
|
||||
last_agent_message,
|
||||
});
|
||||
}
|
||||
}
|
||||
ResponseEvent::OutputItemAdded(item) => {
|
||||
if let Some(turn_item) = handle_non_tool_response_item(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::InitialHistory;
|
||||
use crate::protocol::ResumedHistory;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
@@ -30,15 +30,14 @@ use tokio::sync::oneshot;
|
||||
use tokio::time::timeout;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnArgs;
|
||||
use crate::codex::CodexSpawnOk;
|
||||
use crate::codex::SUBMISSION_CHANNEL_CAPACITY;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::review_approval_request_with_cancel;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
@@ -48,7 +47,9 @@ use crate::mcp_tool_call::MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC;
|
||||
use crate::mcp_tool_call::build_guardian_mcp_tool_review_request;
|
||||
use crate::mcp_tool_call::is_mcp_tool_approval_question_id;
|
||||
use crate::mcp_tool_call::lookup_mcp_tool_metadata;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use codex_login::AuthManager;
|
||||
use codex_models_manager::manager::ModelsManager;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -85,7 +86,7 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
||||
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
|
||||
conversation_history: initial_history.unwrap_or(InitialHistory::New),
|
||||
session_source: SessionSource::SubAgent(subagent_source),
|
||||
session_source: SessionSource::SubAgent(subagent_source.clone()),
|
||||
agent_control: parent_session.services.agent_control.clone(),
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -96,6 +97,17 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
parent_trace: None,
|
||||
})
|
||||
.await?;
|
||||
if parent_session.enabled(codex_features::Feature::GeneralAnalytics) {
|
||||
let thread_config = codex.thread_config_snapshot().await;
|
||||
let client_metadata = parent_session.app_server_client_metadata().await;
|
||||
emit_subagent_session_started(
|
||||
&parent_session.services.analytics_events_client,
|
||||
client_metadata,
|
||||
codex.session.conversation_id,
|
||||
thread_config,
|
||||
subagent_source,
|
||||
);
|
||||
}
|
||||
let codex = Arc::new(codex);
|
||||
|
||||
// Use a child token so parent cancel cascades but we can scope it to this task
|
||||
@@ -516,7 +528,6 @@ async fn handle_patch_approval(
|
||||
} = event;
|
||||
let approval_id = call_id.clone();
|
||||
let guardian_decision = if routes_approval_to_guardian(parent_ctx) {
|
||||
let change_count = changes.len();
|
||||
let maybe_files = changes
|
||||
.keys()
|
||||
.map(|path| parent_ctx.cwd.join(path).ok())
|
||||
@@ -557,7 +568,6 @@ async fn handle_patch_approval(
|
||||
id: approval_id.clone(),
|
||||
cwd: parent_ctx.cwd.to_path_buf(),
|
||||
files,
|
||||
change_count,
|
||||
patch,
|
||||
},
|
||||
reason.clone(),
|
||||
|
||||
@@ -9,8 +9,10 @@ use codex_protocol::protocol::AgentStatus;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::GuardianAssessmentAction;
|
||||
use codex_protocol::protocol::GuardianAssessmentEvent;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_protocol::protocol::McpInvocation;
|
||||
use codex_protocol::protocol::RawResponseItemEvent;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
@@ -23,7 +25,6 @@ use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -317,11 +318,11 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f
|
||||
risk_score: None,
|
||||
risk_level: None,
|
||||
rationale: None,
|
||||
action: Some(json!({
|
||||
"tool": "shell",
|
||||
"command": "rm -rf tmp",
|
||||
"cwd": "/tmp",
|
||||
})),
|
||||
action: GuardianAssessmentAction::Command {
|
||||
source: GuardianCommandSource::Shell,
|
||||
command: "rm -rf tmp".to_string(),
|
||||
cwd: "/tmp".into(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+374
-160
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::test_config;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
@@ -10,16 +9,20 @@ use crate::config_loader::NetworkDomainPermissionsToml;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::config_loader::Sourced;
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::models_manager::model_info;
|
||||
use crate::shell::default_user_shell;
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
use codex_features::Features;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::mcp_connection_manager::ToolInfo;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_models_manager::model_info;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
@@ -34,20 +37,6 @@ use codex_protocol::request_permissions::PermissionGrantScope;
|
||||
use codex_protocol::request_permissions::RequestPermissionProfile;
|
||||
use tracing::Span;
|
||||
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::CreditsSnapshot;
|
||||
use crate::protocol::InitialHistory;
|
||||
use crate::protocol::NetworkApprovalProtocol;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::RateLimitWindow;
|
||||
use crate::protocol::ResumedHistory;
|
||||
use crate::protocol::RolloutItem;
|
||||
use crate::protocol::TokenCountEvent;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use crate::protocol::UserMessageEvent;
|
||||
use crate::rollout::policy::EventPersistenceMode;
|
||||
use crate::rollout::recorder::RolloutRecorder;
|
||||
use crate::rollout::recorder::RolloutRecorderParams;
|
||||
@@ -55,7 +44,6 @@ use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
@@ -77,11 +65,28 @@ use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::ConversationAudioParams;
|
||||
use codex_protocol::protocol::CreditsSnapshot;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::NetworkApprovalProtocol;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use codex_protocol::protocol::RealtimeAudioFrame;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::context_snapshot;
|
||||
@@ -115,12 +120,6 @@ use std::time::Duration as StdDuration;
|
||||
#[path = "codex_tests_guardian.rs"]
|
||||
mod guardian_tests;
|
||||
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
|
||||
fn expect_text_tool_output(output: &FunctionToolOutput) -> String {
|
||||
function_call_output_content_items_to_text(&output.body).unwrap_or_default()
|
||||
}
|
||||
|
||||
struct InstructionsTestCase {
|
||||
slug: &'static str,
|
||||
expects_apply_patch_instructions: bool,
|
||||
@@ -239,7 +238,7 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted
|
||||
.expect("channel open");
|
||||
assert!(matches!(
|
||||
second.msg,
|
||||
EventMsg::TurnAborted(crate::protocol::TurnAbortedEvent {
|
||||
EventMsg::TurnAborted(TurnAbortedEvent {
|
||||
turn_id: Some(turn_id),
|
||||
reason: TurnAbortReason::Interrupted,
|
||||
}) if turn_id == tc.sub_id
|
||||
@@ -251,9 +250,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
|
||||
/*auth_manager*/ None,
|
||||
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
|
||||
.expect("test thread id should be valid"),
|
||||
crate::model_provider_info::ModelProviderInfo::create_openai_provider(
|
||||
/* base_url */ /*base_url*/ None,
|
||||
),
|
||||
ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None),
|
||||
codex_protocol::protocol::SessionSource::Exec,
|
||||
/*model_verbosity*/ None,
|
||||
/*enable_request_compression*/ false,
|
||||
@@ -280,6 +277,23 @@ fn developer_input_texts(items: &[ResponseItem]) -> Vec<&str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn user_input_texts(items: &[ResponseItem]) -> Vec<&str> {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
Some(content.as_slice())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|content| content.iter())
|
||||
.filter_map(|item| match item {
|
||||
ContentItem::InputText { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_project_hooks(dot_codex: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dot_codex)?;
|
||||
std::fs::write(
|
||||
@@ -597,8 +611,8 @@ async fn start_managed_network_proxy_ignores_invalid_execpolicy_network_rules()
|
||||
async fn get_base_instructions_no_user_content() {
|
||||
let prompt_with_apply_patch_instructions =
|
||||
include_str!("../prompt_with_apply_patch_instructions.md");
|
||||
let models_response: ModelsResponse =
|
||||
serde_json::from_str(include_str!("../models.json")).expect("valid models.json");
|
||||
let models_response = bundled_models_response()
|
||||
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
|
||||
let model_info_for_slug = |slug: &str, config: &Config| {
|
||||
let model = models_response
|
||||
.models
|
||||
@@ -606,7 +620,7 @@ async fn get_base_instructions_no_user_content() {
|
||||
.find(|candidate| candidate.slug == slug)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("model slug {slug} is missing from models.json"));
|
||||
model_info::with_config_overrides(model, config)
|
||||
model_info::with_config_overrides(model, &config.to_models_manager_config())
|
||||
};
|
||||
let test_cases = vec![
|
||||
InstructionsTestCase {
|
||||
@@ -671,7 +685,7 @@ async fn reload_user_config_layer_updates_effective_apps_config() {
|
||||
.and_then(|table| table.get("apps"))
|
||||
.cloned()
|
||||
.expect("apps table");
|
||||
let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml)
|
||||
let apps = codex_config::types::AppsConfigToml::deserialize(apps_toml)
|
||||
.expect("deserialize apps config");
|
||||
let app = apps
|
||||
.apps
|
||||
@@ -1854,7 +1868,10 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
let config = build_test_config(codex_home.path()).await;
|
||||
let config = Arc::new(config);
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
model.as_str(),
|
||||
&config.to_models_manager_config(),
|
||||
);
|
||||
let reasoning_effort = config.model_reasoning_effort;
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
@@ -1889,6 +1906,7 @@ async fn set_rate_limits_retains_previous_credits() {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -1952,7 +1970,10 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
let config = build_test_config(codex_home.path()).await;
|
||||
let config = Arc::new(config);
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
model.as_str(),
|
||||
&config.to_models_manager_config(),
|
||||
);
|
||||
let reasoning_effort = config.model_reasoning_effort;
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
@@ -1987,6 +2008,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -2102,7 +2124,10 @@ async fn turn_context_with_model_updates_model_fields() {
|
||||
let expected_model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info("gpt-5.1", updated.config.as_ref())
|
||||
.get_model_info(
|
||||
"gpt-5.1",
|
||||
&updated.config.as_ref().to_models_manager_config(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(updated.config.model.as_deref(), Some("gpt-5.1"));
|
||||
@@ -2190,9 +2215,7 @@ fn success_flag_true_with_no_error_and_content_used() {
|
||||
assert_eq!(expected, got);
|
||||
}
|
||||
|
||||
async fn wait_for_thread_rolled_back(
|
||||
rx: &async_channel::Receiver<Event>,
|
||||
) -> crate::protocol::ThreadRolledBackEvent {
|
||||
async fn wait_for_thread_rolled_back(rx: &async_channel::Receiver<Event>) -> ThreadRolledBackEvent {
|
||||
let deadline = StdDuration::from_secs(2);
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
@@ -2263,7 +2286,7 @@ fn text_block(s: &str) -> serde_json::Value {
|
||||
}
|
||||
|
||||
async fn build_test_config(codex_home: &Path) -> Config {
|
||||
ConfigBuilder::default()
|
||||
ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
@@ -2295,7 +2318,10 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
let config = build_test_config(codex_home.path()).await;
|
||||
let config = Arc::new(config);
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
model.as_str(),
|
||||
&config.to_models_manager_config(),
|
||||
);
|
||||
let reasoning_effort = config.model_reasoning_effort;
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
@@ -2331,6 +2357,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -2559,7 +2586,10 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
CollaborationModesConfig::default(),
|
||||
));
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
model.as_str(),
|
||||
&config.to_models_manager_config(),
|
||||
);
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
@@ -2593,6 +2623,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -2655,7 +2686,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
let exec_policy = Arc::new(ExecPolicyManager::default());
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
model.as_str(),
|
||||
&config.to_models_manager_config(),
|
||||
);
|
||||
let reasoning_effort = config.model_reasoning_effort;
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
@@ -2690,6 +2724,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -2699,7 +2734,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
let per_turn_config = Session::build_per_turn_config(&session_configuration);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
session_configuration.collaboration_mode.model(),
|
||||
&per_turn_config,
|
||||
&per_turn_config.to_models_manager_config(),
|
||||
);
|
||||
let session_telemetry = session_telemetry(
|
||||
conversation_id,
|
||||
@@ -2724,11 +2759,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(
|
||||
McpConnectionManager::new_mcp_connection_manager_for_tests(
|
||||
&config.permissions.approval_policy,
|
||||
),
|
||||
)),
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized(
|
||||
&config.permissions.approval_policy,
|
||||
))),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
@@ -2860,15 +2893,13 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests()
|
||||
Arc::get_mut(&mut turn_context)
|
||||
.expect("single turn context ref")
|
||||
.approval_policy
|
||||
.set(crate::protocol::AskForApproval::Granular(
|
||||
crate::protocol::GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
},
|
||||
))
|
||||
.set(AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: true,
|
||||
}))
|
||||
.expect("test setup should allow updating approval policy");
|
||||
|
||||
let session = Arc::new(session);
|
||||
@@ -2935,15 +2966,13 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req
|
||||
Arc::get_mut(&mut turn_context)
|
||||
.expect("single turn context ref")
|
||||
.approval_policy
|
||||
.set(crate::protocol::AskForApproval::Granular(
|
||||
crate::protocol::GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: false,
|
||||
mcp_elicitations: true,
|
||||
},
|
||||
))
|
||||
.set(AskForApproval::Granular(GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: false,
|
||||
mcp_elicitations: true,
|
||||
}))
|
||||
.expect("test setup should allow updating approval policy");
|
||||
|
||||
let session = Arc::new(session);
|
||||
@@ -3166,7 +3195,7 @@ async fn user_turn_updates_approvals_reviewer() {
|
||||
}],
|
||||
cwd: config.cwd.to_path_buf(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent),
|
||||
approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::GuardianSubagent),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
effort: config.model_reasoning_effort,
|
||||
@@ -3182,7 +3211,7 @@ async fn user_turn_updates_approvals_reviewer() {
|
||||
let state = session.state.lock().await;
|
||||
assert_eq!(
|
||||
state.session_configuration.approvals_reviewer,
|
||||
crate::config::types::ApprovalsReviewer::GuardianSubagent
|
||||
codex_config::types::ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3192,7 +3221,6 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
|
||||
captured_trace: Arc<std::sync::Mutex<Option<W3cTraceContext>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionTask for TraceCaptureTask {
|
||||
fn kind(&self) -> TaskKind {
|
||||
TaskKind::Regular
|
||||
@@ -3498,7 +3526,10 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
let exec_policy = Arc::new(ExecPolicyManager::default());
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
model.as_str(),
|
||||
&config.to_models_manager_config(),
|
||||
);
|
||||
let reasoning_effort = config.model_reasoning_effort;
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
@@ -3533,6 +3564,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools,
|
||||
persist_extended_history: false,
|
||||
@@ -3542,7 +3574,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
let per_turn_config = Session::build_per_turn_config(&session_configuration);
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
session_configuration.collaboration_mode.model(),
|
||||
&per_turn_config,
|
||||
&per_turn_config.to_models_manager_config(),
|
||||
);
|
||||
let session_telemetry = session_telemetry(
|
||||
conversation_id,
|
||||
@@ -3567,11 +3599,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: Arc::new(RwLock::new(
|
||||
McpConnectionManager::new_mcp_connection_manager_for_tests(
|
||||
&config.permissions.approval_policy,
|
||||
),
|
||||
)),
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized(
|
||||
&config.permissions.approval_policy,
|
||||
))),
|
||||
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
|
||||
unified_exec_manager: UnifiedExecProcessManager::new(
|
||||
config.background_terminal_max_timeout,
|
||||
@@ -3830,17 +3860,9 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes(
|
||||
.build_settings_update_items(Some(&reference_context_item), ¤t_context)
|
||||
.await;
|
||||
|
||||
let environment_update = update_items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
text.contains("<environment_context>").then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
let environment_update = user_input_texts(&update_items)
|
||||
.into_iter()
|
||||
.find(|text| text.contains("<environment_context>"))
|
||||
.expect("environment update item should be emitted");
|
||||
assert!(environment_update.contains("<network enabled=\"true\">"));
|
||||
assert!(environment_update.contains("<allowed>api.example.com</allowed>"));
|
||||
@@ -3865,22 +3887,43 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() {
|
||||
.build_settings_update_items(Some(&reference_context_item), ¤t_context)
|
||||
.await;
|
||||
|
||||
let environment_update = update_items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
text.contains("<environment_context>").then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
let environment_update = user_input_texts(&update_items)
|
||||
.into_iter()
|
||||
.find(|text| text.contains("<environment_context>"))
|
||||
.expect("environment update item should be emitted");
|
||||
assert!(environment_update.contains("<current_date>2026-02-27</current_date>"));
|
||||
assert!(environment_update.contains("<timezone>Europe/Berlin</timezone>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_settings_update_items_omits_environment_item_when_disabled() {
|
||||
let (session, previous_context) = make_session_and_context().await;
|
||||
let previous_context = Arc::new(previous_context);
|
||||
let mut current_context = previous_context
|
||||
.with_model(
|
||||
previous_context.model_info.slug.clone(),
|
||||
&session.services.models_manager,
|
||||
)
|
||||
.await;
|
||||
let mut config = (*current_context.config).clone();
|
||||
config.include_environment_context = false;
|
||||
current_context.config = Arc::new(config);
|
||||
current_context.current_date = Some("2026-02-27".to_string());
|
||||
|
||||
let reference_context_item = previous_context.to_turn_context_item();
|
||||
let update_items = session
|
||||
.build_settings_update_items(Some(&reference_context_item), ¤t_context)
|
||||
.await;
|
||||
|
||||
let user_texts = user_input_texts(&update_items);
|
||||
assert!(
|
||||
!user_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<environment_context>")),
|
||||
"did not expect environment context updates when disabled, got {user_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() {
|
||||
let (session, previous_context) = make_session_and_context().await;
|
||||
@@ -4450,7 +4493,6 @@ struct NeverEndingTask {
|
||||
listen_to_cancellation_token: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SessionTask for NeverEndingTask {
|
||||
fn kind(&self) -> TaskKind {
|
||||
self.kind
|
||||
@@ -4858,6 +4900,214 @@ async fn queued_response_items_for_next_turn_move_into_next_active_turn() {
|
||||
assert_eq!(sess.get_pending_input().await, vec![queued_item]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"late queue-only update".to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
|
||||
sess.enqueue_mailbox_communication(communication.clone());
|
||||
|
||||
assert!(
|
||||
!sess.has_pending_input().await,
|
||||
"queue-only mailbox mail should stay buffered once the current turn emitted its answer"
|
||||
);
|
||||
assert_eq!(sess.get_pending_input().await, Vec::new());
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
|
||||
|
||||
assert_eq!(
|
||||
sess.get_pending_input().await,
|
||||
vec![communication.to_response_input_item()],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
|
||||
sess.enqueue_mailbox_communication(InterAgentCommunication::new(
|
||||
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"late trigger update".to_string(),
|
||||
/*trigger_turn*/ true,
|
||||
));
|
||||
|
||||
assert!(
|
||||
!sess.has_pending_input().await,
|
||||
"trigger-turn mailbox mail should not extend the current turn after its answer boundary"
|
||||
);
|
||||
|
||||
sess.abort_all_tasks(TurnAbortReason::Replaced).await;
|
||||
|
||||
assert!(sess.has_trigger_turn_mailbox_items().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"queued child update".to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
|
||||
sess.enqueue_mailbox_communication(communication.clone());
|
||||
sess.steer_input(
|
||||
vec![UserInput::Text {
|
||||
text: "follow up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(&tc.sub_id),
|
||||
)
|
||||
.await
|
||||
.expect("steered input should be accepted");
|
||||
|
||||
assert_eq!(
|
||||
sess.get_pending_input().await,
|
||||
vec![
|
||||
ResponseInputItem::from(vec![UserInput::Text {
|
||||
text: "follow up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]),
|
||||
communication.to_response_input_item(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"queued child update".to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
|
||||
sess.enqueue_mailbox_communication(communication.clone());
|
||||
sess.steer_input(
|
||||
vec![UserInput::Text {
|
||||
text: "follow up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
Some(&tc.sub_id),
|
||||
)
|
||||
.await
|
||||
.expect("steered input should be accepted");
|
||||
|
||||
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
|
||||
|
||||
assert_eq!(
|
||||
sess.get_pending_input().await,
|
||||
vec![
|
||||
ResponseInputItem::from(vec![UserInput::Text {
|
||||
text: "follow up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]),
|
||||
communication.to_response_input_item(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
|
||||
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
|
||||
let communication = InterAgentCommunication::new(
|
||||
AgentPath::try_from("/root/worker").expect("worker path should parse"),
|
||||
AgentPath::root(),
|
||||
Vec::new(),
|
||||
"queued child update".to_string(),
|
||||
/*trigger_turn*/ false,
|
||||
);
|
||||
sess.spawn_task(
|
||||
Arc::clone(&tc),
|
||||
Vec::new(),
|
||||
NeverEndingTask {
|
||||
kind: TaskKind::Regular,
|
||||
listen_to_cancellation_token: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
sess.defer_mailbox_delivery_to_next_turn(&tc.sub_id).await;
|
||||
sess.enqueue_mailbox_communication(communication.clone());
|
||||
|
||||
let item = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "test_tool".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
};
|
||||
let mut ctx = HandleOutputCtx {
|
||||
sess: Arc::clone(&sess),
|
||||
turn_context: Arc::clone(&tc),
|
||||
tool_runtime: test_tool_runtime(Arc::clone(&sess), Arc::clone(&tc)),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
};
|
||||
|
||||
let output = handle_output_item_done(&mut ctx, item, /*previously_active_item*/ None)
|
||||
.await
|
||||
.expect("tool call should be handled");
|
||||
|
||||
assert!(output.needs_follow_up);
|
||||
assert!(output.tool_future.is_some());
|
||||
assert_eq!(
|
||||
sess.get_pending_input().await,
|
||||
vec![communication.to_response_input_item()],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
@@ -5161,10 +5411,12 @@ async fn sample_rollout(
|
||||
#[tokio::test]
|
||||
async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
use crate::exec::ExecParams;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let (session, mut turn_context_raw) = make_session_and_context().await;
|
||||
@@ -5207,23 +5459,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let params2 = ExecParams {
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
command: params.command.clone(),
|
||||
cwd: params.cwd.clone(),
|
||||
expiration: timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
windows_sandbox_level: turn_context.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: turn_context
|
||||
.config
|
||||
.permissions
|
||||
.windows_sandbox_private_desktop,
|
||||
justification: params.justification.clone(),
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
|
||||
let tool_name = "shell";
|
||||
@@ -5261,9 +5496,11 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
);
|
||||
|
||||
pretty_assertions::assert_eq!(output, expected);
|
||||
pretty_assertions::assert_eq!(session.granted_turn_permissions().await, None);
|
||||
|
||||
// Now retry the same command WITHOUT escalated permissions; should succeed.
|
||||
// Force DangerFullAccess to avoid platform sandbox dependencies in tests.
|
||||
// The rejection should not poison the non-escalated path for the same
|
||||
// command. Force DangerFullAccess so this check stays focused on approval
|
||||
// policy rather than platform-specific sandbox behavior.
|
||||
let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc");
|
||||
turn_context_mut
|
||||
.sandbox_policy
|
||||
@@ -5274,51 +5511,28 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
turn_context_mut.network_sandbox_policy =
|
||||
NetworkSandboxPolicy::from(turn_context_mut.sandbox_policy.get());
|
||||
|
||||
let resp2 = handler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn_context),
|
||||
tracker: Arc::clone(&turn_diff_tracker),
|
||||
call_id: "test-call-2".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_namespace: None,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"command": params2.command.clone(),
|
||||
"workdir": Some(turn_context.cwd.to_string_lossy().to_string()),
|
||||
"timeout_ms": params2.expiration.timeout_ms(),
|
||||
"sandbox_permissions": params2.sandbox_permissions,
|
||||
"justification": params2.justification.clone(),
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
let exec_approval_requirement = session
|
||||
.services
|
||||
.exec_policy
|
||||
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
|
||||
command: ¶ms.command,
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get(),
|
||||
file_system_sandbox_policy: &turn_context.file_system_sandbox_policy,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let output = expect_text_tool_output(&resp2.expect("expected Ok result"));
|
||||
|
||||
#[derive(Deserialize, PartialEq, Eq, Debug)]
|
||||
struct ResponseExecMetadata {
|
||||
exit_code: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResponseExecOutput {
|
||||
output: String,
|
||||
metadata: ResponseExecMetadata,
|
||||
}
|
||||
|
||||
let exec_output: ResponseExecOutput =
|
||||
serde_json::from_str(&output).expect("valid exec output json");
|
||||
|
||||
pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 });
|
||||
assert!(exec_output.output.contains("hi"));
|
||||
assert!(matches!(
|
||||
exec_approval_requirement,
|
||||
ExecApprovalRequirement::Skip { .. }
|
||||
));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
|
||||
let (session, mut turn_context_raw) = make_session_and_context().await;
|
||||
turn_context_raw
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::guardian::GUARDIAN_REVIEWER_NAME;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
@@ -24,6 +23,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use core_test_support::codex_linux_sandbox_exe_or_skip;
|
||||
|
||||
@@ -2,23 +2,23 @@ use crate::agent::AgentStatus;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::SteerInputError;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::file_watcher::WatchRegistration;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::Submission;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -26,7 +26,7 @@ use std::path::PathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::state_db::StateDbHandle;
|
||||
use codex_rollout::state_db::StateDbHandle;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ThreadConfigSnapshot {
|
||||
@@ -100,12 +100,13 @@ impl CodexThread {
|
||||
self.codex.steer_input(input, expected_turn_id).await
|
||||
}
|
||||
|
||||
pub async fn set_app_server_client_name(
|
||||
pub async fn set_app_server_client_info(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
app_server_client_version: Option<String>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.codex
|
||||
.set_app_server_client_name(app_server_client_name)
|
||||
.set_app_server_client_info(app_server_client_name, app_server_client_version)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -180,7 +181,7 @@ impl CodexThread {
|
||||
.session
|
||||
.queue_response_items_for_next_turn(items)
|
||||
.await;
|
||||
self.codex.session.ensure_task_for_pending_inputs().await;
|
||||
self.codex.session.maybe_start_turn_for_pending_work().await;
|
||||
}
|
||||
|
||||
Ok(submission_id)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::bash::extract_bash_command;
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
use crate::powershell::extract_powershell_command;
|
||||
use codex_shell_command::bash::extract_bash_command;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::powershell::extract_powershell_command;
|
||||
|
||||
const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__";
|
||||
const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::Prompt;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::client_common::ResponseEvent;
|
||||
@@ -9,18 +8,19 @@ use crate::codex::PreviousTurnSettings;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::get_last_assistant_message_from_turn;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::util::backoff;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::items::ContextCompactionItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
@@ -220,6 +220,7 @@ async fn run_compact_task_inner(
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
client_session.reset_websocket_session();
|
||||
sess.recompute_token_usage(&turn_context).await;
|
||||
|
||||
sess.emit_turn_item_completed(&turn_context, compaction_item)
|
||||
|
||||
@@ -11,15 +11,15 @@ use crate::context_manager::ContextManager;
|
||||
use crate::context_manager::TotalTokenUsageBreakdown;
|
||||
use crate::context_manager::estimate_response_item_model_visible_bytes;
|
||||
use crate::context_manager::is_codex_generated_item;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::CompactedItem;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::items::ContextCompactionItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use futures::TryFutureExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::error;
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::edit::apply_blocking;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::ApprovalsReviewer;
|
||||
use crate::config::types::BundledSkillsConfig;
|
||||
use crate::config::types::FeedbackConfigToml;
|
||||
use crate::config::types::HistoryPersistence;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config::types::MemoriesConfig;
|
||||
use crate::config::types::MemoriesToml;
|
||||
use crate::config::types::ModelAvailabilityNuxConfig;
|
||||
use crate::config::types::NotificationMethod;
|
||||
use crate::config::types::Notifications;
|
||||
use crate::config::types::ToolSuggestDiscoverableType;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::plugins::PluginsManager;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::BundledSkillsConfig;
|
||||
use codex_config::types::FeedbackConfigToml;
|
||||
use codex_config::types::HistoryPersistence;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::MemoriesConfig;
|
||||
use codex_config::types::MemoriesToml;
|
||||
use codex_config::types::ModelAvailabilityNuxConfig;
|
||||
use codex_config::types::NotificationMethod;
|
||||
use codex_config::types::Notifications;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
@@ -243,6 +246,26 @@ web_search = false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_provider_auth_with_env_key() {
|
||||
let err = toml::from_str::<ConfigToml>(
|
||||
r#"
|
||||
[model_providers.corp]
|
||||
name = "Corp"
|
||||
env_key = "CORP_TOKEN"
|
||||
|
||||
[model_providers.corp.auth]
|
||||
command = "print-token"
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("model_providers.corp: provider auth cannot be combined with env_key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_deserializes_model_availability_nux() {
|
||||
let toml = r#"
|
||||
@@ -1603,7 +1626,7 @@ profile = "project"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(workspace.path().to_path_buf()),
|
||||
@@ -1757,10 +1780,7 @@ fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()>
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
config.model_provider.wire_api,
|
||||
crate::model_provider_info::WireApi::Responses
|
||||
);
|
||||
assert_eq!(config.model_provider.wire_api, WireApi::Responses);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1797,12 +1817,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
|
||||
std::fs::write(&config_path, "mcp_oauth_credentials_store = \"file\"\n")?;
|
||||
std::fs::write(&managed_path, "mcp_oauth_credentials_store = \"keyring\"\n")?;
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone());
|
||||
|
||||
let cwd = codex_home.path().abs();
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
@@ -1927,12 +1942,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> {
|
||||
)?;
|
||||
std::fs::write(&managed_path, "model = \"managed_config\"\n")?;
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
|
||||
|
||||
let cwd = codex_home.path().abs();
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
@@ -2047,6 +2057,30 @@ approval_mode = "approve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager);
|
||||
assert!(mcp_config.apps_enabled);
|
||||
|
||||
let _ = config.features.disable(Feature::Apps);
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager);
|
||||
assert!(!mcp_config.apps_enabled);
|
||||
|
||||
let _ = config.features.enable(Feature::Apps);
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager);
|
||||
assert!(mcp_config.apps_enabled);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -3242,7 +3276,7 @@ nickname_candidates = ["Hypatia", "Noether"]
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -3296,7 +3330,7 @@ nickname_candidates = ["Noether"]
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -3359,7 +3393,7 @@ model = "gpt-5"
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(nested_cwd),
|
||||
@@ -3413,7 +3447,7 @@ config_file = "./agents/researcher.toml"
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -3466,7 +3500,7 @@ description = "Review role"
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -3528,7 +3562,7 @@ developer_instructions = "Review carefully"
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(nested_cwd),
|
||||
@@ -3583,7 +3617,7 @@ config_file = "./agents/researcher.toml"
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -3635,7 +3669,7 @@ nickname_candidates = ["Atlas"]
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -3769,7 +3803,7 @@ developer_instructions = "Write carefully"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(nested_cwd),
|
||||
@@ -3893,7 +3927,7 @@ model = "gpt-5"
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(nested_cwd),
|
||||
@@ -4013,7 +4047,7 @@ model = "gpt-5-mini"
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(nested_cwd),
|
||||
@@ -4197,8 +4231,8 @@ fn load_config_rejects_unsafe_agent_role_nickname_candidates() -> std::io::Resul
|
||||
fn model_catalog_json_loads_from_path() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let catalog_path = codex_home.path().join("catalog.json");
|
||||
let mut catalog: ModelsResponse =
|
||||
serde_json::from_str(include_str!("../../models.json")).expect("valid models.json");
|
||||
let mut catalog = bundled_models_response()
|
||||
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
|
||||
catalog.models = catalog.models.into_iter().take(1).collect();
|
||||
std::fs::write(
|
||||
&catalog_path,
|
||||
@@ -4312,9 +4346,10 @@ model_verbosity = "high"
|
||||
name: "OpenAI custom".to_string(),
|
||||
base_url: Some("https://api.openai.com/v1".to_string()),
|
||||
env_key: Some("OPENAI_API_KEY".to_string()),
|
||||
wire_api: crate::WireApi::Responses,
|
||||
wire_api: WireApi::Responses,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
@@ -4448,6 +4483,9 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -4590,6 +4628,9 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -4730,6 +4771,9 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -4856,6 +4900,9 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
guardian_developer_instructions: None,
|
||||
include_permissions_instructions: true,
|
||||
include_apps_instructions: true,
|
||||
include_environment_context: true,
|
||||
compact_prompt: None,
|
||||
commit_attribution: None,
|
||||
forced_chatgpt_workspace_id: None,
|
||||
@@ -4900,6 +4947,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any
|
||||
|
||||
let requirements_toml = crate::config_loader::ConfigRequirementsToml {
|
||||
allowed_approval_policies: None,
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: Some(vec![
|
||||
crate::config_loader::WebSearchModeRequirement::Cached,
|
||||
@@ -5492,7 +5540,7 @@ async fn requirements_disallowing_default_sandbox_falls_back_to_required_default
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(crate::config_loader::ConfigRequirementsToml {
|
||||
@@ -5522,6 +5570,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
|
||||
|
||||
let requirements = crate::config_loader::ConfigRequirementsToml {
|
||||
allowed_approval_policies: None,
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly]),
|
||||
allowed_web_search_modes: None,
|
||||
feature_requirements: None,
|
||||
@@ -5533,7 +5582,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
|
||||
guardian_developer_instructions: None,
|
||||
};
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
@@ -5558,7 +5607,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() ->
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
@@ -5599,7 +5648,7 @@ trust_level = "untrusted"
|
||||
),
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(workspace.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
@@ -5628,7 +5677,7 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() -
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
@@ -5650,7 +5699,7 @@ async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements() -
|
||||
async fn feature_requirements_normalize_effective_feature_values() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(crate::config_loader::ConfigRequirementsToml {
|
||||
@@ -5692,7 +5741,7 @@ shell_tool = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
@@ -5728,7 +5777,7 @@ async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -5738,6 +5787,35 @@ async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> std::io::Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"include_permissions_instructions = false
|
||||
include_apps_instructions = false
|
||||
include_environment_context = false
|
||||
profile = "chatty"
|
||||
|
||||
[profiles.chatty]
|
||||
include_permissions_instructions = true
|
||||
include_environment_context = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert!(config.include_permissions_instructions);
|
||||
assert!(!config.include_apps_instructions);
|
||||
assert!(config.include_environment_context);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approvals_reviewer_stays_manual_only_when_guardian_feature_is_enabled()
|
||||
-> std::io::Result<()> {
|
||||
@@ -5749,7 +5827,7 @@ guardian_approval = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -5769,7 +5847,7 @@ async fn approvals_reviewer_can_be_set_in_config_without_guardian_approval() ->
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -5792,7 +5870,7 @@ approvals_reviewer = "guardian_subagent"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -5805,6 +5883,138 @@ approvals_reviewer = "guardian_subagent"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_required_default()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(crate::config_loader::ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> std::io::Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"approvals_reviewer = "user"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(crate::config_loader::ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
assert!(
|
||||
config.startup_warnings.iter().any(|warning| {
|
||||
warning
|
||||
.contains("Configured value for `approvals_reviewer` is disallowed by requirements")
|
||||
}),
|
||||
"{:?}",
|
||||
config.startup_warnings
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"profile = "default"
|
||||
|
||||
[profiles.default]
|
||||
approvals_reviewer = "user"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(crate::config_loader::ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::GuardianSubagent]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements()
|
||||
-> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"approvals_reviewer = "guardian_subagent"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async {
|
||||
Ok(Some(crate::config_loader::ConfigRequirementsToml {
|
||||
allowed_approvals_reviewers: Some(vec![
|
||||
ApprovalsReviewer::User,
|
||||
ApprovalsReviewer::GuardianSubagent,
|
||||
]),
|
||||
..Default::default()
|
||||
}))
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
config.approvals_reviewer,
|
||||
ApprovalsReviewer::GuardianSubagent
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.startup_warnings
|
||||
.iter()
|
||||
.all(|warning| !warning.contains("approvals_reviewer")),
|
||||
"{:?}",
|
||||
config.startup_warnings
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn smart_approvals_alias_is_ignored() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -5815,7 +6025,7 @@ smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
@@ -5844,7 +6054,7 @@ smart_approvals = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.fallback_cwd(Some(codex_home.path().to_path_buf()))
|
||||
.build()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::Notice;
|
||||
use crate::path_utils::resolve_symlink_write_paths;
|
||||
use crate::path_utils::write_atomically;
|
||||
use anyhow::Context;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_features::FEATURES;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
@@ -20,6 +19,8 @@ use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
use toml_edit::value;
|
||||
|
||||
const NOTICE_TABLE_KEY: &str = "notice";
|
||||
|
||||
/// Discrete config mutations supported by the persistence engine.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ConfigEdit {
|
||||
@@ -125,10 +126,10 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) ->
|
||||
|
||||
// TODO(jif) move to a dedicated file
|
||||
mod document_helpers {
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::InlineTable;
|
||||
use toml_edit::Item as TomlItem;
|
||||
@@ -387,29 +388,29 @@ impl ConfigDocument {
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[Notice::TABLE_KEY, "hide_full_access_warning"],
|
||||
&[NOTICE_TABLE_KEY, "hide_full_access_warning"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[Notice::TABLE_KEY, "hide_world_writable_warning"],
|
||||
&[NOTICE_TABLE_KEY, "hide_world_writable_warning"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"],
|
||||
&[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => {
|
||||
Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[Notice::TABLE_KEY, migration_config.as_str()],
|
||||
&[NOTICE_TABLE_KEY, migration_config.as_str()],
|
||||
value(*acknowledged),
|
||||
))
|
||||
}
|
||||
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[Notice::TABLE_KEY, "model_migrations", from.as_str()],
|
||||
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
|
||||
value(to.clone()),
|
||||
)),
|
||||
ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(unix)]
|
||||
|
||||
+167
-67
@@ -1,32 +1,5 @@
|
||||
use crate::auth::AuthCredentialsStoreMode;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
|
||||
use crate::config::types::History;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerDisabledReason;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config::types::MemoriesConfig;
|
||||
use crate::config::types::MemoriesToml;
|
||||
use crate::config::types::ModelAvailabilityNuxConfig;
|
||||
use crate::config::types::Notice;
|
||||
use crate::config::types::NotificationMethod;
|
||||
use crate::config::types::Notifications;
|
||||
use crate::config::types::OtelConfig;
|
||||
use crate::config::types::OtelConfigToml;
|
||||
use crate::config::types::OtelExporterKind;
|
||||
use crate::config::types::PluginConfig;
|
||||
use crate::config::types::SandboxWorkspaceWrite;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::config::types::ShellEnvironmentPolicyToml;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config::types::ToolSuggestConfig;
|
||||
use crate::config::types::ToolSuggestDiscoverable;
|
||||
use crate::config::types::Tui;
|
||||
use crate::config::types::UriBasedFileOpener;
|
||||
use crate::config::types::WindowsSandboxModeToml;
|
||||
use crate::config::types::WindowsToml;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
@@ -39,20 +12,11 @@ use crate::config_loader::McpServerRequirement;
|
||||
use crate::config_loader::ResidencyRequirement;
|
||||
use crate::config_loader::Sourced;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::config_loader::project_trust_key;
|
||||
use crate::memories::memory_root;
|
||||
use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
|
||||
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
|
||||
use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::OPENAI_PROVIDER_ID;
|
||||
use crate::model_provider_info::built_in_model_providers;
|
||||
use crate::path_utils::normalize_for_native_workdir;
|
||||
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS;
|
||||
use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
|
||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
@@ -60,12 +24,49 @@ use crate::windows_sandbox::resolve_windows_sandbox_mode;
|
||||
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
|
||||
use codex_app_server_protocol::Tools;
|
||||
use codex_app_server_protocol::UserSavedConfig;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::AppsConfigToml;
|
||||
use codex_config::types::DEFAULT_OTEL_ENVIRONMENT;
|
||||
use codex_config::types::History;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerDisabledReason;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::MemoriesConfig;
|
||||
use codex_config::types::MemoriesToml;
|
||||
use codex_config::types::ModelAvailabilityNuxConfig;
|
||||
use codex_config::types::Notice;
|
||||
use codex_config::types::NotificationMethod;
|
||||
use codex_config::types::Notifications;
|
||||
use codex_config::types::OtelConfig;
|
||||
use codex_config::types::OtelConfigToml;
|
||||
use codex_config::types::OtelExporterKind;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::types::SandboxWorkspaceWrite;
|
||||
use codex_config::types::ShellEnvironmentPolicy;
|
||||
use codex_config::types::ShellEnvironmentPolicyToml;
|
||||
use codex_config::types::SkillsConfig;
|
||||
use codex_config::types::ToolSuggestConfig;
|
||||
use codex_config::types::ToolSuggestDiscoverable;
|
||||
use codex_config::types::Tui;
|
||||
use codex_config::types::UriBasedFileOpener;
|
||||
use codex_config::types::WindowsSandboxModeToml;
|
||||
use codex_config::types::WindowsToml;
|
||||
use codex_features::Feature;
|
||||
use codex_features::FeatureConfigSource;
|
||||
use codex_features::FeatureOverrides;
|
||||
use codex_features::Features;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_login::AuthCredentialsStoreMode;
|
||||
use codex_mcp::mcp::McpConfig;
|
||||
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
|
||||
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
|
||||
use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
||||
use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_models_manager::ModelsManagerConfig;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::ForcedLoginMethod;
|
||||
use codex_protocol::config_types::Personality;
|
||||
@@ -82,6 +83,9 @@ use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
@@ -112,7 +116,6 @@ mod permissions;
|
||||
pub mod profile;
|
||||
pub mod schema;
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
pub use codex_config::Constrained;
|
||||
pub use codex_config::ConstraintError;
|
||||
pub use codex_config::ConstraintResult;
|
||||
@@ -134,7 +137,6 @@ pub(crate) use permissions::overlay_network_domain_permissions;
|
||||
pub(crate) use permissions::resolve_permission_profile;
|
||||
pub use service::ConfigService;
|
||||
pub use service::ConfigServiceError;
|
||||
pub use types::ApprovalsReviewer;
|
||||
|
||||
pub use codex_git_utils::GhostSnapshotConfig;
|
||||
|
||||
@@ -147,7 +149,6 @@ pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
|
||||
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
|
||||
|
||||
pub const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
const OPENAI_BASE_URL_ENV_VAR: &str = "OPENAI_BASE_URL";
|
||||
const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [
|
||||
OPENAI_PROVIDER_ID,
|
||||
OLLAMA_OSS_PROVIDER_ID,
|
||||
@@ -280,6 +281,15 @@ pub struct Config {
|
||||
/// Guardian-specific developer instructions override from requirements.toml.
|
||||
pub guardian_developer_instructions: Option<String>,
|
||||
|
||||
/// Whether to inject the `<permissions instructions>` developer block.
|
||||
pub include_permissions_instructions: bool,
|
||||
|
||||
/// Whether to inject the `<apps_instructions>` developer block.
|
||||
pub include_apps_instructions: bool,
|
||||
|
||||
/// Whether to inject the `<environment_context>` user block.
|
||||
pub include_environment_context: bool,
|
||||
|
||||
/// Compact prompt override.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
@@ -581,7 +591,7 @@ pub struct Config {
|
||||
pub tool_suggest: ToolSuggestConfig,
|
||||
|
||||
/// OTEL configuration (exporter type, endpoint, headers, etc.).
|
||||
pub otel: crate::config::types::OtelConfig,
|
||||
pub otel: codex_config::types::OtelConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -680,9 +690,51 @@ impl ConfigBuilder {
|
||||
config_layer_stack,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn without_managed_config_for_tests() -> Self {
|
||||
Self::default().loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn to_models_manager_config(&self) -> ModelsManagerConfig {
|
||||
ModelsManagerConfig {
|
||||
model_context_window: self.model_context_window,
|
||||
model_auto_compact_token_limit: self.model_auto_compact_token_limit,
|
||||
tool_output_token_limit: self.tool_output_token_limit,
|
||||
base_instructions: self.base_instructions.clone(),
|
||||
personality_enabled: self.features.enabled(Feature::Personality),
|
||||
model_supports_reasoning_summaries: self.model_supports_reasoning_summaries,
|
||||
model_catalog: self.model_catalog.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_mcp_config(&self, plugins_manager: &crate::plugins::PluginsManager) -> McpConfig {
|
||||
let loaded_plugins = plugins_manager.plugins_for_config(self);
|
||||
let mut configured_mcp_servers = self.mcp_servers.get().clone();
|
||||
for (name, plugin_server) in loaded_plugins.effective_mcp_servers() {
|
||||
configured_mcp_servers.entry(name).or_insert(plugin_server);
|
||||
}
|
||||
|
||||
McpConfig {
|
||||
chatgpt_base_url: self.chatgpt_base_url.clone(),
|
||||
codex_home: self.codex_home.clone(),
|
||||
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
|
||||
mcp_oauth_callback_port: self.mcp_oauth_callback_port,
|
||||
mcp_oauth_callback_url: self.mcp_oauth_callback_url.clone(),
|
||||
skill_mcp_dependency_install_enabled: self
|
||||
.features
|
||||
.enabled(Feature::SkillMcpDependencyInstall),
|
||||
approval_policy: self.permissions.approval_policy.clone(),
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
|
||||
use_legacy_landlock: self.features.use_legacy_landlock(),
|
||||
apps_enabled: self.features.enabled(Feature::Apps),
|
||||
configured_mcp_servers,
|
||||
plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// This is the preferred way to create an instance of [Config].
|
||||
pub async fn load_with_cli_overrides(
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
@@ -698,6 +750,15 @@ impl Config {
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<Self> {
|
||||
let codex_home = find_codex_home()?;
|
||||
Self::load_default_with_cli_overrides_for_codex_home(codex_home, cli_overrides)
|
||||
}
|
||||
|
||||
/// Load a default configuration for a specific Codex home without reading
|
||||
/// user, project, or system config layers.
|
||||
pub fn load_default_with_cli_overrides_for_codex_home(
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<Self> {
|
||||
let mut merged = toml::Value::try_from(ConfigToml::default()).map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
@@ -970,7 +1031,7 @@ pub(crate) fn set_project_trust_level_inner(
|
||||
//
|
||||
// [projects]
|
||||
// "/path/to/project" = { trust_level = "trusted" }
|
||||
let project_key = project_path.to_string_lossy().to_string();
|
||||
let project_key = project_trust_key(project_path);
|
||||
|
||||
// Ensure top-level `projects` exists as a non-inline, explicit table. If it
|
||||
// exists but was previously represented as a non-table (e.g., inline),
|
||||
@@ -1137,6 +1198,15 @@ pub struct ConfigToml {
|
||||
#[serde(default)]
|
||||
pub developer_instructions: Option<String>,
|
||||
|
||||
/// Whether to inject the `<permissions instructions>` developer block.
|
||||
pub include_permissions_instructions: Option<bool>,
|
||||
|
||||
/// Whether to inject the `<apps_instructions>` developer block.
|
||||
pub include_apps_instructions: Option<bool>,
|
||||
|
||||
/// Whether to inject the `<environment_context>` user block.
|
||||
pub include_environment_context: Option<bool>,
|
||||
|
||||
/// Optional path to a file containing model instructions that will override
|
||||
/// the built-in instructions for the selected model. Users are STRONGLY
|
||||
/// DISCOURAGED from using this field, as deviating from the instructions
|
||||
@@ -1358,18 +1428,18 @@ pub struct ConfigToml {
|
||||
|
||||
/// When `false`, disables analytics across Codex product surfaces in this machine.
|
||||
/// Defaults to `true`.
|
||||
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
|
||||
pub analytics: Option<codex_config::types::AnalyticsConfigToml>,
|
||||
|
||||
/// When `false`, disables feedback collection across Codex product surfaces.
|
||||
/// Defaults to `true`.
|
||||
pub feedback: Option<crate::config::types::FeedbackConfigToml>,
|
||||
pub feedback: Option<codex_config::types::FeedbackConfigToml>,
|
||||
|
||||
/// Settings for app-specific controls.
|
||||
#[serde(default)]
|
||||
pub apps: Option<AppsConfigToml>,
|
||||
|
||||
/// OTEL configuration.
|
||||
pub otel: Option<crate::config::types::OtelConfigToml>,
|
||||
pub otel: Option<codex_config::types::OtelConfigToml>,
|
||||
|
||||
/// Windows-specific configuration.
|
||||
#[serde(default)]
|
||||
@@ -1379,7 +1449,7 @@ pub struct ConfigToml {
|
||||
pub windows_wsl_setup_acknowledged: Option<bool>,
|
||||
|
||||
/// Collection of in-product notices (different from notifications)
|
||||
/// See [`crate::config::types::Notices`] for more details
|
||||
/// See [`codex_config::types::Notice`] for more details
|
||||
pub notice: Option<Notice>,
|
||||
|
||||
/// Legacy, now use features
|
||||
@@ -1868,6 +1938,18 @@ Built-in providers cannot be overridden. Rename your custom provider (for exampl
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_model_providers(
|
||||
model_providers: &HashMap<String, ModelProviderInfo>,
|
||||
) -> Result<(), String> {
|
||||
validate_reserved_model_provider_ids(model_providers)?;
|
||||
for (key, provider) in model_providers {
|
||||
provider
|
||||
.validate()
|
||||
.map_err(|message| format!("model_providers.{key}: {message}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deserialize_model_providers<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
|
||||
@@ -1875,7 +1957,7 @@ where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
|
||||
validate_reserved_model_provider_ids(&model_providers).map_err(serde::de::Error::custom)?;
|
||||
validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?;
|
||||
Ok(model_providers)
|
||||
}
|
||||
|
||||
@@ -2000,12 +2082,13 @@ impl Config {
|
||||
codex_home: PathBuf,
|
||||
config_layer_stack: ConfigLayerStack,
|
||||
) -> std::io::Result<Self> {
|
||||
validate_reserved_model_provider_ids(&cfg.model_providers)
|
||||
validate_model_providers(&cfg.model_providers)
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
// Ensure that every field of ConfigRequirements is applied to the final
|
||||
// Config.
|
||||
let ConfigRequirements {
|
||||
approval_policy: mut constrained_approval_policy,
|
||||
approvals_reviewer: mut constrained_approvals_reviewer,
|
||||
sandbox_policy: mut constrained_sandbox_policy,
|
||||
web_search_mode: mut constrained_web_search_mode,
|
||||
feature_requirements,
|
||||
@@ -2254,10 +2337,22 @@ impl Config {
|
||||
);
|
||||
approval_policy = constrained_approval_policy.value();
|
||||
}
|
||||
let approvals_reviewer = approvals_reviewer_override
|
||||
let approvals_reviewer_was_explicit = approvals_reviewer_override.is_some()
|
||||
|| config_profile.approvals_reviewer.is_some()
|
||||
|| cfg.approvals_reviewer.is_some();
|
||||
let mut approvals_reviewer = approvals_reviewer_override
|
||||
.or(config_profile.approvals_reviewer)
|
||||
.or(cfg.approvals_reviewer)
|
||||
.unwrap_or(ApprovalsReviewer::User);
|
||||
if !approvals_reviewer_was_explicit
|
||||
&& let Err(err) = constrained_approvals_reviewer.can_set(&approvals_reviewer)
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"default approvals reviewer is disallowed by requirements; falling back to required default"
|
||||
);
|
||||
approvals_reviewer = constrained_approvals_reviewer.value();
|
||||
}
|
||||
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
|
||||
.unwrap_or(WebSearchMode::Cached);
|
||||
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
|
||||
@@ -2269,24 +2364,8 @@ impl Config {
|
||||
.openai_base_url
|
||||
.clone()
|
||||
.filter(|value| !value.is_empty());
|
||||
let openai_base_url_from_env = std::env::var(OPENAI_BASE_URL_ENV_VAR)
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty());
|
||||
if openai_base_url_from_env.is_some() {
|
||||
if openai_base_url.is_some() {
|
||||
tracing::warn!(
|
||||
env_var = OPENAI_BASE_URL_ENV_VAR,
|
||||
"deprecated env var is ignored because `openai_base_url` is set in config.toml"
|
||||
);
|
||||
} else {
|
||||
startup_warnings.push(format!(
|
||||
"`{OPENAI_BASE_URL_ENV_VAR}` is deprecated. Set `openai_base_url` in config.toml instead."
|
||||
));
|
||||
}
|
||||
}
|
||||
let effective_openai_base_url = openai_base_url.or(openai_base_url_from_env);
|
||||
|
||||
let mut model_providers = built_in_model_providers(effective_openai_base_url);
|
||||
let mut model_providers = built_in_model_providers(openai_base_url);
|
||||
// Merge user-defined providers into the built-in list.
|
||||
for (key, provider) in cfg.model_providers.into_iter() {
|
||||
model_providers.entry(key).or_insert(provider);
|
||||
@@ -2432,6 +2511,18 @@ impl Config {
|
||||
Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?;
|
||||
let base_instructions = base_instructions.or(file_base_instructions);
|
||||
let developer_instructions = developer_instructions.or(cfg.developer_instructions);
|
||||
let include_permissions_instructions = config_profile
|
||||
.include_permissions_instructions
|
||||
.or(cfg.include_permissions_instructions)
|
||||
.unwrap_or(true);
|
||||
let include_apps_instructions = config_profile
|
||||
.include_apps_instructions
|
||||
.or(cfg.include_apps_instructions)
|
||||
.unwrap_or(true);
|
||||
let include_environment_context = config_profile
|
||||
.include_environment_context
|
||||
.or(cfg.include_environment_context)
|
||||
.unwrap_or(true);
|
||||
let guardian_developer_instructions = guardian_developer_instructions_from_requirements(
|
||||
config_layer_stack.requirements_toml(),
|
||||
);
|
||||
@@ -2504,6 +2595,12 @@ impl Config {
|
||||
&mut constrained_approval_policy,
|
||||
&mut startup_warnings,
|
||||
)?;
|
||||
apply_requirement_constrained_value(
|
||||
"approvals_reviewer",
|
||||
approvals_reviewer,
|
||||
&mut constrained_approvals_reviewer,
|
||||
&mut startup_warnings,
|
||||
)?;
|
||||
apply_requirement_constrained_value(
|
||||
"sandbox_mode",
|
||||
sandbox_policy,
|
||||
@@ -2589,7 +2686,7 @@ impl Config {
|
||||
windows_sandbox_mode,
|
||||
windows_sandbox_private_desktop,
|
||||
},
|
||||
approvals_reviewer,
|
||||
approvals_reviewer: constrained_approvals_reviewer.value(),
|
||||
enforce_residency: enforce_residency.value,
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
@@ -2598,6 +2695,9 @@ impl Config {
|
||||
developer_instructions,
|
||||
compact_prompt,
|
||||
commit_attribution,
|
||||
include_permissions_instructions,
|
||||
include_apps_instructions,
|
||||
include_environment_context,
|
||||
// The config.toml omits "_mode" because it's a config file. However, "_mode"
|
||||
// is important in code to differentiate the mode from the store implementation.
|
||||
cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(),
|
||||
|
||||
@@ -4,10 +4,9 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::ToolsToml;
|
||||
use crate::config::types::ApprovalsReviewer;
|
||||
use crate::config::types::Personality;
|
||||
use crate::config::types::WindowsToml;
|
||||
use crate::protocol::AskForApproval;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::Personality;
|
||||
use codex_config::types::WindowsToml;
|
||||
use codex_features::FeaturesToml;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
@@ -15,6 +14,7 @@ use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
|
||||
/// Collection of common configuration options that a user can define as a unit
|
||||
/// in `config.toml`.
|
||||
@@ -49,12 +49,15 @@ pub struct ConfigProfile {
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub include_permissions_instructions: Option<bool>,
|
||||
pub include_apps_instructions: Option<bool>,
|
||||
pub include_environment_context: Option<bool>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
pub tools_view_image: Option<bool>,
|
||||
pub tools: Option<ToolsToml>,
|
||||
pub web_search: Option<WebSearchMode>,
|
||||
pub analytics: Option<crate::config::types::AnalyticsConfigToml>,
|
||||
pub analytics: Option<codex_config::types::AnalyticsConfigToml>,
|
||||
#[serde(default)]
|
||||
pub windows: Option<WindowsToml>,
|
||||
/// Optional feature toggles scoped to this profile.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::types::RawMcpServerConfig;
|
||||
use codex_config::types::RawMcpServerConfig;
|
||||
use codex_features::FEATURES;
|
||||
use codex_features::legacy_feature_keys;
|
||||
use schemars::r#gen::SchemaGenerator;
|
||||
|
||||
@@ -140,6 +140,16 @@ impl ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn without_managed_config_for_tests(codex_home: PathBuf) -> Self {
|
||||
Self::new(
|
||||
codex_home,
|
||||
Vec::new(),
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn read(
|
||||
&self,
|
||||
params: ConfigReadParams,
|
||||
|
||||
@@ -74,7 +74,7 @@ unified_exec = true
|
||||
"#;
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?;
|
||||
|
||||
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
|
||||
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
@@ -108,7 +108,7 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
|
||||
|
||||
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
|
||||
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
@@ -178,12 +178,7 @@ async fn read_includes_origins_and_layers() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -245,23 +240,23 @@ async fn write_value_succeeds_when_managed_preferences_expand_home_directory_pat
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?;
|
||||
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
let mut loader_overrides =
|
||||
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
|
||||
loader_overrides.managed_preferences_base64 = Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["~/code"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -301,12 +296,7 @@ async fn write_value_reports_override() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -352,7 +342,7 @@ async fn version_conflict_rejected() {
|
||||
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&user_path, "model = \"user\"").unwrap();
|
||||
|
||||
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
|
||||
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
let error = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
@@ -375,7 +365,7 @@ async fn write_value_defaults_to_user_config_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
|
||||
|
||||
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
|
||||
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: None,
|
||||
@@ -405,12 +395,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -439,7 +424,7 @@ async fn reserved_builtin_provider_override_rejected() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap();
|
||||
|
||||
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
|
||||
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
let error = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
@@ -470,12 +455,7 @@ async fn write_value_rejects_feature_requirement_conflict() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
|
||||
@@ -521,12 +501,7 @@ async fn write_value_rejects_profile_feature_requirement_conflict() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
|
||||
@@ -583,12 +558,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
cli_overrides,
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -641,12 +611,7 @@ async fn write_value_reports_managed_override() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -698,7 +663,7 @@ alpha = "a"
|
||||
|
||||
std::fs::write(&path, base)?;
|
||||
|
||||
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
|
||||
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
|
||||
@@ -1,971 +0,0 @@
|
||||
//! Types used to define the fields of [`crate::config::Config`].
|
||||
|
||||
// Note this file should generally be restricted to simple struct/enum
|
||||
// definitions that do not contain business logic.
|
||||
|
||||
use crate::config_loader::RequirementSource;
|
||||
pub use codex_protocol::config_types::AltScreenMode;
|
||||
pub use codex_protocol::config_types::ApprovalsReviewer;
|
||||
pub use codex_protocol::config_types::ModeKind;
|
||||
pub use codex_protocol::config_types::Personality;
|
||||
pub use codex_protocol::config_types::ServiceTier;
|
||||
pub use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use wildmatch::WildMatchPattern;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::de::Error as SerdeError;
|
||||
|
||||
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
|
||||
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16;
|
||||
pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
|
||||
pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6;
|
||||
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256;
|
||||
pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum WindowsSandboxModeToml {
|
||||
Elevated,
|
||||
Unelevated,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct WindowsToml {
|
||||
pub sandbox: Option<WindowsSandboxModeToml>,
|
||||
/// Defaults to `true`. Set to `false` to launch the final sandboxed child
|
||||
/// process on `Winsta0\\Default` instead of a private desktop.
|
||||
pub sandbox_private_desktop: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpServerDisabledReason {
|
||||
Unknown,
|
||||
Requirements { source: RequirementSource },
|
||||
}
|
||||
|
||||
impl fmt::Display for McpServerDisabledReason {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
McpServerDisabledReason::Unknown => write!(f, "unknown"),
|
||||
McpServerDisabledReason::Requirements { source } => {
|
||||
write!(f, "requirements ({source})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct McpServerConfig {
|
||||
#[serde(flatten)]
|
||||
pub transport: McpServerTransportConfig,
|
||||
|
||||
/// When `false`, Codex skips initializing this MCP server.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// When `true`, `codex exec` exits with an error if this MCP server fails to initialize.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub required: bool,
|
||||
|
||||
/// Reason this server was disabled after applying requirements.
|
||||
#[serde(skip)]
|
||||
pub disabled_reason: Option<McpServerDisabledReason>,
|
||||
|
||||
/// Startup timeout in seconds for initializing MCP server & initially listing tools.
|
||||
#[serde(
|
||||
default,
|
||||
with = "option_duration_secs",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub startup_timeout_sec: Option<Duration>,
|
||||
|
||||
/// Default timeout for MCP tool calls initiated via this server.
|
||||
#[serde(default, with = "option_duration_secs")]
|
||||
pub tool_timeout_sec: Option<Duration>,
|
||||
|
||||
/// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_tools: Option<Vec<String>>,
|
||||
|
||||
/// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_tools: Option<Vec<String>>,
|
||||
|
||||
/// Optional OAuth scopes to request during MCP login.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
|
||||
/// Optional OAuth resource parameter to include during MCP login (RFC 8707).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_resource: Option<String>,
|
||||
|
||||
/// Per-tool approval settings keyed by tool name.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub tools: HashMap<String, McpServerToolConfig>,
|
||||
}
|
||||
|
||||
// Raw MCP config shape used for deserialization and JSON Schema generation.
|
||||
// Keep this in sync with the validation logic in `McpServerConfig`.
|
||||
#[derive(Deserialize, Clone, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub(crate) struct RawMcpServerConfig {
|
||||
// stdio
|
||||
pub command: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub env_vars: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub http_headers: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub env_http_headers: Option<HashMap<String, String>>,
|
||||
|
||||
// streamable_http
|
||||
pub url: Option<String>,
|
||||
pub bearer_token: Option<String>,
|
||||
pub bearer_token_env_var: Option<String>,
|
||||
|
||||
// shared
|
||||
#[serde(default)]
|
||||
pub startup_timeout_sec: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub startup_timeout_ms: Option<u64>,
|
||||
#[serde(default, with = "option_duration_secs")]
|
||||
#[schemars(with = "Option<f64>")]
|
||||
pub tool_timeout_sec: Option<Duration>,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub required: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub enabled_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub disabled_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub oauth_resource: Option<String>,
|
||||
/// Legacy display-name field accepted for backward compatibility.
|
||||
#[serde(default, rename = "name")]
|
||||
pub _name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tools: Option<HashMap<String, McpServerToolConfig>>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let mut raw = RawMcpServerConfig::deserialize(deserializer)?;
|
||||
|
||||
let startup_timeout_sec = match (raw.startup_timeout_sec, raw.startup_timeout_ms) {
|
||||
(Some(sec), _) => {
|
||||
let duration = Duration::try_from_secs_f64(sec).map_err(SerdeError::custom)?;
|
||||
Some(duration)
|
||||
}
|
||||
(None, Some(ms)) => Some(Duration::from_millis(ms)),
|
||||
(None, None) => None,
|
||||
};
|
||||
let tool_timeout_sec = raw.tool_timeout_sec;
|
||||
let enabled = raw.enabled.unwrap_or_else(default_enabled);
|
||||
let required = raw.required.unwrap_or_default();
|
||||
let enabled_tools = raw.enabled_tools.clone();
|
||||
let disabled_tools = raw.disabled_tools.clone();
|
||||
let scopes = raw.scopes.clone();
|
||||
let oauth_resource = raw.oauth_resource.clone();
|
||||
let tools = raw.tools.clone().unwrap_or_default();
|
||||
|
||||
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
|
||||
where
|
||||
E: SerdeError,
|
||||
{
|
||||
if value.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(E::custom(format!(
|
||||
"{field} is not supported for {transport}",
|
||||
)))
|
||||
}
|
||||
|
||||
let transport = if let Some(command) = raw.command.clone() {
|
||||
throw_if_set("stdio", "url", raw.url.as_ref())?;
|
||||
throw_if_set(
|
||||
"stdio",
|
||||
"bearer_token_env_var",
|
||||
raw.bearer_token_env_var.as_ref(),
|
||||
)?;
|
||||
throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?;
|
||||
throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?;
|
||||
throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?;
|
||||
throw_if_set("stdio", "oauth_resource", raw.oauth_resource.as_ref())?;
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args: raw.args.clone().unwrap_or_default(),
|
||||
env: raw.env.clone(),
|
||||
env_vars: raw.env_vars.clone().unwrap_or_default(),
|
||||
cwd: raw.cwd.take(),
|
||||
}
|
||||
} else if let Some(url) = raw.url.clone() {
|
||||
throw_if_set("streamable_http", "args", raw.args.as_ref())?;
|
||||
throw_if_set("streamable_http", "env", raw.env.as_ref())?;
|
||||
throw_if_set("streamable_http", "env_vars", raw.env_vars.as_ref())?;
|
||||
throw_if_set("streamable_http", "cwd", raw.cwd.as_ref())?;
|
||||
throw_if_set("streamable_http", "bearer_token", raw.bearer_token.as_ref())?;
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var: raw.bearer_token_env_var.clone(),
|
||||
http_headers: raw.http_headers.clone(),
|
||||
env_http_headers: raw.env_http_headers.take(),
|
||||
}
|
||||
} else {
|
||||
return Err(SerdeError::custom("invalid transport"));
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
transport,
|
||||
startup_timeout_sec,
|
||||
tool_timeout_sec,
|
||||
enabled,
|
||||
required,
|
||||
disabled_reason: None,
|
||||
enabled_tools,
|
||||
disabled_tools,
|
||||
scopes,
|
||||
oauth_resource,
|
||||
tools,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
|
||||
pub enum McpServerTransportConfig {
|
||||
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio
|
||||
Stdio {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
env: Option<HashMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
env_vars: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<PathBuf>,
|
||||
},
|
||||
/// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
|
||||
StreamableHttp {
|
||||
url: String,
|
||||
/// Name of the environment variable to read for an HTTP bearer token.
|
||||
/// When set, requests will include the token via `Authorization: Bearer <token>`.
|
||||
/// The actual secret value must be provided via the environment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
bearer_token_env_var: Option<String>,
|
||||
/// Additional HTTP headers to include in requests to this server.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
http_headers: Option<HashMap<String, String>>,
|
||||
/// HTTP headers where the value is sourced from an environment variable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
env_http_headers: Option<HashMap<String, String>>,
|
||||
},
|
||||
}
|
||||
|
||||
mod option_duration_secs {
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serializer;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match value {
|
||||
Some(duration) => serializer.serialize_some(&duration.as_secs_f64()),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let secs = Option::<f64>::deserialize(deserializer)?;
|
||||
secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)]
|
||||
pub enum UriBasedFileOpener {
|
||||
#[serde(rename = "vscode")]
|
||||
VsCode,
|
||||
|
||||
#[serde(rename = "vscode-insiders")]
|
||||
VsCodeInsiders,
|
||||
|
||||
#[serde(rename = "windsurf")]
|
||||
Windsurf,
|
||||
|
||||
#[serde(rename = "cursor")]
|
||||
Cursor,
|
||||
|
||||
/// Option to disable the URI-based file opener.
|
||||
#[serde(rename = "none")]
|
||||
None,
|
||||
}
|
||||
|
||||
impl UriBasedFileOpener {
|
||||
pub fn get_scheme(&self) -> Option<&str> {
|
||||
match self {
|
||||
UriBasedFileOpener::VsCode => Some("vscode"),
|
||||
UriBasedFileOpener::VsCodeInsiders => Some("vscode-insiders"),
|
||||
UriBasedFileOpener::Windsurf => Some("windsurf"),
|
||||
UriBasedFileOpener::Cursor => Some("cursor"),
|
||||
UriBasedFileOpener::None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct History {
|
||||
/// If true, history entries will not be written to disk.
|
||||
pub persistence: HistoryPersistence,
|
||||
|
||||
/// If set, the maximum size of the history file in bytes. The oldest entries
|
||||
/// are dropped once the file exceeds this limit.
|
||||
pub max_bytes: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum HistoryPersistence {
|
||||
/// Save all history entries to disk.
|
||||
#[default]
|
||||
SaveAll,
|
||||
/// Do not write history to disk.
|
||||
None,
|
||||
}
|
||||
|
||||
// ===== Analytics configuration =====
|
||||
|
||||
/// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AnalyticsConfigToml {
|
||||
/// When `false`, disables analytics across Codex product surfaces in this profile.
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct FeedbackConfigToml {
|
||||
/// When `false`, disables the feedback flow across Codex product surfaces.
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolSuggestDiscoverableType {
|
||||
Connector,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ToolSuggestDiscoverable {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: ToolSuggestDiscoverableType,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ToolSuggestConfig {
|
||||
#[serde(default)]
|
||||
pub discoverables: Vec<ToolSuggestDiscoverable>,
|
||||
}
|
||||
|
||||
/// Memories settings loaded from config.toml.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MemoriesToml {
|
||||
/// When `true`, web searches and MCP tool calls mark the thread `memory_mode` as `"polluted"`.
|
||||
pub no_memories_if_mcp_or_web_search: Option<bool>,
|
||||
/// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB.
|
||||
pub generate_memories: Option<bool>,
|
||||
/// When `false`, skip injecting memory usage instructions into developer prompts.
|
||||
pub use_memories: Option<bool>,
|
||||
/// Maximum number of recent raw memories retained for global consolidation.
|
||||
pub max_raw_memories_for_consolidation: Option<usize>,
|
||||
/// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.
|
||||
pub max_unused_days: Option<i64>,
|
||||
/// Maximum age of the threads used for memories.
|
||||
pub max_rollout_age_days: Option<i64>,
|
||||
/// Maximum number of rollout candidates processed per pass.
|
||||
pub max_rollouts_per_startup: Option<usize>,
|
||||
/// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.
|
||||
pub min_rollout_idle_hours: Option<i64>,
|
||||
/// Model used for thread summarisation.
|
||||
pub extract_model: Option<String>,
|
||||
/// Model used for memory consolidation.
|
||||
pub consolidation_model: Option<String>,
|
||||
}
|
||||
|
||||
/// Effective memories settings after defaults are applied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoriesConfig {
|
||||
pub no_memories_if_mcp_or_web_search: bool,
|
||||
pub generate_memories: bool,
|
||||
pub use_memories: bool,
|
||||
pub max_raw_memories_for_consolidation: usize,
|
||||
pub max_unused_days: i64,
|
||||
pub max_rollout_age_days: i64,
|
||||
pub max_rollouts_per_startup: usize,
|
||||
pub min_rollout_idle_hours: i64,
|
||||
pub extract_model: Option<String>,
|
||||
pub consolidation_model: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MemoriesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
no_memories_if_mcp_or_web_search: false,
|
||||
generate_memories: true,
|
||||
use_memories: true,
|
||||
max_raw_memories_for_consolidation: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
|
||||
max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS,
|
||||
max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS,
|
||||
max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
|
||||
min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS,
|
||||
extract_model: None,
|
||||
consolidation_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoriesToml> for MemoriesConfig {
|
||||
fn from(toml: MemoriesToml) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
no_memories_if_mcp_or_web_search: toml
|
||||
.no_memories_if_mcp_or_web_search
|
||||
.unwrap_or(defaults.no_memories_if_mcp_or_web_search),
|
||||
generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories),
|
||||
use_memories: toml.use_memories.unwrap_or(defaults.use_memories),
|
||||
max_raw_memories_for_consolidation: toml
|
||||
.max_raw_memories_for_consolidation
|
||||
.unwrap_or(defaults.max_raw_memories_for_consolidation)
|
||||
.min(4096),
|
||||
max_unused_days: toml
|
||||
.max_unused_days
|
||||
.unwrap_or(defaults.max_unused_days)
|
||||
.clamp(0, 365),
|
||||
max_rollout_age_days: toml
|
||||
.max_rollout_age_days
|
||||
.unwrap_or(defaults.max_rollout_age_days)
|
||||
.clamp(0, 90),
|
||||
max_rollouts_per_startup: toml
|
||||
.max_rollouts_per_startup
|
||||
.unwrap_or(defaults.max_rollouts_per_startup)
|
||||
.min(128),
|
||||
min_rollout_idle_hours: toml
|
||||
.min_rollout_idle_hours
|
||||
.unwrap_or(defaults.min_rollout_idle_hours)
|
||||
.clamp(1, 48),
|
||||
extract_model: toml.extract_model,
|
||||
consolidation_model: toml.consolidation_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AppToolApproval {
|
||||
#[default]
|
||||
Auto,
|
||||
Prompt,
|
||||
Approve,
|
||||
}
|
||||
|
||||
/// Per-tool approval settings for a single MCP server tool.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct McpServerToolConfig {
|
||||
/// Approval mode for this tool.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approval_mode: Option<AppToolApproval>,
|
||||
}
|
||||
|
||||
/// Default settings that apply to all apps.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppsDefaultConfig {
|
||||
/// When `false`, apps are disabled unless overridden by per-app settings.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Whether tools with `destructive_hint = true` are allowed by default.
|
||||
#[serde(
|
||||
default = "default_enabled",
|
||||
skip_serializing_if = "std::clone::Clone::clone"
|
||||
)]
|
||||
pub destructive_enabled: bool,
|
||||
|
||||
/// Whether tools with `open_world_hint = true` are allowed by default.
|
||||
#[serde(
|
||||
default = "default_enabled",
|
||||
skip_serializing_if = "std::clone::Clone::clone"
|
||||
)]
|
||||
pub open_world_enabled: bool,
|
||||
}
|
||||
|
||||
/// Per-tool settings for a single app tool.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppToolConfig {
|
||||
/// Whether this tool is enabled. `Some(true)` explicitly allows this tool.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
|
||||
/// Approval mode for this tool.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approval_mode: Option<AppToolApproval>,
|
||||
}
|
||||
|
||||
/// Tool settings for a single app.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppToolsConfig {
|
||||
/// Per-tool overrides keyed by tool name (for example `repos/list`).
|
||||
#[serde(default, flatten)]
|
||||
pub tools: HashMap<String, AppToolConfig>,
|
||||
}
|
||||
|
||||
/// Config values for a single app/connector.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppConfig {
|
||||
/// When `false`, Codex does not surface this app.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Whether tools with `destructive_hint = true` are allowed for this app.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destructive_enabled: Option<bool>,
|
||||
|
||||
/// Whether tools with `open_world_hint = true` are allowed for this app.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub open_world_enabled: Option<bool>,
|
||||
|
||||
/// Approval mode for tools in this app unless a tool override exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_tools_approval_mode: Option<AppToolApproval>,
|
||||
|
||||
/// Whether tools are enabled by default for this app.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_tools_enabled: Option<bool>,
|
||||
|
||||
/// Per-tool settings for this app.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<AppToolsConfig>,
|
||||
}
|
||||
|
||||
/// App/connector settings loaded from `config.toml`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AppsConfigToml {
|
||||
/// Default settings for all apps.
|
||||
#[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<AppsDefaultConfig>,
|
||||
|
||||
/// Per-app settings keyed by app ID (for example `[apps.google_drive]`).
|
||||
#[serde(default, flatten)]
|
||||
pub apps: HashMap<String, AppConfig>,
|
||||
}
|
||||
|
||||
// ===== OTEL configuration =====
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OtelHttpProtocol {
|
||||
/// Binary payload
|
||||
Binary,
|
||||
/// JSON payload
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct OtelTlsConfig {
|
||||
pub ca_certificate: Option<AbsolutePathBuf>,
|
||||
pub client_certificate: Option<AbsolutePathBuf>,
|
||||
pub client_private_key: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
/// Which OTEL exporter to use.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum OtelExporterKind {
|
||||
None,
|
||||
Statsig,
|
||||
OtlpHttp {
|
||||
endpoint: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
protocol: OtelHttpProtocol,
|
||||
#[serde(default)]
|
||||
tls: Option<OtelTlsConfig>,
|
||||
},
|
||||
OtlpGrpc {
|
||||
endpoint: String,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
tls: Option<OtelTlsConfig>,
|
||||
},
|
||||
}
|
||||
|
||||
/// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct OtelConfigToml {
|
||||
/// Log user prompt in traces
|
||||
pub log_user_prompt: Option<bool>,
|
||||
|
||||
/// Mark traces with environment (dev, staging, prod, test). Defaults to dev.
|
||||
pub environment: Option<String>,
|
||||
|
||||
/// Optional log exporter
|
||||
pub exporter: Option<OtelExporterKind>,
|
||||
|
||||
/// Optional trace exporter
|
||||
pub trace_exporter: Option<OtelExporterKind>,
|
||||
|
||||
/// Optional metrics exporter
|
||||
pub metrics_exporter: Option<OtelExporterKind>,
|
||||
}
|
||||
|
||||
/// Effective OTEL settings after defaults are applied.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OtelConfig {
|
||||
pub log_user_prompt: bool,
|
||||
pub environment: String,
|
||||
pub exporter: OtelExporterKind,
|
||||
pub trace_exporter: OtelExporterKind,
|
||||
pub metrics_exporter: OtelExporterKind,
|
||||
}
|
||||
|
||||
impl Default for OtelConfig {
|
||||
fn default() -> Self {
|
||||
OtelConfig {
|
||||
log_user_prompt: false,
|
||||
environment: DEFAULT_OTEL_ENVIRONMENT.to_owned(),
|
||||
exporter: OtelExporterKind::None,
|
||||
trace_exporter: OtelExporterKind::None,
|
||||
metrics_exporter: OtelExporterKind::Statsig,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum Notifications {
|
||||
Enabled(bool),
|
||||
Custom(Vec<String>),
|
||||
}
|
||||
|
||||
impl Default for Notifications {
|
||||
fn default() -> Self {
|
||||
Self::Enabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NotificationMethod {
|
||||
#[default]
|
||||
Auto,
|
||||
Osc9,
|
||||
Bel,
|
||||
}
|
||||
|
||||
impl fmt::Display for NotificationMethod {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
NotificationMethod::Auto => write!(f, "auto"),
|
||||
NotificationMethod::Osc9 => write!(f, "osc9"),
|
||||
NotificationMethod::Bel => write!(f, "bel"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ModelAvailabilityNuxConfig {
|
||||
/// Number of times a startup availability NUX has been shown per model slug.
|
||||
#[serde(default, flatten)]
|
||||
pub shown_count: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct Tui {
|
||||
/// Enable desktop notifications from the TUI when the terminal is unfocused.
|
||||
/// Defaults to `true`.
|
||||
#[serde(default)]
|
||||
pub notifications: Notifications,
|
||||
|
||||
/// Notification method to use for unfocused terminal notifications.
|
||||
/// Defaults to `auto`.
|
||||
#[serde(default)]
|
||||
pub notification_method: NotificationMethod,
|
||||
|
||||
/// Enable animations (welcome screen, shimmer effects, spinners).
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub animations: bool,
|
||||
|
||||
/// Show startup tooltips in the TUI welcome screen.
|
||||
/// Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tooltips: bool,
|
||||
|
||||
/// Controls whether the TUI uses the terminal's alternate screen buffer.
|
||||
///
|
||||
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
|
||||
/// - `always`: Always use alternate screen (original behavior).
|
||||
/// - `never`: Never use alternate screen (inline mode only, preserves scrollback).
|
||||
///
|
||||
/// Using alternate screen provides a cleaner fullscreen experience but prevents
|
||||
/// scrollback in terminal multiplexers like Zellij that follow the xterm spec.
|
||||
#[serde(default)]
|
||||
pub alternate_screen: AltScreenMode,
|
||||
|
||||
/// Ordered list of status line item identifiers.
|
||||
///
|
||||
/// When set, the TUI renders the selected items as the status line.
|
||||
/// When unset, the TUI defaults to: `model-with-reasoning`, `context-remaining`, and
|
||||
/// `current-dir`.
|
||||
#[serde(default)]
|
||||
pub status_line: Option<Vec<String>>,
|
||||
|
||||
/// Ordered list of terminal title item identifiers.
|
||||
///
|
||||
/// When set, the TUI renders the selected items into the terminal window/tab title.
|
||||
/// When unset, the TUI defaults to: `spinner` and `project`.
|
||||
#[serde(default)]
|
||||
pub terminal_title: Option<Vec<String>>,
|
||||
|
||||
/// Syntax highlighting theme name (kebab-case).
|
||||
///
|
||||
/// When set, overrides automatic light/dark theme detection.
|
||||
/// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.
|
||||
#[serde(default)]
|
||||
pub theme: Option<String>,
|
||||
|
||||
/// Startup tooltip availability NUX state persisted by the TUI.
|
||||
#[serde(default)]
|
||||
pub model_availability_nux: ModelAvailabilityNuxConfig,
|
||||
}
|
||||
|
||||
const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Settings for notices we display to users via the tui and app-server clients
|
||||
/// (primarily the Codex IDE extension). NOTE: these are different from
|
||||
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
pub struct Notice {
|
||||
/// Tracks whether the user has acknowledged the full access warning prompt.
|
||||
pub hide_full_access_warning: Option<bool>,
|
||||
/// Tracks whether the user has acknowledged the Windows world-writable directories warning.
|
||||
pub hide_world_writable_warning: Option<bool>,
|
||||
/// Tracks whether the user opted out of the rate limit model switch reminder.
|
||||
pub hide_rate_limit_model_nudge: Option<bool>,
|
||||
/// Tracks whether the user has seen the model migration prompt
|
||||
pub hide_gpt5_1_migration_prompt: Option<bool>,
|
||||
/// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt
|
||||
#[serde(rename = "hide_gpt-5.1-codex-max_migration_prompt")]
|
||||
pub hide_gpt_5_1_codex_max_migration_prompt: Option<bool>,
|
||||
/// Tracks acknowledged model migrations as old->new model slug mappings.
|
||||
#[serde(default)]
|
||||
pub model_migrations: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
/// referenced by config_edit helpers when writing notice flags
|
||||
pub(crate) const TABLE_KEY: &'static str = "notice";
|
||||
}
|
||||
|
||||
pub use codex_config::BundledSkillsConfig;
|
||||
pub use codex_config::SkillConfig;
|
||||
pub use codex_config::SkillsConfig;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PluginConfig {
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
#[serde(default)]
|
||||
pub writable_roots: Vec<AbsolutePathBuf>,
|
||||
#[serde(default)]
|
||||
pub network_access: bool,
|
||||
#[serde(default)]
|
||||
pub exclude_tmpdir_env_var: bool,
|
||||
#[serde(default)]
|
||||
pub exclude_slash_tmp: bool,
|
||||
}
|
||||
|
||||
impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings {
|
||||
fn from(sandbox_workspace_write: SandboxWorkspaceWrite) -> Self {
|
||||
Self {
|
||||
writable_roots: sandbox_workspace_write.writable_roots,
|
||||
network_access: Some(sandbox_workspace_write.network_access),
|
||||
exclude_tmpdir_env_var: Some(sandbox_workspace_write.exclude_tmpdir_env_var),
|
||||
exclude_slash_tmp: Some(sandbox_workspace_write.exclude_slash_tmp),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ShellEnvironmentPolicyInherit {
|
||||
/// "Core" environment variables for the platform. On UNIX, this would
|
||||
/// include HOME, LOGNAME, PATH, SHELL, and USER, among others.
|
||||
Core,
|
||||
|
||||
/// Inherits the full environment from the parent process.
|
||||
#[default]
|
||||
All,
|
||||
|
||||
/// Do not inherit any environment variables from the parent process.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Policy for building the `env` when spawning a process via either the
|
||||
/// `shell` or `local_shell` tool.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ShellEnvironmentPolicyToml {
|
||||
pub inherit: Option<ShellEnvironmentPolicyInherit>,
|
||||
|
||||
pub ignore_default_excludes: Option<bool>,
|
||||
|
||||
/// List of regular expressions.
|
||||
pub exclude: Option<Vec<String>>,
|
||||
|
||||
pub r#set: Option<HashMap<String, String>>,
|
||||
|
||||
/// List of regular expressions.
|
||||
pub include_only: Option<Vec<String>>,
|
||||
|
||||
pub experimental_use_profile: Option<bool>,
|
||||
}
|
||||
|
||||
pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
|
||||
|
||||
/// Deriving the `env` based on this policy works as follows:
|
||||
/// 1. Create an initial map based on the `inherit` policy.
|
||||
/// 2. If `ignore_default_excludes` is false, filter the map using the default
|
||||
/// exclude pattern(s), which are: `"*KEY*"`, `"*SECRET*"`, and `"*TOKEN*"`.
|
||||
/// 3. If `exclude` is not empty, filter the map using the provided patterns.
|
||||
/// 4. Insert any entries from `r#set` into the map.
|
||||
/// 5. If non-empty, filter the map using the `include_only` patterns.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ShellEnvironmentPolicy {
|
||||
/// Starting point when building the environment.
|
||||
pub inherit: ShellEnvironmentPolicyInherit,
|
||||
|
||||
/// True to skip the check to exclude default environment variables that
|
||||
/// contain "KEY", "SECRET", or "TOKEN" in their name. Defaults to true.
|
||||
pub ignore_default_excludes: bool,
|
||||
|
||||
/// Environment variable names to exclude from the environment.
|
||||
pub exclude: Vec<EnvironmentVariablePattern>,
|
||||
|
||||
/// (key, value) pairs to insert in the environment.
|
||||
pub r#set: HashMap<String, String>,
|
||||
|
||||
/// Environment variable names to retain in the environment.
|
||||
pub include_only: Vec<EnvironmentVariablePattern>,
|
||||
|
||||
/// If true, the shell profile will be used to run the command.
|
||||
pub use_profile: bool,
|
||||
}
|
||||
|
||||
impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
|
||||
fn from(toml: ShellEnvironmentPolicyToml) -> Self {
|
||||
// Default to inheriting the full environment when not specified.
|
||||
let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All);
|
||||
let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(true);
|
||||
let exclude = toml
|
||||
.exclude
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
|
||||
.collect();
|
||||
let r#set = toml.r#set.unwrap_or_default();
|
||||
let include_only = toml
|
||||
.include_only
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
|
||||
.collect();
|
||||
let use_profile = toml.experimental_use_profile.unwrap_or(false);
|
||||
|
||||
Self {
|
||||
inherit,
|
||||
ignore_default_excludes,
|
||||
exclude,
|
||||
r#set,
|
||||
include_only,
|
||||
use_profile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ShellEnvironmentPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inherit: ShellEnvironmentPolicyInherit::All,
|
||||
ignore_default_excludes: true,
|
||||
exclude: Vec::new(),
|
||||
r#set: HashMap::new(),
|
||||
include_only: Vec::new(),
|
||||
use_profile: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "types_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,390 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize command config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
}
|
||||
);
|
||||
assert!(cfg.enabled);
|
||||
assert!(!cfg.required);
|
||||
assert!(cfg.enabled_tools.is_none());
|
||||
assert!(cfg.disabled_tools.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config_with_args() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
args = ["hello", "world"]
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize command config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec!["hello".to_string(), "world".to_string()],
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
}
|
||||
);
|
||||
assert!(cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
args = ["hello", "world"]
|
||||
env = { "FOO" = "BAR" }
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize command config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec!["hello".to_string(), "world".to_string()],
|
||||
env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])),
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
}
|
||||
);
|
||||
assert!(cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config_with_env_vars() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
env_vars = ["FOO", "BAR"]
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize command config with env_vars");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
env_vars: vec!["FOO".to_string(), "BAR".to_string()],
|
||||
cwd: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config_with_cwd() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
cwd = "/tmp"
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize command config with cwd");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: Some(PathBuf::from("/tmp")),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_disabled_server_config() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize disabled server config");
|
||||
|
||||
assert!(!cfg.enabled);
|
||||
assert!(!cfg.required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_required_server_config() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
required = true
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize required server config");
|
||||
|
||||
assert!(cfg.required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_streamable_http_server_config() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
url = "https://example.com/mcp"
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize http config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
}
|
||||
);
|
||||
assert!(cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_streamable_http_server_config_with_env_var() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
url = "https://example.com/mcp"
|
||||
bearer_token_env_var = "GITHUB_TOKEN"
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize http config");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
bearer_token_env_var: Some("GITHUB_TOKEN".to_string()),
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
}
|
||||
);
|
||||
assert!(cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_streamable_http_server_config_with_headers() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
url = "https://example.com/mcp"
|
||||
http_headers = { "X-Foo" = "bar" }
|
||||
env_http_headers = { "X-Token" = "TOKEN_ENV" }
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize http config with headers");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])),
|
||||
env_http_headers: Some(HashMap::from([(
|
||||
"X-Token".to_string(),
|
||||
"TOKEN_ENV".to_string()
|
||||
)])),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_streamable_http_server_config_with_oauth_resource() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
url = "https://example.com/mcp"
|
||||
oauth_resource = "https://api.example.com"
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize http config with oauth_resource");
|
||||
|
||||
assert_eq!(
|
||||
cfg.oauth_resource,
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_server_config_with_tool_filters() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
enabled_tools = ["allowed"]
|
||||
disabled_tools = ["blocked"]
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize tool filters");
|
||||
|
||||
assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()]));
|
||||
assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_ignores_unknown_server_fields() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
trust_level = "trusted"
|
||||
"#,
|
||||
)
|
||||
.expect("should ignore unknown server fields");
|
||||
|
||||
assert_eq!(
|
||||
cfg,
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_skill_config_with_name_selector() {
|
||||
let cfg: SkillConfig = toml::from_str(
|
||||
r#"
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize skill config with name selector");
|
||||
|
||||
assert_eq!(cfg.name.as_deref(), Some("github:yeet"));
|
||||
assert_eq!(cfg.path, None);
|
||||
assert!(!cfg.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_skill_config_with_path_selector() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let cfg: SkillConfig = toml::from_str(&format!(
|
||||
r#"
|
||||
path = {path:?}
|
||||
enabled = false
|
||||
"#,
|
||||
path = skill_path.display().to_string(),
|
||||
))
|
||||
.expect("should deserialize skill config with path selector");
|
||||
|
||||
assert_eq!(
|
||||
cfg,
|
||||
SkillConfig {
|
||||
path: Some(
|
||||
AbsolutePathBuf::from_absolute_path(&skill_path)
|
||||
.expect("skill path should be absolute"),
|
||||
),
|
||||
name: None,
|
||||
enabled: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_command_and_url() {
|
||||
toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
command = "echo"
|
||||
url = "https://example.com"
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject command+url");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_env_for_http_transport() {
|
||||
toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
url = "https://example.com"
|
||||
env = { "FOO" = "BAR" }
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject env for http transport");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_headers_for_stdio() {
|
||||
toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
command = "echo"
|
||||
http_headers = { "X-Foo" = "bar" }
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject http_headers for stdio transport");
|
||||
|
||||
toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
command = "echo"
|
||||
env_http_headers = { "X-Foo" = "BAR_ENV" }
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject env_http_headers for stdio transport");
|
||||
|
||||
let err = toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
command = "echo"
|
||||
oauth_resource = "https://api.example.com"
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject oauth_resource for stdio transport");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("oauth_resource is not supported for stdio"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_rejects_inline_bearer_token_field() {
|
||||
let err = toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
url = "https://example.com"
|
||||
bearer_token = "secret"
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject bearer_token field");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("bearer_token is not supported"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigRequirementsWithSources;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -730,6 +731,11 @@ fn normalize_project_trust_lookup_key(key: String) -> String {
|
||||
key
|
||||
}
|
||||
}
|
||||
/// Convert the path to the canonical trust key we store and compare against.
|
||||
/// On Windows this also normalizes case so alias paths still match.
|
||||
pub fn project_trust_key(project_path: &Path) -> String {
|
||||
normalized_project_trust_key(project_path)
|
||||
}
|
||||
/// Takes a `toml::Value` parsed from a config.toml file and walks through it,
|
||||
/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new
|
||||
/// `toml::Value` with the same shape but with paths resolved.
|
||||
@@ -921,6 +927,7 @@ async fn load_project_layers(
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
struct LegacyManagedConfigToml {
|
||||
approval_policy: Option<AskForApproval>,
|
||||
approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
sandbox_mode: Option<SandboxMode>,
|
||||
}
|
||||
|
||||
@@ -930,11 +937,15 @@ impl From<LegacyManagedConfigToml> for ConfigRequirementsToml {
|
||||
|
||||
let LegacyManagedConfigToml {
|
||||
approval_policy,
|
||||
approvals_reviewer,
|
||||
sandbox_mode,
|
||||
} = legacy;
|
||||
if let Some(approval_policy) = approval_policy {
|
||||
config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]);
|
||||
}
|
||||
if let Some(approvals_reviewer) = approvals_reviewer {
|
||||
config_requirements_toml.allowed_approvals_reviewers = Some(vec![approvals_reviewer]);
|
||||
}
|
||||
if let Some(sandbox_mode) = sandbox_mode {
|
||||
let required_mode: SandboxModeRequirement = sandbox_mode.into();
|
||||
// Allowing read-only is a requirement for Codex to function correctly.
|
||||
@@ -998,6 +1009,7 @@ foo = "xyzzy"
|
||||
fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() {
|
||||
let legacy = LegacyManagedConfigToml {
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
|
||||
};
|
||||
|
||||
@@ -1012,6 +1024,22 @@ foo = "xyzzy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_managed_config_backfill_includes_approvals_reviewer() {
|
||||
let legacy = LegacyManagedConfigToml {
|
||||
approval_policy: None,
|
||||
approvals_reviewer: Some(ApprovalsReviewer::GuardianSubagent),
|
||||
sandbox_mode: None,
|
||||
};
|
||||
|
||||
let requirements = ConfigRequirementsToml::from(legacy);
|
||||
|
||||
assert_eq!(
|
||||
requirements.allowed_approvals_reviewers,
|
||||
Some(vec![ApprovalsReviewer::GuardianSubagent])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_system_requirements_toml_file_uses_expected_suffix() {
|
||||
|
||||
@@ -115,10 +115,7 @@ async fn returns_config_error_for_invalid_managed_config_toml() {
|
||||
let contents = "model = \"gpt-4\"\ninvalid = [";
|
||||
std::fs::write(&managed_path, contents).expect("write managed config");
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone());
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
|
||||
let err = load_config_layers_state(
|
||||
@@ -202,12 +199,7 @@ extra = true
|
||||
)
|
||||
.expect("write managed config");
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
|
||||
let state = load_config_layers_state(
|
||||
@@ -239,14 +231,7 @@ async fn returns_empty_when_all_layers_missing() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
#[cfg(target_os = "macos")]
|
||||
// Force managed preferences to resolve as empty so this test does not
|
||||
// inherit non-empty machine-specific managed state.
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
let overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
|
||||
let layers = load_config_layers_state(
|
||||
@@ -337,13 +322,9 @@ value = "managed"
|
||||
flag = false
|
||||
"#;
|
||||
|
||||
let overrides = LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(raw_managed_preferences.as_bytes()),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
|
||||
overrides.managed_preferences_base64 =
|
||||
Some(base64::prelude::BASE64_STANDARD.encode(raw_managed_preferences.as_bytes()));
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
|
||||
let state = load_config_layers_state(
|
||||
@@ -391,23 +372,23 @@ async fn managed_preferences_expand_home_directory_in_workspace_write_roots() ->
|
||||
};
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
let mut loader_overrides =
|
||||
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
|
||||
loader_overrides.managed_preferences_base64 = Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["~/code"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
})
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(loader_overrides)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
@@ -435,23 +416,23 @@ async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> {
|
||||
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let mut loader_overrides =
|
||||
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
|
||||
loader_overrides.macos_managed_config_requirements_base64 = Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
|
||||
let state = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
},
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
@@ -498,22 +479,21 @@ async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()
|
||||
|
||||
tokio::fs::write(&managed_path, "approval_policy = \"on-request\"\n").await?;
|
||||
|
||||
let mut loader_overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
|
||||
loader_overrides.macos_managed_config_requirements_base64 = Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
|
||||
let state = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path),
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
},
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
@@ -631,24 +611,24 @@ async fn cloud_requirements_take_precedence_over_mdm_requirements() -> anyhow::R
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir()?;
|
||||
let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
loader_overrides.macos_managed_config_requirements_base64 = Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
);
|
||||
let state = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
..LoaderOverrides::default()
|
||||
},
|
||||
loader_overrides,
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
feature_requirements: None,
|
||||
@@ -700,6 +680,7 @@ allowed_approval_policies = ["on-request"]
|
||||
RequirementSource::CloudRequirements,
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
feature_requirements: None,
|
||||
@@ -740,6 +721,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
||||
|
||||
let requirements = ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_approvals_reviewers: None,
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
feature_requirements: None,
|
||||
|
||||
@@ -24,28 +24,30 @@ use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::SandboxState;
|
||||
use crate::codex::INITIAL_SUBMIT_ID;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::config::types::ToolSuggestDiscoverableType;
|
||||
use crate::config_loader::AppsRequirementsToml;
|
||||
use crate::default_client::create_client;
|
||||
use crate::default_client::is_first_party_chat_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::mcp::ToolPluginProvenance;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::with_codex_apps_mcp;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::list_tool_suggest_discoverable_plugins;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::AppsConfigToml;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_login::default_client::is_first_party_chat_originator;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::mcp::ToolPluginProvenance;
|
||||
use codex_mcp::mcp::auth::compute_auth_statuses;
|
||||
use codex_mcp::mcp::with_codex_apps_mcp;
|
||||
use codex_mcp::mcp_connection_manager::McpConnectionManager;
|
||||
use codex_mcp::mcp_connection_manager::SandboxState;
|
||||
use codex_mcp::mcp_connection_manager::ToolInfo;
|
||||
use codex_mcp::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
|
||||
pub use codex_connectors::CONNECTORS_CACHE_TTL;
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
@@ -153,7 +155,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
||||
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
mcp_tools: &HashMap<String, ToolInfo>,
|
||||
) {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
return;
|
||||
@@ -189,7 +191,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
});
|
||||
}
|
||||
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let mcp_manager = McpManager::new(Arc::clone(&plugins_manager));
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config);
|
||||
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
|
||||
{
|
||||
@@ -201,12 +204,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
});
|
||||
}
|
||||
|
||||
let mcp_servers = with_codex_apps_mcp(
|
||||
HashMap::new(),
|
||||
/*connectors_enabled*/ true,
|
||||
auth.as_ref(),
|
||||
config,
|
||||
);
|
||||
let mcp_config = config.to_mcp_config(plugins_manager.as_ref());
|
||||
let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config);
|
||||
if mcp_servers.is_empty() {
|
||||
return Ok(AccessibleConnectorsStatus {
|
||||
connectors: Vec::new(),
|
||||
@@ -232,6 +231,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_status_entries,
|
||||
&config.permissions.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event,
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
@@ -509,7 +509,7 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
mcp_tools: &HashMap<String, ToolInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
// ToolInfo already carries plugin provenance, so app-level plugin sources
|
||||
// can be derived here instead of requiring a separate enrichment pass.
|
||||
@@ -683,10 +683,7 @@ pub(crate) fn app_tool_policy(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn codex_app_tool_is_enabled(
|
||||
config: &Config,
|
||||
tool_info: &crate::mcp_connection_manager::ToolInfo,
|
||||
) -> bool {
|
||||
pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) -> bool {
|
||||
if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return true;
|
||||
}
|
||||
@@ -717,10 +714,6 @@ pub fn filter_disallowed_connectors(connectors: Vec<AppInfo>) -> Vec<AppInfo> {
|
||||
filter_disallowed_connectors_for_originator(connectors, originator().value.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn is_connector_id_allowed(connector_id: &str) -> bool {
|
||||
is_connector_id_allowed_for_originator(connector_id, originator().value.as_str())
|
||||
}
|
||||
|
||||
fn filter_disallowed_connectors_for_originator(
|
||||
connectors: Vec<AppInfo>,
|
||||
originator_value: &str,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use super::*;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::types::AppConfig;
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsDefaultConfig;
|
||||
use crate::config_loader::AppRequirementToml;
|
||||
use crate::config_loader::AppsRequirementsToml;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use codex_config::types::AppConfig;
|
||||
use codex_config::types::AppToolConfig;
|
||||
use codex_config::types::AppToolsConfig;
|
||||
use codex_config::types::AppsDefaultConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::mcp_connection_manager::ToolInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
|
||||
@@ -16,6 +16,10 @@ fn build_environment_update_item(
|
||||
next: &TurnContext,
|
||||
shell: &Shell,
|
||||
) -> Option<ResponseItem> {
|
||||
if !next.config.include_environment_context {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prev = previous?;
|
||||
let prev_context = EnvironmentContext::from_turn_context_item(prev, shell);
|
||||
let next_context = EnvironmentContext::from_turn_context(next, shell);
|
||||
@@ -33,6 +37,10 @@ fn build_permissions_update_item(
|
||||
next: &TurnContext,
|
||||
exec_policy: &Policy,
|
||||
) -> Option<DeveloperInstructions> {
|
||||
if !next.config.include_permissions_instructions {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prev = previous?;
|
||||
if prev.sandbox_policy == *next.sandbox_policy.get()
|
||||
&& prev.approval_policy == next.approval_policy.value()
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-exported as `crate::default_client` from `lib.rs`.
|
||||
pub use codex_login::default_client::*;
|
||||
@@ -1,656 +0,0 @@
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::network_policy_decision::NetworkPolicyDecisionPayload;
|
||||
use chrono::DateTime;
|
||||
use chrono::Datelike;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_async_utils::CancelErr;
|
||||
pub use codex_login::auth::RefreshTokenFailedError;
|
||||
pub use codex_login::auth::RefreshTokenFailedReason;
|
||||
use codex_login::token_data::KnownPlan;
|
||||
use codex_login::token_data::PlanType;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json;
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
use tokio::task::JoinError;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CodexErr>;
|
||||
|
||||
/// Limit UI error messages to a reasonable size while keeping useful context.
|
||||
const ERROR_MESSAGE_UI_MAX_BYTES: usize = 2 * 1024; // 2 KiB
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SandboxErr {
|
||||
/// Error from sandbox execution
|
||||
#[error(
|
||||
"sandbox denied exec error, exit code: {}, stdout: {}, stderr: {}",
|
||||
.output.exit_code, .output.stdout.text, .output.stderr.text
|
||||
)]
|
||||
Denied {
|
||||
output: Box<ExecToolCallOutput>,
|
||||
network_policy_decision: Option<NetworkPolicyDecisionPayload>,
|
||||
},
|
||||
|
||||
/// Error from linux seccomp filter setup
|
||||
#[cfg(target_os = "linux")]
|
||||
#[error("seccomp setup error")]
|
||||
SeccompInstall(#[from] seccompiler::Error),
|
||||
|
||||
/// Error from linux seccomp backend
|
||||
#[cfg(target_os = "linux")]
|
||||
#[error("seccomp backend error")]
|
||||
SeccompBackend(#[from] seccompiler::BackendError),
|
||||
|
||||
/// Command timed out
|
||||
#[error("command timed out")]
|
||||
Timeout { output: Box<ExecToolCallOutput> },
|
||||
|
||||
/// Command was killed by a signal
|
||||
#[error("command was killed by a signal")]
|
||||
Signal(i32),
|
||||
|
||||
/// Error from linux landlock
|
||||
#[error("Landlock was not able to fully enforce all sandbox rules")]
|
||||
LandlockRestrict,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CodexErr {
|
||||
#[error("turn aborted. Something went wrong? Hit `/feedback` to report the issue.")]
|
||||
TurnAborted,
|
||||
|
||||
/// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP
|
||||
/// handshake has succeeded but **before** it finished emitting `response.completed`.
|
||||
///
|
||||
/// The Session loop treats this as a transient error and will automatically retry the turn.
|
||||
///
|
||||
/// Optionally includes the requested delay before retrying the turn.
|
||||
#[error("stream disconnected before completion: {0}")]
|
||||
Stream(String, Option<Duration>),
|
||||
|
||||
#[error(
|
||||
"Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying."
|
||||
)]
|
||||
ContextWindowExceeded,
|
||||
|
||||
#[error("no thread with id: {0}")]
|
||||
ThreadNotFound(ThreadId),
|
||||
|
||||
#[error("agent thread limit reached (max {max_threads})")]
|
||||
AgentLimitReached { max_threads: usize },
|
||||
|
||||
#[error("session configured event was not the first event in the stream")]
|
||||
SessionConfiguredNotFirstEvent,
|
||||
|
||||
/// Returned by run_command_stream when the spawned child process timed out (10s).
|
||||
#[error("timeout waiting for child process to exit")]
|
||||
Timeout,
|
||||
|
||||
/// Returned by run_command_stream when the child could not be spawned (its stdout/stderr pipes
|
||||
/// could not be captured). Analogous to the previous `CodexError::Spawn` variant.
|
||||
#[error("spawn failed: child stdout/stderr not captured")]
|
||||
Spawn,
|
||||
|
||||
/// Returned by run_command_stream when the user pressed Ctrl‑C (SIGINT). Session uses this to
|
||||
/// surface a polite FunctionCallOutput back to the model instead of crashing the CLI.
|
||||
#[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")]
|
||||
Interrupted,
|
||||
|
||||
/// Unexpected HTTP status code.
|
||||
#[error("{0}")]
|
||||
UnexpectedStatus(UnexpectedResponseError),
|
||||
|
||||
/// Invalid request.
|
||||
#[error("{0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
/// Invalid image.
|
||||
#[error("Image poisoning")]
|
||||
InvalidImageRequest(),
|
||||
|
||||
#[error("{0}")]
|
||||
UsageLimitReached(UsageLimitReachedError),
|
||||
|
||||
#[error("Selected model is at capacity. Please try a different model.")]
|
||||
ServerOverloaded,
|
||||
|
||||
#[error("{0}")]
|
||||
ResponseStreamFailed(ResponseStreamFailed),
|
||||
|
||||
#[error("{0}")]
|
||||
ConnectionFailed(ConnectionFailedError),
|
||||
|
||||
#[error("Quota exceeded. Check your plan and billing details.")]
|
||||
QuotaExceeded,
|
||||
|
||||
#[error(
|
||||
"To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus."
|
||||
)]
|
||||
UsageNotIncluded,
|
||||
|
||||
#[error("We're currently experiencing high demand, which may cause temporary errors.")]
|
||||
InternalServerError,
|
||||
|
||||
/// Retry limit exceeded.
|
||||
#[error("{0}")]
|
||||
RetryLimit(RetryLimitReachedError),
|
||||
|
||||
/// Agent loop died unexpectedly
|
||||
#[error("internal error; agent loop died unexpectedly")]
|
||||
InternalAgentDied,
|
||||
|
||||
/// Sandbox error
|
||||
#[error("sandbox error: {0}")]
|
||||
Sandbox(#[from] SandboxErr),
|
||||
|
||||
#[error("codex-linux-sandbox was required but not provided")]
|
||||
LandlockSandboxExecutableNotProvided,
|
||||
|
||||
#[error("unsupported operation: {0}")]
|
||||
UnsupportedOperation(String),
|
||||
|
||||
#[error("{0}")]
|
||||
RefreshTokenFailed(RefreshTokenFailedError),
|
||||
|
||||
#[error("Fatal error: {0}")]
|
||||
Fatal(String),
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Automatic conversions for common external error types
|
||||
// -----------------------------------------------------------------
|
||||
#[error(transparent)]
|
||||
Io(#[from] io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[error(transparent)]
|
||||
LandlockRuleset(#[from] landlock::RulesetError),
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[error(transparent)]
|
||||
LandlockPathFd(#[from] landlock::PathFdError),
|
||||
|
||||
#[error(transparent)]
|
||||
TokioJoin(#[from] JoinError),
|
||||
|
||||
#[error("{0}")]
|
||||
EnvVar(EnvVarError),
|
||||
}
|
||||
|
||||
impl From<CancelErr> for CodexErr {
|
||||
fn from(_: CancelErr) -> Self {
|
||||
CodexErr::TurnAborted
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexErr {
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
CodexErr::TurnAborted
|
||||
| CodexErr::Interrupted
|
||||
| CodexErr::EnvVar(_)
|
||||
| CodexErr::Fatal(_)
|
||||
| CodexErr::UsageNotIncluded
|
||||
| CodexErr::QuotaExceeded
|
||||
| CodexErr::InvalidImageRequest()
|
||||
| CodexErr::InvalidRequest(_)
|
||||
| CodexErr::RefreshTokenFailed(_)
|
||||
| CodexErr::UnsupportedOperation(_)
|
||||
| CodexErr::Sandbox(_)
|
||||
| CodexErr::LandlockSandboxExecutableNotProvided
|
||||
| CodexErr::RetryLimit(_)
|
||||
| CodexErr::ContextWindowExceeded
|
||||
| CodexErr::ThreadNotFound(_)
|
||||
| CodexErr::AgentLimitReached { .. }
|
||||
| CodexErr::Spawn
|
||||
| CodexErr::SessionConfiguredNotFirstEvent
|
||||
| CodexErr::UsageLimitReached(_)
|
||||
| CodexErr::ServerOverloaded => false,
|
||||
CodexErr::Stream(..)
|
||||
| CodexErr::Timeout
|
||||
| CodexErr::UnexpectedStatus(_)
|
||||
| CodexErr::ResponseStreamFailed(_)
|
||||
| CodexErr::ConnectionFailed(_)
|
||||
| CodexErr::InternalServerError
|
||||
| CodexErr::InternalAgentDied
|
||||
| CodexErr::Io(_)
|
||||
| CodexErr::Json(_)
|
||||
| CodexErr::TokioJoin(_) => true,
|
||||
#[cfg(target_os = "linux")]
|
||||
CodexErr::LandlockRuleset(_) | CodexErr::LandlockPathFd(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ConnectionFailedError {
|
||||
pub source: reqwest::Error,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectionFailedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Connection failed: {}", self.source)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ResponseStreamFailed {
|
||||
pub source: reqwest::Error,
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResponseStreamFailed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Error while reading the server response: {}{}",
|
||||
self.source,
|
||||
self.request_id
|
||||
.as_ref()
|
||||
.map(|id| format!(", request id: {id}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UnexpectedResponseError {
|
||||
pub status: StatusCode,
|
||||
pub body: String,
|
||||
pub url: Option<String>,
|
||||
pub cf_ray: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub identity_authorization_error: Option<String>,
|
||||
pub identity_error_code: Option<String>,
|
||||
}
|
||||
|
||||
const CLOUDFLARE_BLOCKED_MESSAGE: &str =
|
||||
"Access blocked by Cloudflare. This usually happens when connecting from a restricted region";
|
||||
const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000;
|
||||
|
||||
impl UnexpectedResponseError {
|
||||
fn display_body(&self) -> String {
|
||||
if let Some(message) = self.extract_error_message() {
|
||||
return message;
|
||||
}
|
||||
|
||||
let trimmed_body = self.body.trim();
|
||||
if trimmed_body.is_empty() {
|
||||
return "Unknown error".to_string();
|
||||
}
|
||||
|
||||
truncate_with_ellipsis(trimmed_body, UNEXPECTED_RESPONSE_BODY_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn extract_error_message(&self) -> Option<String> {
|
||||
let json = serde_json::from_str::<serde_json::Value>(&self.body).ok()?;
|
||||
let message = json
|
||||
.get("error")
|
||||
.and_then(|error| error.get("message"))
|
||||
.and_then(serde_json::Value::as_str)?;
|
||||
let message = message.trim();
|
||||
if message.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(message.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn friendly_message(&self) -> Option<String> {
|
||||
if self.status != StatusCode::FORBIDDEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !self.body.contains("Cloudflare") || !self.body.contains("blocked") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = self.status;
|
||||
let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})");
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
if let Some(cf_ray) = &self.cf_ray {
|
||||
message.push_str(&format!(", cf-ray: {cf_ray}"));
|
||||
}
|
||||
if let Some(id) = &self.request_id {
|
||||
message.push_str(&format!(", request id: {id}"));
|
||||
}
|
||||
if let Some(auth_error) = &self.identity_authorization_error {
|
||||
message.push_str(&format!(", auth error: {auth_error}"));
|
||||
}
|
||||
if let Some(error_code) = &self.identity_error_code {
|
||||
message.push_str(&format!(", auth error code: {error_code}"));
|
||||
}
|
||||
|
||||
Some(message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UnexpectedResponseError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(friendly) = self.friendly_message() {
|
||||
write!(f, "{friendly}")
|
||||
} else {
|
||||
let status = self.status;
|
||||
let body = self.display_body();
|
||||
let mut message = format!("unexpected status {status}: {body}");
|
||||
if let Some(url) = &self.url {
|
||||
message.push_str(&format!(", url: {url}"));
|
||||
}
|
||||
if let Some(cf_ray) = &self.cf_ray {
|
||||
message.push_str(&format!(", cf-ray: {cf_ray}"));
|
||||
}
|
||||
if let Some(id) = &self.request_id {
|
||||
message.push_str(&format!(", request id: {id}"));
|
||||
}
|
||||
if let Some(auth_error) = &self.identity_authorization_error {
|
||||
message.push_str(&format!(", auth error: {auth_error}"));
|
||||
}
|
||||
if let Some(error_code) = &self.identity_error_code {
|
||||
message.push_str(&format!(", auth error code: {error_code}"));
|
||||
}
|
||||
write!(f, "{message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UnexpectedResponseError {}
|
||||
|
||||
fn truncate_with_ellipsis(text: &str, max_bytes: usize) -> String {
|
||||
if text.len() <= max_bytes {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
let mut cut = max_bytes;
|
||||
while !text.is_char_boundary(cut) {
|
||||
cut = cut.saturating_sub(1);
|
||||
}
|
||||
let mut truncated = text[..cut].to_string();
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RetryLimitReachedError {
|
||||
pub status: StatusCode,
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RetryLimitReachedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"exceeded retry limit, last status: {}{}",
|
||||
self.status,
|
||||
self.request_id
|
||||
.as_ref()
|
||||
.map(|id| format!(", request id: {id}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UsageLimitReachedError {
|
||||
pub(crate) plan_type: Option<PlanType>,
|
||||
pub(crate) resets_at: Option<DateTime<Utc>>,
|
||||
pub(crate) rate_limits: Option<Box<RateLimitSnapshot>>,
|
||||
pub(crate) promo_message: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UsageLimitReachedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(limit_name) = self
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.limit_name.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
&& !limit_name.eq_ignore_ascii_case("codex")
|
||||
{
|
||||
return write!(
|
||||
f,
|
||||
"You've hit your usage limit for {limit_name}. Switch to another model now,{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(promo_message) = &self.promo_message {
|
||||
return write!(
|
||||
f,
|
||||
"You've hit your usage limit. {promo_message},{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
);
|
||||
}
|
||||
|
||||
let message = match self.plan_type.as_ref() {
|
||||
Some(PlanType::Known(KnownPlan::Plus)) => format!(
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Known(
|
||||
KnownPlan::Team
|
||||
| KnownPlan::SelfServeBusinessUsageBased
|
||||
| KnownPlan::Business
|
||||
| KnownPlan::EnterpriseCbpUsageBased,
|
||||
)) => {
|
||||
format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
)
|
||||
}
|
||||
Some(PlanType::Known(KnownPlan::Free)) | Some(PlanType::Known(KnownPlan::Go)) => {
|
||||
format!(
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus),{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
)
|
||||
}
|
||||
Some(PlanType::Known(KnownPlan::Pro)) => format!(
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Known(KnownPlan::Enterprise))
|
||||
| Some(PlanType::Known(KnownPlan::Edu)) => format!(
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Unknown(_)) | None => format!(
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
),
|
||||
};
|
||||
|
||||
write!(f, "{message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_suffix(resets_at: Option<&DateTime<Utc>>) -> String {
|
||||
if let Some(resets_at) = resets_at {
|
||||
let formatted = format_retry_timestamp(resets_at);
|
||||
format!(" Try again at {formatted}.")
|
||||
} else {
|
||||
" Try again later.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_suffix_after_or(resets_at: Option<&DateTime<Utc>>) -> String {
|
||||
if let Some(resets_at) = resets_at {
|
||||
let formatted = format_retry_timestamp(resets_at);
|
||||
format!(" or try again at {formatted}.")
|
||||
} else {
|
||||
" or try again later.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_retry_timestamp(resets_at: &DateTime<Utc>) -> String {
|
||||
let local_reset = resets_at.with_timezone(&Local);
|
||||
let local_now = now_for_retry().with_timezone(&Local);
|
||||
if local_reset.date_naive() == local_now.date_naive() {
|
||||
local_reset.format("%-I:%M %p").to_string()
|
||||
} else {
|
||||
let suffix = day_suffix(local_reset.day());
|
||||
local_reset
|
||||
.format(&format!("%b %-d{suffix}, %Y %-I:%M %p"))
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn day_suffix(day: u32) -> &'static str {
|
||||
match day {
|
||||
11..=13 => "th",
|
||||
_ => match day % 10 {
|
||||
1 => "st",
|
||||
2 => "nd", // codespell:ignore
|
||||
3 => "rd",
|
||||
_ => "th",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static NOW_OVERRIDE: std::cell::RefCell<Option<DateTime<Utc>>> =
|
||||
const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn now_for_retry() -> DateTime<Utc> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Some(now) = NOW_OVERRIDE.with(|cell| *cell.borrow()) {
|
||||
return now;
|
||||
}
|
||||
}
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EnvVarError {
|
||||
/// Name of the environment variable that is missing.
|
||||
pub var: String,
|
||||
|
||||
/// Optional instructions to help the user get a valid value for the
|
||||
/// variable and set it.
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EnvVarError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Missing environment variable: `{}`.", self.var)?;
|
||||
if let Some(instructions) = &self.instructions {
|
||||
write!(f, " {instructions}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexErr {
|
||||
/// Minimal shim so that existing `e.downcast_ref::<CodexErr>()` checks continue to compile
|
||||
/// after replacing `anyhow::Error` in the return signature. This mirrors the behavior of
|
||||
/// `anyhow::Error::downcast_ref` but works directly on our concrete enum.
|
||||
pub fn downcast_ref<T: std::any::Any>(&self) -> Option<&T> {
|
||||
(self as &dyn std::any::Any).downcast_ref::<T>()
|
||||
}
|
||||
|
||||
/// Translate core error to client-facing protocol error.
|
||||
pub fn to_codex_protocol_error(&self) -> CodexErrorInfo {
|
||||
match self {
|
||||
CodexErr::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded,
|
||||
CodexErr::UsageLimitReached(_)
|
||||
| CodexErr::QuotaExceeded
|
||||
| CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded,
|
||||
CodexErr::ServerOverloaded => CodexErrorInfo::ServerOverloaded,
|
||||
CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts {
|
||||
http_status_code: self.http_status_code_value(),
|
||||
},
|
||||
CodexErr::ConnectionFailed(_) => CodexErrorInfo::HttpConnectionFailed {
|
||||
http_status_code: self.http_status_code_value(),
|
||||
},
|
||||
CodexErr::ResponseStreamFailed(_) => CodexErrorInfo::ResponseStreamConnectionFailed {
|
||||
http_status_code: self.http_status_code_value(),
|
||||
},
|
||||
CodexErr::RefreshTokenFailed(_) => CodexErrorInfo::Unauthorized,
|
||||
CodexErr::SessionConfiguredNotFirstEvent
|
||||
| CodexErr::InternalServerError
|
||||
| CodexErr::InternalAgentDied => CodexErrorInfo::InternalServerError,
|
||||
CodexErr::UnsupportedOperation(_)
|
||||
| CodexErr::ThreadNotFound(_)
|
||||
| CodexErr::AgentLimitReached { .. } => CodexErrorInfo::BadRequest,
|
||||
CodexErr::Sandbox(_) => CodexErrorInfo::SandboxError,
|
||||
_ => CodexErrorInfo::Other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_error_event(&self, message_prefix: Option<String>) -> ErrorEvent {
|
||||
let error_message = self.to_string();
|
||||
let message: String = match message_prefix {
|
||||
Some(prefix) => format!("{prefix}: {error_message}"),
|
||||
None => error_message,
|
||||
};
|
||||
ErrorEvent {
|
||||
message,
|
||||
codex_error_info: Some(self.to_codex_protocol_error()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http_status_code_value(&self) -> Option<u16> {
|
||||
let http_status_code = match self {
|
||||
CodexErr::RetryLimit(err) => Some(err.status),
|
||||
CodexErr::UnexpectedStatus(err) => Some(err.status),
|
||||
CodexErr::ConnectionFailed(err) => err.source.status(),
|
||||
CodexErr::ResponseStreamFailed(err) => err.source.status(),
|
||||
_ => None,
|
||||
};
|
||||
http_status_code.as_ref().map(StatusCode::as_u16)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_error_message_ui(e: &CodexErr) -> String {
|
||||
let message = match e {
|
||||
CodexErr::Sandbox(SandboxErr::Denied { output, .. }) => {
|
||||
let aggregated = output.aggregated_output.text.trim();
|
||||
if !aggregated.is_empty() {
|
||||
output.aggregated_output.text.clone()
|
||||
} else {
|
||||
let stderr = output.stderr.text.trim();
|
||||
let stdout = output.stdout.text.trim();
|
||||
match (stderr.is_empty(), stdout.is_empty()) {
|
||||
(false, false) => format!("{stderr}\n{stdout}"),
|
||||
(false, true) => output.stderr.text.clone(),
|
||||
(true, false) => output.stdout.text.clone(),
|
||||
(true, true) => format!(
|
||||
"command failed inside sandbox with exit code {}",
|
||||
output.exit_code
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Timeouts are not sandbox errors from a UX perspective; present them plainly
|
||||
CodexErr::Sandbox(SandboxErr::Timeout { output }) => {
|
||||
format!(
|
||||
"error: command timed out after {} ms",
|
||||
output.duration.as_millis()
|
||||
)
|
||||
}
|
||||
_ => e.to_string(),
|
||||
};
|
||||
|
||||
truncate_text(
|
||||
&message,
|
||||
TruncationPolicy::Bytes(ERROR_MESSAGE_UI_MAX_BYTES),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "error_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,545 +0,0 @@
|
||||
use super::*;
|
||||
use crate::exec::StreamOutput;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::protocol::RateLimitWindow;
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::Response;
|
||||
use reqwest::ResponseBuilderExt;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::Url;
|
||||
|
||||
fn rate_limit_snapshot() -> RateLimitSnapshot {
|
||||
let primary_reset_at = Utc
|
||||
.with_ymd_and_hms(2024, 1, 1, 1, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp();
|
||||
let secondary_reset_at = Utc
|
||||
.with_ymd_and_hms(2024, 1, 1, 2, 0, 0)
|
||||
.unwrap()
|
||||
.timestamp();
|
||||
RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: Some(RateLimitWindow {
|
||||
used_percent: 50.0,
|
||||
window_minutes: Some(60),
|
||||
resets_at: Some(primary_reset_at),
|
||||
}),
|
||||
secondary: Some(RateLimitWindow {
|
||||
used_percent: 30.0,
|
||||
window_minutes: Some(120),
|
||||
resets_at: Some(secondary_reset_at),
|
||||
}),
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_now_override<T>(now: DateTime<Utc>, f: impl FnOnce() -> T) -> T {
|
||||
NOW_OVERRIDE.with(|cell| {
|
||||
*cell.borrow_mut() = Some(now);
|
||||
let result = f();
|
||||
*cell.borrow_mut() = None;
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_plus_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_overloaded_maps_to_protocol() {
|
||||
let err = CodexErr::ServerOverloaded;
|
||||
assert_eq!(
|
||||
err.to_codex_protocol_error(),
|
||||
CodexErrorInfo::ServerOverloaded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_denied_uses_aggregated_output_when_stderr_empty() {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: 77,
|
||||
stdout: StreamOutput::new(String::new()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new("aggregate detail".to_string()),
|
||||
duration: Duration::from_millis(10),
|
||||
timed_out: false,
|
||||
};
|
||||
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
});
|
||||
assert_eq!(get_error_message_ui(&err), "aggregate detail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_denied_reports_both_streams_when_available() {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: 9,
|
||||
stdout: StreamOutput::new("stdout detail".to_string()),
|
||||
stderr: StreamOutput::new("stderr detail".to_string()),
|
||||
aggregated_output: StreamOutput::new(String::new()),
|
||||
duration: Duration::from_millis(10),
|
||||
timed_out: false,
|
||||
};
|
||||
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
});
|
||||
assert_eq!(get_error_message_ui(&err), "stderr detail\nstdout detail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_denied_reports_stdout_when_no_stderr() {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: 11,
|
||||
stdout: StreamOutput::new("stdout only".to_string()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new(String::new()),
|
||||
duration: Duration::from_millis(8),
|
||||
timed_out: false,
|
||||
};
|
||||
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
});
|
||||
assert_eq!(get_error_message_ui(&err), "stdout only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_error_event_handles_response_stream_failed() {
|
||||
let response = http::Response::builder()
|
||||
.status(StatusCode::TOO_MANY_REQUESTS)
|
||||
.url(Url::parse("http://example.com").unwrap())
|
||||
.body("")
|
||||
.unwrap();
|
||||
let source = Response::from(response).error_for_status_ref().unwrap_err();
|
||||
let err = CodexErr::ResponseStreamFailed(ResponseStreamFailed {
|
||||
source,
|
||||
request_id: Some("req-123".to_string()),
|
||||
});
|
||||
|
||||
let event = err.to_error_event(Some("prefix".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
event.message,
|
||||
"prefix: Error while reading the server response: HTTP status client error (429 Too Many Requests) for url (http://example.com/), request id: req-123"
|
||||
);
|
||||
assert_eq!(
|
||||
event.codex_error_info,
|
||||
Some(CodexErrorInfo::ResponseStreamConnectionFailed {
|
||||
http_status_code: Some(429)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_denied_reports_exit_code_when_no_output_available() {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: 13,
|
||||
stdout: StreamOutput::new(String::new()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new(String::new()),
|
||||
duration: Duration::from_millis(5),
|
||||
timed_out: false,
|
||||
};
|
||||
let err = CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
});
|
||||
assert_eq!(
|
||||
get_error_message_ui(&err),
|
||||
"command failed inside sandbox with exit code 13"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_free_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Free)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_go_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Go)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_default_when_none() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_team_plan() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::hours(1);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Team)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_business_plan_without_reset() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Business)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_default_for_other_plans() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Enterprise)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. Try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_pro_plan_with_reset() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::hours(1);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::hours(1);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(RateLimitSnapshot {
|
||||
limit_id: Some("codex_other".to_string()),
|
||||
limit_name: Some("codex_other".to_string()),
|
||||
..rate_limit_snapshot()
|
||||
})),
|
||||
promo_message: Some(
|
||||
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_includes_minutes_when_available() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::minutes(5);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_cloudflare_html_is_simplified() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "<html><body>Cloudflare error: Sorry, you have been blocked</body></html>"
|
||||
.to_string(),
|
||||
url: Some("http://example.com/blocked".to_string()),
|
||||
cf_ray: Some("ray-id".to_string()),
|
||||
request_id: None,
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
let status = StatusCode::FORBIDDEN.to_string();
|
||||
let url = "http://example.com/blocked";
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_non_html_is_unchanged() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
body: "plain text error".to_string(),
|
||||
url: Some("http://example.com/plain".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: None,
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
let status = StatusCode::FORBIDDEN.to_string();
|
||||
let url = "http://example.com/plain";
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!("unexpected status {status}: plain text error, url: {url}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_prefers_error_message_when_present() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"#
|
||||
.to_string(),
|
||||
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: Some("req-123".to_string()),
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
let status = StatusCode::UNAUTHORIZED.to_string();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"unexpected status {status}: Workspace is not authorized in this region., url: https://chatgpt.com/backend-api/codex/responses, request id: req-123"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_truncates_long_body_with_ellipsis() {
|
||||
let long_body = "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES + 10);
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
body: long_body,
|
||||
url: Some("http://example.com/long".to_string()),
|
||||
cf_ray: None,
|
||||
request_id: Some("req-long".to_string()),
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
let status = StatusCode::BAD_GATEWAY.to_string();
|
||||
let expected_body = format!("{}...", "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES));
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"unexpected status {status}: {expected_body}, url: http://example.com/long, request id: req-long"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_includes_cf_ray_and_request_id() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: "plain text error".to_string(),
|
||||
url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()),
|
||||
cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()),
|
||||
request_id: Some("req-xyz".to_string()),
|
||||
identity_authorization_error: None,
|
||||
identity_error_code: None,
|
||||
};
|
||||
let status = StatusCode::UNAUTHORIZED.to_string();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: 9c81f9f18f2fa49d-LHR, request id: req-xyz"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_status_includes_identity_auth_details() {
|
||||
let err = UnexpectedResponseError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
body: "plain text error".to_string(),
|
||||
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
|
||||
cf_ray: Some("cf-ray-auth-401-test".to_string()),
|
||||
request_id: Some("req-auth".to_string()),
|
||||
identity_authorization_error: Some("missing_authorization_header".to_string()),
|
||||
identity_error_code: Some("token_expired".to_string()),
|
||||
};
|
||||
let status = StatusCode::UNAUTHORIZED.to_string();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/models, cf-ray: cf-ray-auth-401-test, request id: req-auth, auth error: missing_authorization_header, auth error code: token_expired"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_includes_hours_and_minutes() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_includes_days_hours_minutes() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at =
|
||||
base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_less_than_minute() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::seconds(30);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_with_promo_message() {
|
||||
let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let resets_at = base + ChronoDuration::seconds(30);
|
||||
with_now_override(base, move || {
|
||||
let expected_time = format_retry_timestamp(&resets_at);
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: None,
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: Some(
|
||||
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
|
||||
),
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
|
||||
);
|
||||
assert_eq!(err.to_string(), expected);
|
||||
});
|
||||
}
|
||||
+12
-79
@@ -17,26 +17,27 @@ use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandOutputDeltaEvent;
|
||||
use crate::protocol::ExecOutputStream;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::spawn::SpawnChildRequest;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use crate::text_encoding::bytes_to_string_smart;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::error::SandboxErr;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::exec_output::StreamOutput;
|
||||
use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
|
||||
use codex_protocol::protocol::ExecOutputStream;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformRequest;
|
||||
@@ -314,7 +315,6 @@ pub fn build_exec_request(
|
||||
|
||||
pub(crate) async fn execute_exec_request(
|
||||
exec_request: ExecRequest,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
after_spawn: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<ExecToolCallOutput> {
|
||||
@@ -328,13 +328,12 @@ pub(crate) async fn execute_exec_request(
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_policy: _sandbox_policy_from_env,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay,
|
||||
arg0,
|
||||
} = exec_request;
|
||||
let _ = _sandbox_policy_from_env;
|
||||
|
||||
let params = ExecParams {
|
||||
command,
|
||||
@@ -354,7 +353,7 @@ pub(crate) async fn execute_exec_request(
|
||||
let raw_output_result = exec(
|
||||
params,
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
&sandbox_policy,
|
||||
&file_system_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay.as_ref(),
|
||||
network_sandbox_policy,
|
||||
@@ -634,25 +633,6 @@ fn finalize_exec_result(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod errors {
|
||||
use super::CodexErr;
|
||||
use codex_sandboxing::SandboxTransformError;
|
||||
|
||||
impl From<SandboxTransformError> for CodexErr {
|
||||
fn from(err: SandboxTransformError) -> Self {
|
||||
match err {
|
||||
SandboxTransformError::MissingLinuxSandboxExecutable => {
|
||||
CodexErr::LandlockSandboxExecutableNotProvided
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
SandboxTransformError::SeatbeltUnavailable => CodexErr::UnsupportedOperation(
|
||||
"seatbelt sandbox is only available on macOS".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// We don't have a fully deterministic way to tell if our command failed
|
||||
/// because of the sandbox - a command in the user's zshrc file might hit an
|
||||
/// error, but the command itself might fail or succeed for other reasons.
|
||||
@@ -715,12 +695,6 @@ pub(crate) fn is_likely_sandbox_denied(
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamOutput<T: Clone> {
|
||||
pub text: T,
|
||||
pub truncated_after_lines: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RawExecToolCallOutput {
|
||||
pub exit_status: ExitStatus,
|
||||
@@ -730,24 +704,6 @@ struct RawExecToolCallOutput {
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
impl StreamOutput<String> {
|
||||
pub fn new(text: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
truncated_after_lines: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamOutput<Vec<u8>> {
|
||||
pub fn from_utf8_lossy(&self) -> StreamOutput<String> {
|
||||
StreamOutput {
|
||||
text: bytes_to_string_smart(&self.text),
|
||||
truncated_after_lines: self.truncated_after_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn append_capped(dst: &mut Vec<u8>, src: &[u8], max_bytes: usize) {
|
||||
if dst.len() >= max_bytes {
|
||||
@@ -802,29 +758,6 @@ fn aggregate_output(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExecToolCallOutput {
|
||||
pub exit_code: i32,
|
||||
pub stdout: StreamOutput<String>,
|
||||
pub stderr: StreamOutput<String>,
|
||||
pub aggregated_output: StreamOutput<String>,
|
||||
pub duration: Duration,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
impl Default for ExecToolCallOutput {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
exit_code: 0,
|
||||
stdout: StreamOutput::new(String::new()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new(String::new()),
|
||||
duration: Duration::ZERO,
|
||||
timed_out: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn exec(
|
||||
params: ExecParams,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::config::types::EnvironmentVariablePattern;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::config::types::ShellEnvironmentPolicyInherit;
|
||||
use codex_config::types::EnvironmentVariablePattern;
|
||||
use codex_config::types::ShellEnvironmentPolicy;
|
||||
use codex_config::types::ShellEnvironmentPolicyInherit;
|
||||
use codex_protocol::ThreadId;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -21,9 +21,46 @@ pub fn create_env(
|
||||
policy: &ShellEnvironmentPolicy,
|
||||
thread_id: Option<ThreadId>,
|
||||
) -> HashMap<String, String> {
|
||||
populate_env(std::env::vars(), policy, thread_id)
|
||||
create_env_from_vars(std::env::vars(), policy, thread_id)
|
||||
}
|
||||
|
||||
fn create_env_from_vars<I>(
|
||||
vars: I,
|
||||
policy: &ShellEnvironmentPolicy,
|
||||
thread_id: Option<ThreadId>,
|
||||
) -> HashMap<String, String>
|
||||
where
|
||||
I: IntoIterator<Item = (String, String)>,
|
||||
{
|
||||
let mut env_map = populate_env(vars, policy, thread_id);
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
// This is a workaround to address the failures we are seeing in the
|
||||
// following tests when run via Bazel on Windows:
|
||||
//
|
||||
// ```
|
||||
// suite::shell_command::unicode_output::with_login
|
||||
// suite::shell_command::unicode_output::without_login
|
||||
// ```
|
||||
//
|
||||
// Currently, we can only reproduce these failures in CI, which makes
|
||||
// iteration times long, so we include this quick fix for now to unblock
|
||||
// getting the Windows Bazel build running.
|
||||
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
|
||||
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
|
||||
}
|
||||
}
|
||||
env_map
|
||||
}
|
||||
|
||||
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
|
||||
|
||||
#[cfg(unix)]
|
||||
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];
|
||||
|
||||
fn populate_env<I>(
|
||||
vars: I,
|
||||
policy: &ShellEnvironmentPolicy,
|
||||
@@ -38,17 +75,18 @@ where
|
||||
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
|
||||
ShellEnvironmentPolicyInherit::None => HashMap::new(),
|
||||
ShellEnvironmentPolicyInherit::Core => {
|
||||
const CORE_VARS: &[&str] = &[
|
||||
"HOME", "LOGNAME", "PATH", "SHELL", "USER", "USERNAME", "TMPDIR", "TEMP", "TMP",
|
||||
];
|
||||
let allow: HashSet<&str> = CORE_VARS.iter().copied().collect();
|
||||
let core_vars: HashSet<&str> = COMMON_CORE_VARS
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(PLATFORM_CORE_VARS.iter().copied())
|
||||
.collect();
|
||||
let is_core_var = |name: &str| {
|
||||
if cfg!(target_os = "windows") {
|
||||
CORE_VARS
|
||||
core_vars
|
||||
.iter()
|
||||
.any(|allowed| allowed.eq_ignore_ascii_case(name))
|
||||
} else {
|
||||
allow.contains(name)
|
||||
core_vars.contains(name)
|
||||
}
|
||||
};
|
||||
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::types::ShellEnvironmentPolicyInherit;
|
||||
use codex_config::types::ShellEnvironmentPolicyInherit;
|
||||
use maplit::hashmap;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn make_vars(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
pairs
|
||||
@@ -171,6 +172,7 @@ fn test_inherit_all_with_default_excludes() {
|
||||
fn test_core_inherit_respects_case_insensitive_names_on_windows() {
|
||||
let vars = make_vars(&[
|
||||
("Path", "C:\\Windows\\System32"),
|
||||
("PathExt", ".COM;.EXE;.BAT;.CMD"),
|
||||
("TEMP", "C:\\Temp"),
|
||||
("FOO", "bar"),
|
||||
]);
|
||||
@@ -185,6 +187,7 @@ fn test_core_inherit_respects_case_insensitive_names_on_windows() {
|
||||
let result = populate_env(vars, &policy, Some(thread_id));
|
||||
let mut expected: HashMap<String, String> = hashmap! {
|
||||
"Path".to_string() => "C:\\Windows\\System32".to_string(),
|
||||
"PathExt".to_string() => ".COM;.EXE;.BAT;.CMD".to_string(),
|
||||
"TEMP".to_string() => "C:\\Temp".to_string(),
|
||||
};
|
||||
expected.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
|
||||
@@ -192,6 +195,47 @@ fn test_core_inherit_respects_case_insensitive_names_on_windows() {
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn create_env_inserts_pathext_on_windows_when_missing() {
|
||||
let vars = make_vars(&[]);
|
||||
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::None,
|
||||
ignore_default_excludes: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = create_env_from_vars(vars, &policy, /*thread_id*/ None);
|
||||
|
||||
let expected: HashMap<String, String> = hashmap! {
|
||||
"PATHEXT".to_string() => ".COM;.EXE;.BAT;.CMD".to_string(),
|
||||
};
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn create_env_preserves_existing_pathext_case_insensitively_on_windows() {
|
||||
let vars = make_vars(&[("PathExt", ".COM;.EXE;.BAT;.CMD;.PS1")]);
|
||||
|
||||
let policy = ShellEnvironmentPolicy {
|
||||
inherit: ShellEnvironmentPolicyInherit::Core,
|
||||
ignore_default_excludes: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = create_env_from_vars(vars, &policy, /*thread_id*/ None);
|
||||
|
||||
let pathext_vars = result
|
||||
.iter()
|
||||
.filter(|(key, _)| key.eq_ignore_ascii_case("PATHEXT"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(pathext_vars.len(), 1);
|
||||
assert_eq!(pathext_vars[0].1, ".COM;.EXE;.BAT;.CMD;.PS1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inherit_none() {
|
||||
let vars = make_vars(&[("PATH", "/usr/bin"), ("HOME", "/home")]);
|
||||
|
||||
@@ -7,8 +7,6 @@ use arc_swap::ArcSwap;
|
||||
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::is_dangerous_command::command_might_be_dangerous;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use codex_execpolicy::AmendError;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Error as ExecPolicyRuleError;
|
||||
@@ -25,16 +23,18 @@ use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_shell_command::is_dangerous_command::command_might_be_dangerous;
|
||||
use codex_shell_command::is_safe_command::is_known_safe_command;
|
||||
use thiserror::Error;
|
||||
use tokio::fs;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
use crate::bash::parse_shell_lc_single_command_prefix;
|
||||
use crate::config::Config;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use shlex::try_join as shlex_try_join;
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::config_loader::Sourced;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
@@ -115,14 +114,8 @@ fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
|
||||
|
||||
async fn test_config() -> (TempDir, Config) {
|
||||
let home = TempDir::new().expect("create temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
let config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.loader_overrides(LoaderOverrides {
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(String::new()),
|
||||
..LoaderOverrides::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::approvals::GuardianAssessmentAction;
|
||||
use codex_protocol::approvals::GuardianCommandSource;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -31,7 +34,7 @@ pub(crate) enum GuardianApprovalRequest {
|
||||
#[cfg(unix)]
|
||||
Execve {
|
||||
id: String,
|
||||
tool_name: String,
|
||||
source: GuardianCommandSource,
|
||||
program: String,
|
||||
argv: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
@@ -41,7 +44,6 @@ pub(crate) enum GuardianApprovalRequest {
|
||||
id: String,
|
||||
cwd: PathBuf,
|
||||
files: Vec<AbsolutePathBuf>,
|
||||
change_count: usize,
|
||||
patch: String,
|
||||
},
|
||||
NetworkAccess {
|
||||
@@ -80,7 +82,7 @@ pub(crate) struct GuardianMcpAnnotations {
|
||||
struct CommandApprovalAction<'a> {
|
||||
tool: &'a str,
|
||||
command: &'a [String],
|
||||
cwd: &'a PathBuf,
|
||||
cwd: &'a Path,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
additional_permissions: Option<&'a PermissionProfile>,
|
||||
@@ -96,7 +98,7 @@ struct ExecveApprovalAction<'a> {
|
||||
tool: &'a str,
|
||||
program: &'a str,
|
||||
argv: &'a [String],
|
||||
cwd: &'a PathBuf,
|
||||
cwd: &'a Path,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
additional_permissions: Option<&'a PermissionProfile>,
|
||||
}
|
||||
@@ -129,7 +131,7 @@ fn serialize_guardian_action(value: impl Serialize) -> serde_json::Result<Value>
|
||||
fn serialize_command_guardian_action(
|
||||
tool: &'static str,
|
||||
command: &[String],
|
||||
cwd: &PathBuf,
|
||||
cwd: &Path,
|
||||
sandbox_permissions: crate::sandboxing::SandboxPermissions,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
justification: Option<&String>,
|
||||
@@ -146,12 +148,24 @@ fn serialize_command_guardian_action(
|
||||
})
|
||||
}
|
||||
|
||||
fn command_assessment_action_value(tool: &'static str, command: &[String], cwd: &PathBuf) -> Value {
|
||||
serde_json::json!({
|
||||
"tool": tool,
|
||||
"command": codex_shell_command::parse_command::shlex_join(command),
|
||||
"cwd": cwd,
|
||||
})
|
||||
fn command_assessment_action(
|
||||
source: GuardianCommandSource,
|
||||
command: &[String],
|
||||
cwd: &Path,
|
||||
) -> GuardianAssessmentAction {
|
||||
GuardianAssessmentAction::Command {
|
||||
source,
|
||||
command: codex_shell_command::parse_command::shlex_join(command),
|
||||
cwd: cwd.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn guardian_command_source_tool_name(source: GuardianCommandSource) -> &'static str {
|
||||
match source {
|
||||
GuardianCommandSource::Shell => "shell",
|
||||
GuardianCommandSource::UnifiedExec => "exec_command",
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_guardian_action_value(value: Value) -> Value {
|
||||
@@ -220,13 +234,13 @@ pub(crate) fn guardian_approval_request_to_json(
|
||||
#[cfg(unix)]
|
||||
GuardianApprovalRequest::Execve {
|
||||
id: _,
|
||||
tool_name,
|
||||
source,
|
||||
program,
|
||||
argv,
|
||||
cwd,
|
||||
additional_permissions,
|
||||
} => serialize_guardian_action(ExecveApprovalAction {
|
||||
tool: tool_name,
|
||||
tool: guardian_command_source_tool_name(*source),
|
||||
program,
|
||||
argv,
|
||||
cwd,
|
||||
@@ -236,13 +250,11 @@ pub(crate) fn guardian_approval_request_to_json(
|
||||
id: _,
|
||||
cwd,
|
||||
files,
|
||||
change_count,
|
||||
patch,
|
||||
} => Ok(serde_json::json!({
|
||||
"tool": "apply_patch",
|
||||
"cwd": cwd,
|
||||
"files": files,
|
||||
"change_count": change_count,
|
||||
"patch": patch,
|
||||
})),
|
||||
GuardianApprovalRequest::NetworkAccess {
|
||||
@@ -285,38 +297,38 @@ pub(crate) fn guardian_approval_request_to_json(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn guardian_assessment_action_value(action: &GuardianApprovalRequest) -> Value {
|
||||
pub(crate) fn guardian_assessment_action(
|
||||
action: &GuardianApprovalRequest,
|
||||
) -> GuardianAssessmentAction {
|
||||
match action {
|
||||
GuardianApprovalRequest::Shell { command, cwd, .. } => {
|
||||
command_assessment_action_value("shell", command, cwd)
|
||||
command_assessment_action(GuardianCommandSource::Shell, command, cwd)
|
||||
}
|
||||
GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
|
||||
command_assessment_action_value("exec_command", command, cwd)
|
||||
command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
GuardianApprovalRequest::Execve {
|
||||
tool_name,
|
||||
source,
|
||||
program,
|
||||
argv,
|
||||
cwd,
|
||||
..
|
||||
} => serde_json::json!({
|
||||
"tool": tool_name,
|
||||
"program": program,
|
||||
"argv": argv,
|
||||
"cwd": cwd,
|
||||
}),
|
||||
GuardianApprovalRequest::ApplyPatch {
|
||||
cwd,
|
||||
files,
|
||||
change_count,
|
||||
..
|
||||
} => serde_json::json!({
|
||||
"tool": "apply_patch",
|
||||
"cwd": cwd,
|
||||
"files": files,
|
||||
"change_count": change_count,
|
||||
}),
|
||||
} => GuardianAssessmentAction::Execve {
|
||||
source: *source,
|
||||
program: program.clone(),
|
||||
argv: argv.clone(),
|
||||
cwd: cwd.clone(),
|
||||
},
|
||||
GuardianApprovalRequest::ApplyPatch { cwd, files, .. } => {
|
||||
GuardianAssessmentAction::ApplyPatch {
|
||||
cwd: cwd.clone(),
|
||||
files: files
|
||||
.iter()
|
||||
.map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
GuardianApprovalRequest::NetworkAccess {
|
||||
id: _,
|
||||
turn_id: _,
|
||||
@@ -324,20 +336,26 @@ pub(crate) fn guardian_assessment_action_value(action: &GuardianApprovalRequest)
|
||||
host,
|
||||
protocol,
|
||||
port,
|
||||
} => serde_json::json!({
|
||||
"tool": "network_access",
|
||||
"target": target,
|
||||
"host": host,
|
||||
"protocol": protocol,
|
||||
"port": port,
|
||||
}),
|
||||
} => GuardianAssessmentAction::NetworkAccess {
|
||||
target: target.clone(),
|
||||
host: host.clone(),
|
||||
protocol: *protocol,
|
||||
port: *port,
|
||||
},
|
||||
GuardianApprovalRequest::McpToolCall {
|
||||
server, tool_name, ..
|
||||
} => serde_json::json!({
|
||||
"tool": "mcp_tool_call",
|
||||
"server": server,
|
||||
"tool_name": tool_name,
|
||||
}),
|
||||
server,
|
||||
tool_name,
|
||||
connector_id,
|
||||
connector_name,
|
||||
tool_title,
|
||||
..
|
||||
} => GuardianAssessmentAction::McpToolCall {
|
||||
server: server.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
connector_id: connector_id.clone(),
|
||||
connector_name: connector_name.clone(),
|
||||
tool_title: tool_title.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ pub(crate) struct GuardianAssessment {
|
||||
#[cfg(test)]
|
||||
use approval_request::format_guardian_action_pretty;
|
||||
#[cfg(test)]
|
||||
use approval_request::guardian_assessment_action_value;
|
||||
use approval_request::guardian_assessment_action;
|
||||
#[cfg(test)]
|
||||
use approval_request::guardian_request_turn_id;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,7 +18,7 @@ use super::GUARDIAN_APPROVAL_RISK_THRESHOLD;
|
||||
use super::GUARDIAN_REVIEWER_NAME;
|
||||
use super::GuardianApprovalRequest;
|
||||
use super::GuardianAssessment;
|
||||
use super::approval_request::guardian_assessment_action_value;
|
||||
use super::approval_request::guardian_assessment_action;
|
||||
use super::approval_request::guardian_request_id;
|
||||
use super::approval_request::guardian_request_turn_id;
|
||||
use super::prompt::build_guardian_prompt_items;
|
||||
@@ -81,7 +81,7 @@ async fn run_guardian_review(
|
||||
) -> ReviewDecision {
|
||||
let assessment_id = guardian_request_id(&request).to_string();
|
||||
let assessment_turn_id = guardian_request_turn_id(&request, &turn.sub_id).to_string();
|
||||
let action_summary = guardian_assessment_action_value(&request);
|
||||
let action_summary = guardian_assessment_action(&request);
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
@@ -92,7 +92,7 @@ async fn run_guardian_review(
|
||||
risk_score: None,
|
||||
risk_level: None,
|
||||
rationale: None,
|
||||
action: Some(action_summary.clone()),
|
||||
action: action_summary.clone(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -111,7 +111,7 @@ async fn run_guardian_review(
|
||||
risk_score: None,
|
||||
risk_level: None,
|
||||
rationale: None,
|
||||
action: Some(action_summary),
|
||||
action: action_summary,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -161,7 +161,7 @@ async fn run_guardian_review(
|
||||
risk_score: None,
|
||||
risk_level: None,
|
||||
rationale: None,
|
||||
action: Some(action_summary),
|
||||
action: action_summary,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -197,7 +197,7 @@ async fn run_guardian_review(
|
||||
risk_score: Some(assessment.risk_score),
|
||||
risk_level: Some(assessment.risk_level),
|
||||
rationale: Some(assessment.rationale.clone()),
|
||||
action: Some(terminal_action),
|
||||
action: terminal_action,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -274,7 +274,7 @@ pub(super) async fn run_guardian_review_session(
|
||||
let available_models = session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(crate::models_manager::manager::RefreshStrategy::Offline)
|
||||
.list_models(codex_models_manager::manager::RefreshStrategy::Offline)
|
||||
.await;
|
||||
let preferred_reasoning_effort = |supports_low: bool, fallback| {
|
||||
if supports_low {
|
||||
|
||||
@@ -17,6 +17,7 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde_json::Value;
|
||||
@@ -33,11 +34,10 @@ use crate::config::Constrained;
|
||||
use crate::config::ManagedFeatures;
|
||||
use crate::config::NetworkProxySpec;
|
||||
use crate::config::Permissions;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::rollout::recorder::RolloutRecorder;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
|
||||
use super::GUARDIAN_REVIEW_TIMEOUT;
|
||||
use super::GUARDIAN_REVIEWER_NAME;
|
||||
|
||||
@@ -15,7 +15,6 @@ use crate::config_loader::NetworkDomainPermissionToml;
|
||||
use crate::config_loader::NetworkDomainPermissionsToml;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::config_loader::Sourced;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::test_support;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
@@ -27,6 +26,7 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::GuardianRiskLevel;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use core_test_support::context_snapshot;
|
||||
@@ -265,7 +265,6 @@ fn format_guardian_action_pretty_truncates_large_string_fields() -> serde_json::
|
||||
id: "patch-1".to_string(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
files: Vec::new(),
|
||||
change_count: 1usize,
|
||||
patch: patch.clone(),
|
||||
};
|
||||
|
||||
@@ -318,7 +317,7 @@ fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_assessment_action_value_redacts_apply_patch_patch_text() {
|
||||
fn guardian_assessment_action_redacts_apply_patch_patch_text() {
|
||||
let (cwd, file) = if cfg!(windows) {
|
||||
(r"C:\tmp", r"C:\tmp\guardian.txt")
|
||||
} else {
|
||||
@@ -330,19 +329,17 @@ fn guardian_assessment_action_value_redacts_apply_patch_patch_text() {
|
||||
id: "patch-1".to_string(),
|
||||
cwd: cwd.clone(),
|
||||
files: vec![file.clone()],
|
||||
change_count: 1usize,
|
||||
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+secret\n*** End Patch"
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
guardian_assessment_action_value(&action),
|
||||
serde_json::to_value(guardian_assessment_action(&action)).expect("serialize action"),
|
||||
serde_json::json!({
|
||||
"tool": "apply_patch",
|
||||
"type": "apply_patch",
|
||||
"cwd": cwd,
|
||||
"files": [file],
|
||||
"change_count": 1,
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -360,7 +357,6 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() {
|
||||
id: "patch-1".to_string(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
files: vec![PathBuf::from("/tmp/guardian.txt").abs()],
|
||||
change_count: 1usize,
|
||||
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
|
||||
.to_string(),
|
||||
};
|
||||
@@ -388,7 +384,6 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
|
||||
id: "patch-1".to_string(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
files: vec![PathBuf::from("/tmp/guardian.txt").abs()],
|
||||
change_count: 1usize,
|
||||
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
|
||||
.to_string(),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::HookCompletedEvent;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
use codex_protocol::protocol::HookStartedEvent;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -306,7 +307,7 @@ async fn emit_hook_started_events(
|
||||
for run in preview_runs {
|
||||
sess.send_event(
|
||||
turn_context,
|
||||
EventMsg::HookStarted(crate::protocol::HookStartedEvent {
|
||||
EventMsg::HookStarted(HookStartedEvent {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
run,
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::spawn::SpawnChildRequest;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
|
||||
use codex_sandboxing::landlock::allow_network_for_proxy;
|
||||
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_policies;
|
||||
|
||||
@@ -5,12 +5,9 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
pub mod api_bridge;
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
mod arc_monitor;
|
||||
pub use codex_login as auth;
|
||||
mod auth_env_telemetry;
|
||||
mod client;
|
||||
mod client_common;
|
||||
pub mod codex;
|
||||
@@ -30,9 +27,7 @@ pub mod config_loader;
|
||||
pub mod connectors;
|
||||
mod context_manager;
|
||||
mod contextual_user_message;
|
||||
pub use codex_utils_path::env;
|
||||
mod environment_context;
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
pub mod exec_env;
|
||||
mod exec_policy;
|
||||
@@ -46,25 +41,25 @@ mod hook_runtime;
|
||||
pub mod instructions;
|
||||
pub mod landlock;
|
||||
pub mod mcp;
|
||||
mod mcp_connection_manager;
|
||||
mod mcp_skill_dependencies;
|
||||
mod mcp_tool_approval_templates;
|
||||
pub mod models_manager;
|
||||
mod network_policy_decision;
|
||||
pub mod network_proxy_loader;
|
||||
mod original_image_detail;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
|
||||
pub use mcp_connection_manager::SandboxState;
|
||||
pub use text_encoding::bytes_to_string_smart;
|
||||
pub use codex_mcp::mcp_connection_manager;
|
||||
pub use codex_mcp::mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
pub use codex_mcp::mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
|
||||
pub use codex_mcp::mcp_connection_manager::SandboxState;
|
||||
mod mcp_tool_call;
|
||||
mod memories;
|
||||
pub mod mention_syntax;
|
||||
pub mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod utils;
|
||||
pub use utils::path_utils;
|
||||
pub mod personality_migration;
|
||||
pub mod plugins;
|
||||
#[doc(hidden)]
|
||||
pub mod prompt_debug;
|
||||
pub(crate) mod mentions {
|
||||
pub(crate) use crate::plugins::build_connector_slug_counts;
|
||||
pub(crate) use crate::plugins::build_skill_name_counts;
|
||||
@@ -93,28 +88,17 @@ pub(crate) use skills::injection;
|
||||
pub(crate) use skills::loader;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::model;
|
||||
pub(crate) use skills::render_skills_section;
|
||||
pub(crate) use skills::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod skills_watcher;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod text_encoding;
|
||||
mod unified_exec;
|
||||
pub mod windows_sandbox;
|
||||
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
|
||||
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
|
||||
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
|
||||
pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::OPENAI_PROVIDER_ID;
|
||||
pub use model_provider_info::WireApi;
|
||||
pub use model_provider_info::built_in_model_providers;
|
||||
pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
pub use codex_protocol::config_types::ModelProviderAuthInfo;
|
||||
mod event_mapping;
|
||||
mod response_debug_context;
|
||||
pub mod review_format;
|
||||
pub mod review_prompts;
|
||||
mod thread_manager;
|
||||
@@ -129,19 +113,6 @@ pub type ConversationManager = ThreadManager;
|
||||
pub type NewConversation = NewThread;
|
||||
#[deprecated(note = "use CodexThread")]
|
||||
pub type CodexConversation = CodexThread;
|
||||
// Re-export common auth types for workspace consumers
|
||||
pub use auth::AuthManager;
|
||||
pub use auth::CodexAuth;
|
||||
pub use codex_analytics::AnalyticsEventsClient;
|
||||
mod default_client_forwarding;
|
||||
|
||||
/// Default Codex HTTP client headers and reqwest construction.
|
||||
///
|
||||
/// Implemented in [`codex_login::default_client`]; this module re-exports that API for crates
|
||||
/// that import `codex_core::default_client`.
|
||||
pub mod default_client {
|
||||
pub use super::default_client_forwarding::*;
|
||||
}
|
||||
pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
@@ -151,7 +122,6 @@ pub mod shell;
|
||||
pub mod shell_snapshot;
|
||||
pub mod spawn;
|
||||
pub mod state_db_bridge;
|
||||
pub use codex_rollout::state_db;
|
||||
mod thread_rollout_truncation;
|
||||
mod tools;
|
||||
pub mod turn_diff_tracker;
|
||||
@@ -185,12 +155,6 @@ mod state;
|
||||
mod tasks;
|
||||
mod user_shell_command;
|
||||
pub mod util;
|
||||
pub(crate) use codex_protocol::protocol;
|
||||
pub(crate) use codex_shell_command::bash;
|
||||
pub(crate) use codex_shell_command::is_dangerous_command;
|
||||
pub(crate) use codex_shell_command::is_safe_command;
|
||||
pub(crate) use codex_shell_command::parse_command;
|
||||
pub(crate) use codex_shell_command::powershell;
|
||||
|
||||
pub use client::ModelClient;
|
||||
pub use client::ModelClientSession;
|
||||
@@ -199,8 +163,6 @@ pub use client_common::Prompt;
|
||||
pub use client_common::REVIEW_PROMPT;
|
||||
pub use client_common::ResponseEvent;
|
||||
pub use client_common::ResponseStream;
|
||||
pub use codex_sandboxing::get_platform_sandbox;
|
||||
pub use codex_tools::parse_tool_input_schema;
|
||||
pub use compact::content_items_to_text;
|
||||
pub use event_mapping::parse_turn_item;
|
||||
pub use exec_policy::ExecPolicyError;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::plugins::PluginsManager;
|
||||
use codex_config::McpServerConfig;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_mcp::mcp::ToolPluginProvenance;
|
||||
use codex_mcp::mcp::configured_mcp_servers;
|
||||
use codex_mcp::mcp::effective_mcp_servers;
|
||||
use codex_mcp::mcp::tool_plugin_provenance as collect_tool_plugin_provenance;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct McpManager {
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn new(plugins_manager: Arc<PluginsManager>) -> Self {
|
||||
Self { plugins_manager }
|
||||
}
|
||||
|
||||
pub fn configured_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
|
||||
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref());
|
||||
configured_mcp_servers(&mcp_config)
|
||||
}
|
||||
|
||||
pub fn effective_servers(
|
||||
&self,
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref());
|
||||
effective_mcp_servers(&mcp_config, auth)
|
||||
}
|
||||
|
||||
pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance {
|
||||
let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref());
|
||||
collect_tool_plugin_provenance(&mcp_config)
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_rmcp_client::OAuthProviderError;
|
||||
use codex_rmcp_client::determine_streamable_http_auth_status;
|
||||
use codex_rmcp_client::discover_streamable_http_oauth;
|
||||
use futures::future::join_all;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpOAuthLoginConfig {
|
||||
pub url: String,
|
||||
pub http_headers: Option<HashMap<String, String>>,
|
||||
pub env_http_headers: Option<HashMap<String, String>>,
|
||||
pub discovered_scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum McpOAuthLoginSupport {
|
||||
Supported(McpOAuthLoginConfig),
|
||||
Unsupported,
|
||||
Unknown(anyhow::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpOAuthScopesSource {
|
||||
Explicit,
|
||||
Configured,
|
||||
Discovered,
|
||||
Empty,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedMcpOAuthScopes {
|
||||
pub scopes: Vec<String>,
|
||||
pub source: McpOAuthScopesSource,
|
||||
}
|
||||
|
||||
pub async fn oauth_login_support(transport: &McpServerTransportConfig) -> McpOAuthLoginSupport {
|
||||
let McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} = transport
|
||||
else {
|
||||
return McpOAuthLoginSupport::Unsupported;
|
||||
};
|
||||
|
||||
if bearer_token_env_var.is_some() {
|
||||
return McpOAuthLoginSupport::Unsupported;
|
||||
}
|
||||
|
||||
match discover_streamable_http_oauth(url, http_headers.clone(), env_http_headers.clone()).await
|
||||
{
|
||||
Ok(Some(discovery)) => McpOAuthLoginSupport::Supported(McpOAuthLoginConfig {
|
||||
url: url.clone(),
|
||||
http_headers: http_headers.clone(),
|
||||
env_http_headers: env_http_headers.clone(),
|
||||
discovered_scopes: discovery.scopes_supported,
|
||||
}),
|
||||
Ok(None) => McpOAuthLoginSupport::Unsupported,
|
||||
Err(err) => McpOAuthLoginSupport::Unknown(err),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn discover_supported_scopes(
|
||||
transport: &McpServerTransportConfig,
|
||||
) -> Option<Vec<String>> {
|
||||
match oauth_login_support(transport).await {
|
||||
McpOAuthLoginSupport::Supported(config) => config.discovered_scopes,
|
||||
McpOAuthLoginSupport::Unsupported | McpOAuthLoginSupport::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_oauth_scopes(
|
||||
explicit_scopes: Option<Vec<String>>,
|
||||
configured_scopes: Option<Vec<String>>,
|
||||
discovered_scopes: Option<Vec<String>>,
|
||||
) -> ResolvedMcpOAuthScopes {
|
||||
if let Some(scopes) = explicit_scopes {
|
||||
return ResolvedMcpOAuthScopes {
|
||||
scopes,
|
||||
source: McpOAuthScopesSource::Explicit,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(scopes) = configured_scopes {
|
||||
return ResolvedMcpOAuthScopes {
|
||||
scopes,
|
||||
source: McpOAuthScopesSource::Configured,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(scopes) = discovered_scopes
|
||||
&& !scopes.is_empty()
|
||||
{
|
||||
return ResolvedMcpOAuthScopes {
|
||||
scopes,
|
||||
source: McpOAuthScopesSource::Discovered,
|
||||
};
|
||||
}
|
||||
|
||||
ResolvedMcpOAuthScopes {
|
||||
scopes: Vec::new(),
|
||||
source: McpOAuthScopesSource::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_retry_without_scopes(scopes: &ResolvedMcpOAuthScopes, error: &anyhow::Error) -> bool {
|
||||
scopes.source == McpOAuthScopesSource::Discovered
|
||||
&& error.downcast_ref::<OAuthProviderError>().is_some()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpAuthStatusEntry {
|
||||
pub config: McpServerConfig,
|
||||
pub auth_status: McpAuthStatus,
|
||||
}
|
||||
|
||||
pub async fn compute_auth_statuses<'a, I>(
|
||||
servers: I,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
) -> HashMap<String, McpAuthStatusEntry>
|
||||
where
|
||||
I: IntoIterator<Item = (&'a String, &'a McpServerConfig)>,
|
||||
{
|
||||
let futures = servers.into_iter().map(|(name, config)| {
|
||||
let name = name.clone();
|
||||
let config = config.clone();
|
||||
async move {
|
||||
let auth_status = match compute_auth_status(&name, &config, store_mode).await {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
warn!("failed to determine auth status for MCP server `{name}`: {error:?}");
|
||||
McpAuthStatus::Unsupported
|
||||
}
|
||||
};
|
||||
let entry = McpAuthStatusEntry {
|
||||
config,
|
||||
auth_status,
|
||||
};
|
||||
(name, entry)
|
||||
}
|
||||
});
|
||||
|
||||
join_all(futures).await.into_iter().collect()
|
||||
}
|
||||
|
||||
async fn compute_auth_status(
|
||||
server_name: &str,
|
||||
config: &McpServerConfig,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
) -> Result<McpAuthStatus> {
|
||||
match &config.transport {
|
||||
McpServerTransportConfig::Stdio { .. } => Ok(McpAuthStatus::Unsupported),
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
} => {
|
||||
determine_streamable_http_auth_status(
|
||||
server_name,
|
||||
url,
|
||||
bearer_token_env_var.as_deref(),
|
||||
http_headers.clone(),
|
||||
env_http_headers.clone(),
|
||||
store_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::anyhow;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::McpOAuthScopesSource;
|
||||
use super::OAuthProviderError;
|
||||
use super::ResolvedMcpOAuthScopes;
|
||||
use super::resolve_oauth_scopes;
|
||||
use super::should_retry_without_scopes;
|
||||
|
||||
#[test]
|
||||
fn resolve_oauth_scopes_prefers_explicit() {
|
||||
let resolved = resolve_oauth_scopes(
|
||||
Some(vec!["explicit".to_string()]),
|
||||
Some(vec!["configured".to_string()]),
|
||||
Some(vec!["discovered".to_string()]),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMcpOAuthScopes {
|
||||
scopes: vec!["explicit".to_string()],
|
||||
source: McpOAuthScopesSource::Explicit,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_oauth_scopes_prefers_configured_over_discovered() {
|
||||
let resolved = resolve_oauth_scopes(
|
||||
/*explicit_scopes*/ None,
|
||||
Some(vec!["configured".to_string()]),
|
||||
Some(vec!["discovered".to_string()]),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMcpOAuthScopes {
|
||||
scopes: vec!["configured".to_string()],
|
||||
source: McpOAuthScopesSource::Configured,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_oauth_scopes_uses_discovered_when_needed() {
|
||||
let resolved = resolve_oauth_scopes(
|
||||
/*explicit_scopes*/ None,
|
||||
/*configured_scopes*/ None,
|
||||
Some(vec!["discovered".to_string()]),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMcpOAuthScopes {
|
||||
scopes: vec!["discovered".to_string()],
|
||||
source: McpOAuthScopesSource::Discovered,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_oauth_scopes_preserves_explicitly_empty_configured_scopes() {
|
||||
let resolved = resolve_oauth_scopes(
|
||||
/*explicit_scopes*/ None,
|
||||
Some(Vec::new()),
|
||||
Some(vec!["ignored".into()]),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMcpOAuthScopes {
|
||||
scopes: Vec::new(),
|
||||
source: McpOAuthScopesSource::Configured,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_oauth_scopes_falls_back_to_empty() {
|
||||
let resolved = resolve_oauth_scopes(
|
||||
/*explicit_scopes*/ None, /*configured_scopes*/ None,
|
||||
/*discovered_scopes*/ None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMcpOAuthScopes {
|
||||
scopes: Vec::new(),
|
||||
source: McpOAuthScopesSource::Empty,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_retry_without_scopes_only_for_discovered_provider_errors() {
|
||||
let discovered = ResolvedMcpOAuthScopes {
|
||||
scopes: vec!["scope".to_string()],
|
||||
source: McpOAuthScopesSource::Discovered,
|
||||
};
|
||||
let provider_error = anyhow!(OAuthProviderError::new(
|
||||
Some("invalid_scope".to_string()),
|
||||
Some("scope rejected".to_string()),
|
||||
));
|
||||
|
||||
assert!(should_retry_without_scopes(&discovered, &provider_error));
|
||||
|
||||
let configured = ResolvedMcpOAuthScopes {
|
||||
scopes: vec!["scope".to_string()],
|
||||
source: McpOAuthScopesSource::Configured,
|
||||
};
|
||||
assert!(!should_retry_without_scopes(&configured, &provider_error));
|
||||
assert!(!should_retry_without_scopes(
|
||||
&discovered,
|
||||
&anyhow!("timed out waiting for OAuth callback"),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
pub mod auth;
|
||||
mod skill_dependencies;
|
||||
pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_channel::unbounded;
|
||||
use codex_protocol::mcp::Resource;
|
||||
use codex_protocol::mcp::ResourceTemplate;
|
||||
use codex_protocol::mcp::Tool;
|
||||
use codex_protocol::protocol::McpListToolsResponseEvent;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::SandboxState;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginsManager;
|
||||
|
||||
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
||||
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
||||
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
|
||||
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
||||
|
||||
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
|
||||
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
|
||||
/// name we expose to the model by replacing any disallowed character with `_`.
|
||||
pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String {
|
||||
let mut sanitized = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
sanitized.push(c);
|
||||
} else {
|
||||
sanitized.push('_');
|
||||
}
|
||||
}
|
||||
|
||||
if sanitized.is_empty() {
|
||||
"_".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
|
||||
sanitize_responses_api_tool_name(&format!(
|
||||
"{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ToolPluginProvenance {
|
||||
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
|
||||
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
impl ToolPluginProvenance {
|
||||
pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
|
||||
self.plugin_display_names_by_connector_id
|
||||
.get(connector_id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
|
||||
self.plugin_display_names_by_mcp_server_name
|
||||
.get(server_name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self {
|
||||
let mut tool_plugin_provenance = Self::default();
|
||||
for plugin in capability_summaries {
|
||||
for connector_id in &plugin.app_connector_ids {
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_connector_id
|
||||
.entry(connector_id.0.clone())
|
||||
.or_default()
|
||||
.push(plugin.display_name.clone());
|
||||
}
|
||||
|
||||
for server_name in &plugin.mcp_server_names {
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_mcp_server_name
|
||||
.entry(server_name.clone())
|
||||
.or_default()
|
||||
.push(plugin.display_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for plugin_names in tool_plugin_provenance
|
||||
.plugin_display_names_by_connector_id
|
||||
.values_mut()
|
||||
.chain(
|
||||
tool_plugin_provenance
|
||||
.plugin_display_names_by_mcp_server_name
|
||||
.values_mut(),
|
||||
)
|
||||
{
|
||||
plugin_names.sort_unstable();
|
||||
plugin_names.dedup();
|
||||
}
|
||||
|
||||
tool_plugin_provenance
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
|
||||
match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) {
|
||||
Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
||||
Ok(_) => None,
|
||||
Err(env::VarError::NotPresent) => None,
|
||||
Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option<String> {
|
||||
let token = auth.and_then(|auth| auth.get_token().ok())?;
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(token.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option<HashMap<String, String>> {
|
||||
let mut headers = HashMap::new();
|
||||
if let Some(token) = codex_apps_mcp_bearer_token(auth) {
|
||||
headers.insert("Authorization".to_string(), format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(account_id) = auth.and_then(CodexAuth::get_account_id) {
|
||||
headers.insert("ChatGPT-Account-ID".to_string(), account_id);
|
||||
}
|
||||
if headers.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(headers)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_codex_apps_base_url(base_url: &str) -> String {
|
||||
let mut base_url = base_url.trim_end_matches('/').to_string();
|
||||
if (base_url.starts_with("https://chatgpt.com")
|
||||
|| base_url.starts_with("https://chat.openai.com"))
|
||||
&& !base_url.contains("/backend-api")
|
||||
{
|
||||
base_url = format!("{base_url}/backend-api");
|
||||
}
|
||||
base_url
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
|
||||
let base_url = normalize_codex_apps_base_url(base_url);
|
||||
if base_url.contains("/backend-api") {
|
||||
format!("{base_url}/wham/apps")
|
||||
} else if base_url.contains("/api/codex") {
|
||||
format!("{base_url}/apps")
|
||||
} else {
|
||||
format!("{base_url}/api/codex/apps")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn codex_apps_mcp_url(config: &Config) -> String {
|
||||
codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url)
|
||||
}
|
||||
|
||||
fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> McpServerConfig {
|
||||
let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var();
|
||||
let http_headers = if bearer_token_env_var.is_some() {
|
||||
None
|
||||
} else {
|
||||
codex_apps_mcp_http_headers(auth)
|
||||
};
|
||||
let url = codex_apps_mcp_url(config);
|
||||
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(Duration::from_secs(30)),
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_codex_apps_mcp(
|
||||
mut servers: HashMap<String, McpServerConfig>,
|
||||
connectors_enabled: bool,
|
||||
auth: Option<&CodexAuth>,
|
||||
config: &Config,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
if connectors_enabled {
|
||||
servers.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
codex_apps_mcp_server_config(config, auth),
|
||||
);
|
||||
} else {
|
||||
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
pub struct McpManager {
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn new(plugins_manager: Arc<PluginsManager>) -> Self {
|
||||
Self { plugins_manager }
|
||||
}
|
||||
|
||||
pub fn configured_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
|
||||
configured_mcp_servers(config, self.plugins_manager.as_ref())
|
||||
}
|
||||
|
||||
pub fn effective_servers(
|
||||
&self,
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
effective_mcp_servers(config, auth, self.plugins_manager.as_ref())
|
||||
}
|
||||
|
||||
pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance {
|
||||
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
|
||||
ToolPluginProvenance::from_capability_summaries(loaded_plugins.capability_summaries())
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_mcp_servers(
|
||||
config: &Config,
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let loaded_plugins = plugins_manager.plugins_for_config(config);
|
||||
let mut servers = config.mcp_servers.get().clone();
|
||||
for (name, plugin_server) in loaded_plugins.effective_mcp_servers() {
|
||||
servers.entry(name).or_insert(plugin_server);
|
||||
}
|
||||
servers
|
||||
}
|
||||
|
||||
fn effective_mcp_servers(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let servers = configured_mcp_servers(config, plugins_manager);
|
||||
with_codex_apps_mcp(
|
||||
servers,
|
||||
config.features.apps_enabled_for_auth(auth),
|
||||
auth,
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent {
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
);
|
||||
let auth = auth_manager.auth().await;
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config);
|
||||
if mcp_servers.is_empty() {
|
||||
return McpListToolsResponseEvent {
|
||||
tools: HashMap::new(),
|
||||
resources: HashMap::new(),
|
||||
resource_templates: HashMap::new(),
|
||||
auth_statuses: HashMap::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let auth_status_entries =
|
||||
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await;
|
||||
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
|
||||
// Use ReadOnly sandbox policy for MCP snapshot collection (safest default)
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
};
|
||||
|
||||
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_status_entries.clone(),
|
||||
&config.permissions.approval_policy,
|
||||
tx_event,
|
||||
sandbox_state,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
)
|
||||
.await;
|
||||
|
||||
let snapshot =
|
||||
collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await;
|
||||
|
||||
cancel_token.cancel();
|
||||
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> {
|
||||
let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER);
|
||||
let prefix = parts.next()?;
|
||||
if prefix != MCP_TOOL_NAME_PREFIX {
|
||||
return None;
|
||||
}
|
||||
let server_name = parts.next()?;
|
||||
let tool_name: String = parts.collect::<Vec<_>>().join(MCP_TOOL_NAME_DELIMITER);
|
||||
if tool_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((server_name.to_string(), tool_name))
|
||||
}
|
||||
|
||||
pub fn group_tools_by_server(
|
||||
tools: &HashMap<String, Tool>,
|
||||
) -> HashMap<String, HashMap<String, Tool>> {
|
||||
let mut grouped = HashMap::new();
|
||||
for (qualified_name, tool) in tools {
|
||||
if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) {
|
||||
grouped
|
||||
.entry(server_name)
|
||||
.or_insert_with(HashMap::new)
|
||||
.insert(tool_name, tool.clone());
|
||||
}
|
||||
}
|
||||
grouped
|
||||
}
|
||||
|
||||
pub(crate) async fn collect_mcp_snapshot_from_manager(
|
||||
mcp_connection_manager: &McpConnectionManager,
|
||||
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
|
||||
) -> McpListToolsResponseEvent {
|
||||
let (tools, resources, resource_templates) = tokio::join!(
|
||||
mcp_connection_manager.list_all_tools(),
|
||||
mcp_connection_manager.list_all_resources(),
|
||||
mcp_connection_manager.list_all_resource_templates(),
|
||||
);
|
||||
|
||||
let auth_statuses = auth_status_entries
|
||||
.iter()
|
||||
.map(|(name, entry)| (name.clone(), entry.auth_status))
|
||||
.collect();
|
||||
|
||||
let tools = tools
|
||||
.into_iter()
|
||||
.filter_map(|(name, tool)| match serde_json::to_value(tool.tool) {
|
||||
Ok(value) => match Tool::from_mcp_value(value) {
|
||||
Ok(tool) => Some((name, tool)),
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to convert MCP tool '{name}': {err}");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to serialize MCP tool '{name}': {err}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resources = resources
|
||||
.into_iter()
|
||||
.map(|(name, resources)| {
|
||||
let resources = resources
|
||||
.into_iter()
|
||||
.filter_map(|resource| match serde_json::to_value(resource) {
|
||||
Ok(value) => match Resource::from_mcp_value(value.clone()) {
|
||||
Ok(resource) => Some(resource),
|
||||
Err(err) => {
|
||||
let (uri, resource_name) = match value {
|
||||
Value::Object(obj) => (
|
||||
obj.get("uri")
|
||||
.and_then(|v| v.as_str().map(ToString::to_string)),
|
||||
obj.get("name")
|
||||
.and_then(|v| v.as_str().map(ToString::to_string)),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
"Failed to convert MCP resource (uri={uri:?}, name={resource_name:?}): {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to serialize MCP resource: {err}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(name, resources)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let resource_templates = resource_templates
|
||||
.into_iter()
|
||||
.map(|(name, templates)| {
|
||||
let templates = templates
|
||||
.into_iter()
|
||||
.filter_map(|template| match serde_json::to_value(template) {
|
||||
Ok(value) => match ResourceTemplate::from_mcp_value(value.clone()) {
|
||||
Ok(template) => Some(template),
|
||||
Err(err) => {
|
||||
let (uri_template, template_name) = match value {
|
||||
Value::Object(obj) => (
|
||||
obj.get("uriTemplate")
|
||||
.or_else(|| obj.get("uri_template"))
|
||||
.and_then(|v| v.as_str().map(ToString::to_string)),
|
||||
obj.get("name")
|
||||
.and_then(|v| v.as_str().map(ToString::to_string)),
|
||||
),
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
"Failed to convert MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to serialize MCP resource template: {err}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(name, templates)
|
||||
})
|
||||
.collect();
|
||||
|
||||
McpListToolsResponseEvent {
|
||||
tools,
|
||||
resources,
|
||||
resource_templates,
|
||||
auth_statuses,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,279 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use codex_features::Feature;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use toml::Value;
|
||||
|
||||
fn write_file(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
fn plugin_config_toml() -> String {
|
||||
let mut root = toml::map::Map::new();
|
||||
|
||||
let mut features = toml::map::Map::new();
|
||||
features.insert("plugins".to_string(), Value::Boolean(true));
|
||||
root.insert("features".to_string(), Value::Table(features));
|
||||
|
||||
let mut plugin = toml::map::Map::new();
|
||||
plugin.insert("enabled".to_string(), Value::Boolean(true));
|
||||
|
||||
let mut plugins = toml::map::Map::new();
|
||||
plugins.insert("sample@test".to_string(), Value::Table(plugin));
|
||||
root.insert("plugins".to_string(), Value::Table(plugins));
|
||||
|
||||
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
|
||||
}
|
||||
|
||||
fn make_tool(name: &str) -> Tool {
|
||||
Tool {
|
||||
name: name.to_string(),
|
||||
title: None,
|
||||
description: None,
|
||||
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_qualified_tool_name_returns_server_and_tool() {
|
||||
assert_eq!(
|
||||
split_qualified_tool_name("mcp__alpha__do_thing"),
|
||||
Some(("alpha".to_string(), "do_thing".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() {
|
||||
assert_eq!(
|
||||
qualified_mcp_tool_name_prefix("Some-Server"),
|
||||
"mcp__Some_Server__".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_qualified_tool_name_rejects_invalid_names() {
|
||||
assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None);
|
||||
assert_eq!(split_qualified_tool_name("mcp__alpha__"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_tools_by_server_strips_prefix_and_groups() {
|
||||
let mut tools = HashMap::new();
|
||||
tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing"));
|
||||
tools.insert(
|
||||
"mcp__alpha__nested__op".to_string(),
|
||||
make_tool("nested__op"),
|
||||
);
|
||||
tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other"));
|
||||
|
||||
let mut expected_alpha = HashMap::new();
|
||||
expected_alpha.insert("do_thing".to_string(), make_tool("do_thing"));
|
||||
expected_alpha.insert("nested__op".to_string(), make_tool("nested__op"));
|
||||
|
||||
let mut expected_beta = HashMap::new();
|
||||
expected_beta.insert("do_other".to_string(), make_tool("do_other"));
|
||||
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert("alpha".to_string(), expected_alpha);
|
||||
expected.insert("beta".to_string(), expected_beta);
|
||||
|
||||
assert_eq!(group_tools_by_server(&tools), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
|
||||
let provenance = ToolPluginProvenance::from_capability_summaries(&[
|
||||
PluginCapabilitySummary {
|
||||
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 {
|
||||
display_name: "beta-plugin".to_string(),
|
||||
app_connector_ids: vec![
|
||||
AppConnectorId("connector_example".to_string()),
|
||||
AppConnectorId("connector_gmail".to_string()),
|
||||
],
|
||||
mcp_server_names: vec!["beta".to_string()],
|
||||
..PluginCapabilitySummary::default()
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
provenance,
|
||||
ToolPluginProvenance {
|
||||
plugin_display_names_by_connector_id: HashMap::from([
|
||||
(
|
||||
"connector_example".to_string(),
|
||||
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
|
||||
),
|
||||
(
|
||||
"connector_gmail".to_string(),
|
||||
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()]),
|
||||
]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url("https://chatgpt.com/backend-api"),
|
||||
"https://chatgpt.com/backend-api/wham/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url("https://chat.openai.com"),
|
||||
"https://chat.openai.com/backend-api/wham/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url("http://localhost:8080/api/codex"),
|
||||
"http://localhost:8080/api/codex/apps"
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url_for_base_url("http://localhost:8080"),
|
||||
"http://localhost:8080/api/codex/apps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_mcp_url_uses_legacy_codex_apps_path() {
|
||||
let mut config = crate::config::test_config();
|
||||
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
||||
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url(&config),
|
||||
"https://chatgpt.com/backend-api/wham/apps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
|
||||
let mut config = crate::config::test_config();
|
||||
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
||||
|
||||
let mut servers = with_codex_apps_mcp(
|
||||
HashMap::new(),
|
||||
/*connectors_enabled*/ false,
|
||||
/*auth*/ None,
|
||||
&config,
|
||||
);
|
||||
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
|
||||
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow apps");
|
||||
|
||||
servers = with_codex_apps_mcp(
|
||||
servers, /*connectors_enabled*/ true, /*auth*/ None, &config,
|
||||
);
|
||||
let server = servers
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
.expect("codex apps should be present when apps is enabled");
|
||||
let url = match &server.transport {
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
||||
_ => panic!("expected streamable http transport for codex apps"),
|
||||
};
|
||||
|
||||
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn effective_mcp_servers_include_plugins_without_overriding_user_config() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"type": "http",
|
||||
"url": "https://plugin.example/mcp"
|
||||
},
|
||||
"docs": {
|
||||
"type": "http",
|
||||
"url": "https://docs.example/mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
&plugin_config_toml(),
|
||||
);
|
||||
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
|
||||
let mut configured_servers = config.mcp_servers.get().clone();
|
||||
configured_servers.insert(
|
||||
"sample".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://user.example/mcp".to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
config
|
||||
.mcp_servers
|
||||
.set(configured_servers)
|
||||
.expect("test config should accept MCP servers");
|
||||
|
||||
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
||||
let effective = mcp_manager.effective_servers(&config, /*auth*/ None);
|
||||
|
||||
let sample = effective.get("sample").expect("user server should exist");
|
||||
let docs = effective.get("docs").expect("plugin server should exist");
|
||||
|
||||
match &sample.transport {
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
||||
assert_eq!(url, "https://user.example/mcp");
|
||||
}
|
||||
other => panic!("expected streamable http transport, got {other:?}"),
|
||||
}
|
||||
match &docs.transport {
|
||||
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
||||
assert_eq!(url, "https://docs.example/mcp");
|
||||
}
|
||||
other => panic!("expected streamable http transport, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
use super::*;
|
||||
use crate::model::SkillDependencies;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn skill_with_tools(tools: Vec<SkillToolDependency>) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: "skill".to_string(),
|
||||
description: "skill".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: Some(SkillDependencies { tools }),
|
||||
policy: None,
|
||||
path_to_skills_md: PathBuf::from("skill"),
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_missing_respects_canonical_installed_key() {
|
||||
let url = "https://example.com/mcp".to_string();
|
||||
let skills = vec![skill_with_tools(vec![SkillToolDependency {
|
||||
r#type: "mcp".to_string(),
|
||||
value: "github".to_string(),
|
||||
description: None,
|
||||
transport: Some("streamable_http".to_string()),
|
||||
command: None,
|
||||
url: Some(url.clone()),
|
||||
}])];
|
||||
let installed = HashMap::from([(
|
||||
"alias".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
collect_missing_mcp_dependencies(&skills, &installed),
|
||||
HashMap::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() {
|
||||
let url = "https://example.com/one".to_string();
|
||||
let skills = vec![skill_with_tools(vec![
|
||||
SkillToolDependency {
|
||||
r#type: "mcp".to_string(),
|
||||
value: "alias-one".to_string(),
|
||||
description: None,
|
||||
transport: Some("streamable_http".to_string()),
|
||||
command: None,
|
||||
url: Some(url.clone()),
|
||||
},
|
||||
SkillToolDependency {
|
||||
r#type: "mcp".to_string(),
|
||||
value: "alias-two".to_string(),
|
||||
description: None,
|
||||
transport: Some("streamable_http".to_string()),
|
||||
command: None,
|
||||
url: Some(url.clone()),
|
||||
},
|
||||
])];
|
||||
|
||||
let expected = HashMap::from([(
|
||||
"alias-one".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
collect_missing_mcp_dependencies(&skills, &HashMap::new()),
|
||||
expected
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,646 +0,0 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use rmcp::model::JsonObject;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
|
||||
ToolInfo {
|
||||
server_name: server_name.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_namespace: if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
||||
format!("mcp__{server_name}__")
|
||||
} else {
|
||||
server_name.to_string()
|
||||
},
|
||||
tool: Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: Some(format!("Test tool: {tool_name}").into()),
|
||||
input_schema: Arc::new(JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_tool_with_connector(
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
connector_id: &str,
|
||||
connector_name: Option<&str>,
|
||||
) -> ToolInfo {
|
||||
let mut tool = create_test_tool(server_name, tool_name);
|
||||
tool.connector_id = Some(connector_id.to_string());
|
||||
tool.connector_name = connector_name.map(ToOwned::to_owned);
|
||||
tool
|
||||
}
|
||||
|
||||
fn create_codex_apps_tools_cache_context(
|
||||
codex_home: PathBuf,
|
||||
account_id: Option<&str>,
|
||||
chatgpt_user_id: Option<&str>,
|
||||
) -> CodexAppsToolsCacheContext {
|
||||
CodexAppsToolsCacheContext {
|
||||
codex_home,
|
||||
user_key: CodexAppsToolsCacheKey {
|
||||
account_id: account_id.map(ToOwned::to_owned),
|
||||
chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned),
|
||||
is_workspace_account: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elicitation_granular_policy_defaults_to_prompting() {
|
||||
assert!(!elicitation_is_rejected_by_policy(
|
||||
AskForApproval::OnFailure
|
||||
));
|
||||
assert!(!elicitation_is_rejected_by_policy(
|
||||
AskForApproval::OnRequest
|
||||
));
|
||||
assert!(!elicitation_is_rejected_by_policy(
|
||||
AskForApproval::UnlessTrusted
|
||||
));
|
||||
assert!(elicitation_is_rejected_by_policy(AskForApproval::Granular(
|
||||
GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: false,
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elicitation_granular_policy_respects_never_and_config() {
|
||||
assert!(elicitation_is_rejected_by_policy(AskForApproval::Never));
|
||||
assert!(elicitation_is_rejected_by_policy(AskForApproval::Granular(
|
||||
GranularApprovalConfig {
|
||||
sandbox_approval: true,
|
||||
rules: true,
|
||||
skill_approval: true,
|
||||
request_permissions: true,
|
||||
mcp_elicitations: false,
|
||||
}
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualify_tools_short_non_duplicated_names() {
|
||||
let tools = vec![
|
||||
create_test_tool("server1", "tool1"),
|
||||
create_test_tool("server1", "tool2"),
|
||||
];
|
||||
|
||||
let qualified_tools = qualify_tools(tools);
|
||||
|
||||
assert_eq!(qualified_tools.len(), 2);
|
||||
assert!(qualified_tools.contains_key("mcp__server1__tool1"));
|
||||
assert!(qualified_tools.contains_key("mcp__server1__tool2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualify_tools_duplicated_names_skipped() {
|
||||
let tools = vec![
|
||||
create_test_tool("server1", "duplicate_tool"),
|
||||
create_test_tool("server1", "duplicate_tool"),
|
||||
];
|
||||
|
||||
let qualified_tools = qualify_tools(tools);
|
||||
|
||||
// Only the first tool should remain, the second is skipped
|
||||
assert_eq!(qualified_tools.len(), 1);
|
||||
assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualify_tools_long_names_same_server() {
|
||||
let server_name = "my_server";
|
||||
|
||||
let tools = vec![
|
||||
create_test_tool(
|
||||
server_name,
|
||||
"extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
||||
),
|
||||
create_test_tool(
|
||||
server_name,
|
||||
"yet_another_extremely_lengthy_function_name_that_absolutely_surpasses_all_reasonable_limits",
|
||||
),
|
||||
];
|
||||
|
||||
let qualified_tools = qualify_tools(tools);
|
||||
|
||||
assert_eq!(qualified_tools.len(), 2);
|
||||
|
||||
let mut keys: Vec<_> = qualified_tools.keys().cloned().collect();
|
||||
keys.sort();
|
||||
|
||||
assert_eq!(keys[0].len(), 64);
|
||||
assert_eq!(
|
||||
keys[0],
|
||||
"mcp__my_server__extremel119a2b97664e41363932dc84de21e2ff1b93b3e9"
|
||||
);
|
||||
|
||||
assert_eq!(keys[1].len(), 64);
|
||||
assert_eq!(
|
||||
keys[1],
|
||||
"mcp__my_server__yet_anot419a82a89325c1b477274a41f8c65ea5f3a7f341"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualify_tools_sanitizes_invalid_characters() {
|
||||
let tools = vec![create_test_tool("server.one", "tool.two-three")];
|
||||
|
||||
let qualified_tools = qualify_tools(tools);
|
||||
|
||||
assert_eq!(qualified_tools.len(), 1);
|
||||
let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
|
||||
assert_eq!(qualified_name, "mcp__server_one__tool_two_three");
|
||||
|
||||
// The key is sanitized for OpenAI, but we keep original parts for the actual MCP call.
|
||||
assert_eq!(tool.server_name, "server.one");
|
||||
assert_eq!(tool.tool_name, "tool.two-three");
|
||||
|
||||
assert!(
|
||||
qualified_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
|
||||
"qualified name must be Responses API compatible: {qualified_name:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_filter_allows_by_default() {
|
||||
let filter = ToolFilter::default();
|
||||
|
||||
assert!(filter.allows("any"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_filter_applies_enabled_list() {
|
||||
let filter = ToolFilter {
|
||||
enabled: Some(HashSet::from(["allowed".to_string()])),
|
||||
disabled: HashSet::new(),
|
||||
};
|
||||
|
||||
assert!(filter.allows("allowed"));
|
||||
assert!(!filter.allows("denied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_filter_applies_disabled_list() {
|
||||
let filter = ToolFilter {
|
||||
enabled: None,
|
||||
disabled: HashSet::from(["blocked".to_string()]),
|
||||
};
|
||||
|
||||
assert!(!filter.allows("blocked"));
|
||||
assert!(filter.allows("open"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_filter_applies_enabled_then_disabled() {
|
||||
let filter = ToolFilter {
|
||||
enabled: Some(HashSet::from(["keep".to_string(), "remove".to_string()])),
|
||||
disabled: HashSet::from(["remove".to_string()]),
|
||||
};
|
||||
|
||||
assert!(filter.allows("keep"));
|
||||
assert!(!filter.allows("remove"));
|
||||
assert!(!filter.allows("unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tools_applies_per_server_filters() {
|
||||
let server1_tools = vec![
|
||||
create_test_tool("server1", "tool_a"),
|
||||
create_test_tool("server1", "tool_b"),
|
||||
];
|
||||
let server2_tools = vec![create_test_tool("server2", "tool_a")];
|
||||
let server1_filter = ToolFilter {
|
||||
enabled: Some(HashSet::from(["tool_a".to_string(), "tool_b".to_string()])),
|
||||
disabled: HashSet::from(["tool_b".to_string()]),
|
||||
};
|
||||
let server2_filter = ToolFilter {
|
||||
enabled: None,
|
||||
disabled: HashSet::from(["tool_a".to_string()]),
|
||||
};
|
||||
|
||||
let filtered: Vec<_> = filter_tools(server1_tools, &server1_filter)
|
||||
.into_iter()
|
||||
.chain(filter_tools(server2_tools, &server2_filter))
|
||||
.collect();
|
||||
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].server_name, "server1");
|
||||
assert_eq!(filtered[0].tool_name, "tool_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tools_cache_is_overwritten_by_last_write() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache_context = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some("account-one"),
|
||||
Some("user-one"),
|
||||
);
|
||||
let tools_gateway_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")];
|
||||
let tools_gateway_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")];
|
||||
|
||||
write_cached_codex_apps_tools(&cache_context, &tools_gateway_1);
|
||||
let cached_gateway_1 =
|
||||
read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for first write");
|
||||
assert_eq!(cached_gateway_1[0].tool_name, "one");
|
||||
|
||||
write_cached_codex_apps_tools(&cache_context, &tools_gateway_2);
|
||||
let cached_gateway_2 =
|
||||
read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for second write");
|
||||
assert_eq!(cached_gateway_2[0].tool_name, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tools_cache_is_scoped_per_user() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache_context_user_1 = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some("account-one"),
|
||||
Some("user-one"),
|
||||
);
|
||||
let cache_context_user_2 = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some("account-two"),
|
||||
Some("user-two"),
|
||||
);
|
||||
let tools_user_1 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")];
|
||||
let tools_user_2 = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "two")];
|
||||
|
||||
write_cached_codex_apps_tools(&cache_context_user_1, &tools_user_1);
|
||||
write_cached_codex_apps_tools(&cache_context_user_2, &tools_user_2);
|
||||
|
||||
let read_user_1 =
|
||||
read_cached_codex_apps_tools(&cache_context_user_1).expect("cache entry for user one");
|
||||
let read_user_2 =
|
||||
read_cached_codex_apps_tools(&cache_context_user_2).expect("cache entry for user two");
|
||||
|
||||
assert_eq!(read_user_1[0].tool_name, "one");
|
||||
assert_eq!(read_user_2[0].tool_name, "two");
|
||||
assert_ne!(
|
||||
cache_context_user_1.cache_path(),
|
||||
cache_context_user_2.cache_path(),
|
||||
"each user should get an isolated cache file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tools_cache_filters_disallowed_connectors() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache_context = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some("account-one"),
|
||||
Some("user-one"),
|
||||
);
|
||||
let tools = vec![
|
||||
create_test_tool_with_connector(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"blocked_tool",
|
||||
"connector_openai_hidden",
|
||||
Some("Hidden"),
|
||||
),
|
||||
create_test_tool_with_connector(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"allowed_tool",
|
||||
"calendar",
|
||||
Some("Calendar"),
|
||||
),
|
||||
];
|
||||
|
||||
write_cached_codex_apps_tools(&cache_context, &tools);
|
||||
let cached = read_cached_codex_apps_tools(&cache_context).expect("cache entry exists for user");
|
||||
|
||||
assert_eq!(cached.len(), 1);
|
||||
assert_eq!(cached[0].tool_name, "allowed_tool");
|
||||
assert_eq!(cached[0].connector_id.as_deref(), Some("calendar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache_context = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some("account-one"),
|
||||
Some("user-one"),
|
||||
);
|
||||
let cache_path = cache_context.cache_path();
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create parent");
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION + 1,
|
||||
"tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "one")],
|
||||
}))
|
||||
.expect("serialize");
|
||||
std::fs::write(cache_path, bytes).expect("write");
|
||||
|
||||
assert!(read_cached_codex_apps_tools(&cache_context).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache_context = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some("account-one"),
|
||||
Some("user-one"),
|
||||
);
|
||||
let cache_path = cache_context.cache_path();
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create parent");
|
||||
}
|
||||
std::fs::write(cache_path, b"{not json").expect("write");
|
||||
|
||||
assert!(read_cached_codex_apps_tools(&cache_context).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_cached_codex_apps_tools_loads_from_disk_cache() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let cache_context = create_codex_apps_tools_cache_context(
|
||||
codex_home.path().to_path_buf(),
|
||||
Some("account-one"),
|
||||
Some("user-one"),
|
||||
);
|
||||
let cached_tools = vec![create_test_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_search",
|
||||
)];
|
||||
write_cached_codex_apps_tools(&cache_context, &cached_tools);
|
||||
|
||||
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&cache_context),
|
||||
);
|
||||
let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache");
|
||||
|
||||
assert_eq!(startup_tools.len(), 1);
|
||||
assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME);
|
||||
assert_eq!(startup_tools[0].tool_name, "calendar_search");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
|
||||
let startup_tools = vec![create_test_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
)];
|
||||
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
||||
.boxed()
|
||||
.shared();
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
||||
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
|
||||
manager.clients.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
AsyncManagedClient {
|
||||
client: pending_client,
|
||||
startup_snapshot: Some(startup_tools),
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
let tools = manager.list_all_tools().await;
|
||||
let tool = tools
|
||||
.get("mcp__codex_apps__calendar_create_event")
|
||||
.expect("tool from startup cache");
|
||||
assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME);
|
||||
assert_eq!(tool.tool_name, "calendar_create_event");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() {
|
||||
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
||||
.boxed()
|
||||
.shared();
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
||||
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
|
||||
manager.clients.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
AsyncManagedClient {
|
||||
client: pending_client,
|
||||
startup_snapshot: None,
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
let timeout_result =
|
||||
tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await;
|
||||
assert!(timeout_result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() {
|
||||
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
|
||||
.boxed()
|
||||
.shared();
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
||||
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
|
||||
manager.clients.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
AsyncManagedClient {
|
||||
client: pending_client,
|
||||
startup_snapshot: Some(Vec::new()),
|
||||
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
let timeout_result =
|
||||
tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await;
|
||||
let tools = timeout_result.expect("cache-hit startup snapshot should not block");
|
||||
assert!(tools.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
|
||||
let startup_tools = vec![create_test_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
)];
|
||||
let failed_client = futures::future::ready::<Result<ManagedClient, StartupOutcomeError>>(Err(
|
||||
StartupOutcomeError::Failed {
|
||||
error: "startup failed".to_string(),
|
||||
},
|
||||
))
|
||||
.boxed()
|
||||
.shared();
|
||||
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
|
||||
let mut manager = McpConnectionManager::new_uninitialized(&approval_policy);
|
||||
let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
manager.clients.insert(
|
||||
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
AsyncManagedClient {
|
||||
client: failed_client,
|
||||
startup_snapshot: Some(startup_tools),
|
||||
startup_complete,
|
||||
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
|
||||
},
|
||||
);
|
||||
|
||||
let tools = manager.list_all_tools().await;
|
||||
let tool = tools
|
||||
.get("mcp__codex_apps__calendar_create_event")
|
||||
.expect("tool from startup cache");
|
||||
assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME);
|
||||
assert_eq!(tool.tool_name, "calendar_create_event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elicitation_capability_enabled_only_for_codex_apps() {
|
||||
let codex_apps_capability = elicitation_capability_for_server(CODEX_APPS_MCP_SERVER_NAME);
|
||||
assert!(matches!(
|
||||
codex_apps_capability,
|
||||
Some(ElicitationCapability {
|
||||
form: Some(FormElicitationCapability {
|
||||
schema_validation: None
|
||||
}),
|
||||
url: None,
|
||||
})
|
||||
));
|
||||
|
||||
assert!(elicitation_capability_for_server("custom_mcp").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_prompts_for_github_pat() {
|
||||
let server_name = "github";
|
||||
let entry = McpAuthStatusEntry {
|
||||
config: McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://api.githubcopilot.com/mcp/".to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into();
|
||||
|
||||
let display = mcp_init_error_display(server_name, Some(&entry), &err);
|
||||
|
||||
let expected = format!(
|
||||
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
);
|
||||
|
||||
assert_eq!(expected, display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_prompts_for_login_when_auth_required() {
|
||||
let server_name = "example";
|
||||
let err: StartupOutcomeError = anyhow::anyhow!("Auth required for server").into();
|
||||
|
||||
let display = mcp_init_error_display(server_name, /*entry*/ None, &err);
|
||||
|
||||
let expected = format!(
|
||||
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
|
||||
);
|
||||
|
||||
assert_eq!(expected, display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_reports_generic_errors() {
|
||||
let server_name = "custom";
|
||||
let entry = McpAuthStatusEntry {
|
||||
config: McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com".to_string(),
|
||||
bearer_token_env_var: Some("TOKEN".to_string()),
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
let err: StartupOutcomeError = anyhow::anyhow!("boom").into();
|
||||
|
||||
let display = mcp_init_error_display(server_name, Some(&entry), &err);
|
||||
|
||||
let expected = format!("MCP client for `{server_name}` failed to start: {err:#}");
|
||||
|
||||
assert_eq!(expected, display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_includes_startup_timeout_hint() {
|
||||
let server_name = "slow";
|
||||
let err: StartupOutcomeError = anyhow::anyhow!("request timed out").into();
|
||||
|
||||
let display = mcp_init_error_display(server_name, /*entry*/ None, &err);
|
||||
|
||||
assert_eq!(
|
||||
"MCP client for `slow` timed out after 30 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
|
||||
display
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_origin_extracts_http_origin() {
|
||||
let transport = McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com:8443/path?query=1".to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
transport_origin(&transport),
|
||||
Some("https://example.com:8443".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_origin_is_stdio_for_stdio_transport() {
|
||||
let transport = McpServerTransportConfig::Stdio {
|
||||
command: "server".to_string(),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
};
|
||||
|
||||
assert_eq!(transport_origin(&transport), Some("stdio".to_string()));
|
||||
}
|
||||
+179
-177
@@ -1,6 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_config::ConfigEditsBuilder;
|
||||
use codex_config::McpServerConfig;
|
||||
use codex_config::McpServerTransportConfig;
|
||||
use codex_config::load_global_mcp_servers;
|
||||
use codex_login::default_client::is_first_party_originator;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
@@ -11,128 +17,19 @@ use codex_rmcp_client::perform_oauth_login;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use super::auth::McpOAuthLoginSupport;
|
||||
use super::auth::oauth_login_support;
|
||||
use super::auth::resolve_oauth_scopes;
|
||||
use super::auth::should_retry_without_scopes;
|
||||
use crate::SkillMetadata;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::load_global_mcp_servers;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::default_client::is_first_party_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::model::SkillToolDependency;
|
||||
use codex_features::Feature;
|
||||
use crate::skills::model::SkillToolDependency;
|
||||
use codex_mcp::mcp::auth::McpOAuthLoginSupport;
|
||||
use codex_mcp::mcp::auth::oauth_login_support;
|
||||
use codex_mcp::mcp::auth::resolve_oauth_scopes;
|
||||
use codex_mcp::mcp::auth::should_retry_without_scopes;
|
||||
|
||||
const SKILL_MCP_DEPENDENCY_PROMPT_ID: &str = "skill_mcp_dependency_install";
|
||||
const MCP_DEPENDENCY_OPTION_INSTALL: &str = "Install";
|
||||
const MCP_DEPENDENCY_OPTION_SKIP: &str = "Continue anyway";
|
||||
|
||||
fn is_full_access_mode(turn_context: &TurnContext) -> bool {
|
||||
matches!(turn_context.approval_policy.value(), AskForApproval::Never)
|
||||
&& matches!(
|
||||
turn_context.sandbox_policy.get(),
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn format_missing_mcp_dependencies(missing: &HashMap<String, McpServerConfig>) -> String {
|
||||
let mut names = missing.keys().cloned().collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
async fn filter_prompted_mcp_dependencies(
|
||||
sess: &Session,
|
||||
missing: &HashMap<String, McpServerConfig>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let prompted = sess.mcp_dependency_prompted().await;
|
||||
if prompted.is_empty() {
|
||||
return missing.clone();
|
||||
}
|
||||
|
||||
missing
|
||||
.iter()
|
||||
.filter(|(name, config)| !prompted.contains(&canonical_mcp_server_key(name, config)))
|
||||
.map(|(name, config)| (name.clone(), config.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn should_install_mcp_dependencies(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
missing: &HashMap<String, McpServerConfig>,
|
||||
cancellation_token: &CancellationToken,
|
||||
) -> bool {
|
||||
if is_full_access_mode(turn_context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let server_list = format_missing_mcp_dependencies(missing);
|
||||
let question = RequestUserInputQuestion {
|
||||
id: SKILL_MCP_DEPENDENCY_PROMPT_ID.to_string(),
|
||||
header: "Install MCP servers?".to_string(),
|
||||
question: format!(
|
||||
"The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?"
|
||||
),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
label: MCP_DEPENDENCY_OPTION_INSTALL.to_string(),
|
||||
description:
|
||||
"Install and enable the missing MCP servers in your global config."
|
||||
.to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
label: MCP_DEPENDENCY_OPTION_SKIP.to_string(),
|
||||
description: "Skip installation for now and do not show again for these MCP servers in this session."
|
||||
.to_string(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
let args = RequestUserInputArgs {
|
||||
questions: vec![question],
|
||||
};
|
||||
let sub_id = &turn_context.sub_id;
|
||||
let call_id = format!("mcp-deps-{sub_id}");
|
||||
let response_fut = sess.request_user_input(turn_context, call_id, args);
|
||||
let response = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation_token.cancelled() => {
|
||||
let empty = RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
};
|
||||
sess.notify_user_input_response(sub_id, empty.clone()).await;
|
||||
empty
|
||||
}
|
||||
response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
}),
|
||||
};
|
||||
|
||||
let install = response
|
||||
.answers
|
||||
.get(SKILL_MCP_DEPENDENCY_PROMPT_ID)
|
||||
.is_some_and(|answer| {
|
||||
answer
|
||||
.answers
|
||||
.iter()
|
||||
.any(|entry| entry == MCP_DEPENDENCY_OPTION_INSTALL)
|
||||
});
|
||||
|
||||
let prompted_keys = missing
|
||||
.iter()
|
||||
.map(|(name, config)| canonical_mcp_server_key(name, config));
|
||||
sess.record_mcp_dependency_prompted(prompted_keys).await;
|
||||
|
||||
install
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_prompt_and_install_mcp_dependencies(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
@@ -146,7 +43,11 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies(
|
||||
}
|
||||
|
||||
let config = turn_context.config.clone();
|
||||
if mentioned_skills.is_empty() || !config.features.enabled(Feature::SkillMcpDependencyInstall) {
|
||||
if mentioned_skills.is_empty()
|
||||
|| !config
|
||||
.features
|
||||
.enabled(codex_features::Feature::SkillMcpDependencyInstall)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,10 +75,14 @@ pub(crate) async fn maybe_prompt_and_install_mcp_dependencies(
|
||||
pub(crate) async fn maybe_install_mcp_dependencies(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
config: &Config,
|
||||
config: &crate::config::Config,
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
) {
|
||||
if mentioned_skills.is_empty() || !config.features.enabled(Feature::SkillMcpDependencyInstall) {
|
||||
if mentioned_skills.is_empty()
|
||||
|| !config
|
||||
.features
|
||||
.enabled(codex_features::Feature::SkillMcpDependencyInstall)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -307,6 +212,107 @@ pub(crate) async fn maybe_install_mcp_dependencies(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn should_install_mcp_dependencies(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
missing: &HashMap<String, McpServerConfig>,
|
||||
cancellation_token: &CancellationToken,
|
||||
) -> bool {
|
||||
if is_full_access_mode(turn_context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let server_list = format_missing_mcp_dependencies(missing);
|
||||
let question = RequestUserInputQuestion {
|
||||
id: SKILL_MCP_DEPENDENCY_PROMPT_ID.to_string(),
|
||||
header: "Install MCP servers?".to_string(),
|
||||
question: format!(
|
||||
"The following MCP servers are required by the selected skills but are not installed yet: {server_list}. Install them now?"
|
||||
),
|
||||
is_other: false,
|
||||
is_secret: false,
|
||||
options: Some(vec![
|
||||
RequestUserInputQuestionOption {
|
||||
label: MCP_DEPENDENCY_OPTION_INSTALL.to_string(),
|
||||
description:
|
||||
"Install and enable the missing MCP servers in your global config."
|
||||
.to_string(),
|
||||
},
|
||||
RequestUserInputQuestionOption {
|
||||
label: MCP_DEPENDENCY_OPTION_SKIP.to_string(),
|
||||
description: "Skip installation for now and do not show again for these MCP servers in this session."
|
||||
.to_string(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
let args = RequestUserInputArgs {
|
||||
questions: vec![question],
|
||||
};
|
||||
let sub_id = &turn_context.sub_id;
|
||||
let call_id = format!("mcp-deps-{sub_id}");
|
||||
let response_fut = sess.request_user_input(turn_context, call_id, args);
|
||||
let response = tokio::select! {
|
||||
biased;
|
||||
_ = cancellation_token.cancelled() => {
|
||||
let empty = RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
};
|
||||
sess.notify_user_input_response(sub_id, empty.clone()).await;
|
||||
empty
|
||||
}
|
||||
response = response_fut => response.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
}),
|
||||
};
|
||||
|
||||
let install = response
|
||||
.answers
|
||||
.get(SKILL_MCP_DEPENDENCY_PROMPT_ID)
|
||||
.is_some_and(|answer| {
|
||||
answer
|
||||
.answers
|
||||
.iter()
|
||||
.any(|entry| entry == MCP_DEPENDENCY_OPTION_INSTALL)
|
||||
});
|
||||
|
||||
let prompted_keys = missing
|
||||
.iter()
|
||||
.map(|(name, config)| canonical_mcp_server_key(name, config));
|
||||
sess.record_mcp_dependency_prompted(prompted_keys).await;
|
||||
|
||||
install
|
||||
}
|
||||
|
||||
async fn filter_prompted_mcp_dependencies(
|
||||
sess: &Session,
|
||||
missing: &HashMap<String, McpServerConfig>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let prompted = sess.mcp_dependency_prompted().await;
|
||||
if prompted.is_empty() {
|
||||
return missing.clone();
|
||||
}
|
||||
|
||||
missing
|
||||
.iter()
|
||||
.filter(|(name, config)| !prompted.contains(&canonical_mcp_server_key(name, config)))
|
||||
.map(|(name, config)| (name.clone(), config.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_missing_mcp_dependencies(missing: &HashMap<String, McpServerConfig>) -> String {
|
||||
let mut names = missing.keys().cloned().collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
fn is_full_access_mode(turn_context: &TurnContext) -> bool {
|
||||
matches!(turn_context.approval_policy.value(), AskForApproval::Never)
|
||||
&& matches!(
|
||||
turn_context.sandbox_policy.get(),
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn canonical_mcp_key(transport: &str, identifier: &str, fallback: &str) -> String {
|
||||
let identifier = identifier.trim();
|
||||
if identifier.is_empty() {
|
||||
@@ -346,63 +352,6 @@ fn canonical_mcp_dependency_key(dependency: &SkillToolDependency) -> Result<Stri
|
||||
Err(format!("unsupported transport {transport}"))
|
||||
}
|
||||
|
||||
pub(crate) fn collect_missing_mcp_dependencies(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
installed: &HashMap<String, McpServerConfig>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let mut missing = HashMap::new();
|
||||
let installed_keys: HashSet<String> = installed
|
||||
.iter()
|
||||
.map(|(name, config)| canonical_mcp_server_key(name, config))
|
||||
.collect();
|
||||
let mut seen_canonical_keys = HashSet::new();
|
||||
|
||||
for skill in mentioned_skills {
|
||||
let Some(dependencies) = skill.dependencies.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for tool in &dependencies.tools {
|
||||
if !tool.r#type.eq_ignore_ascii_case("mcp") {
|
||||
continue;
|
||||
}
|
||||
let dependency_key = match canonical_mcp_dependency_key(tool) {
|
||||
Ok(key) => key,
|
||||
Err(err) => {
|
||||
let dependency = tool.value.as_str();
|
||||
let skill_name = skill.name.as_str();
|
||||
warn!(
|
||||
"unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if installed_keys.contains(&dependency_key)
|
||||
|| seen_canonical_keys.contains(&dependency_key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let config = match mcp_dependency_to_server_config(tool) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
let dependency = dependency_key.as_str();
|
||||
let skill_name = skill.name.as_str();
|
||||
warn!(
|
||||
"unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
missing.insert(tool.value.clone(), config);
|
||||
seen_canonical_keys.insert(dependency_key);
|
||||
}
|
||||
}
|
||||
|
||||
missing
|
||||
}
|
||||
|
||||
fn mcp_dependency_to_server_config(
|
||||
dependency: &SkillToolDependency,
|
||||
) -> Result<McpServerConfig, String> {
|
||||
@@ -461,6 +410,59 @@ fn mcp_dependency_to_server_config(
|
||||
Err(format!("unsupported transport {transport}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "skill_dependencies_tests.rs"]
|
||||
mod tests;
|
||||
fn collect_missing_mcp_dependencies(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
installed: &HashMap<String, McpServerConfig>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let mut missing = HashMap::new();
|
||||
let installed_keys: HashSet<String> = installed
|
||||
.iter()
|
||||
.map(|(name, config)| canonical_mcp_server_key(name, config))
|
||||
.collect();
|
||||
let mut seen_canonical_keys = HashSet::new();
|
||||
|
||||
for skill in mentioned_skills {
|
||||
let Some(dependencies) = skill.dependencies.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for tool in &dependencies.tools {
|
||||
if !tool.r#type.eq_ignore_ascii_case("mcp") {
|
||||
continue;
|
||||
}
|
||||
let dependency_key = match canonical_mcp_dependency_key(tool) {
|
||||
Ok(key) => key,
|
||||
Err(err) => {
|
||||
let dependency = tool.value.as_str();
|
||||
let skill_name = skill.name.as_str();
|
||||
warn!(
|
||||
"unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if installed_keys.contains(&dependency_key)
|
||||
|| seen_canonical_keys.contains(&dependency_key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let config = match mcp_dependency_to_server_config(tool) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
let dependency = dependency_key.as_str();
|
||||
let skill_name = skill.name.as_str();
|
||||
warn!(
|
||||
"unable to auto-install MCP dependency {dependency} for skill {skill_name}: {err}",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
missing.insert(tool.value.clone(), config);
|
||||
seen_canonical_keys.insert(dependency_key);
|
||||
}
|
||||
}
|
||||
|
||||
missing
|
||||
}
|
||||
@@ -19,29 +19,28 @@ use crate::config::Config;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::load_global_mcp_servers;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::connectors;
|
||||
use crate::guardian::GuardianApprovalRequest;
|
||||
use crate::guardian::GuardianMcpAnnotations;
|
||||
use crate::guardian::guardian_approval_request_to_json;
|
||||
use crate::guardian::review_approval_request;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
|
||||
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::McpInvocation;
|
||||
use crate::protocol::McpToolCallBeginEvent;
|
||||
use crate::protocol::McpToolCallEndEvent;
|
||||
use crate::state_db;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_otel::sanitize_metric_tag_value;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::McpInvocation;
|
||||
use codex_protocol::protocol::McpToolCallBeginEvent;
|
||||
use codex_protocol::protocol::McpToolCallEndEvent;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
@@ -51,6 +50,7 @@ use codex_protocol::request_user_input::RequestUserInputQuestionOption;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rollout::state_db;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -583,7 +583,7 @@ fn custom_mcp_tool_approval_mode(
|
||||
.and_then(|table| table.get("mcp_servers"))
|
||||
.cloned()
|
||||
.and_then(|value| {
|
||||
HashMap::<String, crate::config::types::McpServerConfig>::deserialize(value).ok()
|
||||
HashMap::<String, codex_config::types::McpServerConfig>::deserialize(value).ok()
|
||||
})
|
||||
.and_then(|servers| servers.get(server).cloned())
|
||||
.and_then(|server| server.tools.get(tool_name).cloned())
|
||||
@@ -1541,8 +1541,7 @@ fn project_mcp_tool_approval_config_folder(config: &Config, server: &str) -> Opt
|
||||
.and_then(|table| table.get("mcp_servers"))
|
||||
.cloned()
|
||||
.and_then(|value| {
|
||||
HashMap::<String, crate::config::types::McpServerConfig>::deserialize(value)
|
||||
.ok()
|
||||
HashMap::<String, codex_config::types::McpServerConfig>::deserialize(value).ok()
|
||||
})?;
|
||||
if servers.contains_key(server) {
|
||||
layer
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use super::*;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::codex::make_session_and_context_with_rx;
|
||||
use crate::config::ApprovalsReviewer;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::types::AppConfig;
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerToolConfig;
|
||||
use crate::state::ActiveTurn;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::types::AppConfig;
|
||||
use codex_config::types::AppToolConfig;
|
||||
use codex_config::types::AppToolsConfig;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::AppsConfigToml;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
@@ -1384,7 +1384,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
@@ -1453,7 +1453,7 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
@@ -1522,7 +1522,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
@@ -1591,7 +1591,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
turn_context
|
||||
.approval_policy
|
||||
@@ -1689,7 +1689,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
|
||||
|
||||
let (mut session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
turn_context
|
||||
.approval_policy
|
||||
|
||||
@@ -32,7 +32,7 @@ mod artifacts {
|
||||
/// Phase 1 (startup extraction).
|
||||
mod phase_one {
|
||||
/// Default model used for phase 1.
|
||||
pub(super) const MODEL: &str = "gpt-5.1-codex-mini";
|
||||
pub(super) const MODEL: &str = "gpt-5.4-mini";
|
||||
/// Default reasoning effort used for phase 1.
|
||||
pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Low;
|
||||
/// Prompt used for phase 1.
|
||||
|
||||
@@ -3,9 +3,7 @@ use crate::RolloutRecorder;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::MemoriesConfig;
|
||||
use crate::contextual_user_message::is_memory_excluded_contextual_user_fragment;
|
||||
use crate::error::CodexErr;
|
||||
use crate::memories::metrics;
|
||||
use crate::memories::phase_one;
|
||||
use crate::memories::phase_one::PRUNE_BATCH_SIZE;
|
||||
@@ -13,9 +11,11 @@ use crate::memories::prompts::build_stage_one_input_message;
|
||||
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
use crate::rollout::policy::should_persist_response_item_for_memories;
|
||||
use codex_api::ResponseEvent;
|
||||
use codex_config::types::MemoriesConfig;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -228,7 +228,7 @@ async fn build_request_context(session: &Arc<Session>, config: &Config) -> Reque
|
||||
let model = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&model_name, config)
|
||||
.get_model_info(&model_name, &config.to_models_manager_config())
|
||||
.await;
|
||||
let turn_context = session.new_default_turn().await;
|
||||
RequestContext::from_turn_context(
|
||||
@@ -466,7 +466,7 @@ mod job {
|
||||
/// Serializes filtered stage-1 memory items for prompt inclusion.
|
||||
pub(super) fn serialize_filtered_rollout_response_items(
|
||||
items: &[RolloutItem],
|
||||
) -> crate::error::Result<String> {
|
||||
) -> codex_protocol::error::Result<String> {
|
||||
let filtered = items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::status::is_final as is_final_agent_status;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::config::Config;
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::metrics;
|
||||
@@ -143,6 +144,26 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(thread_config) = session
|
||||
.services
|
||||
.agent_control
|
||||
.get_agent_config_snapshot(thread_id)
|
||||
.await
|
||||
{
|
||||
if session.enabled(Feature::GeneralAnalytics) {
|
||||
let client_metadata = session.app_server_client_metadata().await;
|
||||
emit_subagent_session_started(
|
||||
&session.services.analytics_events_client,
|
||||
client_metadata,
|
||||
thread_id,
|
||||
thread_config,
|
||||
SubAgentSource::MemoryConsolidation,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!("failed to load memory consolidation thread config for analytics: {thread_id}");
|
||||
}
|
||||
|
||||
// 6. Spawn the agent handler.
|
||||
agent::handle(
|
||||
session,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::models_manager::model_info::model_info_from_slug;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
use tokio::fs as tokio_fs;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use super::storage::rebuild_raw_memories_file_from_memories;
|
||||
use super::storage::sync_rollout_summaries_from_memories;
|
||||
use crate::config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION;
|
||||
use crate::memories::clear_memory_root_contents;
|
||||
use crate::memories::ensure_layout;
|
||||
use crate::memories::memory_root;
|
||||
@@ -8,6 +7,7 @@ use crate::memories::raw_memories_file;
|
||||
use crate::memories::rollout_summaries_dir;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_state::Stage1Output;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -414,7 +414,6 @@ task_outcome: success
|
||||
}
|
||||
|
||||
mod phase2 {
|
||||
use crate::CodexAuth;
|
||||
use crate::ThreadManager;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::codex::Session;
|
||||
@@ -427,6 +426,7 @@ mod phase2 {
|
||||
use crate::memories::rollout_summaries_dir;
|
||||
use chrono::Utc;
|
||||
use codex_config::Constrained;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -693,7 +693,7 @@ mod phase2 {
|
||||
let rollout_path = subagent
|
||||
.rollout_path()
|
||||
.expect("consolidation thread should have a rollout path");
|
||||
crate::state_db::read_repair_rollout_path(
|
||||
codex_rollout::state_db::read_repair_rollout_path(
|
||||
Some(harness.state_db.as_ref()),
|
||||
Some(thread_id),
|
||||
Some(/*archived_only*/ false),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::unified_exec::ExecCommandArgs;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_shell_command::is_safe_command::is_known_safe_command;
|
||||
use codex_shell_command::parse_command::parse_command;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MEMORIES_USAGE_METRIC: &str = "codex.memories.usage";
|
||||
|
||||
@@ -2,11 +2,11 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ModelClient;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result;
|
||||
use codex_api::RawMemory as ApiRawMemory;
|
||||
use codex_api::RawMemoryMetadata as ApiRawMemoryMetadata;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use serde_json::Map;
|
||||
|
||||
@@ -36,7 +36,7 @@ use tokio::fs;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::types::HistoryPersistence;
|
||||
use codex_config::types::HistoryPersistence;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
//! Registry of model providers supported by Codex.
|
||||
//!
|
||||
//! Providers can be defined in two places:
|
||||
//! 1. Built-in defaults compiled into the binary so Codex works out-of-the-box.
|
||||
//! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers`
|
||||
//! key. These override or extend the defaults at runtime.
|
||||
|
||||
use crate::auth::AuthMode;
|
||||
use crate::error::EnvVarError;
|
||||
use codex_api::Provider as ApiProvider;
|
||||
use codex_api::provider::RetryConfig as ApiRetryConfig;
|
||||
use http::HeaderMap;
|
||||
use http::header::HeaderName;
|
||||
use http::header::HeaderValue;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000;
|
||||
const DEFAULT_STREAM_MAX_RETRIES: u64 = 5;
|
||||
const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4;
|
||||
pub(crate) const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS: u64 = 15_000;
|
||||
/// Hard cap for user-configured `stream_max_retries`.
|
||||
const MAX_STREAM_MAX_RETRIES: u64 = 100;
|
||||
/// Hard cap for user-configured `request_max_retries`.
|
||||
const MAX_REQUEST_MAX_RETRIES: u64 = 100;
|
||||
|
||||
const OPENAI_PROVIDER_NAME: &str = "OpenAI";
|
||||
pub const OPENAI_PROVIDER_ID: &str = "openai";
|
||||
const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782";
|
||||
pub(crate) const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat";
|
||||
pub(crate) const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782";
|
||||
|
||||
/// Wire protocol that the provider speaks.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WireApi {
|
||||
/// The Responses API exposed by OpenAI at `/v1/responses`.
|
||||
#[default]
|
||||
Responses,
|
||||
}
|
||||
|
||||
impl fmt::Display for WireApi {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let value = match self {
|
||||
Self::Responses => "responses",
|
||||
};
|
||||
f.write_str(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for WireApi {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
match value.as_str() {
|
||||
"responses" => Ok(Self::Responses),
|
||||
"chat" => Err(serde::de::Error::custom(CHAT_WIRE_API_REMOVED_ERROR)),
|
||||
_ => Err(serde::de::Error::unknown_variant(&value, &["responses"])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable representation of a provider definition.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ModelProviderInfo {
|
||||
/// Friendly display name.
|
||||
pub name: String,
|
||||
/// Base URL for the provider's OpenAI-compatible API.
|
||||
pub base_url: Option<String>,
|
||||
/// Environment variable that stores the user's API key for this provider.
|
||||
pub env_key: Option<String>,
|
||||
|
||||
/// Optional instructions to help the user get a valid value for the
|
||||
/// variable and set it.
|
||||
pub env_key_instructions: Option<String>,
|
||||
|
||||
/// Value to use with `Authorization: Bearer <token>` header. Use of this
|
||||
/// config is discouraged in favor of `env_key` for security reasons, but
|
||||
/// this may be necessary when using this programmatically.
|
||||
pub experimental_bearer_token: Option<String>,
|
||||
|
||||
/// Which wire protocol this provider expects.
|
||||
#[serde(default)]
|
||||
pub wire_api: WireApi,
|
||||
|
||||
/// Optional query parameters to append to the base URL.
|
||||
pub query_params: Option<HashMap<String, String>>,
|
||||
|
||||
/// Additional HTTP headers to include in requests to this provider where
|
||||
/// the (key, value) pairs are the header name and value.
|
||||
pub http_headers: Option<HashMap<String, String>>,
|
||||
|
||||
/// Optional HTTP headers to include in requests to this provider where the
|
||||
/// (key, value) pairs are the header name and _environment variable_ whose
|
||||
/// value should be used. If the environment variable is not set, or the
|
||||
/// value is empty, the header will not be included in the request.
|
||||
pub env_http_headers: Option<HashMap<String, String>>,
|
||||
|
||||
/// Maximum number of times to retry a failed HTTP request to this provider.
|
||||
pub request_max_retries: Option<u64>,
|
||||
|
||||
/// Number of times to retry reconnecting a dropped streaming response before failing.
|
||||
pub stream_max_retries: Option<u64>,
|
||||
|
||||
/// Idle timeout (in milliseconds) to wait for activity on a streaming response before treating
|
||||
/// the connection as lost.
|
||||
pub stream_idle_timeout_ms: Option<u64>,
|
||||
|
||||
/// Maximum time (in milliseconds) to wait for a websocket connection attempt before treating
|
||||
/// it as failed.
|
||||
pub websocket_connect_timeout_ms: Option<u64>,
|
||||
|
||||
/// Does this provider require an OpenAI API Key or ChatGPT login token? If true,
|
||||
/// user is presented with login screen on first run, and login preference and token/key
|
||||
/// are stored in auth.json. If false (which is the default), login screen is skipped,
|
||||
/// and API key (if needed) comes from the "env_key" environment variable.
|
||||
#[serde(default)]
|
||||
pub requires_openai_auth: bool,
|
||||
|
||||
/// Whether this provider supports the Responses API WebSocket transport.
|
||||
#[serde(default)]
|
||||
pub supports_websockets: bool,
|
||||
}
|
||||
|
||||
impl ModelProviderInfo {
|
||||
fn build_header_map(&self) -> crate::error::Result<HeaderMap> {
|
||||
let capacity = self.http_headers.as_ref().map_or(0, HashMap::len)
|
||||
+ self.env_http_headers.as_ref().map_or(0, HashMap::len);
|
||||
let mut headers = HeaderMap::with_capacity(capacity);
|
||||
if let Some(extra) = &self.http_headers {
|
||||
for (k, v) in extra {
|
||||
if let (Ok(name), Ok(value)) = (HeaderName::try_from(k), HeaderValue::try_from(v)) {
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(env_headers) = &self.env_http_headers {
|
||||
for (header, env_var) in env_headers {
|
||||
if let Ok(val) = std::env::var(env_var)
|
||||
&& !val.trim().is_empty()
|
||||
&& let (Ok(name), Ok(value)) =
|
||||
(HeaderName::try_from(header), HeaderValue::try_from(val))
|
||||
{
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub(crate) fn to_api_provider(
|
||||
&self,
|
||||
auth_mode: Option<AuthMode>,
|
||||
) -> crate::error::Result<ApiProvider> {
|
||||
let default_base_url = if matches!(auth_mode, Some(AuthMode::Chatgpt)) {
|
||||
"https://chatgpt.com/backend-api/codex"
|
||||
} else {
|
||||
"https://api.openai.com/v1"
|
||||
};
|
||||
let base_url = self
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| default_base_url.to_string());
|
||||
|
||||
let headers = self.build_header_map()?;
|
||||
let retry = ApiRetryConfig {
|
||||
max_attempts: self.request_max_retries(),
|
||||
base_delay: Duration::from_millis(200),
|
||||
retry_429: false,
|
||||
retry_5xx: true,
|
||||
retry_transport: true,
|
||||
};
|
||||
|
||||
Ok(ApiProvider {
|
||||
name: self.name.clone(),
|
||||
base_url,
|
||||
query_params: self.query_params.clone(),
|
||||
headers,
|
||||
retry,
|
||||
stream_idle_timeout: self.stream_idle_timeout(),
|
||||
})
|
||||
}
|
||||
|
||||
/// If `env_key` is Some, returns the API key for this provider if present
|
||||
/// (and non-empty) in the environment. If `env_key` is required but
|
||||
/// cannot be found, returns an error.
|
||||
pub fn api_key(&self) -> crate::error::Result<Option<String>> {
|
||||
match &self.env_key {
|
||||
Some(env_key) => {
|
||||
let api_key = std::env::var(env_key)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
crate::error::CodexErr::EnvVar(EnvVarError {
|
||||
var: env_key.clone(),
|
||||
instructions: self.env_key_instructions.clone(),
|
||||
})
|
||||
})?;
|
||||
Ok(Some(api_key))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective maximum number of request retries for this provider.
|
||||
pub fn request_max_retries(&self) -> u64 {
|
||||
self.request_max_retries
|
||||
.unwrap_or(DEFAULT_REQUEST_MAX_RETRIES)
|
||||
.min(MAX_REQUEST_MAX_RETRIES)
|
||||
}
|
||||
|
||||
/// Effective maximum number of stream reconnection attempts for this provider.
|
||||
pub fn stream_max_retries(&self) -> u64 {
|
||||
self.stream_max_retries
|
||||
.unwrap_or(DEFAULT_STREAM_MAX_RETRIES)
|
||||
.min(MAX_STREAM_MAX_RETRIES)
|
||||
}
|
||||
|
||||
/// Effective idle timeout for streaming responses.
|
||||
pub fn stream_idle_timeout(&self) -> Duration {
|
||||
self.stream_idle_timeout_ms
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
/// Effective timeout for websocket connect attempts.
|
||||
pub fn websocket_connect_timeout(&self) -> Duration {
|
||||
self.websocket_connect_timeout_ms
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
pub fn create_openai_provider(base_url: Option<String>) -> ModelProviderInfo {
|
||||
ModelProviderInfo {
|
||||
name: OPENAI_PROVIDER_NAME.into(),
|
||||
base_url,
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: Some(
|
||||
[("version".to_string(), env!("CARGO_PKG_VERSION").to_string())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
env_http_headers: Some(
|
||||
[
|
||||
(
|
||||
"OpenAI-Organization".to_string(),
|
||||
"OPENAI_ORGANIZATION".to_string(),
|
||||
),
|
||||
("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
// Use global defaults for retry/timeout unless overridden in config.toml.
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: true,
|
||||
supports_websockets: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_openai(&self) -> bool {
|
||||
self.name == OPENAI_PROVIDER_NAME
|
||||
}
|
||||
}
|
||||
|
||||
pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234;
|
||||
pub const DEFAULT_OLLAMA_PORT: u16 = 11434;
|
||||
|
||||
pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio";
|
||||
pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama";
|
||||
|
||||
/// Built-in default provider list.
|
||||
pub fn built_in_model_providers(
|
||||
openai_base_url: Option<String>,
|
||||
) -> HashMap<String, ModelProviderInfo> {
|
||||
use ModelProviderInfo as P;
|
||||
let openai_provider = P::create_openai_provider(openai_base_url);
|
||||
|
||||
// We do not want to be in the business of adjucating which third-party
|
||||
// providers are bundled with Codex CLI, so we only include the OpenAI and
|
||||
// open source ("oss") providers by default. Users are encouraged to add to
|
||||
// `model_providers` in config.toml to add their own providers.
|
||||
[
|
||||
(OPENAI_PROVIDER_ID, openai_provider),
|
||||
(
|
||||
OLLAMA_OSS_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses),
|
||||
),
|
||||
(
|
||||
LMSTUDIO_OSS_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_oss_provider(default_provider_port: u16, wire_api: WireApi) -> ModelProviderInfo {
|
||||
// These CODEX_OSS_ environment variables are experimental: we may
|
||||
// switch to reading values from config.toml instead.
|
||||
let default_codex_oss_base_url = format!(
|
||||
"http://localhost:{codex_oss_port}/v1",
|
||||
codex_oss_port = std::env::var("CODEX_OSS_PORT")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.unwrap_or(default_provider_port)
|
||||
);
|
||||
|
||||
let codex_oss_base_url = std::env::var("CODEX_OSS_BASE_URL")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or(default_codex_oss_base_url);
|
||||
create_oss_provider_with_base_url(&codex_oss_base_url, wire_api)
|
||||
}
|
||||
|
||||
pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> ModelProviderInfo {
|
||||
ModelProviderInfo {
|
||||
name: "gpt-oss".into(),
|
||||
base_url: Some(base_url.into()),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_provider_info_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,123 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_ollama_model_provider_toml() {
|
||||
let azure_provider_toml = r#"
|
||||
name = "Ollama"
|
||||
base_url = "http://localhost:11434/v1"
|
||||
"#;
|
||||
let expected_provider = ModelProviderInfo {
|
||||
name: "Ollama".into(),
|
||||
base_url: Some("http://localhost:11434/v1".into()),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
assert_eq!(expected_provider, provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_azure_model_provider_toml() {
|
||||
let azure_provider_toml = r#"
|
||||
name = "Azure"
|
||||
base_url = "https://xxxxx.openai.azure.com/openai"
|
||||
env_key = "AZURE_OPENAI_API_KEY"
|
||||
query_params = { api-version = "2025-04-01-preview" }
|
||||
"#;
|
||||
let expected_provider = ModelProviderInfo {
|
||||
name: "Azure".into(),
|
||||
base_url: Some("https://xxxxx.openai.azure.com/openai".into()),
|
||||
env_key: Some("AZURE_OPENAI_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: Some(maplit::hashmap! {
|
||||
"api-version".to_string() => "2025-04-01-preview".to_string(),
|
||||
}),
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
assert_eq!(expected_provider, provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_example_model_provider_toml() {
|
||||
let azure_provider_toml = r#"
|
||||
name = "Example"
|
||||
base_url = "https://example.com"
|
||||
env_key = "API_KEY"
|
||||
http_headers = { "X-Example-Header" = "example-value" }
|
||||
env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" }
|
||||
"#;
|
||||
let expected_provider = ModelProviderInfo {
|
||||
name: "Example".into(),
|
||||
base_url: Some("https://example.com".into()),
|
||||
env_key: Some("API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: Some(maplit::hashmap! {
|
||||
"X-Example-Header".to_string() => "example-value".to_string(),
|
||||
}),
|
||||
env_http_headers: Some(maplit::hashmap! {
|
||||
"X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(),
|
||||
}),
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
};
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap();
|
||||
assert_eq!(expected_provider, provider);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_chat_wire_api_shows_helpful_error() {
|
||||
let provider_toml = r#"
|
||||
name = "OpenAI using Chat Completions"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
wire_api = "chat"
|
||||
"#;
|
||||
|
||||
let err = toml::from_str::<ModelProviderInfo>(provider_toml).unwrap_err();
|
||||
assert!(err.to_string().contains(CHAT_WIRE_API_REMOVED_ERROR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_websocket_connect_timeout() {
|
||||
let provider_toml = r#"
|
||||
name = "OpenAI"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
websocket_connect_timeout_ms = 15000
|
||||
supports_websockets = true
|
||||
"#;
|
||||
|
||||
let provider: ModelProviderInfo = toml::from_str(provider_toml).unwrap();
|
||||
assert_eq!(provider.websocket_connect_timeout_ms, Some(15_000));
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::io;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
|
||||
/// Manages loading and saving of models cache to disk.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ModelsCacheManager {
|
||||
cache_path: PathBuf,
|
||||
cache_ttl: Duration,
|
||||
}
|
||||
|
||||
impl ModelsCacheManager {
|
||||
/// Create a new cache manager with the given path and TTL.
|
||||
pub(crate) fn new(cache_path: PathBuf, cache_ttl: Duration) -> Self {
|
||||
Self {
|
||||
cache_path,
|
||||
cache_ttl,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to load a fresh cache entry. Returns `None` if the cache doesn't exist or is stale.
|
||||
pub(crate) async fn load_fresh(&self, expected_version: &str) -> Option<ModelsCache> {
|
||||
info!(
|
||||
cache_path = %self.cache_path.display(),
|
||||
expected_version,
|
||||
"models cache: attempting load_fresh"
|
||||
);
|
||||
let cache = match self.load().await {
|
||||
Ok(cache) => cache?,
|
||||
Err(err) => {
|
||||
error!("failed to load models cache: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
info!(
|
||||
cache_path = %self.cache_path.display(),
|
||||
cached_version = ?cache.client_version,
|
||||
fetched_at = %cache.fetched_at,
|
||||
"models cache: loaded cache file"
|
||||
);
|
||||
if cache.client_version.as_deref() != Some(expected_version) {
|
||||
info!(
|
||||
cache_path = %self.cache_path.display(),
|
||||
expected_version,
|
||||
cached_version = ?cache.client_version,
|
||||
"models cache: cache version mismatch"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if !cache.is_fresh(self.cache_ttl) {
|
||||
info!(
|
||||
cache_path = %self.cache_path.display(),
|
||||
cache_ttl_secs = self.cache_ttl.as_secs(),
|
||||
fetched_at = %cache.fetched_at,
|
||||
"models cache: cache is stale"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
info!(
|
||||
cache_path = %self.cache_path.display(),
|
||||
cache_ttl_secs = self.cache_ttl.as_secs(),
|
||||
"models cache: cache hit"
|
||||
);
|
||||
Some(cache)
|
||||
}
|
||||
|
||||
/// Persist the cache to disk, creating parent directories as needed.
|
||||
pub(crate) async fn persist_cache(
|
||||
&self,
|
||||
models: &[ModelInfo],
|
||||
etag: Option<String>,
|
||||
client_version: String,
|
||||
) {
|
||||
let cache = ModelsCache {
|
||||
fetched_at: Utc::now(),
|
||||
etag,
|
||||
client_version: Some(client_version),
|
||||
models: models.to_vec(),
|
||||
};
|
||||
if let Err(err) = self.save_internal(&cache).await {
|
||||
error!("failed to write models cache: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Renew the cache TTL by updating the fetched_at timestamp to now.
|
||||
pub(crate) async fn renew_cache_ttl(&self) -> io::Result<()> {
|
||||
let mut cache = match self.load().await? {
|
||||
Some(cache) => cache,
|
||||
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
|
||||
};
|
||||
cache.fetched_at = Utc::now();
|
||||
self.save_internal(&cache).await
|
||||
}
|
||||
|
||||
async fn load(&self) -> io::Result<Option<ModelsCache>> {
|
||||
match fs::read(&self.cache_path).await {
|
||||
Ok(contents) => {
|
||||
let cache = serde_json::from_slice(&contents)
|
||||
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
Ok(Some(cache))
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_internal(&self, cache: &ModelsCache) -> io::Result<()> {
|
||||
if let Some(parent) = self.cache_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(cache)
|
||||
.map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?;
|
||||
fs::write(&self.cache_path, json).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Set the cache TTL.
|
||||
pub(crate) fn set_ttl(&mut self, ttl: Duration) {
|
||||
self.cache_ttl = ttl;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Manipulate cache file for testing. Allows setting a custom fetched_at timestamp.
|
||||
pub(crate) async fn manipulate_cache_for_test<F>(&self, f: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(&mut DateTime<Utc>),
|
||||
{
|
||||
let mut cache = match self.load().await? {
|
||||
Some(cache) => cache,
|
||||
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
|
||||
};
|
||||
f(&mut cache.fetched_at);
|
||||
self.save_internal(&cache).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Mutate the full cache contents for testing.
|
||||
pub(crate) async fn mutate_cache_for_test<F>(&self, f: F) -> io::Result<()>
|
||||
where
|
||||
F: FnOnce(&mut ModelsCache),
|
||||
{
|
||||
let mut cache = match self.load().await? {
|
||||
Some(cache) => cache,
|
||||
None => return Err(io::Error::new(ErrorKind::NotFound, "cache not found")),
|
||||
};
|
||||
f(&mut cache);
|
||||
self.save_internal(&cache).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialized snapshot of models and metadata cached on disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ModelsCache {
|
||||
pub(crate) fetched_at: DateTime<Utc>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) etag: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) client_version: Option<String>,
|
||||
pub(crate) models: Vec<ModelInfo>,
|
||||
}
|
||||
|
||||
impl ModelsCache {
|
||||
/// Returns `true` when the cache entry has not exceeded the configured TTL.
|
||||
fn is_fresh(&self, ttl: Duration) -> bool {
|
||||
if ttl.is_zero() {
|
||||
return false;
|
||||
}
|
||||
let Ok(ttl_duration) = chrono::Duration::from_std(ttl) else {
|
||||
return false;
|
||||
};
|
||||
let age = Utc::now().signed_duration_since(self.fetched_at);
|
||||
age <= ttl_duration
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_utils_template::Template;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md");
|
||||
const COLLABORATION_MODE_DEFAULT: &str =
|
||||
include_str!("../../templates/collaboration_mode/default.md");
|
||||
const KNOWN_MODE_NAMES_TEMPLATE_KEY: &str = "KNOWN_MODE_NAMES";
|
||||
const REQUEST_USER_INPUT_AVAILABILITY_TEMPLATE_KEY: &str = "REQUEST_USER_INPUT_AVAILABILITY";
|
||||
const ASKING_QUESTIONS_GUIDANCE_TEMPLATE_KEY: &str = "ASKING_QUESTIONS_GUIDANCE";
|
||||
static COLLABORATION_MODE_DEFAULT_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
|
||||
Template::parse(COLLABORATION_MODE_DEFAULT)
|
||||
.unwrap_or_else(|err| panic!("collaboration mode default template must parse: {err}"))
|
||||
});
|
||||
|
||||
/// Stores feature flags that control collaboration-mode behavior.
|
||||
///
|
||||
/// Keep mode-related flags here so new collaboration-mode capabilities can be
|
||||
/// added without large cross-cutting diffs to constructor and call-site
|
||||
/// signatures.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct CollaborationModesConfig {
|
||||
/// Enables `request_user_input` availability in Default mode.
|
||||
pub default_mode_request_user_input: bool,
|
||||
}
|
||||
|
||||
pub fn builtin_collaboration_mode_presets(
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
) -> Vec<CollaborationModeMask> {
|
||||
vec![plan_preset(), default_preset(collaboration_modes_config)]
|
||||
}
|
||||
|
||||
fn plan_preset() -> CollaborationModeMask {
|
||||
CollaborationModeMask {
|
||||
name: ModeKind::Plan.display_name().to_string(),
|
||||
mode: Some(ModeKind::Plan),
|
||||
model: None,
|
||||
reasoning_effort: Some(Some(ReasoningEffort::Medium)),
|
||||
developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_preset(collaboration_modes_config: CollaborationModesConfig) -> CollaborationModeMask {
|
||||
CollaborationModeMask {
|
||||
name: ModeKind::Default.display_name().to_string(),
|
||||
mode: Some(ModeKind::Default),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: Some(Some(default_mode_instructions(collaboration_modes_config))),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_mode_instructions(collaboration_modes_config: CollaborationModesConfig) -> String {
|
||||
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
|
||||
let request_user_input_availability = request_user_input_availability_message(
|
||||
ModeKind::Default,
|
||||
collaboration_modes_config.default_mode_request_user_input,
|
||||
);
|
||||
let asking_questions_guidance = asking_questions_guidance_message(
|
||||
collaboration_modes_config.default_mode_request_user_input,
|
||||
);
|
||||
COLLABORATION_MODE_DEFAULT_TEMPLATE
|
||||
.render([
|
||||
(KNOWN_MODE_NAMES_TEMPLATE_KEY, known_mode_names.as_str()),
|
||||
(
|
||||
REQUEST_USER_INPUT_AVAILABILITY_TEMPLATE_KEY,
|
||||
request_user_input_availability.as_str(),
|
||||
),
|
||||
(
|
||||
ASKING_QUESTIONS_GUIDANCE_TEMPLATE_KEY,
|
||||
asking_questions_guidance.as_str(),
|
||||
),
|
||||
])
|
||||
.unwrap_or_else(|err| panic!("collaboration mode default template must render: {err}"))
|
||||
}
|
||||
|
||||
fn format_mode_names(modes: &[ModeKind]) -> String {
|
||||
let mode_names: Vec<&str> = modes.iter().map(|mode| mode.display_name()).collect();
|
||||
match mode_names.as_slice() {
|
||||
[] => "none".to_string(),
|
||||
[mode_name] => (*mode_name).to_string(),
|
||||
[first, second] => format!("{first} and {second}"),
|
||||
[..] => mode_names.join(", "),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_user_input_availability_message(
|
||||
mode: ModeKind,
|
||||
default_mode_request_user_input: bool,
|
||||
) -> String {
|
||||
let mode_name = mode.display_name();
|
||||
if mode.allows_request_user_input()
|
||||
|| (default_mode_request_user_input && mode == ModeKind::Default)
|
||||
{
|
||||
format!("The `request_user_input` tool is available in {mode_name} mode.")
|
||||
} else {
|
||||
format!(
|
||||
"The `request_user_input` tool is unavailable in {mode_name} mode. If you call it while in {mode_name} mode, it will return an error."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn asking_questions_guidance_message(default_mode_request_user_input: bool) -> String {
|
||||
if default_mode_request_user_input {
|
||||
"In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, prefer using the `request_user_input` tool rather than writing a multiple choice question as a textual assistant message. Never write a multiple choice question as a textual assistant message.".to_string()
|
||||
} else {
|
||||
"In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "collaboration_mode_presets_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,53 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn preset_names_use_mode_display_names() {
|
||||
assert_eq!(plan_preset().name, ModeKind::Plan.display_name());
|
||||
assert_eq!(
|
||||
default_preset(CollaborationModesConfig::default()).name,
|
||||
ModeKind::Default.display_name()
|
||||
);
|
||||
assert_eq!(
|
||||
plan_preset().reasoning_effort,
|
||||
Some(Some(ReasoningEffort::Medium))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mode_instructions_replace_mode_names_placeholder() {
|
||||
let default_instructions = default_preset(CollaborationModesConfig {
|
||||
default_mode_request_user_input: true,
|
||||
})
|
||||
.developer_instructions
|
||||
.expect("default preset should include instructions")
|
||||
.expect("default instructions should be set");
|
||||
|
||||
assert!(!default_instructions.contains("{{KNOWN_MODE_NAMES}}"));
|
||||
assert!(!default_instructions.contains("{{REQUEST_USER_INPUT_AVAILABILITY}}"));
|
||||
assert!(!default_instructions.contains("{{ASKING_QUESTIONS_GUIDANCE}}"));
|
||||
|
||||
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
|
||||
let expected_snippet = format!("Known mode names are {known_mode_names}.");
|
||||
assert!(default_instructions.contains(&expected_snippet));
|
||||
|
||||
let expected_availability_message = request_user_input_availability_message(
|
||||
ModeKind::Default,
|
||||
/*default_mode_request_user_input*/ true,
|
||||
);
|
||||
assert!(default_instructions.contains(&expected_availability_message));
|
||||
assert!(default_instructions.contains("prefer using the `request_user_input` tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mode_instructions_use_plain_text_questions_when_feature_disabled() {
|
||||
let default_instructions = default_preset(CollaborationModesConfig::default())
|
||||
.developer_instructions
|
||||
.expect("default preset should include instructions")
|
||||
.expect("default instructions should be set");
|
||||
|
||||
assert!(!default_instructions.contains("prefer using the `request_user_input` tool"));
|
||||
assert!(
|
||||
default_instructions.contains("ask the user directly with a concise plain-text question")
|
||||
);
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
use super::cache::ModelsCacheManager;
|
||||
use crate::api_bridge::auth_provider_from_auth;
|
||||
use crate::api_bridge::map_api_error;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::auth::AuthMode;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::auth_env_telemetry::AuthEnvTelemetry;
|
||||
use crate::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CoreResult;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
|
||||
use crate::models_manager::model_info;
|
||||
use crate::response_debug_context::extract_response_debug_context;
|
||||
use crate::response_debug_context::telemetry_transport_error_message;
|
||||
use crate::util::FeedbackRequestTags;
|
||||
use crate::util::emit_feedback_request_tags_with_auth_env;
|
||||
use codex_api::ModelsClient;
|
||||
use codex_api::RequestTelemetry;
|
||||
use codex_api::ReqwestTransport;
|
||||
use codex_api::TransportError;
|
||||
use codex_otel::TelemetryAuthMode;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use http::HeaderMap;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::TryLockError;
|
||||
use tokio::time::timeout;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::instrument;
|
||||
|
||||
const MODEL_CACHE_FILE: &str = "models_cache.json";
|
||||
const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MODELS_ENDPOINT: &str = "/models";
|
||||
#[derive(Clone)]
|
||||
struct ModelsRequestTelemetry {
|
||||
auth_mode: Option<String>,
|
||||
auth_header_attached: bool,
|
||||
auth_header_name: Option<&'static str>,
|
||||
auth_env: AuthEnvTelemetry,
|
||||
}
|
||||
|
||||
impl RequestTelemetry for ModelsRequestTelemetry {
|
||||
fn on_request(
|
||||
&self,
|
||||
attempt: u64,
|
||||
status: Option<http::StatusCode>,
|
||||
error: Option<&TransportError>,
|
||||
duration: Duration,
|
||||
) {
|
||||
let success = status.is_some_and(|code| code.is_success()) && error.is_none();
|
||||
let error_message = error.map(telemetry_transport_error_message);
|
||||
let response_debug = error
|
||||
.map(extract_response_debug_context)
|
||||
.unwrap_or_default();
|
||||
let status = status.map(|status| status.as_u16());
|
||||
tracing::event!(
|
||||
target: "codex_otel.log_only",
|
||||
tracing::Level::INFO,
|
||||
event.name = "codex.api_request",
|
||||
duration_ms = %duration.as_millis(),
|
||||
http.response.status_code = status,
|
||||
success = success,
|
||||
error.message = error_message.as_deref(),
|
||||
attempt = attempt,
|
||||
endpoint = MODELS_ENDPOINT,
|
||||
auth.header_attached = self.auth_header_attached,
|
||||
auth.header_name = self.auth_header_name,
|
||||
auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present,
|
||||
auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present,
|
||||
auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled,
|
||||
auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(),
|
||||
auth.env_provider_key_present = self.auth_env.provider_env_key_present,
|
||||
auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present,
|
||||
auth.request_id = response_debug.request_id.as_deref(),
|
||||
auth.cf_ray = response_debug.cf_ray.as_deref(),
|
||||
auth.error = response_debug.auth_error.as_deref(),
|
||||
auth.error_code = response_debug.auth_error_code.as_deref(),
|
||||
auth.mode = self.auth_mode.as_deref(),
|
||||
);
|
||||
tracing::event!(
|
||||
target: "codex_otel.trace_safe",
|
||||
tracing::Level::INFO,
|
||||
event.name = "codex.api_request",
|
||||
duration_ms = %duration.as_millis(),
|
||||
http.response.status_code = status,
|
||||
success = success,
|
||||
error.message = error_message.as_deref(),
|
||||
attempt = attempt,
|
||||
endpoint = MODELS_ENDPOINT,
|
||||
auth.header_attached = self.auth_header_attached,
|
||||
auth.header_name = self.auth_header_name,
|
||||
auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present,
|
||||
auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present,
|
||||
auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled,
|
||||
auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(),
|
||||
auth.env_provider_key_present = self.auth_env.provider_env_key_present,
|
||||
auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present,
|
||||
auth.request_id = response_debug.request_id.as_deref(),
|
||||
auth.cf_ray = response_debug.cf_ray.as_deref(),
|
||||
auth.error = response_debug.auth_error.as_deref(),
|
||||
auth.error_code = response_debug.auth_error_code.as_deref(),
|
||||
auth.mode = self.auth_mode.as_deref(),
|
||||
);
|
||||
emit_feedback_request_tags_with_auth_env(
|
||||
&FeedbackRequestTags {
|
||||
endpoint: MODELS_ENDPOINT,
|
||||
auth_header_attached: self.auth_header_attached,
|
||||
auth_header_name: self.auth_header_name,
|
||||
auth_mode: self.auth_mode.as_deref(),
|
||||
auth_retry_after_unauthorized: None,
|
||||
auth_recovery_mode: None,
|
||||
auth_recovery_phase: None,
|
||||
auth_connection_reused: None,
|
||||
auth_request_id: response_debug.request_id.as_deref(),
|
||||
auth_cf_ray: response_debug.cf_ray.as_deref(),
|
||||
auth_error: response_debug.auth_error.as_deref(),
|
||||
auth_error_code: response_debug.auth_error_code.as_deref(),
|
||||
auth_recovery_followup_success: None,
|
||||
auth_recovery_followup_status: None,
|
||||
},
|
||||
&self.auth_env,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Strategy for refreshing available models.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefreshStrategy {
|
||||
/// Always fetch from the network, ignoring cache.
|
||||
Online,
|
||||
/// Only use cached data, never fetch from the network.
|
||||
Offline,
|
||||
/// Use cache if available and fresh, otherwise fetch from the network.
|
||||
OnlineIfUncached,
|
||||
}
|
||||
|
||||
impl RefreshStrategy {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Online => "online",
|
||||
Self::Offline => "offline",
|
||||
Self::OnlineIfUncached => "online_if_uncached",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for RefreshStrategy {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// How the manager's base catalog is sourced for the lifetime of the process.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CatalogMode {
|
||||
/// Start from bundled `models.json` and allow cache/network refresh updates.
|
||||
Default,
|
||||
/// Use a caller-provided catalog as authoritative and do not mutate it via refresh.
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Coordinates remote model discovery plus cached metadata on disk.
|
||||
#[derive(Debug)]
|
||||
pub struct ModelsManager {
|
||||
remote_models: RwLock<Vec<ModelInfo>>,
|
||||
catalog_mode: CatalogMode,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
etag: RwLock<Option<String>>,
|
||||
cache_manager: ModelsCacheManager,
|
||||
provider: ModelProviderInfo,
|
||||
}
|
||||
|
||||
impl ModelsManager {
|
||||
/// Construct a manager scoped to the provided `AuthManager`.
|
||||
///
|
||||
/// Uses `codex_home` to store cached model metadata and initializes with bundled catalog
|
||||
/// When `model_catalog` is provided, it becomes the authoritative remote model list and
|
||||
/// background refreshes from `/models` are disabled.
|
||||
pub fn new(
|
||||
codex_home: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
model_catalog: Option<ModelsResponse>,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
) -> Self {
|
||||
Self::new_with_provider(
|
||||
codex_home,
|
||||
auth_manager,
|
||||
model_catalog,
|
||||
collaboration_modes_config,
|
||||
ModelProviderInfo::create_openai_provider(/*base_url*/ None),
|
||||
)
|
||||
}
|
||||
|
||||
/// Construct a manager with an explicit provider used for remote model refreshes.
|
||||
pub fn new_with_provider(
|
||||
codex_home: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
model_catalog: Option<ModelsResponse>,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
provider: ModelProviderInfo,
|
||||
) -> Self {
|
||||
let cache_path = codex_home.join(MODEL_CACHE_FILE);
|
||||
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
|
||||
let catalog_mode = if model_catalog.is_some() {
|
||||
CatalogMode::Custom
|
||||
} else {
|
||||
CatalogMode::Default
|
||||
};
|
||||
let remote_models = model_catalog
|
||||
.map(|catalog| catalog.models)
|
||||
.unwrap_or_else(|| {
|
||||
Self::load_remote_models_from_file()
|
||||
.unwrap_or_else(|err| panic!("failed to load bundled models.json: {err}"))
|
||||
});
|
||||
Self {
|
||||
remote_models: RwLock::new(remote_models),
|
||||
catalog_mode,
|
||||
collaboration_modes_config,
|
||||
auth_manager,
|
||||
etag: RwLock::new(None),
|
||||
cache_manager,
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available models, refreshing according to the specified strategy.
|
||||
///
|
||||
/// Returns model presets sorted by priority and filtered by auth mode and visibility.
|
||||
#[instrument(
|
||||
level = "info",
|
||||
skip(self),
|
||||
fields(refresh_strategy = %refresh_strategy)
|
||||
)]
|
||||
pub async fn list_models(&self, refresh_strategy: RefreshStrategy) -> Vec<ModelPreset> {
|
||||
if let Err(err) = self.refresh_available_models(refresh_strategy).await {
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
let remote_models = self.get_remote_models().await;
|
||||
self.build_available_models(remote_models)
|
||||
}
|
||||
|
||||
/// List collaboration mode presets.
|
||||
///
|
||||
/// Returns a static set of presets seeded with the configured model.
|
||||
pub fn list_collaboration_modes(&self) -> Vec<CollaborationModeMask> {
|
||||
self.list_collaboration_modes_for_config(self.collaboration_modes_config)
|
||||
}
|
||||
|
||||
pub fn list_collaboration_modes_for_config(
|
||||
&self,
|
||||
collaboration_modes_config: CollaborationModesConfig,
|
||||
) -> Vec<CollaborationModeMask> {
|
||||
builtin_collaboration_mode_presets(collaboration_modes_config)
|
||||
}
|
||||
|
||||
/// Attempt to list models without blocking, using the current cached state.
|
||||
///
|
||||
/// Returns an error if the internal lock cannot be acquired.
|
||||
pub fn try_list_models(&self) -> Result<Vec<ModelPreset>, TryLockError> {
|
||||
let remote_models = self.try_get_remote_models()?;
|
||||
Ok(self.build_available_models(remote_models))
|
||||
}
|
||||
|
||||
// todo(aibrahim): should be visible to core only and sent on session_configured event
|
||||
/// Get the model identifier to use, refreshing according to the specified strategy.
|
||||
///
|
||||
/// If `model` is provided, returns it directly. Otherwise selects the default based on
|
||||
/// auth mode and available models.
|
||||
#[instrument(
|
||||
level = "info",
|
||||
skip(self, model),
|
||||
fields(
|
||||
model.provided = model.is_some(),
|
||||
refresh_strategy = %refresh_strategy
|
||||
)
|
||||
)]
|
||||
pub async fn get_default_model(
|
||||
&self,
|
||||
model: &Option<String>,
|
||||
refresh_strategy: RefreshStrategy,
|
||||
) -> String {
|
||||
if let Some(model) = model.as_ref() {
|
||||
return model.to_string();
|
||||
}
|
||||
if let Err(err) = self.refresh_available_models(refresh_strategy).await {
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
let remote_models = self.get_remote_models().await;
|
||||
let available = self.build_available_models(remote_models);
|
||||
available
|
||||
.iter()
|
||||
.find(|model| model.is_default)
|
||||
.or_else(|| available.first())
|
||||
.map(|model| model.model.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// todo(aibrahim): look if we can tighten it to pub(crate)
|
||||
/// Look up model metadata, applying remote overrides and config adjustments.
|
||||
#[instrument(level = "info", skip(self, config), fields(model = model))]
|
||||
pub async fn get_model_info(&self, model: &str, config: &Config) -> ModelInfo {
|
||||
let remote_models = self.get_remote_models().await;
|
||||
Self::construct_model_info_from_candidates(model, &remote_models, config)
|
||||
}
|
||||
|
||||
fn find_model_by_longest_prefix(model: &str, candidates: &[ModelInfo]) -> Option<ModelInfo> {
|
||||
let mut best: Option<ModelInfo> = None;
|
||||
for candidate in candidates {
|
||||
if !model.starts_with(&candidate.slug) {
|
||||
continue;
|
||||
}
|
||||
let is_better_match = if let Some(current) = best.as_ref() {
|
||||
candidate.slug.len() > current.slug.len()
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if is_better_match {
|
||||
best = Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Retry metadata lookup for a single namespaced slug like `namespace/model-name`.
|
||||
///
|
||||
/// This only strips one leading namespace segment and only when the namespace is ASCII
|
||||
/// alphanumeric/underscore (`\\w+`) to avoid broadly matching arbitrary aliases.
|
||||
fn find_model_by_namespaced_suffix(model: &str, candidates: &[ModelInfo]) -> Option<ModelInfo> {
|
||||
let (namespace, suffix) = model.split_once('/')?;
|
||||
if suffix.contains('/') {
|
||||
return None;
|
||||
}
|
||||
if !namespace
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Self::find_model_by_longest_prefix(suffix, candidates)
|
||||
}
|
||||
|
||||
fn construct_model_info_from_candidates(
|
||||
model: &str,
|
||||
candidates: &[ModelInfo],
|
||||
config: &Config,
|
||||
) -> ModelInfo {
|
||||
// First use the normal longest-prefix match. If that misses, allow a narrowly scoped
|
||||
// retry for namespaced slugs like `custom/gpt-5.3-codex`.
|
||||
let remote = Self::find_model_by_longest_prefix(model, candidates)
|
||||
.or_else(|| Self::find_model_by_namespaced_suffix(model, candidates));
|
||||
let model_info = if let Some(remote) = remote {
|
||||
ModelInfo {
|
||||
slug: model.to_string(),
|
||||
used_fallback_model_metadata: false,
|
||||
..remote
|
||||
}
|
||||
} else {
|
||||
model_info::model_info_from_slug(model)
|
||||
};
|
||||
model_info::with_config_overrides(model_info, config)
|
||||
}
|
||||
|
||||
/// Refresh models if the provided ETag differs from the cached ETag.
|
||||
///
|
||||
/// Uses `Online` strategy to fetch latest models when ETags differ.
|
||||
pub(crate) async fn refresh_if_new_etag(&self, etag: String) {
|
||||
let current_etag = self.get_etag().await;
|
||||
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
|
||||
if let Err(err) = self.cache_manager.renew_cache_ttl().await {
|
||||
error!("failed to renew cache TTL: {err}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Err(err) = self.refresh_available_models(RefreshStrategy::Online).await {
|
||||
error!("failed to refresh available models: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh available models according to the specified strategy.
|
||||
async fn refresh_available_models(&self, refresh_strategy: RefreshStrategy) -> CoreResult<()> {
|
||||
// don't override the custom model catalog if one was provided by the user
|
||||
if matches!(self.catalog_mode, CatalogMode::Custom) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.auth_manager.auth_mode() != Some(AuthMode::Chatgpt) {
|
||||
if matches!(
|
||||
refresh_strategy,
|
||||
RefreshStrategy::Offline | RefreshStrategy::OnlineIfUncached
|
||||
) {
|
||||
self.try_load_cache().await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match refresh_strategy {
|
||||
RefreshStrategy::Offline => {
|
||||
// Only try to load from cache, never fetch
|
||||
self.try_load_cache().await;
|
||||
Ok(())
|
||||
}
|
||||
RefreshStrategy::OnlineIfUncached => {
|
||||
// Try cache first, fall back to online if unavailable
|
||||
if self.try_load_cache().await {
|
||||
info!("models cache: using cached models for OnlineIfUncached");
|
||||
return Ok(());
|
||||
}
|
||||
info!("models cache: cache miss, fetching remote models");
|
||||
self.fetch_and_update_models().await
|
||||
}
|
||||
RefreshStrategy::Online => {
|
||||
// Always fetch from network
|
||||
self.fetch_and_update_models().await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_and_update_models(&self) -> CoreResult<()> {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
|
||||
let api_provider = self.provider.to_api_provider(auth_mode)?;
|
||||
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
|
||||
let auth_env = collect_auth_env_telemetry(
|
||||
&self.provider,
|
||||
self.auth_manager.codex_api_key_env_enabled(),
|
||||
);
|
||||
let transport = ReqwestTransport::new(build_reqwest_client());
|
||||
let request_telemetry: Arc<dyn RequestTelemetry> = Arc::new(ModelsRequestTelemetry {
|
||||
auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()),
|
||||
auth_header_attached: api_auth.auth_header_attached(),
|
||||
auth_header_name: api_auth.auth_header_name(),
|
||||
auth_env,
|
||||
});
|
||||
let client = ModelsClient::new(transport, api_provider, api_auth)
|
||||
.with_telemetry(Some(request_telemetry));
|
||||
|
||||
let client_version = crate::models_manager::client_version_to_whole();
|
||||
let (models, etag) = timeout(
|
||||
MODELS_REFRESH_TIMEOUT,
|
||||
client.list_models(&client_version, HeaderMap::new()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| CodexErr::Timeout)?
|
||||
.map_err(map_api_error)?;
|
||||
|
||||
self.apply_remote_models(models.clone()).await;
|
||||
*self.etag.write().await = etag.clone();
|
||||
self.cache_manager
|
||||
.persist_cache(&models, etag, client_version)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_etag(&self) -> Option<String> {
|
||||
self.etag.read().await.clone()
|
||||
}
|
||||
|
||||
/// Replace the cached remote models and rebuild the derived presets list.
|
||||
async fn apply_remote_models(&self, models: Vec<ModelInfo>) {
|
||||
let mut existing_models = Self::load_remote_models_from_file().unwrap_or_default();
|
||||
for model in models {
|
||||
if let Some(existing_index) = existing_models
|
||||
.iter()
|
||||
.position(|existing| existing.slug == model.slug)
|
||||
{
|
||||
existing_models[existing_index] = model;
|
||||
} else {
|
||||
existing_models.push(model);
|
||||
}
|
||||
}
|
||||
*self.remote_models.write().await = existing_models;
|
||||
}
|
||||
|
||||
fn load_remote_models_from_file() -> Result<Vec<ModelInfo>, std::io::Error> {
|
||||
let file_contents = include_str!("../../models.json");
|
||||
let response: ModelsResponse = serde_json::from_str(file_contents)?;
|
||||
Ok(response.models)
|
||||
}
|
||||
|
||||
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
|
||||
async fn try_load_cache(&self) -> bool {
|
||||
let _timer =
|
||||
codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]);
|
||||
let client_version = crate::models_manager::client_version_to_whole();
|
||||
info!(client_version, "models cache: evaluating cache eligibility");
|
||||
let cache = match self.cache_manager.load_fresh(&client_version).await {
|
||||
Some(cache) => cache,
|
||||
None => {
|
||||
info!("models cache: no usable cache entry");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let models = cache.models.clone();
|
||||
*self.etag.write().await = cache.etag.clone();
|
||||
self.apply_remote_models(models.clone()).await;
|
||||
info!(
|
||||
models_count = models.len(),
|
||||
etag = ?cache.etag,
|
||||
"models cache: cache entry applied"
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Build picker-ready presets from the active catalog snapshot.
|
||||
fn build_available_models(&self, mut remote_models: Vec<ModelInfo>) -> Vec<ModelPreset> {
|
||||
remote_models.sort_by(|a, b| a.priority.cmp(&b.priority));
|
||||
|
||||
let mut presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
|
||||
let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt));
|
||||
presets = ModelPreset::filter_by_auth(presets, chatgpt_mode);
|
||||
|
||||
ModelPreset::mark_default_by_picker_visibility(&mut presets);
|
||||
|
||||
presets
|
||||
}
|
||||
|
||||
async fn get_remote_models(&self) -> Vec<ModelInfo> {
|
||||
self.remote_models.read().await.clone()
|
||||
}
|
||||
|
||||
fn try_get_remote_models(&self) -> Result<Vec<ModelInfo>, TryLockError> {
|
||||
Ok(self.remote_models.try_read()?.clone())
|
||||
}
|
||||
|
||||
/// Construct a manager with a specific provider for testing.
|
||||
pub(crate) fn with_provider_for_tests(
|
||||
codex_home: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
provider: ModelProviderInfo,
|
||||
) -> Self {
|
||||
Self::new_with_provider(
|
||||
codex_home,
|
||||
auth_manager,
|
||||
/*model_catalog*/ None,
|
||||
CollaborationModesConfig::default(),
|
||||
provider,
|
||||
)
|
||||
}
|
||||
|
||||
/// Get model identifier without consulting remote state or cache.
|
||||
pub(crate) fn get_model_offline_for_tests(model: Option<&str>) -> String {
|
||||
if let Some(model) = model {
|
||||
return model.to_string();
|
||||
}
|
||||
let mut models = Self::load_remote_models_from_file().unwrap_or_default();
|
||||
models.sort_by(|a, b| a.priority.cmp(&b.priority));
|
||||
let presets: Vec<ModelPreset> = models.into_iter().map(Into::into).collect();
|
||||
presets
|
||||
.iter()
|
||||
.find(|preset| preset.show_in_picker)
|
||||
.or_else(|| presets.first())
|
||||
.map(|preset| preset.model.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Build `ModelInfo` without consulting remote state or cache.
|
||||
pub(crate) fn construct_model_info_offline_for_tests(
|
||||
model: &str,
|
||||
config: &Config,
|
||||
) -> ModelInfo {
|
||||
let candidates: &[ModelInfo] = if let Some(model_catalog) = config.model_catalog.as_ref() {
|
||||
&model_catalog.models
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
Self::construct_model_info_from_candidates(model, candidates, config)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "manager_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,739 +0,0 @@
|
||||
use super::*;
|
||||
use crate::CodexAuth;
|
||||
use crate::auth::AuthCredentialsStoreMode;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::model_provider_info::WireApi;
|
||||
use base64::Engine as _;
|
||||
use chrono::Utc;
|
||||
use codex_api::TransportError;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use http::HeaderMap;
|
||||
use http::StatusCode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::tempdir;
|
||||
use tracing::Event;
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::Visit;
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use wiremock::MockServer;
|
||||
|
||||
fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo {
|
||||
remote_model_with_visibility(slug, display, priority, "list")
|
||||
}
|
||||
|
||||
fn remote_model_with_visibility(
|
||||
slug: &str,
|
||||
display: &str,
|
||||
priority: i32,
|
||||
visibility: &str,
|
||||
) -> ModelInfo {
|
||||
serde_json::from_value(json!({
|
||||
"slug": slug,
|
||||
"display_name": display,
|
||||
"description": format!("{display} desc"),
|
||||
"default_reasoning_level": "medium",
|
||||
"supported_reasoning_levels": [{"effort": "low", "description": "low"}, {"effort": "medium", "description": "medium"}],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": visibility,
|
||||
"minimal_client_version": [0, 1, 0],
|
||||
"supported_in_api": true,
|
||||
"priority": priority,
|
||||
"upgrade": null,
|
||||
"base_instructions": "base instructions",
|
||||
"supports_reasoning_summaries": false,
|
||||
"support_verbosity": false,
|
||||
"default_verbosity": null,
|
||||
"apply_patch_tool_type": null,
|
||||
"truncation_policy": {"mode": "bytes", "limit": 10_000},
|
||||
"supports_parallel_tool_calls": false,
|
||||
"supports_image_detail_original": false,
|
||||
"context_window": 272_000,
|
||||
"experimental_supported_tools": [],
|
||||
}))
|
||||
.expect("valid model")
|
||||
}
|
||||
|
||||
fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) {
|
||||
for model in expected {
|
||||
assert!(
|
||||
actual.iter().any(|candidate| candidate.slug == model.slug),
|
||||
"expected model {} in cached list",
|
||||
model.slug
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_for(base_url: String) -> ModelProviderInfo {
|
||||
ModelProviderInfo {
|
||||
name: "mock".into(),
|
||||
base_url: Some(base_url),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: Some(0),
|
||||
stream_max_retries: Some(0),
|
||||
stream_idle_timeout_ms: Some(5_000),
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TagCollectorVisitor {
|
||||
tags: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Visit for TagCollectorVisitor {
|
||||
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), value.to_string());
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
self.tags
|
||||
.insert(field.name().to_string(), format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TagCollectorLayer {
|
||||
tags: Arc<Mutex<BTreeMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for TagCollectorLayer
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
if event.metadata().target() != "feedback_tags" {
|
||||
return;
|
||||
}
|
||||
let mut visitor = TagCollectorVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
self.tags.lock().unwrap().extend(visitor.tags);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_info_tracks_fallback_usage() {
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let manager = ModelsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
/*model_catalog*/ None,
|
||||
CollaborationModesConfig::default(),
|
||||
);
|
||||
let known_slug = manager
|
||||
.get_remote_models()
|
||||
.await
|
||||
.first()
|
||||
.expect("bundled models should include at least one model")
|
||||
.slug
|
||||
.clone();
|
||||
|
||||
let known = manager.get_model_info(known_slug.as_str(), &config).await;
|
||||
assert!(!known.used_fallback_model_metadata);
|
||||
assert_eq!(known.slug, known_slug);
|
||||
|
||||
let unknown = manager
|
||||
.get_model_info("model-that-does-not-exist", &config)
|
||||
.await;
|
||||
assert!(unknown.used_fallback_model_metadata);
|
||||
assert_eq!(unknown.slug, "model-that-does-not-exist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_info_uses_custom_catalog() {
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
let mut overlay = remote_model("gpt-overlay", "Overlay", /*priority*/ 0);
|
||||
overlay.supports_image_detail_original = true;
|
||||
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let manager = ModelsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
Some(ModelsResponse {
|
||||
models: vec![overlay],
|
||||
}),
|
||||
CollaborationModesConfig::default(),
|
||||
);
|
||||
|
||||
let model_info = manager
|
||||
.get_model_info("gpt-overlay-experiment", &config)
|
||||
.await;
|
||||
|
||||
assert_eq!(model_info.slug, "gpt-overlay-experiment");
|
||||
assert_eq!(model_info.display_name, "Overlay");
|
||||
assert_eq!(model_info.context_window, Some(272_000));
|
||||
assert!(model_info.supports_image_detail_original);
|
||||
assert!(!model_info.supports_parallel_tool_calls);
|
||||
assert!(!model_info.used_fallback_model_metadata);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_info_matches_namespaced_suffix() {
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
let mut remote = remote_model("gpt-image", "Image", /*priority*/ 0);
|
||||
remote.supports_image_detail_original = true;
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let manager = ModelsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
Some(ModelsResponse {
|
||||
models: vec![remote],
|
||||
}),
|
||||
CollaborationModesConfig::default(),
|
||||
);
|
||||
let namespaced_model = "custom/gpt-image".to_string();
|
||||
|
||||
let model_info = manager.get_model_info(&namespaced_model, &config).await;
|
||||
|
||||
assert_eq!(model_info.slug, namespaced_model);
|
||||
assert!(model_info.supports_image_detail_original);
|
||||
assert!(!model_info.used_fallback_model_metadata);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_info_rejects_multi_segment_namespace_suffix_matching() {
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load default test config");
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let manager = ModelsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
/*model_catalog*/ None,
|
||||
CollaborationModesConfig::default(),
|
||||
);
|
||||
let known_slug = manager
|
||||
.get_remote_models()
|
||||
.await
|
||||
.first()
|
||||
.expect("bundled models should include at least one model")
|
||||
.slug
|
||||
.clone();
|
||||
let namespaced_model = format!("ns1/ns2/{known_slug}");
|
||||
|
||||
let model_info = manager.get_model_info(&namespaced_model, &config).await;
|
||||
|
||||
assert_eq!(model_info.slug, namespaced_model);
|
||||
assert!(model_info.used_fallback_model_metadata);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_sorts_by_priority() {
|
||||
let server = MockServer::start().await;
|
||||
let remote_models = vec![
|
||||
remote_model("priority-low", "Low", /*priority*/ 1),
|
||||
remote_model("priority-high", "High", /*priority*/ 0),
|
||||
];
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: remote_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let provider = provider_for(server.uri());
|
||||
let manager = ModelsManager::with_provider_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
provider,
|
||||
);
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
let cached_remote = manager.get_remote_models().await;
|
||||
assert_models_contain(&cached_remote, &remote_models);
|
||||
|
||||
let available = manager.list_models(RefreshStrategy::OnlineIfUncached).await;
|
||||
let high_idx = available
|
||||
.iter()
|
||||
.position(|model| model.model == "priority-high")
|
||||
.expect("priority-high should be listed");
|
||||
let low_idx = available
|
||||
.iter()
|
||||
.position(|model| model.model == "priority-low")
|
||||
.expect("priority-low should be listed");
|
||||
assert!(
|
||||
high_idx < low_idx,
|
||||
"higher priority should be listed before lower priority"
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"expected a single /models request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_uses_cache_when_fresh() {
|
||||
let server = MockServer::start().await;
|
||||
let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)];
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: remote_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let provider = provider_for(server.uri());
|
||||
let manager = ModelsManager::with_provider_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
provider,
|
||||
);
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("first refresh succeeds");
|
||||
assert_models_contain(&manager.get_remote_models().await, &remote_models);
|
||||
|
||||
// Second call should read from cache and avoid the network.
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("cached refresh succeeds");
|
||||
assert_models_contain(&manager.get_remote_models().await, &remote_models);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
1,
|
||||
"cache hit should avoid a second /models request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_refetches_when_cache_stale() {
|
||||
let server = MockServer::start().await;
|
||||
let initial_models = vec![remote_model("stale", "Stale", /*priority*/ 1)];
|
||||
let initial_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: initial_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let provider = provider_for(server.uri());
|
||||
let manager = ModelsManager::with_provider_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
provider,
|
||||
);
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("initial refresh succeeds");
|
||||
|
||||
// Rewrite cache with an old timestamp so it is treated as stale.
|
||||
manager
|
||||
.cache_manager
|
||||
.manipulate_cache_for_test(|fetched_at| {
|
||||
*fetched_at = Utc::now() - chrono::Duration::hours(1);
|
||||
})
|
||||
.await
|
||||
.expect("cache manipulation succeeds");
|
||||
|
||||
let updated_models = vec![remote_model("fresh", "Fresh", /*priority*/ 9)];
|
||||
server.reset().await;
|
||||
let refreshed_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: updated_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("second refresh succeeds");
|
||||
assert_models_contain(&manager.get_remote_models().await, &updated_models);
|
||||
assert_eq!(
|
||||
initial_mock.requests().len(),
|
||||
1,
|
||||
"initial refresh should only hit /models once"
|
||||
);
|
||||
assert_eq!(
|
||||
refreshed_mock.requests().len(),
|
||||
1,
|
||||
"stale cache refresh should fetch /models once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_refetches_when_version_mismatch() {
|
||||
let server = MockServer::start().await;
|
||||
let initial_models = vec![remote_model("old", "Old", /*priority*/ 1)];
|
||||
let initial_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: initial_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let provider = provider_for(server.uri());
|
||||
let manager = ModelsManager::with_provider_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
provider,
|
||||
);
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("initial refresh succeeds");
|
||||
|
||||
manager
|
||||
.cache_manager
|
||||
.mutate_cache_for_test(|cache| {
|
||||
let client_version = crate::models_manager::client_version_to_whole();
|
||||
cache.client_version = Some(format!("{client_version}-mismatch"));
|
||||
})
|
||||
.await
|
||||
.expect("cache mutation succeeds");
|
||||
|
||||
let updated_models = vec![remote_model("new", "New", /*priority*/ 2)];
|
||||
server.reset().await;
|
||||
let refreshed_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: updated_models.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("second refresh succeeds");
|
||||
assert_models_contain(&manager.get_remote_models().await, &updated_models);
|
||||
assert_eq!(
|
||||
initial_mock.requests().len(),
|
||||
1,
|
||||
"initial refresh should only hit /models once"
|
||||
);
|
||||
assert_eq!(
|
||||
refreshed_mock.requests().len(),
|
||||
1,
|
||||
"version mismatch should fetch /models once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_drops_removed_remote_models() {
|
||||
let server = MockServer::start().await;
|
||||
let initial_models = vec![remote_model(
|
||||
"remote-old",
|
||||
"Remote Old",
|
||||
/*priority*/ 1,
|
||||
)];
|
||||
let initial_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: initial_models,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let provider = provider_for(server.uri());
|
||||
let mut manager = ModelsManager::with_provider_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
provider,
|
||||
);
|
||||
manager.cache_manager.set_ttl(Duration::ZERO);
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("initial refresh succeeds");
|
||||
|
||||
server.reset().await;
|
||||
let refreshed_models = vec![remote_model(
|
||||
"remote-new",
|
||||
"Remote New",
|
||||
/*priority*/ 1,
|
||||
)];
|
||||
let refreshed_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: refreshed_models,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.expect("second refresh succeeds");
|
||||
|
||||
let available = manager
|
||||
.try_list_models()
|
||||
.expect("models should be available");
|
||||
assert!(
|
||||
available.iter().any(|preset| preset.model == "remote-new"),
|
||||
"new remote model should be listed"
|
||||
);
|
||||
assert!(
|
||||
!available.iter().any(|preset| preset.model == "remote-old"),
|
||||
"removed remote model should not be listed"
|
||||
);
|
||||
assert_eq!(
|
||||
initial_mock.requests().len(),
|
||||
1,
|
||||
"initial refresh should only hit /models once"
|
||||
);
|
||||
assert_eq!(
|
||||
refreshed_mock.requests().len(),
|
||||
1,
|
||||
"second refresh should only hit /models once"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_available_models_skips_network_without_chatgpt_auth() {
|
||||
let server = MockServer::start().await;
|
||||
let dynamic_slug = "dynamic-model-only-for-test-noauth";
|
||||
let models_mock = mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![remote_model(dynamic_slug, "No Auth", /*priority*/ 1)],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let auth_manager = Arc::new(AuthManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
));
|
||||
let provider = provider_for(server.uri());
|
||||
let manager = ModelsManager::with_provider_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
provider,
|
||||
);
|
||||
|
||||
manager
|
||||
.refresh_available_models(RefreshStrategy::Online)
|
||||
.await
|
||||
.expect("refresh should no-op without chatgpt auth");
|
||||
let cached_remote = manager.get_remote_models().await;
|
||||
assert!(
|
||||
!cached_remote
|
||||
.iter()
|
||||
.any(|candidate| candidate.slug == dynamic_slug),
|
||||
"remote refresh should be skipped without chatgpt auth"
|
||||
);
|
||||
assert_eq!(
|
||||
models_mock.requests().len(),
|
||||
0,
|
||||
"no auth should avoid /models requests"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() {
|
||||
let tags = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
let _guard = tracing_subscriber::registry()
|
||||
.with(TagCollectorLayer { tags: tags.clone() })
|
||||
.set_default();
|
||||
|
||||
let telemetry = ModelsRequestTelemetry {
|
||||
auth_mode: Some(TelemetryAuthMode::Chatgpt.to_string()),
|
||||
auth_header_attached: true,
|
||||
auth_header_name: Some("authorization"),
|
||||
auth_env: crate::auth_env_telemetry::AuthEnvTelemetry {
|
||||
openai_api_key_env_present: false,
|
||||
codex_api_key_env_present: false,
|
||||
codex_api_key_env_enabled: false,
|
||||
provider_env_key_name: Some("configured".to_string()),
|
||||
provider_env_key_present: Some(false),
|
||||
refresh_token_url_override_present: false,
|
||||
},
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-request-id", "req-models-401".parse().unwrap());
|
||||
headers.insert("cf-ray", "ray-models-401".parse().unwrap());
|
||||
headers.insert(
|
||||
"x-openai-authorization-error",
|
||||
"missing_authorization_header".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
"x-error-json",
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.encode(r#"{"error":{"code":"token_expired"}}"#)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
telemetry.on_request(
|
||||
/*attempt*/ 1,
|
||||
Some(StatusCode::UNAUTHORIZED),
|
||||
Some(&TransportError::Http {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
url: Some("https://example.test/models".to_string()),
|
||||
headers: Some(headers),
|
||||
body: Some("plain text error".to_string()),
|
||||
}),
|
||||
Duration::from_millis(17),
|
||||
);
|
||||
|
||||
let tags = tags.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
tags.get("endpoint").map(String::as_str),
|
||||
Some("\"/models\"")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_mode").map(String::as_str),
|
||||
Some("\"Chatgpt\"")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_request_id").map(String::as_str),
|
||||
Some("\"req-models-401\"")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_error").map(String::as_str),
|
||||
Some("\"missing_authorization_header\"")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_error_code").map(String::as_str),
|
||||
Some("\"token_expired\"")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_env_openai_api_key_present")
|
||||
.map(String::as_str),
|
||||
Some("false")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_env_codex_api_key_present")
|
||||
.map(String::as_str),
|
||||
Some("false")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_env_codex_api_key_enabled")
|
||||
.map(String::as_str),
|
||||
Some("false")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_env_provider_key_name").map(String::as_str),
|
||||
Some("\"configured\"")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_env_provider_key_present")
|
||||
.map(String::as_str),
|
||||
Some("\"false\"")
|
||||
);
|
||||
assert_eq!(
|
||||
tags.get("auth_env_refresh_token_url_override_present")
|
||||
.map(String::as_str),
|
||||
Some("false")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_available_models_picks_default_after_hiding_hidden_models() {
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let provider = provider_for("http://example.test".to_string());
|
||||
let manager = ModelsManager::with_provider_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
auth_manager,
|
||||
provider,
|
||||
);
|
||||
|
||||
let hidden_model =
|
||||
remote_model_with_visibility("hidden", "Hidden", /*priority*/ 0, "hide");
|
||||
let visible_model =
|
||||
remote_model_with_visibility("visible", "Visible", /*priority*/ 1, "list");
|
||||
|
||||
let expected_hidden = ModelPreset::from(hidden_model.clone());
|
||||
let mut expected_visible = ModelPreset::from(visible_model.clone());
|
||||
expected_visible.is_default = true;
|
||||
|
||||
let available = manager.build_available_models(vec![hidden_model, visible_model]);
|
||||
|
||||
assert_eq!(available, vec![expected_hidden, expected_visible]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_models_json_roundtrips() {
|
||||
let file_contents = include_str!("../../models.json");
|
||||
let response: ModelsResponse =
|
||||
serde_json::from_str(file_contents).expect("bundled models.json should deserialize");
|
||||
|
||||
let serialized =
|
||||
serde_json::to_string(&response).expect("bundled models.json should serialize");
|
||||
let roundtripped: ModelsResponse =
|
||||
serde_json::from_str(&serialized).expect("serialized models.json should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
response, roundtripped,
|
||||
"bundled models.json should round trip through serde"
|
||||
);
|
||||
assert!(
|
||||
!response.models.is_empty(),
|
||||
"bundled models.json should contain at least one model"
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
pub mod cache;
|
||||
pub mod collaboration_mode_presets;
|
||||
pub mod manager;
|
||||
pub mod model_info;
|
||||
pub mod model_presets;
|
||||
|
||||
/// Convert the client version string to a whole version string (e.g. "1.2.3-alpha.4" -> "1.2.3").
|
||||
pub fn client_version_to_whole() -> String {
|
||||
format!(
|
||||
"{}.{}.{}",
|
||||
env!("CARGO_PKG_VERSION_MAJOR"),
|
||||
env!("CARGO_PKG_VERSION_MINOR"),
|
||||
env!("CARGO_PKG_VERSION_PATCH")
|
||||
)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelInstructionsVariables;
|
||||
use codex_protocol::openai_models::ModelMessages;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::TruncationMode;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
|
||||
use crate::config::Config;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_output_truncation::approx_bytes_for_tokens;
|
||||
use tracing::warn;
|
||||
|
||||
pub const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md");
|
||||
const DEFAULT_PERSONALITY_HEADER: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals.";
|
||||
const LOCAL_FRIENDLY_TEMPLATE: &str =
|
||||
"You optimize for team morale and being a supportive teammate as much as code quality.";
|
||||
const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer.";
|
||||
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
|
||||
|
||||
pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> ModelInfo {
|
||||
if let Some(supports_reasoning_summaries) = config.model_supports_reasoning_summaries
|
||||
&& supports_reasoning_summaries
|
||||
{
|
||||
model.supports_reasoning_summaries = true;
|
||||
}
|
||||
if let Some(context_window) = config.model_context_window {
|
||||
model.context_window = Some(context_window);
|
||||
}
|
||||
if let Some(auto_compact_token_limit) = config.model_auto_compact_token_limit {
|
||||
model.auto_compact_token_limit = Some(auto_compact_token_limit);
|
||||
}
|
||||
if let Some(token_limit) = config.tool_output_token_limit {
|
||||
model.truncation_policy = match model.truncation_policy.mode {
|
||||
TruncationMode::Bytes => {
|
||||
let byte_limit =
|
||||
i64::try_from(approx_bytes_for_tokens(token_limit)).unwrap_or(i64::MAX);
|
||||
TruncationPolicyConfig::bytes(byte_limit)
|
||||
}
|
||||
TruncationMode::Tokens => {
|
||||
let limit = i64::try_from(token_limit).unwrap_or(i64::MAX);
|
||||
TruncationPolicyConfig::tokens(limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(base_instructions) = &config.base_instructions {
|
||||
model.base_instructions = base_instructions.clone();
|
||||
model.model_messages = None;
|
||||
} else if !config.features.enabled(Feature::Personality) {
|
||||
model.model_messages = None;
|
||||
}
|
||||
|
||||
model
|
||||
}
|
||||
|
||||
/// Build a minimal fallback model descriptor for missing/unknown slugs.
|
||||
pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo {
|
||||
warn!("Unknown model {slug} is used. This will use fallback model metadata.");
|
||||
ModelInfo {
|
||||
slug: slug.to_string(),
|
||||
display_name: slug.to_string(),
|
||||
description: None,
|
||||
default_reasoning_level: None,
|
||||
supported_reasoning_levels: Vec::new(),
|
||||
shell_type: ConfigShellToolType::Default,
|
||||
visibility: ModelVisibility::None,
|
||||
supported_in_api: true,
|
||||
priority: 99,
|
||||
availability_nux: None,
|
||||
upgrade: None,
|
||||
base_instructions: BASE_INSTRUCTIONS.to_string(),
|
||||
model_messages: local_personality_messages_for_slug(slug),
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
apply_patch_tool_type: None,
|
||||
web_search_tool_type: WebSearchToolType::Text,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
supports_image_detail_original: false,
|
||||
context_window: Some(272_000),
|
||||
auto_compact_token_limit: None,
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: Vec::new(),
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: true, // this is the fallback model metadata
|
||||
supports_search_tool: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_personality_messages_for_slug(slug: &str) -> Option<ModelMessages> {
|
||||
match slug {
|
||||
"gpt-5.2-codex" | "exp-codex-personality" => Some(ModelMessages {
|
||||
instructions_template: Some(format!(
|
||||
"{DEFAULT_PERSONALITY_HEADER}\n\n{PERSONALITY_PLACEHOLDER}\n\n{BASE_INSTRUCTIONS}"
|
||||
)),
|
||||
instructions_variables: Some(ModelInstructionsVariables {
|
||||
personality_default: Some(String::new()),
|
||||
personality_friendly: Some(LOCAL_FRIENDLY_TEMPLATE.to_string()),
|
||||
personality_pragmatic: Some(LOCAL_PRAGMATIC_TEMPLATE.to_string()),
|
||||
}),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_info_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,39 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::test_config;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn reasoning_summaries_override_true_enables_support() {
|
||||
let model = model_info_from_slug("unknown-model");
|
||||
let mut config = test_config();
|
||||
config.model_supports_reasoning_summaries = Some(true);
|
||||
|
||||
let updated = with_config_overrides(model.clone(), &config);
|
||||
let mut expected = model;
|
||||
expected.supports_reasoning_summaries = true;
|
||||
|
||||
assert_eq!(updated, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summaries_override_false_does_not_disable_support() {
|
||||
let mut model = model_info_from_slug("unknown-model");
|
||||
model.supports_reasoning_summaries = true;
|
||||
let mut config = test_config();
|
||||
config.model_supports_reasoning_summaries = Some(false);
|
||||
|
||||
let updated = with_config_overrides(model.clone(), &config);
|
||||
|
||||
assert_eq!(updated, model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summaries_override_false_is_noop_when_model_is_false() {
|
||||
let model = model_info_from_slug("unknown-model");
|
||||
let mut config = test_config();
|
||||
config.model_supports_reasoning_summaries = Some(false);
|
||||
|
||||
let updated = with_config_overrides(model.clone(), &config);
|
||||
|
||||
assert_eq!(updated, model);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/// Legacy notice keys kept for config compatibility with older migration prompts.
|
||||
///
|
||||
/// Hardcoded model presets were removed; model listings are now derived from the active catalog.
|
||||
pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt";
|
||||
pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str =
|
||||
"hide_gpt-5.1-codex-max_migration_prompt";
|
||||
@@ -1,25 +1,12 @@
|
||||
use codex_execpolicy::Decision as ExecPolicyDecision;
|
||||
use codex_execpolicy::NetworkRuleProtocol as ExecPolicyNetworkRuleProtocol;
|
||||
use codex_network_proxy::BlockedRequest;
|
||||
use codex_network_proxy::NetworkDecisionSource;
|
||||
use codex_network_proxy::NetworkPolicyDecision;
|
||||
use codex_protocol::approvals::NetworkApprovalContext;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkPolicyDecisionPayload {
|
||||
pub decision: NetworkPolicyDecision,
|
||||
pub source: NetworkDecisionSource,
|
||||
#[serde(default)]
|
||||
pub protocol: Option<NetworkApprovalProtocol>,
|
||||
pub host: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
use codex_protocol::network_policy::NetworkPolicyDecisionPayload;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct ExecPolicyNetworkRuleAmendment {
|
||||
@@ -28,12 +15,6 @@ pub(crate) struct ExecPolicyNetworkRuleAmendment {
|
||||
pub justification: String,
|
||||
}
|
||||
|
||||
impl NetworkPolicyDecisionPayload {
|
||||
pub(crate) fn is_ask_from_decider(&self) -> bool {
|
||||
self.decision == NetworkPolicyDecision::Ask && self.source == NetworkDecisionSource::Decider
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_network_policy_decision(value: &str) -> Option<NetworkPolicyDecision> {
|
||||
match value {
|
||||
"deny" => Some(NetworkPolicyDecision::Deny),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use codex_network_proxy::BlockedRequest;
|
||||
use codex_network_proxy::NetworkDecisionSource;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -1,28 +1,2 @@
|
||||
use codex_features::Feature;
|
||||
use codex_features::Features;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
|
||||
pub(crate) fn can_request_original_image_detail(
|
||||
features: &Features,
|
||||
model_info: &ModelInfo,
|
||||
) -> bool {
|
||||
model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_output_image_detail(
|
||||
features: &Features,
|
||||
model_info: &ModelInfo,
|
||||
detail: Option<ImageDetail>,
|
||||
) -> Option<ImageDetail> {
|
||||
match detail {
|
||||
Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => {
|
||||
Some(ImageDetail::Original)
|
||||
}
|
||||
Some(ImageDetail::Original) | Some(_) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "original_image_detail_tests.rs"]
|
||||
mod tests;
|
||||
pub(crate) use codex_tools::can_request_original_image_detail;
|
||||
pub(crate) use codex_tools::normalize_output_image_detail;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
use crate::config::test_config;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use codex_features::Features;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn image_detail_original_feature_enables_explicit_original_without_force() {
|
||||
let config = test_config();
|
||||
let mut model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
model_info.supports_image_detail_original = true;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ImageDetailOriginal);
|
||||
|
||||
assert!(can_request_original_image_detail(&features, &model_info));
|
||||
assert_eq!(
|
||||
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)),
|
||||
Some(ImageDetail::Original)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_output_image_detail(&features, &model_info, /*detail*/ None),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_original_is_dropped_without_feature_or_model_support() {
|
||||
let config = test_config();
|
||||
let mut model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
model_info.supports_image_detail_original = true;
|
||||
let features = Features::with_defaults();
|
||||
|
||||
assert_eq!(
|
||||
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)),
|
||||
None
|
||||
);
|
||||
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ImageDetailOriginal);
|
||||
model_info.supports_image_detail_original = false;
|
||||
assert_eq!(
|
||||
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_non_original_detail_is_dropped() {
|
||||
let config = test_config();
|
||||
let mut model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
model_info.supports_image_detail_original = true;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ImageDetailOriginal);
|
||||
|
||||
assert_eq!(
|
||||
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Low)),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::config::Config;
|
||||
use crate::config::types::OtelExporterKind as Kind;
|
||||
use crate::config::types::OtelHttpProtocol as Protocol;
|
||||
use crate::default_client::originator;
|
||||
use codex_config::types::OtelExporterKind as Kind;
|
||||
use codex_config::types::OtelHttpProtocol as Protocol;
|
||||
use codex_features::Feature;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_otel::OtelProvider;
|
||||
use codex_otel::config::OtelExporter;
|
||||
use codex_otel::config::OtelHttpProtocol;
|
||||
|
||||
@@ -6,9 +6,9 @@ use crate::rollout::list::ThreadListConfig;
|
||||
use crate::rollout::list::ThreadListLayout;
|
||||
use crate::rollout::list::ThreadSortKey;
|
||||
use crate::rollout::list::get_threads_in_root;
|
||||
use crate::state_db;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_rollout::state_db;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tokio::fs::OpenOptions;
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::PluginCapabilitySummary;
|
||||
use super::PluginReadRequest;
|
||||
use super::PluginsManager;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::ToolSuggestDiscoverableType;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
use codex_features::Feature;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::render_explicit_plugin_instructions;
|
||||
use codex_mcp::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::mcp_connection_manager::ToolInfo;
|
||||
|
||||
pub(crate) fn build_plugin_injections(
|
||||
mentioned_plugins: &[PluginCapabilitySummary],
|
||||
|
||||
@@ -26,17 +26,13 @@ use super::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use super::store::PluginStore;
|
||||
use super::store::PluginStoreError;
|
||||
use super::sync_openai_plugins_repo;
|
||||
use crate::AuthManager;
|
||||
use crate::SkillMetadata;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigService;
|
||||
use crate::config::ConfigServiceError;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::PluginConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_rules::SkillConfigRules;
|
||||
use crate::config_rules::resolve_disabled_skill_paths;
|
||||
@@ -46,7 +42,11 @@ use crate::loader::load_skills_from_roots;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use super::*;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
@@ -15,6 +13,8 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::protocol::Product;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerConfig;
|
||||
|
||||
mod discoverable;
|
||||
mod injection;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user