merge main into code-branch-20251003-051152 before merge

This commit is contained in:
James Peter
2025-10-03 19:33:03 +10:00
124 changed files with 3448 additions and 1003 deletions
+18
View File
@@ -0,0 +1,18 @@
You are an assistant that triages new GitHub issues by identifying potential duplicates.
You will receive the following JSON files located in the current working directory:
- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body).
- `codex-existing-issues.json`: JSON array of recent issues (each element includes number, title, body, createdAt).
Instructions:
- Load both files as JSON and review their contents carefully. The codex-existing-issues.json file is large, ensure you explore all of it.
- Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request.
- Only consider an issue a potential duplicate if there is a clear overlap in symptoms, feature requests, reproduction steps, or error messages.
- Prioritize newer issues when similarity is comparable.
- Ignore pull requests and issues whose similarity is tenuous.
- When unsure, prefer returning fewer matches.
Output requirements:
- Respond with a JSON array of issue numbers (integers), ordered from most likely duplicate to least.
- Include at most five numbers.
- If you find no plausible duplicates, respond with `[]`.
+26
View File
@@ -0,0 +1,26 @@
You are an assistant that reviews GitHub issues for the repository.
Your job is to choose the most appropriate existing labels for the issue described later in this prompt.
Follow these rules:
- Only pick labels out of the list below.
- Prefer a small set of precise labels over many broad ones.
- If none of the labels fit, respond with an empty JSON array: []
- Output must be a JSON array of label names (strings) with no additional commentary.
Labels to apply:
1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth).
2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks.
3. extension — VS Code (or other IDE) extension-specific issues.
4. windows-os — Bugs or friction specific to Windows environments (PowerShell behavior, path handling, copy/paste, OS-specific auth or tooling failures).
5. mcp — Topics involving Model Context Protocol servers/clients.
6. codex-web — Issues targeting the Codex web UI/Cloud experience.
8. azure — Problems or requests tied to Azure OpenAI deployments.
9. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests).
10. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies.
Issue information is available in environment variables:
ISSUE_NUMBER
ISSUE_TITLE
ISSUE_BODY
REPO_FULL_NAME
+104
View File
@@ -0,0 +1,104 @@
name: Issue Deduplicator
on:
issues:
types:
- opened
- labeled
jobs:
gather-duplicates:
name: Identify potential duplicates
if: ${{ (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate')) && secrets.CODEX_OPENAI_API_KEY != '' }}
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
codex_output: ${{ steps.codex.outputs.final_message }}
steps:
- uses: actions/checkout@v4
- name: Prepare Codex inputs
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eo pipefail
CURRENT_ISSUE_FILE=codex-current-issue.json
EXISTING_ISSUES_FILE=codex-existing-issues.json
gh issue list --repo "${{ github.repository }}" \
--json number,title,body,createdAt \
--limit 1000 \
--state all \
--search "sort:created-desc" \
| jq '.' \
> "$EXISTING_ISSUES_FILE"
gh issue view "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--json number,title,body \
| jq '.' \
> "$CURRENT_ISSUE_FILE"
- id: codex
uses: openai/codex-action@main
with:
openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }}
prompt_file: .github/prompts/issue-deduplicator.txt
require_repo_write: false
codex_version: 0.43.0-alpha.16
codex_args: -m gpt-5
comment-on-issue:
name: Comment with potential duplicates
needs: gather-duplicates
if: ${{ needs.gather-duplicates.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Comment on issue
uses: actions/github-script@v7
env:
CODEX_OUTPUT: ${{ needs.gather-duplicates.outputs.codex_output }}
with:
github-token: ${{ github.token }}
script: |
const raw = process.env.CODEX_OUTPUT ?? '';
let numbers;
try {
numbers = JSON.parse(raw);
} catch (error) {
core.info(`Codex output was not valid JSON. Raw output: ${raw}`);
return;
}
if (!Array.isArray(numbers) || numbers.length === 0) {
core.info('Codex reported no potential duplicates.');
return;
}
const lines = [
'Potential duplicates detected:',
...numbers.map((value) => `- #${value}`),
'',
'*Powered by [Codex Action](https://github.com/openai/codex-action)*',
];
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: lines.join("\n"),
});
- name: Remove codex-deduplicate label
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh issue edit "${{ github.event.issue.number }}" --remove-label codex-deduplicate || true
echo "Attempted to remove label: codex-deduplicate"
+78
View File
@@ -0,0 +1,78 @@
name: Issue Labeler
on:
issues:
types:
- opened
- labeled
jobs:
gather-labels:
name: Generate label suggestions
if: ${{ (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label')) && secrets.CODEX_OPENAI_API_KEY != '' }}
runs-on: ubuntu-latest
permissions:
contents: read
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
REPO_FULL_NAME: ${{ github.repository }}
outputs:
codex_output: ${{ steps.codex.outputs.final_message }}
steps:
- uses: actions/checkout@v4
- id: codex
uses: openai/codex-action@main
with:
openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }}
prompt_file: .github/prompts/issue-labeler.txt
require_repo_write: false
codex_version: 0.43.0-alpha.16
apply-labels:
name: Apply labels from Codex output
needs: gather-labels
if: ${{ needs.gather-labels.result != 'skipped' }}
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }}
steps:
- name: Apply labels
run: |
json=${CODEX_OUTPUT//$'\r'/}
if [ -z "$json" ]; then
echo "Codex produced no output. Skipping label application."
exit 0
fi
if ! printf '%s' "$json" | jq -e 'type == "array"' >/dev/null 2>&1; then
echo "Codex output was not a JSON array. Raw output: $json"
exit 0
fi
labels=$(printf '%s' "$json" | jq -r '.[] | tostring')
if [ -z "$labels" ]; then
echo "Codex returned an empty array. Nothing to do."
exit 0
fi
cmd=(gh issue edit "$ISSUE_NUMBER")
while IFS= read -r label; do
cmd+=(--add-label "$label")
done <<< "$labels"
"${cmd[@]}" || true
- name: Remove codex-label trigger
if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }}
run: |
gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true
echo "Attempted to remove label: codex-label"
+7
View File
@@ -3,6 +3,13 @@
> [!TIP]
> We're constantly improving Code! This page documents the core changes. You can also check our [releases page](https://github.com/just-every/code/releases) for additional information.
## [0.2.182] - 2025-10-03
- Agents: add first-class cloud agent flows with submit --wait support and richer previews. (6556ac9, 51e60cb, cdc833d)
- Protocol: introduce MCP shim and sync the tool stack for new runtime integrations. (d0a0f01)
- Auto-drive: forward CLI context, export rendered history, and surface progress for coordinator runs. (0d2e51f, ec0bbc4, bccd39f)
- TUI: open the undo timeline overlay on double Esc and persist auto-resolve workflows between sessions. (a658b1a, e1916f7, 4e35c09)
## [0.2.180] - 2025-10-01
- Auto-drive: add final observer validation before finishing runs so missed work is caught. (94061ee)
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@just-every/code",
"version": "0.2.181",
"version": "0.2.182",
"license": "Apache-2.0",
"description": "Lightweight coding agent that runs in your terminal - fork of OpenAI Codex",
"bin": {
@@ -35,10 +35,10 @@
"prettier": "^3.3.3"
},
"optionalDependencies": {
"@just-every/code-darwin-arm64": "0.2.181",
"@just-every/code-darwin-x64": "0.2.181",
"@just-every/code-linux-x64-musl": "0.2.181",
"@just-every/code-linux-arm64-musl": "0.2.181",
"@just-every/code-win32-x64": "0.2.181"
"@just-every/code-darwin-arm64": "0.2.182",
"@just-every/code-darwin-x64": "0.2.182",
"@just-every/code-linux-x64-musl": "0.2.182",
"@just-every/code-linux-arm64-musl": "0.2.182",
"@just-every/code-win32-x64": "0.2.182"
}
}
+13 -5
View File
@@ -1,11 +1,19 @@
# npm releases
Run the following:
To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows:
Use the staging helper in the repo root to generate npm tarballs for a release. For
example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0`:
```bash
./codex-cli/scripts/build_npm_package.py --release-version 0.6.0
./scripts/stage_npm_packages.py \
--release-version 0.6.0 \
--package codex \
--package codex-responses-api-proxy \
--package codex-sdk
```
Note this will create `./codex-cli/vendor/` as a side-effect.
This downloads the native artifacts once, hydrates `vendor/` for each package, and writes
tarballs to `dist/npm/`.
If you need to invoke `build_npm_package.py` directly, run
`codex-cli/scripts/install_native_deps.py` first and pass `--vendor-src` pointing to the
directory that contains the populated `vendor/` tree.
+34 -4
View File
@@ -197,8 +197,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-protocol",
"mcp-types",
"codex-app-server-protocol",
"serde",
"serde_json",
"tokio",
@@ -907,6 +906,14 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-app-server-protocol"
version = "0.0.0"
dependencies = [
"codex-protocol",
"mcp-types",
]
[[package]]
name = "codex-apply-patch"
version = "0.0.0"
@@ -987,6 +994,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"codex-common",
"codex-core",
"codex-git-apply",
@@ -1006,6 +1014,7 @@ dependencies = [
"clap",
"clap_complete",
"codex-app-server",
"codex-app-server-protocol",
"codex-arg0",
"codex-chatgpt",
"codex-cloud-tasks",
@@ -1088,6 +1097,7 @@ name = "codex-common"
version = "0.0.0"
dependencies = [
"clap",
"codex-app-server-protocol",
"codex-core",
"codex-protocol",
"serde",
@@ -1103,9 +1113,11 @@ dependencies = [
"askama",
"assert_cmd",
"async-channel",
"async-trait",
"base64 0.22.1",
"bytes",
"chrono",
"codex-app-server-protocol",
"codex-apply-patch",
"codex-browser",
"codex-file-search",
@@ -1116,12 +1128,14 @@ dependencies = [
"codex-version",
"core_test_support",
"dirs",
"dunce",
"env-flags",
"eventsource-stream",
"fs2",
"futures",
"htmd",
"img_hash",
"indexmap 2.11.4",
"landlock",
"lazy_static",
"libc",
@@ -1173,6 +1187,7 @@ dependencies = [
"assert_cmd",
"chrono",
"clap",
"codex-app-server-protocol",
"codex-arg0",
"codex-common",
"codex-core",
@@ -1181,6 +1196,7 @@ dependencies = [
"core_test_support",
"libc",
"mcp-types",
"opentelemetry-appender-tracing",
"owo-colors",
"predicates",
"serde_json",
@@ -1266,8 +1282,8 @@ dependencies = [
"anyhow",
"base64 0.22.1",
"chrono",
"codex-app-server-protocol",
"codex-core",
"codex-protocol",
"core_test_support",
"rand 0.9.2",
"reqwest",
@@ -1304,6 +1320,7 @@ dependencies = [
"anyhow",
"assert_cmd",
"codex-app-server",
"codex-app-server-protocol",
"codex-arg0",
"codex-common",
"codex-core",
@@ -1346,6 +1363,7 @@ name = "codex-otel"
version = "0.0.0"
dependencies = [
"chrono",
"codex-app-server-protocol",
"codex-protocol",
"eventsource-stream",
"opentelemetry",
@@ -1378,7 +1396,6 @@ dependencies = [
"icu_locale_core",
"mcp-types",
"mime_guess",
"pretty_assertions",
"serde",
"serde_json",
"serde_with",
@@ -1397,6 +1414,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-app-server-protocol",
"codex-protocol",
"mcp-types",
"ts-rs",
@@ -4215,6 +4233,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "opentelemetry-appender-tracing"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e68f63eca5fad47e570e00e893094fc17be959c80c79a7d6ec1abdd5ae6ffc16"
dependencies = [
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "opentelemetry-http"
version = "0.30.0"
+3
View File
@@ -3,6 +3,7 @@ members = [
"backend-client",
"ansi-escape",
"app-server",
"app-server-protocol",
"apply-patch",
"arg0",
"codex-backend-openapi-models",
@@ -47,6 +48,7 @@ edition = "2024"
app_test_support = { path = "app-server/tests/common" }
codex-ansi-escape = { path = "ansi-escape" }
codex-app-server = { path = "app-server" }
codex-app-server-protocol = { path = "app-server-protocol" }
codex-apply-patch = { path = "apply-patch" }
codex-arg0 = { path = "arg0" }
codex-chatgpt = { path = "chatgpt" }
@@ -97,6 +99,7 @@ derive_more = "2"
diffy = "0.4.2"
dirs = "6"
dotenvy = "0.15.7"
dunce = "1.0.5"
env-flags = "0.1.1"
env_logger = "0.11.5"
escargot = "0.5"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "codex-app-server-protocol"
version = { workspace = true }
edition = "2024"
[lints]
workspace = true
[dependencies]
codex-protocol = { workspace = true }
mcp-types = { workspace = true }
+71
View File
@@ -0,0 +1,71 @@
//! Compatibility shim for the legacy `codex_app_server_protocol` crate.
//! This crate re-exports the MCP protocol surface that downstream crates
//! still depend on while the fork finishes migrating to the new structure.
pub use codex_protocol::mcp_protocol::{
self,
AddConversationListenerParams,
AddConversationSubscriptionResponse,
ApplyPatchApprovalParams,
ApplyPatchApprovalResponse,
ArchiveConversationParams,
ArchiveConversationResponse,
AuthMode,
CancelLoginChatGptParams,
CancelLoginChatGptResponse,
ClientInfo,
ClientNotification,
ClientRequest,
ConversationId,
ExecCommandApprovalParams,
ExecCommandApprovalResponse,
GetAuthStatusParams,
GetAuthStatusResponse,
GetUserAgentResponse,
GetUserSavedConfigResponse,
GitDiffToRemoteParams,
GitDiffToRemoteResponse,
GitSha,
InitializeParams,
InputItem,
InterruptConversationParams,
InterruptConversationResponse,
ListConversationsParams,
ListConversationsResponse,
LoginApiKeyParams,
LoginChatGptCompleteNotification,
LoginChatGptResponse,
LogoutChatGptParams,
LogoutChatGptResponse,
NewConversationParams,
NewConversationResponse,
Profile,
RemoveConversationListenerParams,
RemoveConversationSubscriptionResponse,
ResumeConversationParams,
ResumeConversationResponse,
SandboxSettings,
SendUserMessageParams,
SendUserMessageResponse,
SendUserTurnParams,
SendUserTurnResponse,
ServerNotification,
ServerRequest,
SessionConfiguredNotification,
SetDefaultModelParams,
SetDefaultModelResponse,
Tools,
UserInfoResponse,
UserSavedConfig,
APPLY_PATCH_APPROVAL_METHOD,
EXEC_COMMAND_APPROVAL_METHOD,
};
pub use mcp_types::{
JSONRPCError,
JSONRPCErrorError,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
};
+5 -1
View File
@@ -7,6 +7,7 @@ use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::ClientInfo;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::InitializeResponse;
use codex_protocol::protocol::SessionSource;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -41,7 +42,10 @@ impl MessageProcessor {
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Mcp,
));
let config_for_processor = config.clone();
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
+1 -2
View File
@@ -9,8 +9,7 @@ path = "lib.rs"
[dependencies]
anyhow = { workspace = true }
assert_cmd = { workspace = true }
codex-protocol = { workspace = true }
mcp-types = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = [
+1 -1
View File
@@ -2,8 +2,8 @@ mod mcp_process;
mod mock_model_server;
mod responses;
use codex_app_server_protocol::JSONRPCResponse;
pub use mcp_process::McpProcess;
use mcp_types::JSONRPCResponse;
pub use mock_model_server::create_mock_chat_completions_server;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_final_assistant_message_sse_response;
+30 -33
View File
@@ -11,29 +11,30 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::ArchiveConversationParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::ClientInfo;
use codex_protocol::mcp_protocol::ClientNotification;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::InitializeParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::LoginApiKeyParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SetDefaultModelParams;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::ArchiveConversationParams;
use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::RemoveConversationListenerParams;
use codex_app_server_protocol::ResumeConversationParams;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserTurnParams;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SetDefaultModelParams;
use mcp_types::JSONRPC_VERSION;
use mcp_types::JSONRPCMessage;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCRequest;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use std::process::Command as StdCommand;
use tokio::process::Command;
@@ -317,7 +318,6 @@ impl McpProcess {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let message = JSONRPCMessage::Request(JSONRPCRequest {
jsonrpc: JSONRPC_VERSION.into(),
id: RequestId::Integer(request_id),
method: method.to_string(),
params,
@@ -331,12 +331,8 @@ impl McpProcess {
id: RequestId,
result: serde_json::Value,
) -> anyhow::Result<()> {
self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse {
jsonrpc: JSONRPC_VERSION.into(),
id,
result,
}))
.await
self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse { id, result }))
.await
}
pub async fn send_notification(
@@ -345,7 +341,6 @@ impl McpProcess {
) -> anyhow::Result<()> {
let value = serde_json::to_value(notification)?;
self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification {
jsonrpc: JSONRPC_VERSION.into(),
method: value
.get("method")
.and_then(|m| m.as_str())
@@ -373,7 +368,7 @@ impl McpProcess {
Ok(message)
}
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<JSONRPCRequest> {
pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result<ServerRequest> {
eprintln!("in read_stream_until_request_message()");
loop {
@@ -384,7 +379,9 @@ impl McpProcess {
eprintln!("notification: {message:?}");
}
JSONRPCMessage::Request(jsonrpc_request) => {
return Ok(jsonrpc_request);
return jsonrpc_request.try_into().with_context(
|| "failed to deserialize ServerRequest from JSONRPCRequest",
);
}
JSONRPCMessage::Error(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}");
@@ -426,7 +423,7 @@ impl McpProcess {
pub async fn read_stream_until_error_message(
&mut self,
request_id: RequestId,
) -> anyhow::Result<mcp_types::JSONRPCError> {
) -> anyhow::Result<JSONRPCError> {
loop {
let message = self.read_jsonrpc_message().await?;
match message {
@@ -2,13 +2,13 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::ArchiveConversationParams;
use codex_app_server_protocol::ArchiveConversationResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_protocol::mcp_protocol::ArchiveConversationParams;
use codex_protocol::mcp_protocol::ArchiveConversationResponse;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
use tokio::time::timeout;
+3 -3
View File
@@ -1,9 +1,9 @@
use std::path::Path;
use codex_core::auth::login_with_api_key;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use mcp_test_support::McpProcess;
use mcp_test_support::to_response;
use mcp_types::JSONRPCResponse;
@@ -5,20 +5,6 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SendUserTurnResponse;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use std::env;
use tempfile::TempDir;
@@ -109,7 +95,7 @@ async fn test_codex_jsonrpc_conversation_flow() {
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "text".to_string(),
}],
})
@@ -259,7 +245,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "run python".to_string(),
}],
})
@@ -284,11 +270,28 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
.await
.expect("waiting for exec approval request timeout")
.expect("exec approval request");
assert_eq!(request.method, EXEC_COMMAND_APPROVAL_METHOD);
let ServerRequest::ExecCommandApproval { request_id, params } = request else {
panic!("expected ExecCommandApproval request, got: {request:?}");
};
assert_eq!(
ExecCommandApprovalParams {
conversation_id,
call_id: "call1".to_string(),
command: vec![
"python3".to_string(),
"-c".to_string(),
"print(42)".to_string(),
],
cwd: working_directory.clone(),
reason: None,
},
params
);
// Approve so the first turn can complete
mcp.send_response(
request.id,
request_id,
serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }),
)
.await
@@ -307,7 +310,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
let send_turn_id = mcp
.send_send_user_turn_request(SendUserTurnParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "run python again".to_string(),
}],
cwd: working_directory.clone(),
+7 -7
View File
@@ -3,18 +3,18 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::GetUserSavedConfigResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::Profile;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxSettings;
use codex_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_core::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::mcp_protocol::GetUserSavedConfigResponse;
use codex_protocol::mcp_protocol::Profile;
use codex_protocol::mcp_protocol::SandboxSettings;
use codex_protocol::mcp_protocol::Tools;
use codex_protocol::mcp_protocol::UserSavedConfig;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -4,15 +4,15 @@ use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::InputItem;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::InputItem;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
@@ -1,6 +1,6 @@
use app_test_support::McpProcess;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
+8 -8
View File
@@ -3,13 +3,13 @@
use std::path::Path;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::InterruptConversationResponse;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::InterruptConversationResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use core_test_support::non_sandbox_test;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
@@ -99,7 +99,7 @@ async fn shell_command_interruption() -> anyhow::Result<()> {
let send_user_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![codex_protocol::mcp_protocol::InputItem::Text {
items: vec![codex_app_server_protocol::InputItem::Text {
text: "run first sleep command".to_string(),
}],
})
+10 -10
View File
@@ -3,16 +3,16 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::ListConversationsParams;
use codex_protocol::mcp_protocol::ListConversationsResponse;
use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape
use codex_protocol::mcp_protocol::ResumeConversationParams;
use codex_protocol::mcp_protocol::ResumeConversationResponse;
use codex_protocol::mcp_protocol::ServerNotification;
use codex_protocol::mcp_protocol::SessionConfiguredNotification;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ListConversationsParams;
use codex_app_server_protocol::ListConversationsResponse;
use codex_app_server_protocol::NewConversationParams; // reused for overrides shape
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ResumeConversationParams;
use codex_app_server_protocol::ResumeConversationResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SessionConfiguredNotification;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
+6 -6
View File
@@ -2,12 +2,12 @@ use std::path::Path;
use std::time::Duration;
use codex_core::auth::login_with_api_key;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptResponse;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
use codex_protocol::mcp_protocol::LoginChatGptResponse;
use codex_protocol::mcp_protocol::LogoutChatGptResponse;
use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::CancelLoginChatGptResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::LoginChatGptResponse;
use codex_app_server_protocol::LogoutChatGptResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use tempfile::TempDir;
+11 -11
View File
@@ -4,17 +4,17 @@ use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_chat_completions_server;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::InputItem;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::NewConversationResponse;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserMessageResponse;
use mcp_types::JSONRPCNotification;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
use codex_app_server_protocol::InputItem;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use codex_protocol::ConversationId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -2,11 +2,11 @@ use std::path::Path;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SetDefaultModelParams;
use codex_app_server_protocol::SetDefaultModelResponse;
use codex_core::config::ConfigToml;
use codex_protocol::mcp_protocol::SetDefaultModelParams;
use codex_protocol::mcp_protocol::SetDefaultModelResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -1,8 +1,8 @@
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_protocol::mcp_protocol::GetUserAgentResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use codex_app_server_protocol::GetUserAgentResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
+3 -3
View File
@@ -5,14 +5,14 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::UserInfoResponse;
use codex_core::auth::AuthDotJson;
use codex_core::auth::get_auth_file;
use codex_core::auth::write_auth_json;
use codex_core::token_data::IdTokenInfo;
use codex_core::token_data::TokenData;
use codex_protocol::mcp_protocol::UserInfoResponse;
use mcp_types::JSONRPCResponse;
use mcp_types::RequestId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
+1
View File
@@ -12,6 +12,7 @@ clap = { workspace = true, features = ["derive"] }
codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
+1 -1
View File
@@ -1,5 +1,5 @@
use codex_core::CodexAuth;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use std::path::Path;
use std::sync::LazyLock;
use std::sync::RwLock;
+3 -2
View File
@@ -28,6 +28,7 @@ codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-process-hardening = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-responses-api-proxy = { workspace = true }
codex-tui = { workspace = true }
@@ -54,8 +55,8 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
[dev-dependencies]
assert_cmd = { workspace = true }
+1 -1
View File
@@ -9,7 +9,7 @@ use codex_core::ModelProviderInfo;
use codex_core::AuthManager;
use codex_core::Prompt;
use codex_core::TextFormat;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::{ContentItem, ResponseItem};
use futures::StreamExt;
+1 -1
View File
@@ -1,3 +1,4 @@
use codex_app_server_protocol::AuthMode;
use codex_common::CliConfigOverrides;
use codex_core::CodexAuth;
use codex_core::auth::CLIENT_ID;
@@ -9,7 +10,6 @@ use codex_core::config::ConfigOverrides;
use codex_login::ServerOptions;
use codex_login::run_device_code_login;
use codex_login::run_login_server;
use codex_protocol::mcp_protocol::AuthMode;
use std::env;
use std::path::PathBuf;
+1 -14
View File
@@ -14,7 +14,6 @@ use codex_cli::login::run_login_with_api_key;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_login_with_device_code;
use codex_cli::login::run_logout;
use codex_cli::proto;
mod llm;
use llm::{LlmCli, run_llm};
use codex_common::CliConfigOverrides;
@@ -34,7 +33,6 @@ use tokio::runtime::{Builder as TokioRuntimeBuilder, Handle as TokioHandle};
mod mcp_cmd;
use crate::mcp_cmd::McpCli;
use crate::proto::ProtoCli;
const CLI_COMMAND_NAME: &str = "code";
pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE";
@@ -106,10 +104,6 @@ enum Subcommand {
/// [experimental] Run the app server.
AppServer,
/// Run the Protocol stream via stdin/stdout
#[clap(visible_alias = "p")]
Proto(ProtoCli),
/// Generate shell completion scripts.
Completion(CompletionCommand),
@@ -380,13 +374,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
);
run_logout(logout_cli.config_overrides).await;
}
Some(Subcommand::Proto(mut proto_cli)) => {
prepend_config_flags(
&mut proto_cli.config_overrides,
root_config_overrides.clone(),
);
proto::run_main(proto_cli).await?;
}
Some(Subcommand::Completion(completion_cli)) => {
print_completion(completion_cli);
}
@@ -591,7 +578,7 @@ fn resolve_resume_path(session_id: Option<&str>, last: bool) -> anyhow::Result<O
.context("failed to look up session by id")?;
Ok(maybe)
} else if last {
let page = RolloutRecorder::list_conversations(&codex_home, 1, None)
let page = RolloutRecorder::list_conversations(&codex_home, 1, None, &[])
.await
.context("failed to list recorded sessions")?;
Ok(page.items.first().map(|it| it.path.clone()))
+9 -7
View File
@@ -10,6 +10,7 @@ use codex_core::config::ConfigOverrides;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Submission;
use codex_protocol::protocol::SessionSource;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tracing::error;
@@ -37,18 +38,19 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
// Use conversation_manager API to start a conversation
let conversation_manager = ConversationManager::new(
AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_login::AuthMode::ApiKey,
config.responses_originator_header.clone(),
),
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_login::AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Cli);
let NewConversation {
conversation_id: _,
conversation,
session_configured,
} = conversation_manager.new_conversation(config).await?;
} = conversation_manager
.new_conversation(config.clone())
.await?;
// Simulate streaming the session_configured event.
let synthetic_event = Event {
+3
View File
@@ -851,6 +851,9 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> an
&& matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N'))
|| matches!(key.code, KeyCode::Char('\u{000E}'));
if is_ctrl_n {
if app.new_task.is_none() {
continue;
}
if app.best_of_modal.is_some() {
app.best_of_modal = None;
needs_redraw = true;
+21 -13
View File
@@ -262,9 +262,9 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
help.push(": Apply ".dim());
}
help.push("o : Set Env ".dim());
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
if app.new_task.is_some() {
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
help.push("(editing new task) ".dim());
} else {
help.push("n : New Task ".dim());
@@ -1004,32 +1004,40 @@ pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
let inner = overlay_outer(area);
const MAX_WIDTH: u16 = 40;
const MIN_WIDTH: u16 = 20;
const MAX_HEIGHT: u16 = 12;
const MIN_HEIGHT: u16 = 6;
let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH));
let modal_height = inner
.height
.min(MAX_HEIGHT)
.max(inner.height.min(MIN_HEIGHT));
let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2;
let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
let title = Line::from(vec!["Parallel Attempts".magenta().bold()]);
let block = overlay_block().title(title);
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let content = overlay_content(inner);
frame.render_widget(Clear, modal_area);
frame.render_widget(block.clone(), modal_area);
let content = overlay_content(modal_area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
let hint = Paragraph::new(Line::from(
"Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel"
.cyan()
.dim(),
))
.wrap(Wrap { trim: true });
let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim()))
.wrap(Wrap { trim: true });
frame.render_widget(hint, rows[0]);
let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0);
let options = [1usize, 2, 3, 4];
let mut items: Vec<ListItem> = Vec::new();
for &attempts in &options {
let mut spans: Vec<ratatui::text::Span> =
vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()];
let noun = if attempts == 1 { "attempt" } else { "attempts" };
let mut spans: Vec<ratatui::text::Span> = vec![format!("{attempts} {noun:<8}").into()];
spans.push(" ".into());
spans.push(format!("{attempts}x parallel").dim());
if attempts == app.best_of_n {
+1
View File
@@ -10,6 +10,7 @@ workspace = true
clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true, optional = true }
toml = { workspace = true, optional = true }
+8 -8
View File
@@ -1,5 +1,5 @@
use codex_app_server_protocol::AuthMode;
use codex_core::protocol_config_types::ReasoningEffort;
use codex_protocol::mcp_protocol::AuthMode;
/// A simple preset pairing a model slug with a reasoning effort.
#[derive(Debug, Clone, Copy)]
@@ -20,49 +20,49 @@ const PRESETS: &[ModelPreset] = &[
ModelPreset {
id: "gpt-5-codex-low",
label: "gpt-5-codex low",
description: "",
description: "Fastest responses with limited reasoning",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-codex-medium",
label: "gpt-5-codex medium",
description: "",
description: "Dynamically adjusts reasoning based on the task",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-codex-high",
label: "gpt-5-codex high",
description: "",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5-codex",
effort: Some(ReasoningEffort::High),
},
ModelPreset {
id: "gpt-5-minimal",
label: "gpt-5 minimal",
description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks",
description: "Fastest responses with little reasoning",
model: "gpt-5",
effort: Some(ReasoningEffort::Minimal),
},
ModelPreset {
id: "gpt-5-low",
label: "gpt-5 low",
description: "— balances speed with some reasoning; useful for straightforward queries and short explanations",
description: "Balances speed with some reasoning; useful for straightforward queries and short explanations",
model: "gpt-5",
effort: Some(ReasoningEffort::Low),
},
ModelPreset {
id: "gpt-5-medium",
label: "gpt-5 medium",
description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks",
description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks",
model: "gpt-5",
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-high",
label: "gpt-5 high",
description: "— maximizes reasoning depth for complex or ambiguous problems",
description: "Maximizes reasoning depth for complex or ambiguous problems",
model: "gpt-5",
effort: Some(ReasoningEffort::High),
},
+52 -48
View File
@@ -12,71 +12,75 @@ path = "src/lib.rs"
workspace = true
[dependencies]
anyhow = "1"
askama = "0.12"
async-channel = "2.3.1"
base64 = "0.22.1"
bytes = "1.10.1"
chrono = { version = "0.4", features = ["serde"] }
codex-apply-patch = { path = "../apply-patch" }
codex-file-search = { path = "../file-search" }
codex-mcp-client = { path = "../mcp-client" }
codex-protocol = { path = "../protocol" }
codex-rmcp-client = { path = "../rmcp-client" }
codex-otel = { path = "../otel", features = ["otel"] }
anyhow = { workspace = true }
askama = { workspace = true }
async-channel = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
bytes = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-apply-patch = { workspace = true }
codex-file-search = { workspace = true }
codex-mcp-client = { workspace = true }
codex-protocol = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-otel = { workspace = true, features = ["otel"] }
codex-browser = { path = "../browser" }
codex-version = { path = "../codex-version" }
agent-client-protocol = "0.4.3"
dirs = "6"
env-flags = "0.1.1"
eventsource-stream = "0.2.3"
futures = "0.3"
libc = "0.2.175"
mcp-types = { path = "../mcp-types" }
os_info = "3.12.0"
portable-pty = "0.9.0"
rand = "0.9"
regex-lite = "0.1.7"
reqwest = { version = "0.12", features = ["json", "stream"] }
dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
indexmap = { workspace = true }
lazy_static = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
mime_guess = { workspace = true }
os_info = { workspace = true }
portable-pty = { workspace = true }
rand = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
schemars = "0.8.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde = { workspace = true, features = ["derive"] }
serde_bytes = "0.11"
serde_json = { workspace = true }
serde_yaml = "0.9"
sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
strum_macros = "0.27.2"
tempfile = "3"
thiserror = "2.0.16"
time = { version = "0.3", features = [
sha1 = { workspace = true }
shlex = { workspace = true }
similar = { workspace = true }
strum_macros = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true, features = [
"formatting",
"parsing",
"local-offset",
"macros",
] }
tokio = { version = "1", features = [
tokio = { workspace = true, features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
tokio-util = "0.7.16"
toml = "0.9.5"
toml_edit = "0.23.4"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.9"
tree-sitter-bash = "0.25.0"
uuid = { version = "1", features = ["serde", "v4"] }
which = "6"
wildmatch = "2.5.0"
lazy_static = "1"
mime_guess = "2"
serde_bytes = "0.11"
fs2 = "0.4"
tokio-util = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tree-sitter = { workspace = true }
tree-sitter-bash = { workspace = true }
uuid = { workspace = true, features = ["serde", "v4"] }
url = "2"
which = { workspace = true }
wildmatch = { workspace = true }
fs2 = "0.4"
htmd = "0.1"
img_hash = "3"
codex-browser = { path = "../browser" }
codex-version = { path = "../codex-version" }
[target.'cfg(target_os = "linux")'.dependencies]
+1 -2
View File
@@ -5,7 +5,6 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.
- 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.)
- When editing or creating files, you MUST use apply_patch as a standalone tool without going through ["bash", "-lc"], `Python`, `cat`, `sed`, ... Example: functions.shell({"command":["apply_patch","*** Begin Patch\nAdd File: hello.txt\n+Hello, world!\n*** End Patch"]}).
## Editing constraints
@@ -90,7 +89,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; add a language hint whenever obvious.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
+79 -136
View File
@@ -992,22 +992,47 @@ async fn execute_cloud_built_in_streaming(
}
// Tool creation functions
pub fn create_run_agent_tool() -> OpenAiTool {
pub fn create_agent_tool(allowed_models: &[String]) -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"action".to_string(),
JsonSchema::String {
description: Some(
"Required: choose one of ['create','status','wait','result','cancel','list']".to_string(),
),
allowed_values: Some(
["create", "status", "wait", "result", "cancel", "list"]
.into_iter()
.map(|value| value.to_string())
.collect(),
),
},
);
properties.insert(
"task".to_string(),
JsonSchema::String {
description: Some("The task prompt - what to perform (required)".to_string()),
description: Some("For action=create: task prompt to execute".to_string()),
allowed_values: None,
},
);
properties.insert(
"models".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
items: Box::new(JsonSchema::String {
description: None,
allowed_values: if allowed_models.is_empty() {
None
} else {
Some(allowed_models.iter().cloned().collect())
},
}),
description: Some(
"Optional: Array of model names (e.g., ['claude','gemini','qwen','code','cloud'])".to_string(),
"For action=create: optional array of model names (e.g., ['claude','gemini','qwen','code','cloud'])"
.to_string(),
),
},
);
@@ -1015,23 +1040,28 @@ pub fn create_run_agent_tool() -> OpenAiTool {
properties.insert(
"context".to_string(),
JsonSchema::String {
description: Some("Optional: Background context for the agent".to_string()),
description: Some("For action=create: optional background context".to_string()),
allowed_values: None,
},
);
properties.insert(
"output".to_string(),
JsonSchema::String {
description: Some("Optional: The desired output/success state".to_string()),
description: Some("For action=create: optional desired output description".to_string()),
allowed_values: None,
},
);
properties.insert(
"files".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
items: Box::new(JsonSchema::String {
description: None,
allowed_values: None,
}),
description: Some(
"Optional: Array of file paths to include in the agent context".to_string(),
"For action=create: optional array of file paths to include in context".to_string(),
),
},
);
@@ -1040,76 +1070,18 @@ pub fn create_run_agent_tool() -> OpenAiTool {
"read_only".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional: When true, agent runs in read-only mode (default: false)".to_string(),
"For action=create: when true, run in read-only mode (default: false)".to_string(),
),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "agent_run".to_string(),
description: "Start a complex AI task asynchronously. Returns an agent ID immediately to check status and retrieve results. Once an agent is running, enables: agent_check, agent_result, agent_cancel, agent_wait, agent_list.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["task".to_string()]),
additional_properties: Some(false),
},
})
}
pub fn create_check_agent_status_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("The agent ID returned from run_agent".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "agent_check".to_string(),
description: "Check the status of a running agent. Returns current status, progress, and partial results if available.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false),
},
})
}
pub fn create_get_agent_result_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("The agent ID returned from run_agent".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "agent_result".to_string(),
description: "Get the final result of a completed agent. Returns a preview (first 500 lines) and a file path to the full output/error.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false),
},
})
}
pub fn create_cancel_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some(
"The agent ID to cancel (required if batch_id not provided)".to_string(),
"For actions=status/result/cancel/wait: specify the target agent ID".to_string(),
),
allowed_values: None,
},
);
@@ -1117,44 +1089,9 @@ pub fn create_cancel_agent_tool() -> OpenAiTool {
"batch_id".to_string(),
JsonSchema::String {
description: Some(
"Cancel all agents with this batch ID (required if agent_id not provided)"
.to_string(),
),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "agent_cancel".to_string(),
description: "Cancel a pending or running agent, or all agents in a batch.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
pub fn create_wait_for_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some(
"Wait for this specific agent to complete (required if batch_id not provided)"
.to_string(),
),
},
);
properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some(
"Wait for any agent in this batch to complete (required if agent_id not provided)"
.to_string(),
"For actions=cancel/wait/list: optional batch identifier".to_string(),
),
allowed_values: None,
},
);
@@ -1162,7 +1099,7 @@ pub fn create_wait_for_agent_tool() -> OpenAiTool {
"timeout_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum seconds to wait before timing out (default: 300, max: 600)".to_string(),
"For action=wait: optional timeout before giving up (default 300, max 600)".to_string(),
),
},
);
@@ -1170,36 +1107,20 @@ pub fn create_wait_for_agent_tool() -> OpenAiTool {
properties.insert(
"return_all".to_string(),
JsonSchema::Boolean {
description: Some("For batch_id: return all completed agents instead of just the first one (default: false)".to_string()),
description: Some(
"For action=wait with batch_id: return all completed agents instead of the first".to_string(),
),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "agent_wait".to_string(),
description: "Wait for an agent or any agent in a batch to complete, fail, or be cancelled. Returns status plus a preview (first 500 lines) and a file path to the full output/error. For batches, set return_all:true to receive per-agent summaries. Use agent_result with {\"agent_id\": \"...\"} to fetch after completion.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
pub fn create_list_agents_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"status_filter".to_string(),
JsonSchema::String {
description: Some("Optional: Filter agents by status (pending, running, completed, failed, cancelled)".to_string()),
},
);
properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some("Optional: Filter agents by batch ID".to_string()),
description: Some(
"For action=list: optional status filter (pending, running, completed, failed, cancelled)"
.to_string(),
),
allowed_values: None,
},
);
@@ -1207,19 +1128,21 @@ pub fn create_list_agents_tool() -> OpenAiTool {
"recent_only".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional: Only show agents from the last 2 hours (default: false)".to_string(),
"For action=list: when true, only include agents from the last two hours".to_string(),
),
},
);
let required = Some(vec!["action".to_string()]);
OpenAiTool::Function(ResponsesApiTool {
name: "agent_list".to_string(),
description: "List all agents with their current status.".to_string(),
name: "agent".to_string(),
description: "Unified agent manager for launching, monitoring, and collecting results from asynchronous agents.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
required,
additional_properties: Some(false.into()),
},
})
}
@@ -1236,6 +1159,26 @@ pub struct RunAgentParams {
pub read_only: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentToolRequest {
pub action: String,
#[serde(default)]
pub task: Option<String>,
#[serde(default, deserialize_with = "deserialize_models_field")]
pub models: Vec<String>,
pub context: Option<String>,
pub output: Option<String>,
#[serde(default)]
pub files: Option<Vec<String>>,
pub read_only: Option<bool>,
pub agent_id: Option<String>,
pub batch_id: Option<String>,
pub timeout_seconds: Option<u64>,
pub return_all: Option<bool>,
pub status_filter: Option<String>,
pub recent_only: Option<bool>,
}
fn deserialize_models_field<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
+36 -8
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use crate::token_data::TokenData;
use crate::token_data::parse_id_token;
@@ -197,6 +197,7 @@ impl CodexAuth {
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
fn read_openai_api_key_from_env() -> Option<String> {
env::var(OPENAI_API_KEY_ENV_VAR)
@@ -204,6 +205,13 @@ fn read_openai_api_key_from_env() -> Option<String> {
.filter(|s| !s.is_empty())
}
pub fn read_codex_api_key_from_env() -> Option<String> {
env::var(CODEX_API_KEY_ENV_VAR)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}
@@ -777,6 +785,7 @@ pub struct AuthManager {
codex_home: PathBuf,
originator: String,
inner: RwLock<CachedAuth>,
enable_codex_api_key_env: bool,
}
impl AuthManager {
@@ -785,16 +794,23 @@ impl AuthManager {
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode, originator: String) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator)
.ok()
.flatten();
let mut effective_mode = preferred_auth_mode;
let auth = if let Some(api_key) = read_codex_api_key_from_env() {
effective_mode = AuthMode::ApiKey;
Some(CodexAuth::from_api_key(&api_key))
} else {
CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator)
.ok()
.flatten()
};
Self {
codex_home,
originator,
inner: RwLock::new(CachedAuth {
preferred_auth_mode,
preferred_auth_mode: effective_mode,
auth,
}),
enable_codex_api_key_env: true,
}
}
@@ -809,6 +825,7 @@ impl AuthManager {
codex_home: PathBuf::new(),
originator: "codex_cli_rs".to_string(),
inner: RwLock::new(cached),
enable_codex_api_key_env: false,
})
}
@@ -829,12 +846,23 @@ impl AuthManager {
/// whether the auth value changed.
pub fn reload(&self) -> bool {
let preferred = self.preferred_auth_method();
let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator)
.ok()
.flatten();
let env_auth = if self.enable_codex_api_key_env {
read_codex_api_key_from_env().map(|api_key| CodexAuth::from_api_key(&api_key))
} else {
None
};
let new_auth = env_auth.clone().or_else(|| {
CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator)
.ok()
.flatten()
});
if let Ok(mut guard) = self.inner.write() {
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
guard.auth = new_auth;
guard.preferred_auth_mode = env_auth
.as_ref()
.map(|auth| auth.mode)
.unwrap_or(preferred);
changed
} else {
false
+1 -1
View File
@@ -1,5 +1,5 @@
use chrono::{DateTime, Utc};
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::fs::OpenOptions;
+12 -4
View File
@@ -24,11 +24,13 @@ use crate::client_common::ResponseStream;
use crate::debug_logger::DebugLogger;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use crate::model_family::ModelFamily;
use crate::openai_tools::create_tools_json_for_chat_completions_api;
use crate::util::backoff;
use std::sync::{Arc, Mutex};
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ReasoningItemContent;
use codex_protocol::models::ResponseItem;
@@ -403,7 +405,6 @@ pub(crate) async fn stream_chat_completions(
let status = res.status();
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
let body = (res.text().await).unwrap_or_default();
// Log error response
if let Ok(logger) = debug_logger.lock() {
let _ = logger.append_response_event(
&request_id,
@@ -415,11 +416,18 @@ pub(crate) async fn stream_chat_completions(
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::UnexpectedStatus(status, body));
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
}
if attempt > max_retries {
return Err(CodexErr::RetryLimit(status));
return Err(CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: None,
}));
}
let retry_after_secs = res
+12 -3
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use crate::AuthManager;
use bytes::Bytes;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::ResponseItem;
use eventsource_stream::Eventsource;
use futures::prelude::*;
@@ -39,6 +39,8 @@ use crate::debug_logger::DebugLogger;
use crate::default_client::create_client;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use crate::error::UsageLimitReachedError;
use crate::flags::CODEX_RS_SSE_FIXTURE;
use crate::model_family::ModelFamily;
@@ -509,7 +511,11 @@ impl ModelClient {
);
let _ = logger.end_request_log(&request_id);
}
return Err(CodexErr::UnexpectedStatus(status, body_text));
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body: body_text,
request_id: None,
}));
}
if status == StatusCode::TOO_MANY_REQUESTS {
@@ -589,7 +595,10 @@ impl ModelClient {
return Err(CodexErr::ServerError(msg));
}
return Err(CodexErr::RetryLimit(status));
return Err(CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: None,
}));
}
let delay = retry_after_secs
+192 -29
View File
@@ -58,6 +58,8 @@ use crate::protocol::WebSearchCompleteEvent;
use codex_protocol::mcp_protocol::AuthMode;
use crate::account_usage;
use crate::auth_accounts;
const DEFAULT_AGENT_MODELS: &[&str] = &["claude", "gemini", "qwen", "code", "cloud"];
use codex_protocol::models::WebSearchAction;
use codex_protocol::protocol::RolloutItem;
use shlex::split as shlex_split;
@@ -521,6 +523,7 @@ async fn build_turn_status_items(sess: &Session) -> Vec<ResponseItem> {
}
use crate::agent_tool::AGENT_MANAGER;
use crate::agent_tool::AgentStatus;
use crate::agent_tool::AgentToolRequest;
use crate::agent_tool::CancelAgentParams;
use crate::agent_tool::CheckAgentStatusParams;
use crate::agent_tool::GetAgentResultParams;
@@ -564,6 +567,7 @@ use codex_protocol::models::ShellToolCallParams;
use crate::openai_model_info::get_model_info;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::get_openai_tools;
use crate::slash_commands::get_enabled_agents;
use crate::dry_run_guard::{analyze_command, DryRunAnalysis, DryRunDisposition, DryRunGuardState};
use crate::parse_command::parse_command;
use crate::plan_tool::handle_update_plan;
@@ -614,6 +618,7 @@ use crate::shell;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use codex_protocol::protocol::SessionSource;
use crate::rollout::recorder::SessionStateSnapshot;
use serde_json::Value;
use crate::exec_command::ExecSessionManager;
@@ -828,7 +833,8 @@ struct State {
pending_user_input: Vec<QueuedUserInput>,
history: ConversationHistory,
/// Tracks which completed agents (by id) have already been returned to the
/// model for a given batch when using `agent_wait` without `return_all`.
/// model for a given batch when using `agent` with `action="wait"` and
/// `return_all=false`.
/// This enables sequential waiting behavior across multiple calls.
seen_completed_agents_by_batch: HashMap<String, HashSet<String>>,
/// Scratchpad that buffers streamed items/deltas for the current HTTP attempt
@@ -3042,6 +3048,7 @@ async fn submission_loop(
crate::rollout::recorder::RolloutRecorderParams::new(
codex_protocol::mcp_protocol::ConversationId::from(session_id),
effective_user_instructions.clone(),
SessionSource::Cli,
),
)
.await
@@ -3185,6 +3192,24 @@ async fn submission_loop(
tools_config.web_search_allowed_domains =
config.tools_web_search_allowed_domains.clone();
let mut agent_models: Vec<String> = if config.agents.is_empty() {
DEFAULT_AGENT_MODELS
.iter()
.map(|s| (*s).to_string())
.collect()
} else {
get_enabled_agents(&config.agents)
};
if agent_models.is_empty() {
agent_models = DEFAULT_AGENT_MODELS
.iter()
.map(|s| (*s).to_string())
.collect();
}
agent_models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()));
agent_models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
tools_config.set_agent_models(agent_models);
let mut new_session = Arc::new(Session {
id: session_id,
client,
@@ -5040,13 +5065,8 @@ async fn handle_function_call(
.await
}
"update_plan" => handle_update_plan(sess, &ctx, arguments).await,
// agent_* tools
"agent_run" => handle_run_agent(sess, &ctx, arguments).await,
"agent_check" => handle_check_agent_status(sess, &ctx, arguments).await,
"agent_result" => handle_get_agent_result(sess, &ctx, arguments).await,
"agent_cancel" => handle_cancel_agent(sess, &ctx, arguments).await,
"agent_wait" => handle_wait_for_agent(sess, &ctx, arguments).await,
"agent_list" => handle_list_agents(sess, &ctx, arguments).await,
// agent tool
"agent" => handle_agent_tool(sess, &ctx, arguments).await,
// browser_* tools
"browser_open" => handle_browser_open(sess, &ctx, arguments).await,
"browser_close" => handle_browser_close(sess, &ctx).await,
@@ -6472,6 +6492,127 @@ fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams
params
}
fn agent_tool_failure(ctx: &ToolCallCtx, message: impl Into<String>) -> ResponseInputItem {
ResponseInputItem::FunctionCallOutput {
call_id: ctx.call_id.clone(),
output: FunctionCallOutputPayload {
content: message.into(),
success: Some(false),
},
}
}
pub(crate) async fn handle_agent_tool(
sess: &Session,
ctx: &ToolCallCtx,
arguments: String,
) -> ResponseInputItem {
let parsed = serde_json::from_str::<AgentToolRequest>(&arguments);
let mut req = match parsed {
Ok(req) => req,
Err(e) => {
return agent_tool_failure(ctx, format!("Invalid agent arguments: {}", e));
}
};
let action = req.action.to_ascii_lowercase();
match action.as_str() {
"create" => {
let task = match req.task.take() {
Some(task) if !task.trim().is_empty() => task,
_ => {
return agent_tool_failure(
ctx,
"action=create requires a non-empty 'task' field",
);
}
};
let run_params = RunAgentParams {
task,
models: std::mem::take(&mut req.models),
context: req.context.take(),
output: req.output.take(),
files: req.files.take(),
read_only: req.read_only.take(),
};
match serde_json::to_string(&run_params) {
Ok(json) => handle_run_agent(sess, ctx, json).await,
Err(e) => agent_tool_failure(ctx, format!("Failed to encode create arguments: {}", e)),
}
}
"status" => {
let agent_id = match req.agent_id.take() {
Some(id) => id,
None => return agent_tool_failure(ctx, "action=status requires 'agent_id'"),
};
let params = CheckAgentStatusParams { agent_id };
match serde_json::to_string(&params) {
Ok(json) => handle_check_agent_status(sess, ctx, json).await,
Err(e) => agent_tool_failure(ctx, format!("Failed to encode status arguments: {}", e)),
}
}
"result" => {
let agent_id = match req.agent_id.take() {
Some(id) => id,
None => return agent_tool_failure(ctx, "action=result requires 'agent_id'"),
};
let params = GetAgentResultParams { agent_id };
match serde_json::to_string(&params) {
Ok(json) => handle_get_agent_result(sess, ctx, json).await,
Err(e) => agent_tool_failure(ctx, format!("Failed to encode result arguments: {}", e)),
}
}
"cancel" => {
if req.agent_id.is_none() && req.batch_id.is_none() {
return agent_tool_failure(
ctx,
"action=cancel requires 'agent_id' or 'batch_id'",
);
}
let params = CancelAgentParams {
agent_id: req.agent_id.take(),
batch_id: req.batch_id.take(),
};
match serde_json::to_string(&params) {
Ok(json) => handle_cancel_agent(sess, ctx, json).await,
Err(e) => agent_tool_failure(ctx, format!("Failed to encode cancel arguments: {}", e)),
}
}
"wait" => {
if req.agent_id.is_none() && req.batch_id.is_none() {
return agent_tool_failure(
ctx,
"action=wait requires 'agent_id' or 'batch_id'",
);
}
let params = WaitForAgentParams {
agent_id: req.agent_id.take(),
batch_id: req.batch_id.take(),
timeout_seconds: req.timeout_seconds,
return_all: req.return_all,
};
match serde_json::to_string(&params) {
Ok(json) => handle_wait_for_agent(sess, ctx, json).await,
Err(e) => agent_tool_failure(ctx, format!("Failed to encode wait arguments: {}", e)),
}
}
"list" => {
let params = ListAgentsParams {
status_filter: req.status_filter.take(),
batch_id: req.batch_id.take(),
recent_only: req.recent_only,
};
match serde_json::to_string(&params) {
Ok(json) => handle_list_agents(sess, ctx, json).await,
Err(e) => agent_tool_failure(ctx, format!("Failed to encode list arguments: {}", e)),
}
}
other => agent_tool_failure(ctx, format!("Unsupported agent action: {}", other)),
}
}
pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
let params_for_event = serde_json::from_str(&arguments).ok();
let arguments_clone = arguments.clone();
@@ -6479,7 +6620,7 @@ pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, argument
execute_custom_tool(
sess,
ctx,
"agent_run".to_string(),
"agent".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<RunAgentParams>(&arguments_clone) {
@@ -6537,9 +6678,22 @@ pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, argument
}
if models.is_empty() {
models.push("code".to_string());
if sess.tools_config.agent_model_allowed_values.is_empty() {
models.push("code".to_string());
} else {
models.extend(
sess
.tools_config
.agent_model_allowed_values
.iter()
.cloned(),
);
}
}
models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()));
models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
// Helper: derive the command to check for a given model/config pair.
fn resolve_command_for_check(model: &str, cfg: Option<&crate::config_types::AgentConfig>) -> (String, bool) {
if let Some(c) = cfg { return (c.command.clone(), false); }
@@ -6692,7 +6846,7 @@ pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, argument
.map(|(id, _)| id.as_str())
.unwrap_or(batch.as_str());
format!(
"🤖 Agent batch {short_batch} started: {agent_phrase}.\nUse `agent_wait {{\"batch_id\":\"{batch}\",\"return_all\":true}}` to wait for all agents, then `agent_result {{\"agent_id\":\"{first_agent}\"}}` for a detailed report."
"🤖 Agent batch {short_batch} started: {agent_phrase}.\nUse `agent {{\"action\":\"wait\",\"batch_id\":\"{batch}\",\"return_all\":true}}` to wait for all agents, then `agent {{\"action\":\"result\",\"agent_id\":\"{first_agent}\"}}` for a detailed report."
)
} else {
let (single_id, single_model) = agent_labels
@@ -6700,7 +6854,7 @@ pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, argument
.map(|(id, model)| (id.as_str(), model.as_str()))
.unwrap();
format!(
"🤖 Agent {} [{}] started. Use `agent_wait {{\"agent_id\":\"{}\",\"return_all\":true}}` to follow progress, or `agent_result {{\"agent_id\":\"{}\"}}` when it finishes.",
"🤖 Agent {} [{}] started. Use `agent {{\"action\":\"wait\",\"agent_id\":\"{}\",\"return_all\":true}}` to follow progress, or `agent {{\"action\":\"result\",\"agent_id\":\"{}\"}}` when it finishes.",
short_id(single_id),
single_model,
single_id,
@@ -6744,7 +6898,7 @@ pub(crate) async fn handle_run_agent(sess: &Session, ctx: &ToolCallCtx, argument
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid agent_run arguments: {}", e),
content: format!("Invalid agent arguments: {}", e),
success: None,
},
},
@@ -6764,7 +6918,7 @@ async fn handle_check_agent_status(sess: &Session, ctx: &ToolCallCtx, arguments:
execute_custom_tool(
sess,
ctx,
"agent_check".to_string(),
"agent".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<CheckAgentStatusParams>(&arguments_clone) {
@@ -6858,7 +7012,7 @@ async fn handle_check_agent_status(sess: &Session, ctx: &ToolCallCtx, arguments:
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid agent_check arguments: {}", e),
content: format!("Invalid agent arguments for action=status: {}", e),
success: None,
},
},
@@ -6874,7 +7028,7 @@ async fn handle_get_agent_result(sess: &Session, ctx: &ToolCallCtx, arguments: S
execute_custom_tool(
sess,
ctx,
"agent_result".to_string(),
"agent".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<GetAgentResultParams>(&arguments_clone) {
@@ -6966,7 +7120,7 @@ async fn handle_get_agent_result(sess: &Session, ctx: &ToolCallCtx, arguments: S
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid agent_result arguments: {}", e),
content: format!("Invalid agent arguments for action=result: {}", e),
success: None,
},
},
@@ -6982,7 +7136,7 @@ async fn handle_cancel_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Strin
execute_custom_tool(
sess,
ctx,
"agent_cancel".to_string(),
"agent".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<CancelAgentParams>(&arguments_clone) {
@@ -7029,7 +7183,7 @@ async fn handle_cancel_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Strin
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid agent_cancel arguments: {}", e),
content: format!("Invalid agent arguments for action=cancel: {}", e),
success: None,
},
},
@@ -7045,7 +7199,7 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
execute_custom_tool(
sess,
ctx,
"agent_wait".to_string(),
"agent".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<WaitForAgentParams>(&arguments_clone) {
@@ -7117,14 +7271,17 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
_ => unreachable!(),
};
let hint = format!("agent_result {{\"agent_id\":\"{}\"}}", agent.id);
let hint = format!(
"agent {{\"action\":\"result\",\"agent_id\":\"{}\"}}",
agent.id
);
let mut response = serde_json::json!({
"agent_id": agent.id,
"status": agent.status,
"wait_time_seconds": start.elapsed().as_secs(),
"total_lines": total_lines,
"agent_result_hint": hint,
"agent_result_params": { "agent_id": agent.id },
"agent_result_params": { "action": "result", "agent_id": agent.id },
});
if let Some(obj) = response.as_object_mut() {
obj.insert(preview_key.to_string(), serde_json::Value::String(preview));
@@ -7208,13 +7365,16 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
_ => unreachable!(),
};
let hint = format!("agent_result {{\"agent_id\":\"{}\"}}", a.id);
let hint = format!(
"agent {{\"action\":\"result\",\"agent_id\":\"{}\"}}",
a.id
);
let mut obj = serde_json::json!({
"agent_id": a.id,
"status": a.status,
"total_lines": total_lines,
"agent_result_hint": hint,
"agent_result_params": { "agent_id": a.id },
"agent_result_params": { "action": "result", "agent_id": a.id },
});
if let Some(map) = obj.as_object_mut() {
map.insert(preview_key.to_string(), serde_json::Value::String(preview));
@@ -7299,14 +7459,17 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
_ => unreachable!(),
};
let hint = format!(r#"agent_result {{"agent_id":"{}"}}"#, unseen.id);
let hint = format!(
"agent {{\"action\":\"result\",\"agent_id\":\"{}\"}}",
unseen.id
);
let mut response = serde_json::json!({
"agent_id": unseen.id,
"status": unseen.status,
"wait_time_seconds": start.elapsed().as_secs(),
"total_lines": total_lines,
"agent_result_hint": hint,
"agent_result_params": { "agent_id": unseen.id },
"agent_result_params": { "action": "result", "agent_id": unseen.id },
});
if let Some(obj) = response.as_object_mut() {
obj.insert(preview_key.to_string(), serde_json::Value::String(preview));
@@ -7352,7 +7515,7 @@ async fn handle_wait_for_agent(sess: &Session, ctx: &ToolCallCtx, arguments: Str
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid wait_for_agent arguments: {}", e),
content: format!("Invalid agent arguments for action=wait: {}", e),
success: None,
},
},
@@ -7368,7 +7531,7 @@ async fn handle_list_agents(sess: &Session, ctx: &ToolCallCtx, arguments: String
execute_custom_tool(
sess,
ctx,
"agent_list".to_string(),
"agent".to_string(),
params_for_event,
|| async move {
match serde_json::from_str::<ListAgentsParams>(&arguments_clone) {
@@ -7466,7 +7629,7 @@ async fn handle_list_agents(sess: &Session, ctx: &ToolCallCtx, arguments: String
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id: call_id_clone,
output: FunctionCallOutputPayload {
content: format!("Invalid list_agents arguments: {}", e),
content: format!("Invalid agent arguments for action=list: {}", e),
success: None,
},
},
@@ -1,25 +1,485 @@
// This is a WIP. This will eventually contain a real list of common safe Windows commands.
pub fn is_safe_command_windows(_command: &[String]) -> bool {
use shlex::split as shlex_split;
/// On Windows, we conservatively allow only clearly read-only PowerShell invocations
/// that match a small safelist. Anything else (including direct CMD commands) is unsafe.
pub fn is_safe_command_windows(command: &[String]) -> bool {
if let Some(commands) = try_parse_powershell_command_sequence(command) {
return commands
.iter()
.all(|cmd| is_safe_powershell_command(cmd.as_slice()));
}
// Only PowerShell invocations are allowed on Windows for now; anything else is unsafe.
false
}
/// Returns each command sequence if the invocation starts with a PowerShell binary.
/// For example, the tokens from `pwsh Get-ChildItem | Measure-Object` become two sequences.
fn try_parse_powershell_command_sequence(command: &[String]) -> Option<Vec<Vec<String>>> {
let (exe, rest) = command.split_first()?;
if !is_powershell_executable(exe) {
return None;
}
parse_powershell_invocation(rest)
}
/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns.
fn parse_powershell_invocation(args: &[String]) -> Option<Vec<Vec<String>>> {
if args.is_empty() {
// Examples rejected here: "pwsh" and "powershell.exe" with no additional arguments.
return None;
}
let mut idx = 0;
while idx < args.len() {
let arg = &args[idx];
let lower = arg.to_ascii_lowercase();
match lower.as_str() {
"-command" | "/command" | "-c" => {
let script_args = args.get(idx + 1..)?;
if script_args.is_empty() {
return None;
}
if script_args.len() == 1 {
if let Some(commands) = parse_powershell_script(&script_args[0]) {
return Some(commands);
}
}
return split_into_commands(script_args.to_vec());
}
_ if lower.starts_with("-command:") || lower.starts_with("/command:") => {
if idx + 1 != args.len() {
// Reject if there are more tokens after the command itself.
// Examples rejected here: "pwsh -Command:dir C:\\" and "powershell /Command:dir C:\\" with trailing args.
return None;
}
let script = arg.split_once(':')?.1;
return parse_powershell_script(script);
}
// Benign, no-arg flags we tolerate.
"-nologo" | "-noprofile" | "-noninteractive" | "-mta" | "-sta" => {
idx += 1;
continue;
}
// Explicitly forbidden/opaque or unnecessary for read-only operations.
"-encodedcommand" | "-ec" | "-file" | "/file" | "-windowstyle" | "-executionpolicy"
| "-workingdirectory" => {
// Examples rejected here: "pwsh -EncodedCommand ..." and "powershell -File script.ps1".
return None;
}
// Unknown switch → bail conservatively.
_ if lower.starts_with('-') => {
// Examples rejected here: "pwsh -UnknownFlag" and "powershell -foo bar".
return None;
}
// If we hit non-flag tokens, treat the remainder as a command sequence.
// This happens if powershell is invoked without -Command, e.g.
// ["pwsh", "-NoLogo", "git", "-c", "core.pager=cat", "status"]
_ => {
return split_into_commands(args[idx..].to_vec());
}
}
}
// Examples rejected here: "pwsh" and "powershell.exe -NoLogo" without a script.
None
}
/// Tokenizes an inline PowerShell script and delegates to the command splitter.
/// Examples of when this is called: pwsh.exe -Command '<script>' or pwsh.exe -Command:<script>
fn parse_powershell_script(script: &str) -> Option<Vec<Vec<String>>> {
let tokens = shlex_split(script)?;
split_into_commands(tokens)
}
/// Splits tokens into pipeline segments while ensuring no unsafe separators slip through.
/// e.g. Get-ChildItem | Measure-Object -> [['Get-ChildItem'], ['Measure-Object']]
fn split_into_commands(tokens: Vec<String>) -> Option<Vec<Vec<String>>> {
if tokens.is_empty() {
// Examples rejected here: "pwsh -Command ''" and "powershell -Command \"\"".
return None;
}
let mut commands = Vec::new();
let mut current = Vec::new();
for token in tokens.into_iter() {
match token.as_str() {
"|" | "||" | "&&" | ";" => {
if current.is_empty() {
// Examples rejected here: "pwsh -Command '| Get-ChildItem'" and "pwsh -Command '; dir'".
return None;
}
commands.push(current);
current = Vec::new();
}
// Reject if any token embeds separators, redirection, or call operator characters.
_ if token.contains(['|', ';', '>', '<', '&']) || token.contains("$(") => {
// Examples rejected here: "pwsh -Command 'dir|select'" and "pwsh -Command 'echo hi > out.txt'".
return None;
}
_ => current.push(token),
}
}
if current.is_empty() {
// Examples rejected here: "pwsh -Command 'dir |'" and "pwsh -Command 'Get-ChildItem ;'".
return None;
}
commands.push(current);
Some(commands)
}
/// Returns true when the executable name is one of the supported PowerShell binaries.
fn is_powershell_executable(exe: &str) -> bool {
matches!(
exe.to_ascii_lowercase().as_str(),
"powershell" | "powershell.exe" | "pwsh" | "pwsh.exe"
)
}
/// Validates that a parsed PowerShell command stays within our read-only safelist.
/// Everything before this is parsing, and rejecting things that make us feel uncomfortable.
fn is_safe_powershell_command(words: &[String]) -> bool {
if words.is_empty() {
// Examples rejected here: "pwsh -Command ''" and "pwsh -Command \"\"".
return false;
}
// Reject nested unsafe cmdlets inside parentheses or arguments
for w in words.iter() {
let inner = w
.trim_matches(|c| c == '(' || c == ')')
.trim_start_matches('-')
.to_ascii_lowercase();
if matches!(
inner.as_str(),
"set-content"
| "add-content"
| "out-file"
| "new-item"
| "remove-item"
| "move-item"
| "copy-item"
| "rename-item"
| "start-process"
| "stop-process"
) {
// Examples rejected here: "Write-Output (Set-Content foo6.txt 'abc')" and "Get-Content (New-Item bar.txt)".
return false;
}
}
// Block PowerShell call operator or any redirection explicitly.
if words.iter().any(|w| {
matches!(
w.as_str(),
"&" | ">" | ">>" | "1>" | "2>" | "2>&1" | "*>" | "<" | "<<"
)
}) {
// Examples rejected here: "pwsh -Command '& Remove-Item foo'" and "pwsh -Command 'Get-Content foo > bar'".
return false;
}
let command = words[0]
.trim_matches(|c| c == '(' || c == ')')
.trim_start_matches('-')
.to_ascii_lowercase();
match command.as_str() {
"echo" | "write-output" | "write-host" => true, // (no redirection allowed)
"dir" | "ls" | "get-childitem" | "gci" => true,
"cat" | "type" | "gc" | "get-content" => true,
"select-string" | "sls" | "findstr" => true,
"measure-object" | "measure" => true,
"get-location" | "gl" | "pwd" => true,
"test-path" | "tp" => true,
"resolve-path" | "rvpa" => true,
"select-object" | "select" => true,
"get-item" => true,
"git" => is_safe_git_command(words),
"rg" => is_safe_ripgrep(words),
// Extra safety: explicitly prohibit common side-effecting cmdlets regardless of args.
"set-content" | "add-content" | "out-file" | "new-item" | "remove-item" | "move-item"
| "copy-item" | "rename-item" | "start-process" | "stop-process" => {
// Examples rejected here: "pwsh -Command 'Set-Content notes.txt data'" and "pwsh -Command 'Remove-Item temp.log'".
false
}
_ => {
// Examples rejected here: "pwsh -Command 'Invoke-WebRequest https://example.com'" and "pwsh -Command 'Start-Service Spooler'".
false
}
}
}
/// Checks that an `rg` invocation avoids options that can spawn arbitrary executables.
fn is_safe_ripgrep(words: &[String]) -> bool {
const UNSAFE_RIPGREP_OPTIONS_WITH_ARGS: &[&str] = &["--pre", "--hostname-bin"];
const UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS: &[&str] = &["--search-zip", "-z"];
!words.iter().skip(1).any(|arg| {
let arg_lc = arg.to_ascii_lowercase();
// Examples rejected here: "pwsh -Command 'rg --pre cat pattern'" and "pwsh -Command 'rg --search-zip pattern'".
UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS.contains(&arg_lc.as_str())
|| UNSAFE_RIPGREP_OPTIONS_WITH_ARGS
.iter()
.any(|opt| arg_lc == *opt || arg_lc.starts_with(&format!("{opt}=")))
})
}
/// Ensures a Git command sticks to whitelisted read-only subcommands and flags.
fn is_safe_git_command(words: &[String]) -> bool {
const SAFE_SUBCOMMANDS: &[&str] = &["status", "log", "show", "diff", "cat-file"];
let mut iter = words.iter().skip(1);
while let Some(arg) = iter.next() {
let arg_lc = arg.to_ascii_lowercase();
if arg.starts_with('-') {
if arg.eq_ignore_ascii_case("-c") || arg.eq_ignore_ascii_case("--config") {
let Some(value) = iter.next() else {
// Examples rejected here: "pwsh -Command 'git -c'" and "pwsh -Command 'git --config'".
return false;
};
if !is_safe_git_config_override(value) {
return false;
}
continue;
}
if arg_lc.starts_with("-c=")
|| arg_lc.starts_with("--config=")
|| arg_lc.starts_with("--git-dir=")
|| arg_lc.starts_with("--work-tree=")
{
if arg_lc.starts_with("-c=") || arg_lc.starts_with("--config=") {
let Some((_, value)) = arg.split_once('=') else {
return false;
};
if !is_safe_git_config_override(value) {
return false;
}
}
continue;
}
if arg.eq_ignore_ascii_case("--git-dir") || arg.eq_ignore_ascii_case("--work-tree") {
if iter.next().is_none() {
// Examples rejected here: "pwsh -Command 'git --git-dir'" and "pwsh -Command 'git --work-tree'".
return false;
}
continue;
}
continue;
}
return SAFE_SUBCOMMANDS.contains(&arg_lc.as_str());
}
// Examples rejected here: "pwsh -Command 'git'" and "pwsh -Command 'git status --short | Remove-Item foo'".
false
}
fn is_safe_git_config_override(raw: &str) -> bool {
let (key, value) = match raw.split_once('=') {
Some(parts) => parts,
None => return false,
};
let key = key.trim().to_ascii_lowercase();
let value = value
.trim()
.trim_matches(|c| matches!(c, '"' | '\''));
let value_lc = value.to_ascii_lowercase();
matches!(
(key.as_str(), value_lc.as_str()),
("core.pager", "cat") | ("core.pager", "type")
)
}
#[cfg(test)]
mod tests {
use super::is_safe_command_windows;
use std::string::ToString;
/// Converts a slice of string literals into owned `String`s for the tests.
fn vec_str(args: &[&str]) -> Vec<String> {
args.iter().map(ToString::to_string).collect()
}
#[test]
fn everything_is_unsafe() {
for cmd in [
vec_str(&["powershell.exe", "-NoLogo", "-Command", "echo hello"]),
vec_str(&["copy", "foo", "bar"]),
vec_str(&["del", "file.txt"]),
vec_str(&["powershell.exe", "Get-ChildItem"]),
] {
assert!(!is_safe_command_windows(&cmd));
}
fn recognizes_safe_powershell_wrappers() {
assert!(is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoLogo",
"-Command",
"Get-ChildItem -Path .",
])));
assert!(is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoProfile",
"-Command",
"git status",
])));
assert!(is_safe_command_windows(&vec_str(&[
"powershell.exe",
"Get-Content",
"Cargo.toml",
])));
assert!(is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Get-ChildItem",
"-Path",
".",
])));
// pwsh parity
assert!(is_safe_command_windows(&vec_str(&[
"pwsh.exe",
"-NoProfile",
"-Command",
"Get-ChildItem",
])));
}
#[test]
fn allows_read_only_pipelines_and_git_usage() {
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-NoLogo",
"-NoProfile",
"-Command",
"rg --files-with-matches foo | Measure-Object | Select-Object -ExpandProperty Count",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-NoLogo",
"-NoProfile",
"-Command",
"Get-Content foo.rs | Select-Object -Skip 200",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-NoLogo",
"-NoProfile",
"-Command",
"git -c core.pager=cat show HEAD:foo.rs",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-Command",
"-git cat-file -p HEAD:foo.rs",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-Command",
"(Get-Content foo.rs -Raw)",
])));
assert!(is_safe_command_windows(&vec_str(&[
"pwsh",
"-Command",
"Get-Item foo.rs | Select-Object Length",
])));
}
#[test]
fn rejects_powershell_commands_with_side_effects() {
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoLogo",
"-Command",
"Remove-Item foo.txt",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-NoProfile",
"-Command",
"rg --pre cat",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Set-Content foo.txt 'hello'",
])));
// Redirections are blocked
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"echo hi > out.txt",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Get-Content x | Out-File y",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Write-Output foo 2> err.txt",
])));
// Call operator is blocked
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"& Remove-Item foo",
])));
// Chained safe + unsafe must fail
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Get-ChildItem; Remove-Item foo",
])));
// Nested unsafe cmdlet inside safe command must fail
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Write-Output (Set-Content foo6.txt 'abc')",
])));
// Additional nested unsafe cmdlet examples must fail
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Write-Host (Remove-Item foo.txt)",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"Get-Content (New-Item bar.txt)",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"git -c core.pager=\"powershell -Command Remove-Item foo\" status",
])));
assert!(!is_safe_command_windows(&vec_str(&[
"powershell.exe",
"-Command",
"git -c color.ui=always status",
])));
}
}
+25 -2
View File
@@ -42,7 +42,7 @@ use std::sync::LazyLock;
use std::sync::Mutex;
use crate::config_types::ReasoningSummary;
use crate::project_features::{load_project_commands, ProjectCommand, ProjectHooks};
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::SandboxMode;
use std::time::Duration;
use dirs::home_dir;
@@ -220,6 +220,9 @@ pub struct Config {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: usize,
/// Ordered list of fallback filenames to consider when loading project docs.
pub project_doc_fallback_filenames: Vec<String>,
/// Directory containing all Codex state (defaults to `~/.code`; can be
/// overridden by the `CODE_HOME` or `CODEX_HOME` environment variables).
pub codex_home: PathBuf,
@@ -1617,6 +1620,9 @@ pub struct ConfigToml {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: Option<usize>,
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
pub project_doc_fallback_filenames: Option<Vec<String>>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
@@ -2161,6 +2167,19 @@ impl Config {
agents,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
project_doc_fallback_filenames: cfg
.project_doc_fallback_filenames
.unwrap_or_default()
.into_iter()
.filter_map(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.collect(),
codex_home,
history,
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
@@ -2239,7 +2258,7 @@ impl Config {
/// Check if we're using ChatGPT authentication
fn is_using_chatgpt_auth(codex_home: &Path) -> bool {
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use crate::CodexAuth;
// Prefer ChatGPT when both ChatGPT tokens and an API key are present.
@@ -2896,6 +2915,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -2969,6 +2989,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -3057,6 +3078,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -3130,6 +3152,7 @@ model_verbosity = "high"
experimental_client_tools: None,
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
+18 -8
View File
@@ -11,7 +11,8 @@ use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::SessionConfiguredEvent;
use crate::rollout::RolloutRecorder;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::RolloutItem;
@@ -33,20 +34,25 @@ pub struct NewConversation {
pub struct ConversationManager {
conversations: Arc<RwLock<HashMap<ConversationId, Arc<CodexConversation>>>>,
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
}
impl ConversationManager {
pub fn new(auth_manager: Arc<AuthManager>) -> Self {
pub fn new(auth_manager: Arc<AuthManager>, session_source: SessionSource) -> Self {
Self {
conversations: Arc::new(RwLock::new(HashMap::new())),
auth_manager,
session_source,
}
}
/// Construct with a dummy AuthManager containing the provided CodexAuth.
/// Used for integration tests: should not be used by ordinary business logic.
pub fn with_auth(auth: CodexAuth) -> Self {
Self::new(crate::AuthManager::from_auth_for_testing(auth))
Self::new(
crate::AuthManager::from_auth_for_testing(auth),
SessionSource::Exec,
)
}
pub async fn new_conversation(&self, config: Config) -> CodexResult<NewConversation> {
@@ -67,7 +73,7 @@ impl ConversationManager {
init_id: _,
session_id,
} = Codex::spawn_with_auth_manager(config, Some(auth_manager.clone())).await?;
let conversation_id: codex_protocol::mcp_protocol::ConversationId = session_id.into();
let conversation_id: codex_protocol::ConversationId = session_id.into();
self.finalize_spawn(codex, conversation_id).await
}
@@ -128,7 +134,7 @@ impl ConversationManager {
init_id: _,
session_id,
} = Codex::spawn_with_auth_manager(config, Some(auth_manager.clone())).await?;
let conversation_id: codex_protocol::mcp_protocol::ConversationId = session_id.into();
let conversation_id: codex_protocol::ConversationId = session_id.into();
self.finalize_spawn(codex, conversation_id).await
}
@@ -163,11 +169,15 @@ impl ConversationManager {
}
// Otherwise, create a temporary rollout with the truncated items and resume from it.
let convo_id = codex_protocol::mcp_protocol::ConversationId::new();
let convo_id = codex_protocol::ConversationId::new();
let instructions = config.user_instructions.clone();
let recorder = RolloutRecorder::new(
&config,
crate::rollout::recorder::RolloutRecorderParams::new(convo_id, instructions),
crate::rollout::recorder::RolloutRecorderParams::new(
convo_id,
instructions,
self.session_source,
),
)
.await
.map_err(|e| CodexErr::Io(e))?;
@@ -190,7 +200,7 @@ impl ConversationManager {
init_id: _,
session_id,
} = Codex::spawn_with_auth_manager(config, Some(self.auth_manager.clone())).await?;
let conversation_id: codex_protocol::mcp_protocol::ConversationId = session_id.into();
let conversation_id: codex_protocol::ConversationId = session_id.into();
self.finalize_spawn(codex, conversation_id).await
}
}
+47 -4
View File
@@ -73,8 +73,8 @@ pub enum CodexErr {
Interrupted,
/// Unexpected HTTP status code.
#[error("unexpected status {0}: {1}")]
UnexpectedStatus(StatusCode, String),
#[error("{0}")]
UnexpectedStatus(UnexpectedResponseError),
#[error("{0}")]
UsageLimitReached(UsageLimitReachedError),
@@ -93,8 +93,8 @@ pub enum CodexErr {
ServerError(String),
/// Retry limit exceeded.
#[error("exceeded retry limit, last status: {0}")]
RetryLimit(StatusCode),
#[error("{0}")]
RetryLimit(RetryLimitReachedError),
/// Agent loop died unexpectedly
#[error("internal error; agent loop died unexpectedly")]
@@ -137,6 +137,49 @@ pub enum CodexErr {
EnvVar(EnvVarError),
}
#[derive(Debug)]
pub struct UnexpectedResponseError {
pub status: StatusCode,
pub body: String,
pub request_id: Option<String>,
}
impl std::fmt::Display for UnexpectedResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unexpected status {}: {}{}",
self.status,
self.body,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
impl std::error::Error for UnexpectedResponseError {}
#[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 plan_type: Option<String>,
@@ -12,6 +12,7 @@ pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool {
"cmd".to_string(),
JsonSchema::String {
description: Some("The shell command to execute.".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -30,6 +31,7 @@ pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool {
"shell".to_string(),
JsonSchema::String {
description: Some("The shell to use. Defaults to \"/bin/bash\".".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -49,7 +51,7 @@ pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
}
}
@@ -66,6 +68,7 @@ pub fn create_write_stdin_tool_for_responses_api() -> ResponsesApiTool {
"chars".to_string(),
JsonSchema::String {
description: Some("The characters to write to stdin.".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -92,7 +95,7 @@ Can write control characters (\u0003 for Ctrl-C), or an empty string to just pol
parameters: JsonSchema::Object {
properties,
required: Some(vec!["session_id".to_string(), "chars".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::GitSha;
use codex_app_server_protocol::GitSha;
use codex_protocol::protocol::GitInfo;
use futures::future::join_all;
use serde::Deserialize;
+1
View File
@@ -86,6 +86,7 @@ mod tool_apply_patch;
mod workflow_validation;
pub mod turn_diff_tracker;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
pub use rollout::SESSIONS_SUBDIR;
pub use rollout::SessionMeta;
+1 -1
View File
@@ -7,7 +7,7 @@
//! key. These override or extend the defaults at runtime.
use crate::CodexAuth;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
+358 -107
View File
@@ -1,16 +1,12 @@
use serde::Deserialize;
use serde::Serialize;
use serde::ser::{SerializeStruct, Serializer};
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
use crate::agent_tool::create_cancel_agent_tool;
use crate::agent_tool::create_check_agent_status_tool;
use crate::agent_tool::create_get_agent_result_tool;
use crate::agent_tool::create_list_agents_tool;
use crate::agent_tool::create_run_agent_tool;
use crate::agent_tool::create_wait_for_agent_tool;
use crate::agent_tool::create_agent_tool;
use crate::model_family::ModelFamily;
use crate::plan_tool::PLAN_TOOL;
use crate::protocol::AskForApproval;
@@ -91,6 +87,7 @@ pub(crate) struct ToolsConfig {
#[allow(dead_code)]
pub include_view_image_tool: bool,
pub web_search_allowed_domains: Option<Vec<String>>,
pub agent_model_allowed_values: Vec<String>,
}
#[allow(dead_code)]
@@ -145,6 +142,7 @@ impl ToolsConfig {
web_search_request: include_web_search_request,
include_view_image_tool,
web_search_allowed_domains: None,
agent_model_allowed_values: Vec::new(),
}
}
@@ -164,8 +162,38 @@ impl ToolsConfig {
}
}
/// Generic JSON‑Schema subset needed for our tool definitions
impl ToolsConfig {
pub fn set_agent_models(&mut self, models: Vec<String>) {
self.agent_model_allowed_values = models;
}
pub fn agent_models(&self) -> &[String] {
&self.agent_model_allowed_values
}
}
/// Whether additional properties are allowed, and if so, any required schema
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
}
impl From<bool> for AdditionalProperties {
fn from(b: bool) -> Self {
Self::Boolean(b)
}
}
impl From<JsonSchema> for AdditionalProperties {
fn from(s: JsonSchema) -> Self {
Self::Schema(Box::new(s))
}
}
/// Generic JSON‑Schema subset needed for our tool definitions
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub(crate) enum JsonSchema {
Boolean {
@@ -175,6 +203,8 @@ pub(crate) enum JsonSchema {
String {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", rename = "enum")]
allowed_values: Option<Vec<String>>,
},
/// MCP schema allows "number" | "integer" for Number
#[serde(alias = "integer")]
@@ -196,16 +226,103 @@ pub(crate) enum JsonSchema {
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
additional_properties: Option<bool>,
additional_properties: Option<AdditionalProperties>,
},
}
impl Serialize for JsonSchema {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
JsonSchema::Boolean { description } => {
let mut state = serializer.serialize_struct("JsonSchema", if description.is_some() { 2 } else { 1 })?;
state.serialize_field("type", "boolean")?;
if let Some(desc) = description {
state.serialize_field("description", desc)?;
}
state.end()
}
JsonSchema::String {
description,
allowed_values,
} => {
let mut fields = 1;
if description.is_some() {
fields += 1;
}
if allowed_values.is_some() {
fields += 1;
}
let mut state = serializer.serialize_struct("JsonSchema", fields)?;
state.serialize_field("type", "string")?;
if let Some(desc) = description {
state.serialize_field("description", desc)?;
}
if let Some(values) = allowed_values {
state.serialize_field("enum", values)?;
}
state.end()
}
JsonSchema::Number { description } => {
let mut state = serializer.serialize_struct("JsonSchema", if description.is_some() { 2 } else { 1 })?;
state.serialize_field("type", "number")?;
if let Some(desc) = description {
state.serialize_field("description", desc)?;
}
state.end()
}
JsonSchema::Array { items, description } => {
let mut fields = 2; // type + items
if description.is_some() {
fields += 1;
}
let mut state = serializer.serialize_struct("JsonSchema", fields)?;
state.serialize_field("type", "array")?;
state.serialize_field("items", items)?;
if let Some(desc) = description {
state.serialize_field("description", desc)?;
}
state.end()
}
JsonSchema::Object {
properties,
required,
additional_properties,
} => {
let mut req = required.clone().unwrap_or_default();
for key in properties.keys() {
if !req.iter().any(|existing| existing == key) {
req.push(key.clone());
}
}
let mut fields = 3; // type, properties, required
if additional_properties.is_some() {
fields += 1;
}
let mut state = serializer.serialize_struct("JsonSchema", fields)?;
state.serialize_field("type", "object")?;
state.serialize_field("properties", properties)?;
state.serialize_field("required", &req)?;
if let Some(additional) = additional_properties {
state.serialize_field("additionalProperties", additional)?;
}
state.end()
}
}
}
}
fn create_shell_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
items: Box::new(JsonSchema::String {
description: None,
allowed_values: None,
}),
description: Some("The command to execute".to_string()),
},
);
@@ -213,6 +330,7 @@ fn create_shell_tool() -> OpenAiTool {
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -229,7 +347,7 @@ fn create_shell_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -239,7 +357,10 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
properties.insert(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
items: Box::new(JsonSchema::String {
description: None,
allowed_values: None,
}),
description: Some("The command to execute".to_string()),
},
);
@@ -247,6 +368,7 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -258,17 +380,18 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
properties.insert(
"with_escalated_permissions".to_string(),
JsonSchema::Boolean {
description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
},
);
"with_escalated_permissions".to_string(),
JsonSchema::Boolean {
description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
},
);
properties.insert(
"justification".to_string(),
JsonSchema::String {
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
},
);
"justification".to_string(),
JsonSchema::String {
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
allowed_values: None,
},
);
}
let description = match sandbox_policy {
@@ -326,7 +449,7 @@ Long-running commands may be backgrounded after an initial window. Use `wait` to
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -530,7 +653,7 @@ pub(crate) fn get_openai_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
browser_enabled: bool,
agents_active: bool,
_agents_active: bool,
) -> Vec<OpenAiTool> {
let mut tools: Vec<OpenAiTool> = Vec::new();
@@ -580,15 +703,8 @@ pub(crate) fn get_openai_tools(
tools.push(create_browser_status_tool());
}
// Add agent management tools for calling external LLMs asynchronously
tools.push(create_run_agent_tool());
if agents_active {
tools.push(create_check_agent_status_tool());
tools.push(create_get_agent_result_tool());
tools.push(create_cancel_agent_tool());
tools.push(create_wait_for_agent_tool());
tools.push(create_list_agents_tool());
}
// Add agent management tool for launching and monitoring asynchronous agents
tools.push(create_agent_tool(config.agent_models()));
// Add general wait tool for background completions
tools.push(create_wait_tool());
@@ -638,6 +754,7 @@ pub fn create_wait_tool() -> OpenAiTool {
"call_id".to_string(),
JsonSchema::String {
description: Some("Background call_id to wait for.".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -656,7 +773,7 @@ pub fn create_wait_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["call_id".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -667,6 +784,7 @@ pub fn create_kill_tool() -> OpenAiTool {
"call_id".to_string(),
JsonSchema::String {
description: Some("Background call_id to terminate.".to_string()),
allowed_values: None,
},
);
@@ -677,7 +795,7 @@ pub fn create_kill_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["call_id".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -691,6 +809,17 @@ mod tests {
use super::*;
const TEST_AGENT_MODELS: &[&str] = &["claude", "gemini", "qwen", "code", "cloud"];
fn apply_default_agent_models(config: &mut ToolsConfig) {
config.set_agent_models(
TEST_AGENT_MODELS
.iter()
.map(|name| (*name).to_string())
.collect(),
);
}
fn assert_eq_tool_names(tools: &[OpenAiTool], expected_names: &[&str]) {
let tool_names = tools
.iter()
@@ -719,7 +848,7 @@ mod tests {
fn test_get_openai_tools() {
let model_family = find_family_for_model("codex-mini-latest")
.expect("codex-mini-latest should be a valid model family");
let config = ToolsConfig::new(
let mut config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
@@ -729,6 +858,7 @@ mod tests {
/*use_experimental_streamable_shell_tool*/ false,
false,
);
apply_default_agent_models(&mut config);
let tools = get_openai_tools(&config, Some(HashMap::new()), false, false);
assert_eq_tool_names(
@@ -738,7 +868,7 @@ mod tests {
"update_plan",
"browser_open",
"browser_status",
"agent_run",
"agent",
"wait",
"kill",
"web_search",
@@ -751,7 +881,7 @@ mod tests {
fn test_get_openai_tools_with_active_agents() {
let model_family = find_family_for_model("codex-mini-latest")
.expect("codex-mini-latest should be a valid model family");
let config = ToolsConfig::new(
let mut config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
@@ -761,6 +891,7 @@ mod tests {
/*use_experimental_streamable_shell_tool*/ false,
false,
);
apply_default_agent_models(&mut config);
let tools = get_openai_tools(&config, Some(HashMap::new()), false, true);
assert_eq_tool_names(
@@ -770,12 +901,7 @@ mod tests {
"update_plan",
"browser_open",
"browser_status",
"agent_run",
"agent_check",
"agent_result",
"agent_cancel",
"agent_wait",
"agent_list",
"agent",
"wait",
"kill",
"web_search",
@@ -787,7 +913,7 @@ mod tests {
#[test]
fn test_get_openai_tools_default_shell() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
let mut config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
@@ -797,6 +923,7 @@ mod tests {
/*use_experimental_streamable_shell_tool*/ false,
false,
);
apply_default_agent_models(&mut config);
let tools = get_openai_tools(&config, Some(HashMap::new()), false, false);
assert_eq_tool_names(
@@ -806,7 +933,7 @@ mod tests {
"update_plan",
"browser_open",
"browser_status",
"agent_run",
"agent",
"wait",
"kill",
"web_search",
@@ -818,7 +945,7 @@ mod tests {
#[test]
fn test_get_openai_tools_mcp_tools() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
let mut config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
@@ -828,6 +955,7 @@ mod tests {
/*use_experimental_streamable_shell_tool*/ false,
false,
);
apply_default_agent_models(&mut config);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
@@ -874,12 +1002,7 @@ mod tests {
"shell",
"browser_open",
"browser_status",
"agent_run",
"agent_check",
"agent_result",
"agent_cancel",
"agent_wait",
"agent_list",
"agent",
"wait",
"kill",
"web_search",
@@ -889,14 +1012,14 @@ mod tests {
);
assert_eq!(
tools[12],
tools[8],
OpenAiTool::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"string_argument".to_string(),
JsonSchema::String { description: None }
JsonSchema::String { description: None, allowed_values: None }
),
(
"number_argument".to_string(),
@@ -908,7 +1031,7 @@ mod tests {
properties: BTreeMap::from([
(
"string_property".to_string(),
JsonSchema::String { description: None }
JsonSchema::String { description: None, allowed_values: None }
),
(
"number_property".to_string(),
@@ -919,7 +1042,139 @@ mod tests {
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
),
]),
required: None,
additional_properties: None,
},
description: "Do something cool".to_string(),
strict: false,
})
);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let mut config = ToolsConfig::new_from_params(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
include_plan_tool: false,
include_apply_patch_tool: false,
include_web_search_request: true,
use_streamable_shell_tool: false,
include_view_image_tool: true,
});
apply_default_agent_models(&mut config);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"test_server/do_something_cool".to_string(),
mcp_types::Tool {
name: "do_something_cool".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"string_argument": {
"type": "string",
},
"number_argument": {
"type": "number",
},
"object_argument": {
"type": "object",
"properties": {
"string_property": { "type": "string" },
"number_property": { "type": "number" },
},
"required": [
"string_property",
"number_property",
],
"additionalProperties": {
"type": "object",
"properties": {
"addtl_prop": { "type": "string" },
},
"required": [
"addtl_prop",
],
"additionalProperties": false,
},
},
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Do something cool".to_string()),
},
)])),
false,
true,
);
assert_eq_tool_names(
&tools,
&[
"shell",
"browser_open",
"browser_status",
"agent",
"wait",
"kill",
"web_search",
"web_fetch",
"test_server/do_something_cool",
],
);
assert_eq!(
tools[8],
OpenAiTool::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
(
"string_argument".to_string(),
JsonSchema::String { description: None, allowed_values: None }
),
(
"number_argument".to_string(),
JsonSchema::Number { description: None }
),
(
"object_argument".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
(
"string_property".to_string(),
JsonSchema::String { description: None, allowed_values: None }
),
(
"number_property".to_string(),
JsonSchema::Number { description: None }
),
]),
required: Some(vec![
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(
JsonSchema::Object {
properties: BTreeMap::from([(
"addtl_prop".to_string(),
JsonSchema::String { description: None, allowed_values: None }
),]),
required: Some(vec!["addtl_prop".to_string(),]),
additional_properties: Some(false.into()),
}
.into()
),
},
),
]),
@@ -950,7 +1205,7 @@ mod tests {
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new_from_params(&ToolsConfigParams {
let mut config = ToolsConfig::new_from_params(&ToolsConfigParams {
model_family: &model_family,
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::ReadOnly,
@@ -960,6 +1215,7 @@ mod tests {
use_streamable_shell_tool: false,
include_view_image_tool: true,
});
apply_default_agent_models(&mut config);
let tools = get_openai_tools(
&config,
@@ -992,12 +1248,7 @@ mod tests {
"shell",
"browser_open",
"browser_status",
"agent_run",
"agent_check",
"agent_result",
"agent_cancel",
"agent_wait",
"agent_list",
"agent",
"wait",
"kill",
"web_search",
@@ -1007,14 +1258,15 @@ mod tests {
);
assert_eq!(
tools[12],
tools[8],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/search".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"query".to_string(),
JsonSchema::String {
description: Some("search query".to_string())
description: Some("search query".to_string()),
allowed_values: None,
}
)]),
required: None,
@@ -1029,7 +1281,7 @@ mod tests {
#[test]
fn test_mcp_tool_integer_normalized_to_number() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
let mut config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
@@ -1039,6 +1291,7 @@ mod tests {
/*use_experimental_streamable_shell_tool*/ false,
false,
);
apply_default_agent_models(&mut config);
let tools = get_openai_tools(
&config,
@@ -1069,12 +1322,7 @@ mod tests {
"shell",
"browser_open",
"browser_status",
"agent_run",
"agent_check",
"agent_result",
"agent_cancel",
"agent_wait",
"agent_list",
"agent",
"wait",
"kill",
"web_search",
@@ -1083,7 +1331,7 @@ mod tests {
],
);
assert_eq!(
tools[12],
tools[8],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/paginate".to_string(),
parameters: JsonSchema::Object {
@@ -1103,7 +1351,7 @@ mod tests {
#[test]
fn test_mcp_tool_array_without_items_gets_default_string_items() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
let mut config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
@@ -1113,6 +1361,7 @@ mod tests {
/*use_experimental_streamable_shell_tool*/ false,
false,
);
apply_default_agent_models(&mut config);
let tools = get_openai_tools(
&config,
@@ -1143,12 +1392,7 @@ mod tests {
"shell",
"browser_open",
"browser_status",
"agent_run",
"agent_check",
"agent_result",
"agent_cancel",
"agent_wait",
"agent_list",
"agent",
"wait",
"kill",
"web_search",
@@ -1157,14 +1401,14 @@ mod tests {
],
);
assert_eq!(
tools[12],
tools[8],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/tags".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"tags".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
items: Box::new(JsonSchema::String { description: None, allowed_values: None }),
description: None
}
)]),
@@ -1180,7 +1424,7 @@ mod tests {
#[test]
fn test_mcp_tool_anyof_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
let mut config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
@@ -1190,6 +1434,7 @@ mod tests {
/*use_experimental_streamable_shell_tool*/ false,
false,
);
apply_default_agent_models(&mut config);
let tools = get_openai_tools(
&config,
@@ -1220,12 +1465,7 @@ mod tests {
"shell",
"browser_open",
"browser_status",
"agent_run",
"agent_check",
"agent_result",
"agent_cancel",
"agent_wait",
"agent_list",
"agent",
"wait",
"kill",
"web_search",
@@ -1234,13 +1474,13 @@ mod tests {
],
);
assert_eq!(
tools[12],
tools[8],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/value".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"value".to_string(),
JsonSchema::String { description: None }
JsonSchema::String { description: None, allowed_values: None }
)]),
required: None,
additional_properties: None,
@@ -1326,6 +1566,7 @@ fn create_browser_open_tool() -> OpenAiTool {
"url".to_string(),
JsonSchema::String {
description: Some("The URL to navigate to (e.g., https://example.com)".to_string()),
allowed_values: None,
},
);
@@ -1336,7 +1577,7 @@ fn create_browser_open_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["url".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1351,7 +1592,7 @@ fn create_browser_close_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1366,7 +1607,7 @@ fn create_browser_status_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1377,6 +1618,7 @@ fn create_browser_click_tool() -> OpenAiTool {
"type".to_string(),
JsonSchema::String {
description: Some("Optional type of mouse event: 'click' (default), 'mousedown', or 'mouseup'. Use mousedown, browser_move, mouseup sequence to drag.".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -1399,7 +1641,7 @@ fn create_browser_click_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1448,7 +1690,7 @@ fn create_browser_move_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1459,6 +1701,7 @@ fn create_browser_type_tool() -> OpenAiTool {
"text".to_string(),
JsonSchema::String {
description: Some("The text to type into the currently focused element".to_string()),
allowed_values: None,
},
);
@@ -1469,7 +1712,7 @@ fn create_browser_type_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["text".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1480,6 +1723,7 @@ fn create_browser_key_tool() -> OpenAiTool {
"key".to_string(),
JsonSchema::String {
description: Some("The key to press (e.g., 'Enter', 'Tab', 'Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace', 'Delete')".to_string()),
allowed_values: None,
},
);
@@ -1492,7 +1736,7 @@ fn create_browser_key_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["key".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1503,6 +1747,7 @@ fn create_browser_javascript_tool() -> OpenAiTool {
"code".to_string(),
JsonSchema::String {
description: Some("The JavaScript code to execute in the browser context".to_string()),
allowed_values: None,
},
);
@@ -1513,7 +1758,7 @@ fn create_browser_javascript_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["code".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1540,7 +1785,7 @@ fn create_browser_scroll_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1551,6 +1796,7 @@ fn create_browser_history_tool() -> OpenAiTool {
"direction".to_string(),
JsonSchema::String {
description: Some("History direction: 'back' or 'forward'".to_string()),
allowed_values: None,
},
);
@@ -1561,7 +1807,7 @@ fn create_browser_history_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["direction".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1584,6 +1830,7 @@ fn create_browser_inspect_tool() -> OpenAiTool {
"id".to_string(),
JsonSchema::String {
description: Some("Optional element id attribute value. If provided, looks up '#id' and inspects that element.".to_string()),
allowed_values: None,
},
);
@@ -1594,7 +1841,7 @@ fn create_browser_inspect_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1615,7 +1862,7 @@ fn create_browser_console_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1628,7 +1875,7 @@ fn create_browser_cleanup_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: Some(vec![]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1639,6 +1886,7 @@ fn create_browser_cdp_tool() -> OpenAiTool {
"method".to_string(),
JsonSchema::String {
description: Some("CDP method name, e.g. 'Page.navigate' or 'Input.dispatchKeyEvent'".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -1646,13 +1894,14 @@ fn create_browser_cdp_tool() -> OpenAiTool {
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(true),
additional_properties: Some(true.into()),
},
);
properties.insert(
"target".to_string(),
JsonSchema::String {
description: Some("Target for the command: 'page' (default) or 'browser'".to_string()),
allowed_values: None,
},
);
@@ -1663,7 +1912,7 @@ fn create_browser_cdp_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["method".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1674,6 +1923,7 @@ fn create_web_fetch_tool() -> OpenAiTool {
"url".to_string(),
JsonSchema::String {
description: Some("The URL to fetch (e.g., https://example.com)".to_string()),
allowed_values: None,
},
);
properties.insert(
@@ -1688,6 +1938,7 @@ fn create_web_fetch_tool() -> OpenAiTool {
"mode".to_string(),
JsonSchema::String {
description: Some("Optional: 'auto' (default) falls back to the internal browser on challenges; 'browser' forces CDP-based fetch; 'http' disables browser fallback.".to_string()),
allowed_values: None,
},
);
@@ -1698,7 +1949,7 @@ fn create_web_fetch_tool() -> OpenAiTool {
parameters: JsonSchema::Object {
properties,
required: Some(vec!["url".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
+11 -3
View File
@@ -19,11 +19,18 @@ pub use codex_protocol::plan_tool::UpdatePlanArgs;
pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
let mut plan_item_props = BTreeMap::new();
plan_item_props.insert("step".to_string(), JsonSchema::String { description: None });
plan_item_props.insert(
"step".to_string(),
JsonSchema::String {
description: None,
allowed_values: None,
},
);
plan_item_props.insert(
"status".to_string(),
JsonSchema::String {
description: Some("One of: pending, in_progress, completed".to_string()),
allowed_values: None,
},
);
@@ -32,7 +39,7 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
items: Box::new(JsonSchema::Object {
properties: plan_item_props,
required: Some(vec!["step".to_string(), "status".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
}),
};
@@ -41,6 +48,7 @@ pub(crate) static PLAN_TOOL: LazyLock<OpenAiTool> = LazyLock::new(|| {
"name".to_string(),
JsonSchema::String {
description: Some("2-5 word title describing the plan e.g. 'Fix Box Rendering'".to_string()),
allowed_values: None,
},
);
properties.insert("plan".to_string(), plan_items_schema);
@@ -56,7 +64,7 @@ At most one step can be in_progress at a time.
parameters: JsonSchema::Object {
properties,
required: Some(vec!["plan".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
});
+105 -55
View File
@@ -1,4 +1,3 @@
#![allow(dead_code)]
use std::cmp::Reverse;
use std::io::{self};
use std::path::Path;
@@ -15,9 +14,12 @@ use time::macros::format_description;
use uuid::Uuid;
use super::SESSIONS_SUBDIR;
use crate::config::resolve_codex_path_for_read;
use crate::protocol::event_msg_from_protocol;
use crate::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use crate::config::resolve_codex_path_for_read;
use codex_protocol::protocol::SessionSource;
/// Returned page of conversation summaries.
#[derive(Debug, Default, PartialEq)]
@@ -37,13 +39,31 @@ pub struct ConversationsPage {
pub struct ConversationItem {
/// Absolute path to the rollout file.
pub path: PathBuf,
/// First up to 5 JSONL records parsed as JSON (includes meta line).
/// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line).
pub head: Vec<serde_json::Value>,
/// Last up to `TAIL_RECORD_LIMIT` JSONL response records parsed as JSON.
pub tail: Vec<serde_json::Value>,
/// RFC3339 timestamp string for when the session was created, if available.
pub created_at: Option<String>,
/// RFC3339 timestamp string for the most recent response in the tail, if available.
pub updated_at: Option<String>,
}
#[derive(Default)]
struct HeadTailSummary {
head: Vec<serde_json::Value>,
tail: Vec<serde_json::Value>,
saw_session_meta: bool,
saw_user_event: bool,
source: Option<SessionSource>,
created_at: Option<String>,
updated_at: Option<String>,
}
/// Hard cap to bound worst‑case work per request.
const MAX_SCAN_FILES: usize = 100;
const MAX_SCAN_FILES: usize = 10000;
const HEAD_RECORD_LIMIT: usize = 10;
const TAIL_RECORD_LIMIT: usize = 10;
/// Pagination cursor identifying a file by timestamp and UUID.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -90,6 +110,7 @@ pub(crate) async fn get_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let root = resolve_codex_path_for_read(codex_home, Path::new(SESSIONS_SUBDIR));
@@ -104,7 +125,8 @@ pub(crate) async fn get_conversations(
let anchor = cursor.cloned();
let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?;
let result =
traverse_directories_for_paths(root.clone(), page_size, anchor, allowed_sources).await?;
Ok(result)
}
@@ -117,13 +139,13 @@ pub(crate) async fn get_conversation(path: &Path) -> io::Result<String> {
/// Load conversation file paths from disk using directory traversal.
///
/// Directory layout: `~/.code/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
/// (Code still reads legacy `~/.codex/sessions/...`).
/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
/// Returned newest (latest) first.
async fn traverse_directories_for_paths(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
) -> io::Result<ConversationsPage> {
let mut items: Vec<ConversationItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
@@ -175,15 +197,32 @@ async fn traverse_directories_for_paths(
if items.len() == page_size {
break 'outer;
}
// Read head and simultaneously detect message events within the same
// first N JSONL records to avoid a second file read.
let (head, saw_session_meta, saw_user_event) =
read_head_and_flags(&path, HEAD_RECORD_LIMIT)
.await
.unwrap_or((Vec::new(), false, false));
// Apply filters: must have session meta and at least one user message event
if saw_session_meta && saw_user_event {
items.push(ConversationItem { path, head });
let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT)
.await
.unwrap_or_default();
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.iter().any(|s| s == &source))
{
continue;
}
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
tail,
created_at,
mut updated_at,
..
} = summary;
updated_at = updated_at.or_else(|| created_at.clone());
items.push(ConversationItem {
path,
head,
tail,
created_at,
updated_at,
});
}
}
}
@@ -199,9 +238,13 @@ async fn traverse_directories_for_paths(
})
}
/// Pagination cursor token format: "<file_ts>|<uuid>" where `file_ts` matches the
/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames.
/// The cursor orders files by timestamp desc, then UUID desc.
fn build_next_cursor(items: &[ConversationItem]) -> Option<Cursor> {
let last = items.last()?;
let file_name = last.path.file_name()?.to_string_lossy();
let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
Some(Cursor::new(ts, id))
}
fn parse_cursor(token: &str) -> Option<Cursor> {
let (file_ts, uuid_str) = token.split_once('|')?;
@@ -216,15 +259,6 @@ fn parse_cursor(token: &str) -> Option<Cursor> {
Some(Cursor::new(ts, uuid))
}
fn build_next_cursor(items: &[ConversationItem]) -> Option<Cursor> {
let last = items.last()?;
let file_name = last.path.file_name()?.to_string_lossy();
let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
Some(Cursor::new(ts, id))
}
/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`,
/// and returns them sorted descending by the parsed key.
async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> io::Result<Vec<(T, PathBuf)>>
where
T: Ord + Copy,
@@ -248,7 +282,6 @@ where
Ok(vec)
}
/// Collects files in a directory and parses them with `parse`.
async fn collect_files<T, F>(parent: &Path, parse: F) -> io::Result<Vec<T>>
where
F: Fn(&str, &Path) -> Option<T>,
@@ -271,10 +304,8 @@ where
}
fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> {
// Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
// Scan from the right for a '-' such that the suffix parses as a UUID.
let (sep_idx, uuid) = core
.match_indices('-')
.rev()
@@ -287,20 +318,19 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui
Some((ts, uuid))
}
async fn read_head_and_flags(
async fn read_head_and_tail(
path: &Path,
max_records: usize,
) -> io::Result<(Vec<serde_json::Value>, bool, bool)> {
head_limit: usize,
tail_limit: usize,
) -> io::Result<HeadTailSummary> {
use tokio::io::AsyncBufReadExt;
let file = tokio::fs::File::open(path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut head: Vec<serde_json::Value> = Vec::new();
let mut saw_session_meta = false;
let mut saw_user_event = false;
let mut summary = HeadTailSummary::default();
while head.len() < max_records {
while summary.head.len() < head_limit {
let line_opt = lines.next_line().await?;
let Some(line) = line_opt else { break };
let trimmed = line.trim();
@@ -311,33 +341,53 @@ async fn read_head_and_flags(
let parsed: Result<RolloutLine, _> = serde_json::from_str(trimmed);
let Ok(rollout_line) = parsed else { continue };
match rollout_line.item {
match &rollout_line.item {
RolloutItem::SessionMeta(session_meta_line) => {
if let Ok(val) = serde_json::to_value(session_meta_line) {
head.push(val);
saw_session_meta = true;
}
}
RolloutItem::ResponseItem(item) => {
if let Ok(val) = serde_json::to_value(item) {
head.push(val);
}
summary.source = Some(session_meta_line.meta.source);
summary.created_at = summary
.created_at
.clone()
.or_else(|| Some(rollout_line.timestamp.clone()));
summary.updated_at = Some(rollout_line.timestamp.clone());
summary.saw_session_meta = true;
}
RolloutItem::Event(event) => {
if let Some(msg) = crate::protocol::event_msg_from_protocol(&event.msg) {
if matches!(msg, crate::protocol::EventMsg::AgentMessage(_)) {
saw_user_event = true;
if let Some(msg) = event_msg_from_protocol(&event.msg) {
if matches!(msg, EventMsg::UserMessage(_) | EventMsg::AgentMessage(_)) {
summary.saw_user_event = true;
summary.updated_at = Some(rollout_line.timestamp.clone());
}
}
}
// Skip variants not displayed in list summaries.
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {}
RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {}
}
summary
.head
.push(serde_json::to_value(&rollout_line.item).unwrap_or_default());
}
Ok((head, saw_session_meta, saw_user_event))
}
// Collect tail by reading entire file (bounded by TAIL_RECORD_LIMIT).
let tail_lines = tokio::fs::read_to_string(path).await?;
let mut tail_events = Vec::new();
for line in tail_lines.lines().rev() {
if tail_events.len() >= tail_limit {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let parsed: Result<RolloutLine, _> = serde_json::from_str(trimmed);
if parsed.is_ok() {
tail_events.push(serde_json::from_str(trimmed).unwrap_or_default());
}
}
tail_events.reverse();
summary.tail = tail_events;
Ok(summary)
}
/// Locate a recorded conversation rollout file by its UUID string using the existing
/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
+4
View File
@@ -1,8 +1,12 @@
//! Rollout module: persistence and discovery of session rollout files.
use codex_protocol::protocol::SessionSource;
pub const SESSIONS_SUBDIR: &str = "sessions";
#[allow(dead_code)]
pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] =
&[SessionSource::Cli, SessionSource::VSCode];
pub mod list;
pub(crate) mod policy;
+3 -1
View File
@@ -1,3 +1,4 @@
use crate::protocol::event_msg_from_protocol;
use crate::protocol::EventMsg;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::RolloutItem;
@@ -7,7 +8,8 @@ use codex_protocol::protocol::RolloutItem;
pub(crate) fn should_persist_rollout_item(item: &RolloutItem) -> bool {
match item {
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
RolloutItem::Event(_) => true,
RolloutItem::Event(ev) => event_msg_from_protocol(&ev.msg)
.is_some_and(|msg| should_persist_event_msg(&msg)),
// Always persist session meta
RolloutItem::SessionMeta(_) => true,
// Persist compacted summaries and turn context for accurate history reconstruction.
+29 -12
View File
@@ -6,9 +6,10 @@ use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde::{Serialize, Deserialize};
use time::OffsetDateTime;
use time::format_description::FormatItem;
use time::macros::format_description;
@@ -20,13 +21,15 @@ use tracing::info;
use tracing::warn;
use super::SESSIONS_SUBDIR;
use super::list::get_conversations;
use super::list::ConversationsPage;
use super::list::Cursor;
use super::list::get_conversations;
use super::policy::{should_persist_response_item, should_persist_rollout_item};
use crate::config::Config;
use crate::default_client::DEFAULT_ORIGINATOR;
use crate::git_info::collect_git_info;
use crate::history::HistorySnapshot;
use crate::protocol::event_msg_from_protocol;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
@@ -44,7 +47,7 @@ pub struct SavedSession {
#[serde(default)]
pub items: Vec<RolloutItem>,
#[serde(default)]
pub history_snapshot: Option<crate::history::HistorySnapshot>,
pub history_snapshot: Option<HistorySnapshot>,
#[serde(default)]
pub state: SessionStateSnapshot,
pub session_id: uuid::Uuid,
@@ -70,6 +73,7 @@ pub enum RolloutRecorderParams {
Create {
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
},
Resume {
path: PathBuf,
@@ -83,10 +87,15 @@ enum RolloutCmd {
}
impl RolloutRecorderParams {
pub fn new(conversation_id: ConversationId, instructions: Option<String>) -> Self {
pub fn new(
conversation_id: ConversationId,
instructions: Option<String>,
source: SessionSource,
) -> Self {
Self::Create {
conversation_id,
instructions,
source,
}
}
@@ -95,13 +104,13 @@ impl RolloutRecorderParams {
impl RolloutRecorder {
/// List conversations (rollout files) under the provided Codex home directory.
#[allow(dead_code)]
pub async fn list_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
allowed_sources: &[SessionSource],
) -> std::io::Result<ConversationsPage> {
get_conversations(codex_home, page_size, cursor).await
get_conversations(codex_home, page_size, cursor, allowed_sources).await
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
@@ -112,6 +121,7 @@ impl RolloutRecorder {
RolloutRecorderParams::Create {
conversation_id,
instructions,
source,
} => {
let LogFileInfo {
file,
@@ -138,6 +148,7 @@ impl RolloutRecorder {
originator: DEFAULT_ORIGINATOR.to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
source,
}),
)
}
@@ -210,11 +221,17 @@ impl RolloutRecorder {
if events.is_empty() {
return Ok(());
}
let filtered = events
.iter()
.cloned()
.map(RolloutItem::Event)
.collect::<Vec<_>>();
let mut filtered = Vec::new();
for event in events {
if event_msg_from_protocol(&event.msg)
.is_some_and(|msg| crate::rollout::policy::should_persist_event_msg(&msg))
{
filtered.push(RolloutItem::Event(event.clone()));
}
}
if filtered.is_empty() {
return Ok(());
}
self.tx
.send(RolloutCmd::AddItems(filtered))
.await
+211 -36
View File
@@ -2,6 +2,7 @@
use std::fs::File;
use std::fs::{self};
use std::io::BufWriter;
use std::io::Write;
use std::path::Path;
@@ -12,17 +13,30 @@ use time::format_description::FormatItem;
use time::macros::format_description;
use uuid::Uuid;
use crate::rollout::INTERACTIVE_SESSION_SOURCES;
use crate::rollout::list::ConversationItem;
use crate::rollout::list::ConversationsPage;
use crate::rollout::list::Cursor;
use crate::rollout::list::get_conversation;
use crate::rollout::list::get_conversations;
use codex_protocol::ConversationId;
use codex_protocol::protocol::RecordedEvent;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::UserMessageEvent;
use crate::protocol::EventMsg;
const NO_SOURCE_FILTER: &[SessionSource] = &[];
fn write_session_file(
root: &Path,
ts_str: &str,
uuid: Uuid,
num_records: usize,
source: Option<SessionSource>,
) -> std::io::Result<(OffsetDateTime, Uuid)> {
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
@@ -38,20 +52,46 @@ fn write_session_file(
let filename = format!("rollout-{ts_str}-{uuid}.jsonl");
let file_path = dir.join(filename);
let mut file = File::create(file_path)?;
let file = File::create(file_path)?;
let mut writer = BufWriter::new(file);
let meta = serde_json::json!({
"timestamp": ts_str,
"id": uuid.to_string()
});
writeln!(file, "{meta}")?;
let conversation_id = ConversationId::from(uuid);
let session_meta = SessionMeta {
id: conversation_id,
timestamp: ts_str.to_string(),
cwd: Path::new(".").to_path_buf(),
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
instructions: None,
source: source.unwrap_or_default(),
};
let session_meta_line = RolloutLine {
timestamp: ts_str.to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
serde_json::to_writer(&mut writer, &session_meta_line)?;
writer.write_all(b"\n")?;
for i in 0..num_records {
let rec = serde_json::json!({
"record_type": "response",
"index": i
});
writeln!(file, "{rec}")?;
let event = RecordedEvent {
id: format!("event-{i}"),
event_seq: i as u64,
order: None,
msg: EventMsg::UserMessage(UserMessageEvent {
message: format!("Message {i}"),
kind: None,
images: None,
}),
};
let line = RolloutLine {
timestamp: ts_str.to_string(),
item: RolloutItem::Event(event),
};
serde_json::to_writer(&mut writer, &line)?;
writer.write_all(b"\n")?;
}
Ok((dt, uuid))
}
@@ -67,11 +107,34 @@ async fn test_list_conversations_latest_first() {
let u3 = Uuid::from_u128(3);
// Create three sessions across three days
write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap();
write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap();
write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap();
write_session_file(
home,
"2025-01-01T12-00-00",
u1,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-02T12-00-00",
u2,
3,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-01-03T12-00-00",
u3,
3,
Some(SessionSource::VSCode),
)
.unwrap();
let page = get_conversations(home, 10, None).await.unwrap();
let page = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
// Build expected objects
let p1 = home
@@ -151,13 +214,50 @@ async fn test_pagination_cursor() {
let u5 = Uuid::from_u128(55);
// Oldest to newest
write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap();
write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap();
write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap();
write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap();
write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap();
write_session_file(
home,
"2025-03-01T09-00-00",
u1,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-02T09-00-00",
u2,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-03T09-00-00",
u3,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-04T09-00-00",
u4,
1,
Some(SessionSource::VSCode),
)
.unwrap();
write_session_file(
home,
"2025-03-05T09-00-00",
u5,
1,
Some(SessionSource::VSCode),
)
.unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let p5 = home
.join("sessions")
.join("2025")
@@ -197,9 +297,14 @@ async fn test_pagination_cursor() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p3 = home
.join("sessions")
.join("2025")
@@ -239,9 +344,14 @@ async fn test_pagination_cursor() {
};
assert_eq!(page2, expected_page2);
let page3 = get_conversations(home, 2, page2.next_cursor.as_ref())
.await
.unwrap();
let page3 = get_conversations(
home,
2,
page2.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -273,9 +383,11 @@ async fn test_get_conversation_contents() {
let uuid = Uuid::new_v4();
let ts = "2025-04-01T10-30-00";
write_session_file(home, ts, uuid, 2).unwrap();
write_session_file(home, ts, uuid, 2, Some(SessionSource::VSCode)).unwrap();
let page = get_conversations(home, 1, None).await.unwrap();
let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let path = &page.items[0].path;
let content = get_conversation(path).await.unwrap();
@@ -322,11 +434,13 @@ async fn test_stable_ordering_same_second_pagination() {
let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3);
write_session_file(home, ts, u1, 0).unwrap();
write_session_file(home, ts, u2, 0).unwrap();
write_session_file(home, ts, u3, 0).unwrap();
write_session_file(home, ts, u1, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u2, 0, Some(SessionSource::VSCode)).unwrap();
write_session_file(home, ts, u3, 0, Some(SessionSource::VSCode)).unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let page1 = get_conversations(home, 2, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let p3 = home
.join("sessions")
@@ -361,9 +475,14 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let page2 = get_conversations(
home,
2,
page1.next_cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
@@ -382,3 +501,59 @@ async fn test_stable_ordering_same_second_pagination() {
};
assert_eq!(page2, expected_page2);
}
#[tokio::test]
async fn test_source_filter_excludes_non_matching_sessions() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let interactive_id = Uuid::from_u128(42);
let non_interactive_id = Uuid::from_u128(77);
write_session_file(
home,
"2025-08-02T10-00-00",
interactive_id,
2,
Some(SessionSource::Cli),
)
.unwrap();
write_session_file(
home,
"2025-08-01T10-00-00",
non_interactive_id,
2,
Some(SessionSource::Exec),
)
.unwrap();
let interactive_only = get_conversations(home, 10, None, INTERACTIVE_SESSION_SOURCES)
.await
.unwrap();
let paths: Vec<_> = interactive_only
.items
.iter()
.map(|item| item.path.as_path())
.collect();
assert_eq!(paths.len(), 1);
assert!(paths.iter().all(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
let all_sessions = get_conversations(home, 10, None, NO_SOURCE_FILTER)
.await
.unwrap();
let all_paths: Vec<_> = all_sessions
.items
.into_iter()
.map(|item| item.path)
.collect();
assert_eq!(all_paths.len(), 2);
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-02T10-00-00-00000000-0000-0000-0000-00000000002a.jsonl")
}));
assert!(all_paths.iter().any(|path| {
path.ends_with("rollout-2025-08-01T10-00-00-00000000-0000-0000-0000-00000000004d.jsonl")
}));
}
+2 -1
View File
@@ -40,6 +40,7 @@ pub(crate) fn create_apply_patch_json_tool() -> OpenAiTool {
"input".to_string(),
JsonSchema::String {
description: Some(r#"The entire contents of the apply_patch command"#.to_string()),
allowed_values: None,
},
);
@@ -118,7 +119,7 @@ It is important to remember:
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false),
additional_properties: Some(false.into()),
},
})
}
@@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex};
use codex_app_server_protocol::AuthMode;
use codex_core::ContentItem;
use codex_core::LocalShellAction;
use codex_core::LocalShellExecAction;
+2 -1
View File
@@ -1,5 +1,6 @@
use std::sync::{Arc, Mutex};
use codex_app_server_protocol::AuthMode;
use codex_core::ContentItem;
use codex_core::ModelClient;
use codex_core::ModelProviderInfo;
@@ -19,7 +20,7 @@ use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use codex_protocol::mcp_protocol::{AuthMode, ConversationId};
use codex_protocol::ConversationId;
fn network_disabled() -> bool {
std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok()
@@ -1,4 +1,5 @@
#![allow(clippy::expect_used)]
use codex_core::auth::CODEX_API_KEY_ENV_VAR;
use std::path::Path;
use tempfile::TempDir;
use wiremock::MockServer;
@@ -14,7 +15,7 @@ impl TestCodexExecBuilder {
.expect("should find binary for codex-exec");
cmd.current_dir(self.cwd.path())
.env("CODEX_HOME", self.home.path())
.env("OPENAI_API_KEY", "dummy");
.env(CODEX_API_KEY_ENV_VAR, "dummy");
cmd
}
pub fn cmd_with_server(&self, server: &MockServer) -> assert_cmd::Command {
+1 -1
View File
@@ -76,7 +76,7 @@ async fn chat_mode_stream_cli() {
server.verify().await;
// Verify a new session rollout was created and is discoverable via list_conversations
let page = RolloutRecorder::list_conversations(home.path(), 10, None)
let page = RolloutRecorder::list_conversations(home.path(), 10, None, &[])
.await
.expect("list conversations");
assert!(
+2 -1
View File
@@ -1,3 +1,4 @@
use codex_app_server_protocol::AuthMode;
use codex_core::CodexAuth;
use codex_core::ContentItem;
use codex_core::debug_logger::DebugLogger;
@@ -542,7 +543,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager);
let conversation_manager = ConversationManager::new(auth_manager, SessionSource::Exec);
let NewConversation {
conversation: codex,
..
+1 -1
View File
@@ -44,7 +44,7 @@ use std::sync::Mutex;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use chrono::Utc;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
// --- Test helpers -----------------------------------------------------------
pub(super) const FIRST_REPLY: &str = "FIRST_REPLY";
+7 -3
View File
@@ -1,7 +1,8 @@
use codex_core::protocol::{event_msg_to_protocol, AgentMessageEvent, EventMsg, RecordedEvent};
use codex_core::rollout::RolloutRecorder;
use codex_core::rollout::recorder::RolloutRecorderParams;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::models::{ContentItem, ResponseItem};
use tempfile::TempDir;
use uuid::Uuid;
@@ -14,8 +15,11 @@ async fn resume_restores_recorded_events() {
let mut config = load_default_config_for_test(&codex_home);
config.cwd = codex_home.path().to_path_buf();
let conversation_id = ConversationId(Uuid::new_v4());
let recorder = RolloutRecorder::new(&config, RolloutRecorderParams::new(conversation_id, None))
let conversation_id = ConversationId::from(Uuid::new_v4());
let recorder = RolloutRecorder::new(
&config,
RolloutRecorderParams::new(conversation_id, None, SessionSource::Cli),
)
.await
.expect("create recorder");
+3 -3
View File
@@ -10,7 +10,7 @@ This document describes Codex’s experimental MCP interface: a JSON‑RPC API t
## Overview
Codex exposes a small set of MCP‑compatible methods to create and manage conversations, send user input, receive live events, and handle approval prompts. The types are defined in `protocol/src/mcp_protocol.rs` and re‑used by the MCP server implementation in `mcp-server/`.
Codex exposes a small set of MCP‑compatible methods to create and manage conversations, send user input, receive live events, and handle approval prompts. The types are defined in `app-server-protocol/src/protocol.rs` and re-used by the MCP server implementation in `mcp-server/`.
At a glance:
@@ -31,7 +31,7 @@ At a glance:
- `loginChatGptComplete`, `authStatusChange`
- `codex/event` stream with agent events
See code for full type definitions and exact shapes: `protocol/src/mcp_protocol.rs`.
See code for full type definitions and exact shapes: `app-server-protocol/src/protocol.rs`.
## Starting the server
@@ -123,4 +123,4 @@ While processing, the server emits `codex/event` notifications containing agent
## Compatibility and stability
This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`.
This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `app-server-protocol/src/protocol.rs` and the corresponding server wiring in `mcp-server/`.
+4 -2
View File
@@ -16,7 +16,6 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = [
@@ -27,6 +26,9 @@ codex-common = { workspace = true, features = [
codex-core = { workspace = true }
codex-ollama = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
chrono = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
owo-colors = { workspace = true }
serde_json = { workspace = true }
shlex = { workspace = true }
@@ -45,9 +47,9 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] }
assert_cmd = { workspace = true }
core_test_support = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
predicates = { workspace = true }
tempfile = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
wiremock = { workspace = true }
mcp-types = { workspace = true }
+47 -17
View File
@@ -2,34 +2,45 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Top-level events emitted on the Codex Exec thread stream.
/// Top-level JSONL events emitted by codex exec
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "type")]
pub enum ThreadEvent {
/// Emitted when a new thread is started as the first event.
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent),
/// Emitted when a turn is started by sending a new prompt to the model.
/// A turn encompasses all events that happen while agent is processing the prompt.
#[serde(rename = "turn.started")]
TurnStarted(TurnStartedEvent),
/// Emitted when a turn is completed. Typically right after the assistant's response.
#[serde(rename = "turn.completed")]
TurnCompleted(TurnCompletedEvent),
/// Indicates that a turn failed with an error.
#[serde(rename = "turn.failed")]
TurnFailed(TurnFailedEvent),
/// Emitted when a new item is added to the thread. Typically the item will be in an "in progress" state.
#[serde(rename = "item.started")]
ItemStarted(ItemStartedEvent),
/// Emitted when an item is updated.
#[serde(rename = "item.updated")]
ItemUpdated(ItemUpdatedEvent),
/// Signals that an item has reached a terminal state—either success or failure.
#[serde(rename = "item.completed")]
ItemCompleted(ItemCompletedEvent),
/// Represents an unrecoverable error emitted directly by the event stream.
#[serde(rename = "error")]
Error(ThreadErrorEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ThreadStartedEvent {
/// The identified of the new thread. Can be used to resume the thread later.
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct TurnStartedEvent {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
@@ -42,11 +53,14 @@ pub struct TurnFailedEvent {
pub error: ThreadErrorEvent,
}
/// Minimal usage summary for a turn.
/// Describes the usage of tokens during a turn.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct Usage {
/// The number of input tokens used during the turn.
pub input_tokens: u64,
/// The number of cached input tokens used during the turn.
pub cached_input_tokens: u64,
/// The number of output tokens used during the turn.
pub output_tokens: u64,
}
@@ -81,36 +95,46 @@ pub struct ThreadItem {
/// Typed payloads for each supported thread item type.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "item_type", rename_all = "snake_case")]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThreadItemDetails {
AssistantMessage(AssistantMessageItem),
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
AgentMessage(AgentMessageItem),
/// Agent's reasoning summary.
Reasoning(ReasoningItem),
/// Tracks a command executed by the agent. The item starts when the command is
/// spawned, and completes when the process exits with an exit code.
CommandExecution(CommandExecutionItem),
/// Represents a set of file changes by the agent. The item is emitted only as a
/// completed event once the patch succeeds or fails.
FileChange(FileChangeItem),
/// Represents a call to an MCP tool. The item starts when the invocation is
/// dispatched and completes when the MCP server reports success or failure.
McpToolCall(McpToolCallItem),
/// Captures a web search request. It starts when the search is kicked off
/// and completes when results are returned to the agent.
WebSearch(WebSearchItem),
/// Tracks the agent's running to-do list. It starts when the plan is first
/// issued, updates as steps change state, and completes when the turn ends.
TodoList(TodoListItem),
/// Describes a non-fatal error surfaced as an item.
Error(ErrorItem),
}
/// Session metadata.
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct SessionItem {
pub session_id: String,
}
/// Assistant message payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct AssistantMessageItem {
pub struct AgentMessageItem {
pub text: String,
}
/// Model reasoning summary payload.
/// Agent's reasoning summary.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ReasoningItem {
pub text: String,
}
/// The status of a command execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum CommandExecutionStatus {
@@ -120,7 +144,7 @@ pub enum CommandExecutionStatus {
Failed,
}
/// Local shell command execution payload.
/// A command executed by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct CommandExecutionItem {
pub command: String,
@@ -130,13 +154,14 @@ pub struct CommandExecutionItem {
pub status: CommandExecutionStatus,
}
/// Single file change summary for a patch.
/// A set of file changes by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileUpdateChange {
pub path: String,
pub kind: PatchChangeKind,
}
/// The status of a file change.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchApplyStatus {
@@ -144,14 +169,14 @@ pub enum PatchApplyStatus {
Failed,
}
/// Patch application payload.
/// A set of file changes by the agent.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileChangeItem {
pub changes: Vec<FileUpdateChange>,
pub status: PatchApplyStatus,
}
/// Known change kinds for a patch.
/// Indicates the type of the file change.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchChangeKind {
@@ -160,6 +185,7 @@ pub enum PatchChangeKind {
Update,
}
/// The status of an MCP tool call.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum McpToolCallStatus {
@@ -169,6 +195,7 @@ pub enum McpToolCallStatus {
Failed,
}
/// A call to an MCP tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct McpToolCallItem {
pub server: String,
@@ -176,16 +203,19 @@ pub struct McpToolCallItem {
pub status: McpToolCallStatus,
}
/// A web search request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct WebSearchItem {
pub query: String,
}
/// An error notification.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ErrorItem {
pub message: String,
}
/// An item in agent's to-do list.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct TodoItem {
pub text: String,
+13 -14
View File
@@ -18,6 +18,7 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::TaskCompleteEvent;
use codex_protocol::protocol::SessionSource;
use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
use event_processor_with_human_output::EventProcessorWithHumanOutput;
@@ -219,11 +220,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let conversation_manager = ConversationManager::new(AuthManager::shared_with_mode_and_originator(
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_protocol::mcp_protocol::AuthMode::ApiKey,
config.responses_originator_header.clone(),
));
);
let conversation_manager = ConversationManager::new(auth_manager.clone(), SessionSource::Exec);
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
let NewConversation {
@@ -235,22 +237,19 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
if let Some(path) = resume_path {
conversation_manager
.resume_conversation_from_rollout(
config.clone(),
path,
AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
codex_protocol::mcp_protocol::AuthMode::ApiKey,
config.responses_originator_header.clone(),
),
)
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
.await?
} else {
conversation_manager.new_conversation(config).await?
conversation_manager
.new_conversation(config.clone())
.await?
}
} else {
conversation_manager.new_conversation(config).await?
conversation_manager
.new_conversation(config.clone())
.await?
};
event_processor.print_config_summary(&config, &prompt);
info!("Codex initialized with event: {session_configured:?}");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
@@ -353,7 +352,7 @@ async fn resolve_resume_path(
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> {
if args.last {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None, &[]).await {
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
Err(e) => {
error!("Error listing conversations: {e}");
+34
View File
@@ -0,0 +1,34 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use core_test_support::responses::ev_completed;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex_exec::test_codex_exec;
use wiremock::Mock;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_uses_codex_api_key_env_var() -> anyhow::Result<()> {
let test = test_codex_exec();
let server = start_mock_server().await;
Mock::given(method("POST"))
.and(path("/v1/responses"))
.and(header("Authorization", "Bearer dummy"))
.respond_with(sse_response(sse(vec![ev_completed("request_0")])))
.expect(1)
.mount(&server)
.await;
test.cmd_with_server(&server)
.arg("--skip-git-repo-check")
.arg("-C")
.arg(env!("CARGO_MANIFEST_DIR"))
.arg("echo testing codex api key")
.assert()
.success();
Ok(())
}
+27 -45
View File
@@ -1,10 +1,9 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::Context;
use assert_cmd::prelude::*;
use core_test_support::test_codex_exec::test_codex_exec;
use serde_json::Value;
use std::process::Command;
use std::path::Path;
use std::string::ToString;
use tempfile::TempDir;
use uuid::Uuid;
use walkdir::WalkDir;
@@ -72,18 +71,15 @@ fn extract_conversation_id(path: &std::path::Path) -> String {
#[test]
fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session with a unique marker in the content.
let marker = format!("resume-last-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -94,7 +90,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.success();
// Find the created session file containing the marker.
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
@@ -102,11 +98,7 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-last-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -114,8 +106,9 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg("--last");
cmd.assert().success();
.arg("--last")
.assert()
.success();
// Ensure the same file was updated and contains both markers.
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
@@ -132,18 +125,15 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
// 1) First run: create a session
let marker = format!("resume-by-id-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -153,7 +143,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let session_id = extract_conversation_id(&path);
@@ -166,11 +156,7 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let mut binding = assert_cmd::Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?;
let cmd = binding
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -178,8 +164,9 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
.arg(env!("CARGO_MANIFEST_DIR"))
.arg(&prompt2)
.arg("resume")
.arg(&session_id);
cmd.assert().success();
.arg(&session_id)
.assert()
.success();
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
.expect("no resumed session file containing marker2");
@@ -195,17 +182,14 @@ fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> {
#[test]
fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
let home = TempDir::new()?;
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/cli_responses_fixture.sse");
let test = test_codex_exec();
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse");
let marker = format!("resume-config-{}", Uuid::new_v4());
let prompt = format!("echo {marker}");
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
test.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
@@ -219,17 +203,15 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
.assert()
.success();
let sessions_dir = home.path().join("sessions");
let sessions_dir = test.home_path().join("sessions");
let path = find_session_file_containing_marker(&sessions_dir, &marker)
.expect("no session file found after first run");
let marker2 = format!("resume-config-2-{}", Uuid::new_v4());
let prompt2 = format!("echo {marker2}");
let output = Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.env("CODEX_HOME", home.path())
.env("OPENAI_API_KEY", "dummy")
let output = test
.cmd()
.env("CODEX_RS_SSE_FIXTURE", &fixture)
.env("OPENAI_BASE_URL", "http://unused.local")
.arg("--skip-git-repo-check")
+1 -1
View File
@@ -10,7 +10,7 @@ workspace = true
base64 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
rand = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls"] }
serde = { workspace = true, features = ["derive"] }
+7 -7
View File
@@ -148,20 +148,20 @@ fn print_colored_warning_device_code() {
/// Full device code login flow.
pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
let client = reqwest::Client::new();
let auth_base_url = opts.issuer.trim_end_matches('/').to_owned();
let base_url = opts.issuer.trim_end_matches('/');
let api_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/'));
print_colored_warning_device_code();
println!("⏳ Generating a new 9-digit device code for authentication...\n");
let uc = request_user_code(&client, &auth_base_url, &opts.client_id).await?;
let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?;
println!(
"To authenticate, visit: {}/deviceauth/authorize and enter code: {}",
opts.issuer.trim_end_matches('/'),
uc.user_code
api_base_url, uc.user_code
);
let code_resp = poll_for_token(
&client,
&auth_base_url,
&api_base_url,
&uc.device_auth_id,
&uc.user_code,
uc.interval,
@@ -173,10 +173,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> {
code_challenge: code_resp.code_challenge,
};
println!("authorization code received");
let redirect_uri = format!("{}/deviceauth/callback", opts.issuer.trim_end_matches('/'));
let redirect_uri = format!("{base_url}/deviceauth/callback");
let tokens = crate::server::exchange_code_for_tokens(
&opts.issuer,
base_url,
&opts.client_id,
&redirect_uri,
&pkce,
+2 -1
View File
@@ -9,10 +9,12 @@ pub use server::ShutdownHandle;
pub use server::run_login_server;
// Re-export commonly used auth types and helpers from codex-core for compatibility
pub use codex_app_server_protocol::AuthMode;
pub use codex_core::AuthManager;
pub use codex_core::CodexAuth;
pub use codex_core::auth::AuthDotJson;
pub use codex_core::auth::CLIENT_ID;
pub use codex_core::auth::CODEX_API_KEY_ENV_VAR;
pub use codex_core::auth::OPENAI_API_KEY_ENV_VAR;
pub use codex_core::auth::get_auth_file;
pub use codex_core::auth::login_with_api_key;
@@ -20,4 +22,3 @@ pub use codex_core::auth::logout;
pub use codex_core::auth::try_read_auth_json;
pub use codex_core::auth::write_auth_json;
pub use codex_core::token_data::TokenData;
pub use codex_protocol::mcp_protocol::AuthMode;
+9
View File
@@ -295,6 +295,15 @@ async fn process_request(
}
}
/// tiny_http filters `Connection` headers out of `Response` objects, so using
/// `req.respond` never informs the client (or the library) that a keep-alive
/// socket should be closed. That leaves the per-connection worker parked in a
/// loop waiting for more requests, which in turn causes the next login attempt
/// to hang on the old connection. This helper bypasses tiny_http’s response
/// machinery: it extracts the raw writer, prints the HTTP response manually,
/// and always appends `Connection: close`, ensuring the socket is closed from
/// the server side. Ideally, tiny_http would provide an API to control
/// server-side connection persistence, but it does not.
fn build_authorize_url(
issuer: &str,
client_id: &str,
@@ -33,7 +33,7 @@ fn make_jwt(payload: serde_json::Value) -> String {
async fn mock_usercode_success(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/deviceauth/usercode"))
.and(path("/api/accounts/deviceauth/usercode"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"device_auth_id": "device-auth-123",
"user_code": "CODE-12345",
@@ -46,7 +46,7 @@ async fn mock_usercode_success(server: &MockServer) {
async fn mock_usercode_failure(server: &MockServer, status: u16) {
Mock::given(method("POST"))
.and(path("/deviceauth/usercode"))
.and(path("/api/accounts/deviceauth/usercode"))
.respond_with(ResponseTemplate::new(status))
.mount(server)
.await;
@@ -59,7 +59,7 @@ async fn mock_poll_token_two_step(
) {
let c = counter.clone();
Mock::given(method("POST"))
.and(path("/deviceauth/token"))
.and(path("/api/accounts/deviceauth/token"))
.respond_with(move |_: &Request| {
let attempt = c.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
@@ -223,7 +223,7 @@ async fn device_code_login_integration_handles_error_payload() {
// // /deviceauth/token → returns error payload with status 401
mock_poll_token_single(
&mock_server,
"/deviceauth/token",
"/api/accounts/deviceauth/token",
ResponseTemplate::new(401).set_body_json(json!({
"error": "authorization_declined",
"error_description": "Denied"
+1
View File
@@ -21,6 +21,7 @@ codex-arg0 = { workspace = true }
codex-common = { workspace = true, features = ["cli"] }
codex-core = { workspace = true }
codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
codex-app-server = { workspace = true }
mcp-types = { workspace = true }
+8 -4
View File
@@ -19,8 +19,9 @@ use crate::session_store::SessionMap;
use agent_client_protocol as acp;
use anyhow::anyhow;
use anyhow::Context as _;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_app_server_protocol::ClientRequest;
use codex_protocol::ConversationId;
use codex_protocol::protocol::SessionSource;
use codex_common::model_presets::{builtin_model_presets, ModelPreset};
use codex_core::AuthManager;
@@ -32,7 +33,7 @@ use codex_core::default_client::get_codex_user_agent_default;
use codex_core::model_family::{derive_default_model_family, find_family_for_model};
use codex_core::protocol::Submission;
use codex_core::protocol::Op;
use codex_protocol::mcp_protocol::AuthMode;
use codex_app_server_protocol::AuthMode;
use mcp_types::CallToolRequestParams;
use mcp_types::CallToolResult;
use mcp_types::ClientRequest as McpClientRequest;
@@ -78,7 +79,10 @@ impl MessageProcessor {
AuthMode::ApiKey,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Mcp,
));
let config_for_processor = config.clone();
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
+1 -1
View File
@@ -75,7 +75,7 @@ impl OutgoingMessageSenderExt for OutgoingMessageSender {
mod tests {
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::ConversationId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::sync::mpsc;
@@ -12,14 +12,14 @@ use tokio::process::ChildStdout;
use anyhow::Context;
use assert_cmd::prelude::*;
use codex_mcp_server::CodexToolCallParam;
use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
use codex_protocol::mcp_protocol::GetAuthStatusParams;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::NewConversationParams;
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
use codex_protocol::mcp_protocol::SendUserMessageParams;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::CancelLoginChatGptParams;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::InterruptConversationParams;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::RemoveConversationListenerParams;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserTurnParams;
use mcp_types::CallToolRequestParams;
use mcp_types::ClientCapabilities;
+22 -12
View File
@@ -4,9 +4,9 @@ name = "codex-otel"
version = { workspace = true }
[lib]
doctest = false
name = "codex_otel"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
@@ -24,17 +24,27 @@ otel = [
otel-shim = []
[dependencies]
codex-protocol = { path = "../protocol" }
chrono = { workspace = true }
tracing = { workspace = true }
opentelemetry = { workspace = true, features = ["logs"], optional = true }
opentelemetry_sdk = { workspace = true, features = ["logs", "rt-tokio"], optional = true }
opentelemetry-otlp = { workspace = true, features = ["grpc-tonic", "http-proto", "http-json", "reqwest", "reqwest-rustls"], optional = true }
opentelemetry-semantic-conventions = { workspace = true }
tonic = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
strum_macros = { workspace = true }
reqwest = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-protocol = { workspace = true }
eventsource-stream = { workspace = true }
tokio = { workspace = true }
opentelemetry = { workspace = true, features = ["logs"], optional = true }
opentelemetry-otlp = { workspace = true, features = [
"grpc-tonic",
"http-proto",
"http-json",
"reqwest",
"reqwest-rustls",
], optional = true }
opentelemetry-semantic-conventions = { workspace = true }
opentelemetry_sdk = { workspace = true, features = [
"logs",
"rt-tokio",
], optional = true }
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
strum_macros = { workspace = true }
tokio = { workspace = true }
tonic = { workspace = true, optional = true }
tracing = { workspace = true }
+2 -2
View File
@@ -1,9 +1,9 @@
use chrono::SecondsFormat;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConversationId;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::InputItem;
+1
View File
@@ -17,6 +17,7 @@ path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-protocol = { workspace = true }
ts-rs = { workspace = true }
mcp-types = { workspace = true }
+32 -32
View File
@@ -17,38 +17,38 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
// Generate TS bindings
mcp_types::InitializeResult::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ConversationId::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationListenerParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserMessageParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserTurnParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ClientNotification::export_all_to(out_dir)?;
codex_app_server_protocol::ConversationId::export_all_to(out_dir)?;
codex_app_server_protocol::InputItem::export_all_to(out_dir)?;
codex_app_server_protocol::ClientRequest::export_all_to(out_dir)?;
codex_app_server_protocol::ServerRequest::export_all_to(out_dir)?;
codex_app_server_protocol::NewConversationParams::export_all_to(out_dir)?;
codex_app_server_protocol::NewConversationResponse::export_all_to(out_dir)?;
codex_app_server_protocol::AddConversationListenerParams::export_all_to(out_dir)?;
codex_app_server_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_app_server_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?;
codex_app_server_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserMessageParams::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserMessageResponse::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserTurnParams::export_all_to(out_dir)?;
codex_app_server_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
codex_app_server_protocol::InterruptConversationParams::export_all_to(out_dir)?;
codex_app_server_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
codex_app_server_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
codex_app_server_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
codex_app_server_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
codex_app_server_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
codex_app_server_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
codex_app_server_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
codex_app_server_protocol::LogoutChatGptParams::export_all_to(out_dir)?;
codex_app_server_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
codex_app_server_protocol::GetAuthStatusParams::export_all_to(out_dir)?;
codex_app_server_protocol::GetAuthStatusResponse::export_all_to(out_dir)?;
codex_app_server_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
codex_app_server_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
codex_app_server_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
codex_app_server_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?;
codex_app_server_protocol::ServerNotification::export_all_to(out_dir)?;
codex_app_server_protocol::ClientNotification::export_all_to(out_dir)?;
generate_index_ts(out_dir)?;
+1 -2
View File
@@ -28,11 +28,10 @@ ts-rs = { workspace = true, features = [
"serde-json-impl",
"no-serde-warnings",
] }
uuid = { workspace = true, features = ["serde", "v7"] }
uuid = { workspace = true, features = ["serde", "v4", "v7"] }
[dev-dependencies]
anyhow = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
[package.metadata.cargo-shear]
+1
View File
@@ -7,3 +7,4 @@ pub mod num_format;
pub mod parse_command;
pub mod plan_tool;
pub mod protocol;
pub use mcp_protocol::ConversationId;
+42 -2
View File
@@ -10,10 +10,10 @@ use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use crate::ConversationId;
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::custom_prompts::CustomPrompt;
use crate::mcp_protocol::ConversationId;
use crate::message_history::HistoryEntry;
use crate::models::ContentItem;
use crate::models::ResponseItem;
@@ -533,6 +533,9 @@ pub enum EventMsg {
ExecCommandEnd(ExecCommandEndEvent),
/// Notification that the agent attached a local image via the view_image tool.
ViewImageToolCall(ViewImageToolCallEvent),
ExecApprovalRequest(ExecApprovalRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
@@ -980,7 +983,20 @@ impl InitialHistory {
}
}
#[derive(Serialize, Deserialize, Clone, Default, Debug, TS)]
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, TS, Default)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
pub enum SessionSource {
Cli,
#[default]
VSCode,
Exec,
Mcp,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Clone, Debug, TS)]
pub struct SessionMeta {
pub id: ConversationId,
pub timestamp: String,
@@ -988,6 +1004,22 @@ pub struct SessionMeta {
pub originator: String,
pub cli_version: String,
pub instructions: Option<String>,
#[serde(default)]
pub source: SessionSource,
}
impl Default for SessionMeta {
fn default() -> Self {
SessionMeta {
id: ConversationId::default(),
timestamp: String::new(),
cwd: PathBuf::new(),
originator: String::new(),
cli_version: String::new(),
instructions: None,
source: SessionSource::default(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
@@ -1157,6 +1189,14 @@ pub struct ExecCommandEndEvent {
pub formatted_output: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
pub struct ViewImageToolCallEvent {
/// Identifier for the originating tool call.
pub call_id: String,
/// Local filesystem path provided to the tool.
pub path: PathBuf,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecOutputStream {
+11 -8
View File
@@ -49,6 +49,7 @@ pub struct Args {
#[derive(Serialize)]
struct ServerInfo {
port: u16,
pid: u32,
}
/// Entry point for the library main, for parity with other crates.
@@ -100,15 +101,17 @@ fn write_server_info(path: &Path, port: u16) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
let parent_display = parent.display();
fs::create_dir_all(parent).with_context(|| format!("create_dir_all {parent_display}"))?;
fs::create_dir_all(parent)?;
}
let info = ServerInfo { port };
let data = serde_json::to_vec(&info).context("serialize startup info")?;
let p = path.display();
let mut f = File::create(path).with_context(|| format!("create {p}"))?;
f.write_all(&data).with_context(|| format!("write {p}"))?;
f.write_all(b"\n").with_context(|| format!("newline {p}"))?;
let info = ServerInfo {
port,
pid: std::process::id(),
};
let mut data = serde_json::to_string(&info)?;
data.push('\n');
let mut f = File::create(path)?;
f.write_all(data.as_bytes())?;
Ok(())
}
+20 -3
View File
@@ -22,7 +22,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
help="Print the version that would be used and exit before making changes.",
)
group = parser.add_mutually_exclusive_group(required=True)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--publish-alpha",
action="store_true",
@@ -33,13 +33,30 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Publish the next stable release by bumping the minor version.",
)
return parser.parse_args(argv[1:])
parser.add_argument(
"--emergency-version-override",
help="Publish a specific version because tag was created for the previous release but it never succeeded. Value should be semver, e.g., `0.43.0-alpha.9`.",
)
args = parser.parse_args(argv[1:])
if not (
args.publish_alpha
or args.publish_release
or args.emergency_version_override
):
parser.error(
"Must specify --publish-alpha, --publish-release, or --emergency-version-override."
)
return args
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
version = determine_version(args)
if args.emergency_version_override:
version = args.emergency_version_override
else:
version = determine_version(args)
print(f"Publishing version {version}")
if args.dry_run:
return 0
+7 -2
View File
@@ -25,6 +25,7 @@ use codex_core::ConversationManager;
use codex_login::{AuthManager, AuthMode, ServerOptions};
use codex_cloud_tasks_client::TaskId;
use codex_cloud_tasks_client::CloudTaskError;
use codex_protocol::protocol::SessionSource;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -208,11 +209,15 @@ impl App<'_> {
startup_footer_notice: Option<String>,
latest_upgrade_version: Option<String>,
) -> Self {
let conversation_manager = Arc::new(ConversationManager::new(AuthManager::shared_with_mode_and_originator(
let auth_manager = AuthManager::shared_with_mode_and_originator(
config.codex_home.clone(),
AuthMode::ApiKey,
config.responses_originator_header.clone(),
)));
);
let conversation_manager = Arc::new(ConversationManager::new(
auth_manager.clone(),
SessionSource::Cli,
));
// Split queues so interactive input never waits behind bulk updates.
let (high_tx, app_event_rx_high) = channel();
+35 -8
View File
@@ -44,6 +44,7 @@ use codex_core::auth_accounts::{self, StoredAccount};
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_protocol::mcp_protocol::AuthMode as McpAuthMode;
use codex_protocol::protocol::SessionSource;
use codex_protocol::num_format::format_with_separators;
@@ -1601,7 +1602,10 @@ impl ChatWidget<'_> {
tokio::spawn(async move {
let mut codex_op_rx = codex_op_rx;
let conversation_manager = ConversationManager::new(auth_manager.clone());
let conversation_manager = ConversationManager::new(
auth_manager.clone(),
SessionSource::Cli,
);
let resume_path = config.experimental_resume.clone();
let new_conversation = match resume_path {
Some(path) => conversation_manager
@@ -18863,15 +18867,38 @@ impl ChatWidget<'_> {
match status.as_str() {
"no_issue" => {
if rationale.trim().is_empty() {
self.auto_resolve_notice("Auto-resolve: agent reported no remaining issues. Done.");
let rationale_text = rationale.trim();
let attempt_limit_reached = self
.auto_resolve_state
.as_ref()
.is_some_and(|state| state.attempt >= state.max_attempts);
if attempt_limit_reached {
if rationale_text.is_empty() {
self.auto_resolve_notice(
"Auto-resolve: agent reported no remaining issues but hit the review attempt limit. Please inspect manually.".to_string(),
);
} else {
self.auto_resolve_notice(format!(
"Auto-resolve: no remaining issues. {rationale_text} Attempt limit reached; handing control back to you."
));
}
self.auto_resolve_clear();
} else {
let rationale_text = rationale.trim();
self.auto_resolve_notice(format!(
"Auto-resolve: no remaining issues. {rationale_text}"
));
if rationale_text.is_empty() {
self.auto_resolve_notice(
"Auto-resolve: agent reported no remaining issues. Running follow-up /review to confirm.".to_string(),
);
} else {
self.auto_resolve_notice(format!(
"Auto-resolve: no remaining issues. {rationale_text} Running follow-up /review to confirm."
));
}
if let Some(state) = self.auto_resolve_state.as_mut() {
state.phase = AutoResolvePhase::WaitingForReview;
}
self.restart_auto_resolve_review();
}
self.auto_resolve_clear();
}
"continue_fix" => {
if let Some(state) = self.auto_resolve_state.as_mut() {
@@ -970,11 +970,11 @@ fn build_prompt_for_model(
fn should_retry_with_default_model(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
if let Some(codex_err) = cause.downcast_ref::<CodexErr>() {
if let CodexErr::UnexpectedStatus(status, body) = codex_err {
if !status.is_client_error() {
if let CodexErr::UnexpectedStatus(err) = codex_err {
if !err.status.is_client_error() {
return false;
}
let body_lower = body.to_lowercase();
let body_lower = err.body.to_lowercase();
return body_lower.contains("invalid model")
|| body_lower.contains("unknown model")
|| body_lower.contains("model_not_found")
@@ -998,8 +998,10 @@ fn classify_model_error(error: &anyhow::Error) -> RetryDecision {
reason: "model request timed out".to_string(),
};
}
CodexErr::UnexpectedStatus(status, body) => {
if *status == StatusCode::REQUEST_TIMEOUT || status.as_u16() == 408 {
CodexErr::UnexpectedStatus(err) => {
let status = err.status;
let body = &err.body;
if status == StatusCode::REQUEST_TIMEOUT || status.as_u16() == 408 {
return RetryDecision::RetryAfterBackoff {
reason: format!("provider returned {status}"),
};
@@ -1009,7 +1011,7 @@ fn classify_model_error(error: &anyhow::Error) -> RetryDecision {
reason: "client closed request (499)".to_string(),
};
}
if *status == StatusCode::TOO_MANY_REQUESTS {
if status == StatusCode::TOO_MANY_REQUESTS {
if let Some(wait_until) = parse_rate_limit_hint(body) {
return RetryDecision::RateLimited {
wait_until,
@@ -1203,7 +1205,7 @@ pub(crate) use classify_model_error as test_classify_model_error;
#[cfg(test)]
mod tests {
use super::*;
use codex_core::error::UsageLimitReachedError;
use codex_core::error::{UnexpectedResponseError, UsageLimitReachedError};
#[test]
fn rate_limit_hint_uses_reset_seconds() {
@@ -1260,7 +1262,11 @@ mod tests {
#[test]
fn classify_fatal_on_bad_request() {
let err = anyhow!(CodexErr::UnexpectedStatus(StatusCode::BAD_REQUEST, "bad".to_string()));
let err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::BAD_REQUEST,
body: "bad".to_string(),
request_id: None,
}));
match classify_model_error(&err) {
RetryDecision::Fatal(_) => {}
other => panic!("expected fatal, got {:?}", other),
+3 -3
View File
@@ -274,11 +274,11 @@ fn build_observer_prompt(trigger: &ObserverTrigger, model_slug: &str) -> Prompt
fn should_retry_with_default_model(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
if let Some(codex_err) = cause.downcast_ref::<CodexErr>() {
if let CodexErr::UnexpectedStatus(status, body) = codex_err {
if !status.is_client_error() {
if let CodexErr::UnexpectedStatus(err) = codex_err {
if !err.status.is_client_error() {
return false;
}
let body_lower = body.to_lowercase();
let body_lower = err.body.to_lowercase();
return body_lower.contains("invalid model")
|| body_lower.contains("unknown model")
|| body_lower.contains("model_not_found")
+14 -7
View File
@@ -1,7 +1,7 @@
#![cfg(feature = "dev-faults")]
use anyhow::anyhow;
use codex_core::error::{CodexErr, UsageLimitReachedError};
use codex_core::error::{CodexErr, UnexpectedResponseError, UsageLimitReachedError};
use once_cell::sync::OnceCell;
use rand::Rng;
use std::collections::HashMap;
@@ -129,19 +129,26 @@ pub fn fault_to_error(fault: InjectedFault) -> anyhow::Error {
resets_in_seconds: Some(secs),
})),
Some(FaultReset::Timestamp(instant)) => {
let reset_at = chrono::Utc::now() + ChronoDuration::from_std(instant.saturating_duration_since(Instant::now())).unwrap_or_else(|_| ChronoDuration::seconds(0));
let reset_at = chrono::Utc::now()
+ ChronoDuration::from_std(instant.saturating_duration_since(Instant::now()))
.unwrap_or_else(|_| ChronoDuration::seconds(0));
let body = json!({
"error": {
"reset_at": reset_at.to_rfc3339(),
}
})
.to_string();
anyhow!(CodexErr::UnexpectedStatus( StatusCode::TOO_MANY_REQUESTS, body ))
anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}))
}
None => anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
json!({ "error": { "message": "fault injector 429" } }).to_string()
)),
None => anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body: json!({ "error": { "message": "fault injector 429" } }).to_string(),
request_id: None,
})),
},
}
}
+16 -14
View File
@@ -2,7 +2,7 @@ use super::auto_coordinator::test_classify_model_error;
use super::retry::{retry_with_backoff, RetryDecision, RetryError, RetryOptions, RetryStatus};
use anyhow::{anyhow, Error};
use chrono::{Duration as ChronoDuration, Utc};
use codex_core::error::{CodexErr, UsageLimitReachedError};
use codex_core::error::{CodexErr, UnexpectedResponseError, UsageLimitReachedError};
use rand::{rngs::StdRng, Rng, SeedableRng};
use reqwest::StatusCode;
use serde_json::json;
@@ -128,10 +128,11 @@ async fn retry_respects_reset_seconds_rate_limit() {
}
})
.to_string();
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
body
));
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}));
let attempts = Arc::new(Mutex::new(VecDeque::from([
Err(rate_limit_err),
@@ -194,10 +195,11 @@ async fn retry_respects_reset_at_rate_limit() {
}
})
.to_string();
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
body
));
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}));
let attempts = Arc::new(Mutex::new(VecDeque::from([
Err(rate_limit_err),
@@ -251,10 +253,11 @@ async fn retry_rate_limit_without_reset_falls_back_to_backoff() {
};
let body = json!({ "error": { "message": "slow down" } }).to_string();
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(
StatusCode::TOO_MANY_REQUESTS,
body
));
let rate_limit_err = anyhow!(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status: StatusCode::TOO_MANY_REQUESTS,
body,
request_id: None,
}));
let attempts = Arc::new(Mutex::new(VecDeque::from([
Err(rate_limit_err),
@@ -346,4 +349,3 @@ async fn retry_cancellation_interrupts_sleep() {
let result = task.await.unwrap();
assert!(matches!(result, Err(RetryError::Aborted)));
}

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