mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Merge branch 'main' into goden/fix_issue_6700
This commit is contained in:
@@ -64,6 +64,9 @@ apply_patch/
|
||||
# coverage
|
||||
coverage/
|
||||
|
||||
# personal files
|
||||
personal/
|
||||
|
||||
# os
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
Generated
+32
-3
@@ -874,7 +874,6 @@ dependencies = [
|
||||
"clap",
|
||||
"codex-protocol",
|
||||
"mcp-types",
|
||||
"paste",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
@@ -1061,6 +1060,8 @@ dependencies = [
|
||||
"clap",
|
||||
"codex-app-server-protocol",
|
||||
"codex-core",
|
||||
"codex-lmstudio",
|
||||
"codex-ollama",
|
||||
"codex-protocol",
|
||||
"once_cell",
|
||||
"serde",
|
||||
@@ -1159,7 +1160,6 @@ dependencies = [
|
||||
"codex-arg0",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-ollama",
|
||||
"codex-protocol",
|
||||
"core_test_support",
|
||||
"libc",
|
||||
@@ -1202,6 +1202,21 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-execpolicy2"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"multimap",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"starlark",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-feedback"
|
||||
version = "0.0.0"
|
||||
@@ -1263,6 +1278,19 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-lmstudio"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-core",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"which",
|
||||
"wiremock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-login"
|
||||
version = "0.0.0"
|
||||
@@ -1455,12 +1483,12 @@ dependencies = [
|
||||
"codex-ansi-escape",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-feedback",
|
||||
"codex-file-search",
|
||||
"codex-login",
|
||||
"codex-ollama",
|
||||
"codex-protocol",
|
||||
"codex-windows-sandbox",
|
||||
"color-eyre",
|
||||
@@ -1483,6 +1511,7 @@ dependencies = [
|
||||
"ratatui",
|
||||
"ratatui-macros",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
|
||||
+3
-1
@@ -17,9 +17,11 @@ members = [
|
||||
"core",
|
||||
"exec",
|
||||
"execpolicy",
|
||||
"execpolicy2",
|
||||
"keyring-store",
|
||||
"file-search",
|
||||
"linux-sandbox",
|
||||
"lmstudio",
|
||||
"login",
|
||||
"mcp-server",
|
||||
"mcp-types",
|
||||
@@ -69,6 +71,7 @@ codex-file-search = { path = "file-search" }
|
||||
codex-git = { path = "utils/git" }
|
||||
codex-keyring-store = { path = "keyring-store" }
|
||||
codex-linux-sandbox = { path = "linux-sandbox" }
|
||||
codex-lmstudio = { path = "lmstudio" }
|
||||
codex-login = { path = "login" }
|
||||
codex-mcp-server = { path = "mcp-server" }
|
||||
codex-ollama = { path = "ollama" }
|
||||
@@ -150,7 +153,6 @@ opentelemetry-semantic-conventions = "0.30.0"
|
||||
opentelemetry_sdk = "0.30.0"
|
||||
os_info = "3.12.0"
|
||||
owo-colors = "4.2.0"
|
||||
paste = "1.0.15"
|
||||
path-absolutize = "3.1.1"
|
||||
pathdiff = "0.2"
|
||||
portable-pty = "0.9.0"
|
||||
|
||||
@@ -15,7 +15,6 @@ anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-protocol = { workspace = true }
|
||||
mcp-types = { workspace = true }
|
||||
paste = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::JSONRPCNotification;
|
||||
use crate::JSONRPCRequest;
|
||||
@@ -9,12 +7,6 @@ use crate::export::GeneratedSchema;
|
||||
use crate::export::write_json_schema;
|
||||
use crate::protocol::v1;
|
||||
use crate::protocol::v2;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxCommandAssessment;
|
||||
use paste::paste;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -277,34 +269,36 @@ macro_rules! server_request_definitions {
|
||||
(
|
||||
$(
|
||||
$(#[$variant_meta:meta])*
|
||||
$variant:ident
|
||||
$variant:ident $(=> $wire:literal)? {
|
||||
params: $params:ty,
|
||||
response: $response:ty,
|
||||
}
|
||||
),* $(,)?
|
||||
) => {
|
||||
paste! {
|
||||
/// Request initiated from the server and sent to the client.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "method", rename_all = "camelCase")]
|
||||
pub enum ServerRequest {
|
||||
$(
|
||||
$(#[$variant_meta])*
|
||||
$variant {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
params: [<$variant Params>],
|
||||
},
|
||||
)*
|
||||
}
|
||||
/// Request initiated from the server and sent to the client.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "method", rename_all = "camelCase")]
|
||||
pub enum ServerRequest {
|
||||
$(
|
||||
$(#[$variant_meta])*
|
||||
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
|
||||
$variant {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
params: $params,
|
||||
},
|
||||
)*
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, JsonSchema)]
|
||||
pub enum ServerRequestPayload {
|
||||
$( $variant([<$variant Params>]), )*
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, JsonSchema)]
|
||||
pub enum ServerRequestPayload {
|
||||
$( $variant($params), )*
|
||||
}
|
||||
|
||||
impl ServerRequestPayload {
|
||||
pub fn request_with_id(self, request_id: RequestId) -> ServerRequest {
|
||||
match self {
|
||||
$(Self::$variant(params) => ServerRequest::$variant { request_id, params },)*
|
||||
}
|
||||
impl ServerRequestPayload {
|
||||
pub fn request_with_id(self, request_id: RequestId) -> ServerRequest {
|
||||
match self {
|
||||
$(Self::$variant(params) => ServerRequest::$variant { request_id, params },)*
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,9 +306,9 @@ macro_rules! server_request_definitions {
|
||||
pub fn export_server_responses(
|
||||
out_dir: &::std::path::Path,
|
||||
) -> ::std::result::Result<(), ::ts_rs::ExportError> {
|
||||
paste! {
|
||||
$(<[<$variant Response>] as ::ts_rs::TS>::export_all_to(out_dir)?;)*
|
||||
}
|
||||
$(
|
||||
<$response as ::ts_rs::TS>::export_all_to(out_dir)?;
|
||||
)*
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -323,9 +317,12 @@ macro_rules! server_request_definitions {
|
||||
out_dir: &Path,
|
||||
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
||||
let mut schemas = Vec::new();
|
||||
paste! {
|
||||
$(schemas.push(crate::export::write_json_schema::<[<$variant Response>]>(out_dir, stringify!([<$variant Response>]))?);)*
|
||||
}
|
||||
$(
|
||||
schemas.push(crate::export::write_json_schema::<$response>(
|
||||
out_dir,
|
||||
concat!(stringify!($variant), "Response"),
|
||||
)?);
|
||||
)*
|
||||
Ok(schemas)
|
||||
}
|
||||
|
||||
@@ -334,9 +331,12 @@ macro_rules! server_request_definitions {
|
||||
out_dir: &Path,
|
||||
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
||||
let mut schemas = Vec::new();
|
||||
paste! {
|
||||
$(schemas.push(crate::export::write_json_schema::<[<$variant Params>]>(out_dir, stringify!([<$variant Params>]))?);)*
|
||||
}
|
||||
$(
|
||||
schemas.push(crate::export::write_json_schema::<$params>(
|
||||
out_dir,
|
||||
concat!(stringify!($variant), "Params"),
|
||||
)?);
|
||||
)*
|
||||
Ok(schemas)
|
||||
}
|
||||
};
|
||||
@@ -426,49 +426,27 @@ impl TryFrom<JSONRPCRequest> for ServerRequest {
|
||||
}
|
||||
|
||||
server_request_definitions! {
|
||||
/// NEW APIs
|
||||
/// Sent when approval is requested for a specific command execution.
|
||||
/// This request is used for Turns started via turn/start.
|
||||
CommandExecutionRequestApproval => "item/commandExecution/requestApproval" {
|
||||
params: v2::CommandExecutionRequestApprovalParams,
|
||||
response: v2::CommandExecutionRequestApprovalResponse,
|
||||
},
|
||||
|
||||
/// DEPRECATED APIs below
|
||||
/// Request to approve a patch.
|
||||
ApplyPatchApproval,
|
||||
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
||||
ApplyPatchApproval {
|
||||
params: v1::ApplyPatchApprovalParams,
|
||||
response: v1::ApplyPatchApprovalResponse,
|
||||
},
|
||||
/// Request to exec a command.
|
||||
ExecCommandApproval,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyPatchApprovalParams {
|
||||
pub conversation_id: ConversationId,
|
||||
/// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent]
|
||||
/// and [codex_core::protocol::PatchApplyEndEvent].
|
||||
pub call_id: String,
|
||||
pub file_changes: HashMap<PathBuf, FileChange>,
|
||||
/// Optional explanatory reason (e.g. request for extra write access).
|
||||
pub reason: Option<String>,
|
||||
/// When set, the agent is asking the user to allow writes under this root
|
||||
/// for the remainder of the session (unclear if this is honored today).
|
||||
pub grant_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecCommandApprovalParams {
|
||||
pub conversation_id: ConversationId,
|
||||
/// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent]
|
||||
/// and [codex_core::protocol::ExecCommandEndEvent].
|
||||
pub call_id: String,
|
||||
pub command: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub reason: Option<String>,
|
||||
pub risk: Option<SandboxCommandAssessment>,
|
||||
pub parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
pub struct ExecCommandApprovalResponse {
|
||||
pub decision: ReviewDecision,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
pub struct ApplyPatchApprovalResponse {
|
||||
pub decision: ReviewDecision,
|
||||
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
||||
ExecCommandApproval {
|
||||
params: v1::ExecCommandApprovalParams,
|
||||
response: v1::ExecCommandApprovalResponse,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -533,10 +511,13 @@ client_notification_definitions! {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn serialize_new_conversation() -> Result<()> {
|
||||
@@ -616,7 +597,7 @@ mod tests {
|
||||
#[test]
|
||||
fn serialize_server_request() -> Result<()> {
|
||||
let conversation_id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
||||
let params = ExecCommandApprovalParams {
|
||||
let params = v1::ExecCommandApprovalParams {
|
||||
conversation_id,
|
||||
call_id: "call-42".to_string(),
|
||||
command: vec!["echo".to_string(), "hello".to_string()],
|
||||
|
||||
@@ -8,8 +8,12 @@ use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::FileChange;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxCommandAssessment;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
@@ -191,6 +195,46 @@ pub struct GitDiffToRemoteResponse {
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyPatchApprovalParams {
|
||||
pub conversation_id: ConversationId,
|
||||
/// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent]
|
||||
/// and [codex_core::protocol::PatchApplyEndEvent].
|
||||
pub call_id: String,
|
||||
pub file_changes: HashMap<PathBuf, FileChange>,
|
||||
/// Optional explanatory reason (e.g. request for extra write access).
|
||||
pub reason: Option<String>,
|
||||
/// When set, the agent is asking the user to allow writes under this root
|
||||
/// for the remainder of the session (unclear if this is honored today).
|
||||
pub grant_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApplyPatchApprovalResponse {
|
||||
pub decision: ReviewDecision,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecCommandApprovalParams {
|
||||
pub conversation_id: ConversationId,
|
||||
/// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent]
|
||||
/// and [codex_core::protocol::ExecCommandEndEvent].
|
||||
pub call_id: String,
|
||||
pub command: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub reason: Option<String>,
|
||||
pub risk: Option<SandboxCommandAssessment>,
|
||||
pub parsed_cmd: Vec<ParsedCommand>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
pub struct ExecCommandApprovalResponse {
|
||||
pub decision: ReviewDecision,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelLoginChatGptParams {
|
||||
|
||||
@@ -4,11 +4,13 @@ use std::path::PathBuf;
|
||||
use crate::protocol::common::AuthMode;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
|
||||
use codex_protocol::items::TurnItem as CoreTurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
use codex_protocol::user_input::UserInput as CoreUserInput;
|
||||
@@ -20,7 +22,7 @@ use serde_json::Value as JsonValue;
|
||||
use ts_rs::TS;
|
||||
|
||||
// Macro to declare a camelCased API v2 enum mirroring a core enum which
|
||||
// tends to use kebab-case.
|
||||
// tends to use either snake_case or kebab-case.
|
||||
macro_rules! v2_enum_from_core {
|
||||
(
|
||||
pub enum $Name:ident from $Src:path { $( $Variant:ident ),+ $(,)? }
|
||||
@@ -56,6 +58,23 @@ v2_enum_from_core!(
|
||||
}
|
||||
);
|
||||
|
||||
v2_enum_from_core!(
|
||||
pub enum CommandRiskLevel from codex_protocol::approvals::SandboxRiskLevel {
|
||||
Low,
|
||||
Medium,
|
||||
High
|
||||
}
|
||||
);
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ApprovalDecision {
|
||||
Accept,
|
||||
Decline,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
@@ -63,6 +82,8 @@ v2_enum_from_core!(
|
||||
pub enum SandboxPolicy {
|
||||
DangerFullAccess,
|
||||
ReadOnly,
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
WorkspaceWrite {
|
||||
#[serde(default)]
|
||||
writable_roots: Vec<PathBuf>,
|
||||
@@ -119,6 +140,98 @@ impl From<codex_protocol::protocol::SandboxPolicy> for SandboxPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SandboxCommandAssessment {
|
||||
pub description: String,
|
||||
pub risk_level: CommandRiskLevel,
|
||||
}
|
||||
|
||||
impl SandboxCommandAssessment {
|
||||
pub fn into_core(self) -> CoreSandboxCommandAssessment {
|
||||
CoreSandboxCommandAssessment {
|
||||
description: self.description,
|
||||
risk_level: self.risk_level.to_core(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreSandboxCommandAssessment> for SandboxCommandAssessment {
|
||||
fn from(value: CoreSandboxCommandAssessment) -> Self {
|
||||
Self {
|
||||
description: value.description,
|
||||
risk_level: CommandRiskLevel::from(value.risk_level),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum CommandAction {
|
||||
Read {
|
||||
command: String,
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
ListFiles {
|
||||
command: String,
|
||||
path: Option<String>,
|
||||
},
|
||||
Search {
|
||||
command: String,
|
||||
query: Option<String>,
|
||||
path: Option<String>,
|
||||
},
|
||||
Unknown {
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommandAction {
|
||||
pub fn into_core(self) -> CoreParsedCommand {
|
||||
match self {
|
||||
CommandAction::Read {
|
||||
command: cmd,
|
||||
name,
|
||||
path,
|
||||
} => CoreParsedCommand::Read { cmd, name, path },
|
||||
CommandAction::ListFiles { command: cmd, path } => {
|
||||
CoreParsedCommand::ListFiles { cmd, path }
|
||||
}
|
||||
CommandAction::Search {
|
||||
command: cmd,
|
||||
query,
|
||||
path,
|
||||
} => CoreParsedCommand::Search { cmd, query, path },
|
||||
CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreParsedCommand> for CommandAction {
|
||||
fn from(value: CoreParsedCommand) -> Self {
|
||||
match value {
|
||||
CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read {
|
||||
command: cmd,
|
||||
name,
|
||||
path,
|
||||
},
|
||||
CoreParsedCommand::ListFiles { cmd, path } => {
|
||||
CommandAction::ListFiles { command: cmd, path }
|
||||
}
|
||||
CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search {
|
||||
command: cmd,
|
||||
query,
|
||||
path,
|
||||
},
|
||||
CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
@@ -279,7 +392,7 @@ pub struct ThreadStartParams {
|
||||
pub cwd: Option<String>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox: Option<SandboxMode>,
|
||||
pub config: Option<HashMap<String, serde_json::Value>>,
|
||||
pub config: Option<HashMap<String, JsonValue>>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
}
|
||||
@@ -506,14 +619,14 @@ impl From<CoreUserInput> for UserInput {
|
||||
#[ts(tag = "type")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ThreadItem {
|
||||
UserMessage {
|
||||
id: String,
|
||||
content: Vec<UserInput>,
|
||||
},
|
||||
AgentMessage {
|
||||
id: String,
|
||||
text: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
UserMessage { id: String, content: Vec<UserInput> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
AgentMessage { id: String, text: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Reasoning {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
@@ -521,19 +634,35 @@ pub enum ThreadItem {
|
||||
#[serde(default)]
|
||||
content: Vec<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
CommandExecution {
|
||||
id: String,
|
||||
/// The command to be executed.
|
||||
command: String,
|
||||
aggregated_output: String,
|
||||
exit_code: Option<i32>,
|
||||
/// The command's working directory.
|
||||
cwd: PathBuf,
|
||||
status: CommandExecutionStatus,
|
||||
/// A best-effort parsing of the command to understand the action(s) it will perform.
|
||||
/// This returns a list of CommandAction objects because a single shell command may
|
||||
/// be composed of many commands piped together.
|
||||
command_actions: Vec<CommandAction>,
|
||||
/// The command's output, aggregated from stdout and stderr.
|
||||
aggregated_output: Option<String>,
|
||||
/// The command's exit code.
|
||||
exit_code: Option<i32>,
|
||||
/// The duration of the command execution in milliseconds.
|
||||
duration_ms: Option<i64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
FileChange {
|
||||
id: String,
|
||||
changes: Vec<FileUpdateChange>,
|
||||
status: PatchApplyStatus,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
McpToolCall {
|
||||
id: String,
|
||||
server: String,
|
||||
@@ -543,22 +672,18 @@ pub enum ThreadItem {
|
||||
result: Option<McpToolCallResult>,
|
||||
error: Option<McpToolCallError>,
|
||||
},
|
||||
WebSearch {
|
||||
id: String,
|
||||
query: String,
|
||||
},
|
||||
TodoList {
|
||||
id: String,
|
||||
items: Vec<TodoItem>,
|
||||
},
|
||||
ImageView {
|
||||
id: String,
|
||||
path: String,
|
||||
},
|
||||
CodeReview {
|
||||
id: String,
|
||||
review: String,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
WebSearch { id: String, query: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
TodoList { id: String, items: Vec<TodoItem> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
ImageView { id: String, path: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
CodeReview { id: String, review: String },
|
||||
}
|
||||
|
||||
impl From<CoreTurnItem> for ThreadItem {
|
||||
@@ -758,6 +883,39 @@ pub struct McpToolCallProgressNotification {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CommandExecutionRequestApprovalParams {
|
||||
pub thread_id: String,
|
||||
pub turn_id: String,
|
||||
pub item_id: String,
|
||||
/// Optional explanatory reason (e.g. request for network access).
|
||||
pub reason: Option<String>,
|
||||
/// Optional model-provided risk assessment describing the blocked command.
|
||||
pub risk: Option<SandboxCommandAssessment>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CommandExecutionRequestAcceptSettings {
|
||||
/// If true, automatically approve this command for the duration of the session.
|
||||
#[serde(default)]
|
||||
pub for_session: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct CommandExecutionRequestApprovalResponse {
|
||||
pub decision: ApprovalDecision,
|
||||
/// Optional approval settings for when the decision is `accept`.
|
||||
/// Ignored if the decision is `decline` or `cancel`.
|
||||
#[serde(default)]
|
||||
pub accept_settings: Option<CommandExecutionRequestAcceptSettings>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable.
|
||||
|
||||
## Table of Contents
|
||||
- [Protocol](#protocol)
|
||||
- [Message Schema](#message-schema)
|
||||
- [Lifecycle Overview](#lifecycle-overview)
|
||||
- [Initialization](#initialization)
|
||||
- [Core primitives](#core-primitives)
|
||||
- [Thread & turn endpoints](#thread--turn-endpoints)
|
||||
- [Auth endpoints](#auth-endpoints)
|
||||
- [Events (work-in-progress)](#v2-streaming-events-work-in-progress)
|
||||
|
||||
## Protocol
|
||||
|
||||
Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports bidirectional communication, streaming JSONL over stdio. The protocol is JSON-RPC 2.0, though the `"jsonrpc":"2.0"` header is omitted.
|
||||
@@ -15,6 +25,14 @@ codex app-server generate-ts --out DIR
|
||||
codex app-server generate-json-schema --out DIR
|
||||
```
|
||||
|
||||
## Lifecycle Overview
|
||||
|
||||
- Initialize once: Immediately after launching the codex app-server process, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request before this handshake gets rejected.
|
||||
- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead.
|
||||
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object and triggers a `turn/started` notification.
|
||||
- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes).
|
||||
- Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage.
|
||||
|
||||
## Initialization
|
||||
|
||||
Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error.
|
||||
@@ -258,3 +276,33 @@ Field notes:
|
||||
- `codex app-server generate-ts --out <dir>` emits v2 types under `v2/`.
|
||||
- `codex app-server generate-json-schema --out <dir>` outputs `codex_app_server_protocol.schemas.json`.
|
||||
- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs.
|
||||
|
||||
|
||||
## Events (work-in-progress)
|
||||
|
||||
Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `turn/*`, and `item/*` notifications.
|
||||
|
||||
### Turn events
|
||||
|
||||
The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` plus token `usage`), and clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
|
||||
|
||||
#### Thread items
|
||||
|
||||
`ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items:
|
||||
- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`).
|
||||
- `agentMessage` — `{id, text}` containing the accumulated agent reply.
|
||||
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
|
||||
- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
|
||||
- `webSearch` — `{id, query}` for a web search request issued by the agent.
|
||||
|
||||
All items emit two shared lifecycle events:
|
||||
- `item/started` — emits the full `item` when a new unit of work begins so the UI can render it immediately; the `item.id` in this payload matches the `itemId` used by deltas.
|
||||
- `item/completed` — sends the final `item` once that work finishes (e.g., after a tool call or message completes); treat this as the authoritative state.
|
||||
|
||||
There are additional item-specific events:
|
||||
#### agentMessage
|
||||
- `item/agentMessage/delta` — appends streamed text for the agent message; concatenate `delta` values for the same `itemId` in order to reconstruct the full reply.
|
||||
#### reasoning
|
||||
- `item/reasoning/summaryTextDelta` — streams readable reasoning summaries; `summaryIndex` increments when a new summary section opens.
|
||||
- `item/reasoning/summaryPartAdded` — marks the boundary between reasoning summary sections for an `itemId`; subsequent `summaryTextDelta` entries share the same `summaryIndex`.
|
||||
- `item/reasoning/textDelta` — streams raw reasoning text (only applicable for e.g. open source models); use `contentIndex` to group deltas that belong together before showing them in the UI.
|
||||
|
||||
@@ -5,6 +5,12 @@ use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
|
||||
use codex_app_server_protocol::AgentMessageDeltaNotification;
|
||||
use codex_app_server_protocol::ApplyPatchApprovalParams;
|
||||
use codex_app_server_protocol::ApplyPatchApprovalResponse;
|
||||
use codex_app_server_protocol::ApprovalDecision;
|
||||
use codex_app_server_protocol::CommandAction as V2ParsedCommand;
|
||||
use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalParams;
|
||||
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::ExecCommandApprovalParams;
|
||||
use codex_app_server_protocol::ExecCommandApprovalResponse;
|
||||
use codex_app_server_protocol::InterruptConversationResponse;
|
||||
@@ -16,25 +22,29 @@ use codex_app_server_protocol::McpToolCallStatus;
|
||||
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
|
||||
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
|
||||
use codex_app_server_protocol::ReasoningTextDeltaNotification;
|
||||
use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestPayload;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::TurnInterruptResponse;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::parse_command::shlex_join;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_protocol::ConversationId;
|
||||
use std::convert::TryFrom;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::error;
|
||||
|
||||
type JsonRpcResult = serde_json::Value;
|
||||
type JsonValue = serde_json::Value;
|
||||
|
||||
pub(crate) async fn apply_bespoke_event_handling(
|
||||
event: Event,
|
||||
@@ -42,6 +52,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
conversation: Arc<CodexConversation>,
|
||||
outgoing: Arc<OutgoingMessageSender>,
|
||||
pending_interrupts: PendingInterrupts,
|
||||
api_version: ApiVersion,
|
||||
) {
|
||||
let Event { id: event_id, msg } = event;
|
||||
match msg {
|
||||
@@ -61,11 +72,57 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
let rx = outgoing
|
||||
.send_request(ServerRequestPayload::ApplyPatchApproval(params))
|
||||
.await;
|
||||
// TODO(mbolin): Enforce a timeout so this task does not live indefinitely?
|
||||
tokio::spawn(async move {
|
||||
on_patch_approval_response(event_id, rx, conversation).await;
|
||||
});
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
risk,
|
||||
parsed_cmd,
|
||||
}) => match api_version {
|
||||
ApiVersion::V1 => {
|
||||
let params = ExecCommandApprovalParams {
|
||||
conversation_id,
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
risk,
|
||||
parsed_cmd,
|
||||
};
|
||||
let rx = outgoing
|
||||
.send_request(ServerRequestPayload::ExecCommandApproval(params))
|
||||
.await;
|
||||
tokio::spawn(async move {
|
||||
on_exec_approval_response(event_id, rx, conversation).await;
|
||||
});
|
||||
}
|
||||
ApiVersion::V2 => {
|
||||
let params = CommandExecutionRequestApprovalParams {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
// Until we migrate the core to be aware of a first class CommandExecutionItem
|
||||
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
|
||||
item_id: call_id.clone(),
|
||||
reason,
|
||||
risk: risk.map(V2SandboxCommandAssessment::from),
|
||||
};
|
||||
let rx = outgoing
|
||||
.send_request(ServerRequestPayload::CommandExecutionRequestApproval(
|
||||
params,
|
||||
))
|
||||
.await;
|
||||
tokio::spawn(async move {
|
||||
on_command_execution_request_approval_response(event_id, rx, conversation)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
},
|
||||
// TODO(celia): properly construct McpToolCall TurnItem in core.
|
||||
EventMsg::McpToolCallBegin(begin_event) => {
|
||||
let notification = construct_mcp_tool_call_notification(begin_event).await;
|
||||
@@ -121,32 +178,6 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
risk,
|
||||
parsed_cmd,
|
||||
}) => {
|
||||
let params = ExecCommandApprovalParams {
|
||||
conversation_id,
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
risk,
|
||||
parsed_cmd,
|
||||
};
|
||||
let rx = outgoing
|
||||
.send_request(ServerRequestPayload::ExecCommandApproval(params))
|
||||
.await;
|
||||
|
||||
// TODO(mbolin): Enforce a timeout so this task does not live indefinitely?
|
||||
tokio::spawn(async move {
|
||||
on_exec_approval_response(event_id, rx, conversation).await;
|
||||
});
|
||||
}
|
||||
EventMsg::TokenCount(token_count_event) => {
|
||||
if let Some(rate_limits) = token_count_event.rate_limits {
|
||||
outgoing
|
||||
@@ -172,6 +203,79 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
|
||||
let item = ThreadItem::CommandExecution {
|
||||
id: exec_command_begin_event.call_id.clone(),
|
||||
command: shlex_join(&exec_command_begin_event.command),
|
||||
cwd: exec_command_begin_event.cwd,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
command_actions: exec_command_begin_event
|
||||
.parsed_cmd
|
||||
.into_iter()
|
||||
.map(V2ParsedCommand::from)
|
||||
.collect(),
|
||||
aggregated_output: None,
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
};
|
||||
let notification = ItemStartedNotification { item };
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
|
||||
let notification = CommandExecutionOutputDeltaNotification {
|
||||
item_id: exec_command_output_delta_event.call_id.clone(),
|
||||
delta: String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string(),
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::CommandExecutionOutputDelta(
|
||||
notification,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ExecCommandEnd(exec_command_end_event) => {
|
||||
let ExecCommandEndEvent {
|
||||
call_id,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
duration,
|
||||
..
|
||||
} = exec_command_end_event;
|
||||
|
||||
let status = if exit_code == 0 {
|
||||
CommandExecutionStatus::Completed
|
||||
} else {
|
||||
CommandExecutionStatus::Failed
|
||||
};
|
||||
|
||||
let aggregated_output = if aggregated_output.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(aggregated_output)
|
||||
};
|
||||
|
||||
let duration_ms = i64::try_from(duration.as_millis()).unwrap_or(i64::MAX);
|
||||
|
||||
let item = ThreadItem::CommandExecution {
|
||||
id: call_id,
|
||||
command: shlex_join(&command),
|
||||
cwd,
|
||||
status,
|
||||
command_actions: parsed_cmd.into_iter().map(V2ParsedCommand::from).collect(),
|
||||
aggregated_output,
|
||||
exit_code: Some(exit_code),
|
||||
duration_ms: Some(duration_ms),
|
||||
};
|
||||
|
||||
let notification = ItemCompletedNotification { item };
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
// If this is a TurnAborted, reply to any pending interrupt requests.
|
||||
EventMsg::TurnAborted(turn_aborted_event) => {
|
||||
let pending = {
|
||||
@@ -202,7 +306,7 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
|
||||
async fn on_patch_approval_response(
|
||||
event_id: String,
|
||||
receiver: oneshot::Receiver<JsonRpcResult>,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
codex: Arc<CodexConversation>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
@@ -244,7 +348,7 @@ async fn on_patch_approval_response(
|
||||
|
||||
async fn on_exec_approval_response(
|
||||
event_id: String,
|
||||
receiver: oneshot::Receiver<JsonRpcResult>,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
conversation: Arc<CodexConversation>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
@@ -278,6 +382,53 @@ async fn on_exec_approval_response(
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_command_execution_request_approval_response(
|
||||
event_id: String,
|
||||
receiver: oneshot::Receiver<JsonValue>,
|
||||
conversation: Arc<CodexConversation>,
|
||||
) {
|
||||
let response = receiver.await;
|
||||
let value = match response {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
error!("request failed: {err:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let response = serde_json::from_value::<CommandExecutionRequestApprovalResponse>(value)
|
||||
.unwrap_or_else(|err| {
|
||||
error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}");
|
||||
CommandExecutionRequestApprovalResponse {
|
||||
decision: ApprovalDecision::Decline,
|
||||
accept_settings: None,
|
||||
}
|
||||
});
|
||||
|
||||
let CommandExecutionRequestApprovalResponse {
|
||||
decision,
|
||||
accept_settings,
|
||||
} = response;
|
||||
|
||||
let decision = match (decision, accept_settings) {
|
||||
(ApprovalDecision::Accept, Some(settings)) if settings.for_session => {
|
||||
ReviewDecision::ApprovedForSession
|
||||
}
|
||||
(ApprovalDecision::Accept, _) => ReviewDecision::Approved,
|
||||
(ApprovalDecision::Decline, _) => ReviewDecision::Denied,
|
||||
(ApprovalDecision::Cancel, _) => ReviewDecision::Abort,
|
||||
};
|
||||
if let Err(err) = conversation
|
||||
.submit(Op::ExecApproval {
|
||||
id: event_id,
|
||||
decision,
|
||||
})
|
||||
.await
|
||||
{
|
||||
error!("failed to submit ExecApproval: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// similar to handle_mcp_tool_call_begin in exec
|
||||
async fn construct_mcp_tool_call_notification(
|
||||
begin_event: McpToolCallBeginEvent,
|
||||
@@ -287,10 +438,7 @@ async fn construct_mcp_tool_call_notification(
|
||||
server: begin_event.invocation.server,
|
||||
tool: begin_event.invocation.tool,
|
||||
status: McpToolCallStatus::InProgress,
|
||||
arguments: begin_event
|
||||
.invocation
|
||||
.arguments
|
||||
.unwrap_or(JsonRpcResult::Null),
|
||||
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
|
||||
result: None,
|
||||
error: None,
|
||||
};
|
||||
@@ -328,10 +476,7 @@ async fn construct_mcp_tool_call_end_notification(
|
||||
server: end_event.invocation.server,
|
||||
tool: end_event.invocation.tool,
|
||||
status,
|
||||
arguments: end_event
|
||||
.invocation
|
||||
.arguments
|
||||
.unwrap_or(JsonRpcResult::Null),
|
||||
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
|
||||
result,
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -1245,7 +1245,7 @@ impl CodexMessageProcessor {
|
||||
// Auto-attach a conversation listener when starting a thread.
|
||||
// Use the same behavior as the v1 API with experimental_raw_events=false.
|
||||
if let Err(err) = self
|
||||
.attach_conversation_listener(conversation_id, false)
|
||||
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
@@ -1523,7 +1523,7 @@ impl CodexMessageProcessor {
|
||||
}) => {
|
||||
// Auto-attach a conversation listener when resuming a thread.
|
||||
if let Err(err) = self
|
||||
.attach_conversation_listener(conversation_id, false)
|
||||
.attach_conversation_listener(conversation_id, false, ApiVersion::V2)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
@@ -2376,7 +2376,7 @@ impl CodexMessageProcessor {
|
||||
experimental_raw_events,
|
||||
} = params;
|
||||
match self
|
||||
.attach_conversation_listener(conversation_id, experimental_raw_events)
|
||||
.attach_conversation_listener(conversation_id, experimental_raw_events, ApiVersion::V1)
|
||||
.await
|
||||
{
|
||||
Ok(subscription_id) => {
|
||||
@@ -2417,6 +2417,7 @@ impl CodexMessageProcessor {
|
||||
&mut self,
|
||||
conversation_id: ConversationId,
|
||||
experimental_raw_events: bool,
|
||||
api_version: ApiVersion,
|
||||
) -> Result<Uuid, JSONRPCErrorError> {
|
||||
let conversation = match self
|
||||
.conversation_manager
|
||||
@@ -2440,6 +2441,7 @@ impl CodexMessageProcessor {
|
||||
|
||||
let outgoing_for_task = self.outgoing.clone();
|
||||
let pending_interrupts = self.pending_interrupts.clone();
|
||||
let api_version_for_task = api_version;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -2495,6 +2497,7 @@ impl CodexMessageProcessor {
|
||||
conversation.clone(),
|
||||
outgoing_for_task.clone(),
|
||||
pending_interrupts.clone(),
|
||||
api_version_for_task,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ use app_test_support::create_mock_chat_completions_server;
|
||||
use app_test_support::create_mock_chat_completions_server_unchecked;
|
||||
use app_test_support::create_shell_sse_response;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
@@ -17,9 +20,6 @@ use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_core::protocol_config_types::ReasoningEffort;
|
||||
use codex_core::protocol_config_types::ReasoningSummary;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
@@ -235,7 +235,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
|
||||
.await??;
|
||||
let ThreadStartResponse { thread } = to_response::<ThreadStartResponse>(start_resp)?;
|
||||
|
||||
// turn/start — expect ExecCommandApproval request from server
|
||||
// turn/start — expect CommandExecutionRequestApproval request from server
|
||||
let first_turn_id = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
@@ -258,16 +258,10 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let ServerRequest::ExecCommandApproval { request_id, params } = server_req else {
|
||||
panic!("expected ExecCommandApproval request");
|
||||
let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else {
|
||||
panic!("expected CommandExecutionRequestApproval request");
|
||||
};
|
||||
assert_eq!(params.call_id, "call1");
|
||||
assert_eq!(
|
||||
params.parsed_cmd,
|
||||
vec![ParsedCommand::Unknown {
|
||||
cmd: "python3 -c 'print(42)'".to_string()
|
||||
}]
|
||||
);
|
||||
assert_eq!(params.item_id, "call1");
|
||||
|
||||
// Approve and wait for task completion
|
||||
mcp.send_response(
|
||||
@@ -302,7 +296,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
|
||||
)
|
||||
.await??;
|
||||
|
||||
// Ensure we do NOT receive an ExecCommandApproval request before task completes
|
||||
// Ensure we do NOT receive a CommandExecutionRequestApproval request before task completes
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("codex/event/task_complete"),
|
||||
@@ -314,8 +308,6 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
// When returning Result from a test, pass an Ok(()) to the skip macro
|
||||
// so the early return type matches. The no-arg form returns unit.
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let tmp = TempDir::new()?;
|
||||
@@ -424,29 +416,35 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
|
||||
)
|
||||
.await??;
|
||||
|
||||
let exec_begin_notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("codex/event/exec_command_begin"),
|
||||
)
|
||||
let command_exec_item = timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let item_started_notification = mcp
|
||||
.read_stream_until_notification_message("item/started")
|
||||
.await?;
|
||||
let params = item_started_notification
|
||||
.params
|
||||
.clone()
|
||||
.expect("item/started params");
|
||||
let item_started: ItemStartedNotification =
|
||||
serde_json::from_value(params).expect("deserialize item/started notification");
|
||||
if matches!(item_started.item, ThreadItem::CommandExecution { .. }) {
|
||||
return Ok::<ThreadItem, anyhow::Error>(item_started.item);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
let params = exec_begin_notification
|
||||
.params
|
||||
.clone()
|
||||
.expect("exec_command_begin params");
|
||||
let event: Event = serde_json::from_value(params).expect("deserialize exec begin event");
|
||||
let exec_begin = match event.msg {
|
||||
EventMsg::ExecCommandBegin(exec_begin) => exec_begin,
|
||||
other => panic!("expected ExecCommandBegin event, got {other:?}"),
|
||||
let ThreadItem::CommandExecution {
|
||||
cwd,
|
||||
command,
|
||||
status,
|
||||
..
|
||||
} = command_exec_item
|
||||
else {
|
||||
unreachable!("loop ensures we break on command execution items");
|
||||
};
|
||||
assert_eq!(exec_begin.cwd, second_cwd);
|
||||
assert_eq!(
|
||||
exec_begin.command,
|
||||
vec![
|
||||
"bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
"echo second turn".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(cwd, second_cwd);
|
||||
assert_eq!(command, "bash -lc 'echo second turn'");
|
||||
assert_eq!(status, CommandExecutionStatus::InProgress);
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
|
||||
@@ -155,11 +155,11 @@ async fn run_command_under_sandbox(
|
||||
run_windows_sandbox_capture(
|
||||
policy_str,
|
||||
&sandbox_cwd,
|
||||
base_dir.as_path(),
|
||||
command_vec,
|
||||
&cwd_clone,
|
||||
env_map,
|
||||
None,
|
||||
Some(base_dir.as_path()),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -10,6 +10,8 @@ workspace = true
|
||||
clap = { workspace = true, features = ["derive", "wrap_help"], optional = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-lmstudio = { workspace = true }
|
||||
codex-ollama = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
@@ -37,3 +37,5 @@ pub mod model_presets;
|
||||
// Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server
|
||||
// Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy.
|
||||
pub mod approval_presets;
|
||||
// Shared OSS provider utilities used by TUI and exec
|
||||
pub mod oss;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! OSS provider utilities shared between TUI and exec.
|
||||
|
||||
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_core::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_core::config::Config;
|
||||
|
||||
/// Returns the default model for a given OSS provider.
|
||||
pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static str> {
|
||||
match provider_id {
|
||||
LMSTUDIO_OSS_PROVIDER_ID => Some(codex_lmstudio::DEFAULT_OSS_MODEL),
|
||||
OLLAMA_OSS_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures the specified OSS provider is ready (models downloaded, service reachable).
|
||||
pub async fn ensure_oss_provider_ready(
|
||||
provider_id: &str,
|
||||
config: &Config,
|
||||
) -> Result<(), std::io::Error> {
|
||||
match provider_id {
|
||||
LMSTUDIO_OSS_PROVIDER_ID => {
|
||||
codex_lmstudio::ensure_oss_ready(config)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
|
||||
}
|
||||
OLLAMA_OSS_PROVIDER_ID => {
|
||||
codex_ollama::ensure_oss_ready(config)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
|
||||
}
|
||||
_ => {
|
||||
// Unknown provider, skip setup
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_default_model_for_provider_lmstudio() {
|
||||
let result = get_default_model_for_oss_provider(LMSTUDIO_OSS_PROVIDER_ID);
|
||||
assert_eq!(result, Some(codex_lmstudio::DEFAULT_OSS_MODEL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_model_for_provider_ollama() {
|
||||
let result = get_default_model_for_oss_provider(OLLAMA_OSS_PROVIDER_ID);
|
||||
assert_eq!(result, Some(codex_ollama::DEFAULT_OSS_MODEL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_model_for_provider_unknown() {
|
||||
let result = get_default_model_for_oss_provider("unknown-provider");
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
}
|
||||
@@ -318,8 +318,6 @@ For casual greetings, acknowledgements, or other one-off conversational messages
|
||||
|
||||
When using the shell, you must adhere to the following guidelines:
|
||||
|
||||
- The arguments to `shell` will be passed to execvp().
|
||||
- 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.)
|
||||
- Read files in chunks with a max chunk size of 250 lines. Do not use python scripts to attempt to output larger chunks of a file. Command line output will be truncated after 10 kilobytes or 256 lines of output, regardless of the command used.
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
|
||||
|
||||
## General
|
||||
|
||||
- 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.)
|
||||
|
||||
## Editing constraints
|
||||
|
||||
+84
-207
@@ -9,8 +9,6 @@ use crate::client_common::REVIEW_PROMPT;
|
||||
use crate::compact;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::auth::McpAuthStatusEntry;
|
||||
use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::parse_turn_item;
|
||||
use crate::response_processing::process_items;
|
||||
@@ -45,6 +43,7 @@ use mcp_types::ReadResourceResult;
|
||||
use serde_json;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::debug;
|
||||
@@ -57,7 +56,6 @@ use crate::client::ModelClient;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
@@ -476,21 +474,13 @@ impl Session {
|
||||
),
|
||||
};
|
||||
|
||||
// Error messages to dispatch after SessionConfigured is sent.
|
||||
let mut post_session_configured_events = Vec::<Event>::new();
|
||||
|
||||
// Kick off independent async setup tasks in parallel to reduce startup latency.
|
||||
//
|
||||
// - initialize RolloutRecorder with new or resumed session info
|
||||
// - spin up MCP connection manager
|
||||
// - perform default shell discovery
|
||||
// - load history metadata
|
||||
let rollout_fut = RolloutRecorder::new(&config, rollout_params);
|
||||
|
||||
let mcp_fut = McpConnectionManager::new(
|
||||
config.mcp_servers.clone(),
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
);
|
||||
let default_shell_fut = shell::default_user_shell();
|
||||
let history_meta_fut = crate::message_history::history_metadata(&config);
|
||||
let auth_statuses_fut = compute_auth_statuses(
|
||||
@@ -499,15 +489,8 @@ impl Session {
|
||||
);
|
||||
|
||||
// Join all independent futures.
|
||||
let (
|
||||
rollout_recorder,
|
||||
mcp_res,
|
||||
default_shell,
|
||||
(history_log_id, history_entry_count),
|
||||
auth_statuses,
|
||||
) = tokio::join!(
|
||||
let (rollout_recorder, default_shell, (history_log_id, history_entry_count), auth_statuses) = tokio::join!(
|
||||
rollout_fut,
|
||||
mcp_fut,
|
||||
default_shell_fut,
|
||||
history_meta_fut,
|
||||
auth_statuses_fut
|
||||
@@ -519,34 +502,7 @@ impl Session {
|
||||
})?;
|
||||
let rollout_path = rollout_recorder.rollout_path.clone();
|
||||
|
||||
// Handle MCP manager result and record any startup failures.
|
||||
let (mcp_connection_manager, failed_clients) = match mcp_res {
|
||||
Ok((mgr, failures)) => (mgr, failures),
|
||||
Err(e) => {
|
||||
let message = format!("Failed to create MCP connection manager: {e:#}");
|
||||
error!("{message}");
|
||||
post_session_configured_events.push(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::Error(ErrorEvent { message }),
|
||||
});
|
||||
(McpConnectionManager::default(), Default::default())
|
||||
}
|
||||
};
|
||||
|
||||
// Surface individual client start-up failures to the user.
|
||||
if !failed_clients.is_empty() {
|
||||
for (server_name, err) in failed_clients {
|
||||
let auth_entry = auth_statuses.get(&server_name);
|
||||
let display_message = mcp_init_error_display(&server_name, auth_entry, &err);
|
||||
warn!("MCP client for `{server_name}` failed to start: {err:#}");
|
||||
post_session_configured_events.push(Event {
|
||||
id: INITIAL_SUBMIT_ID.to_owned(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message: display_message,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut post_session_configured_events = Vec::<Event>::new();
|
||||
|
||||
for (alias, feature) in session_configuration.features.legacy_feature_usages() {
|
||||
let canonical = feature.key();
|
||||
@@ -595,7 +551,8 @@ impl Session {
|
||||
warm_model_cache(&session_configuration.model);
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager,
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
unified_exec_manager: UnifiedExecSessionManager::default(),
|
||||
notifier: UserNotifier::new(config.notify.clone()),
|
||||
rollout: Mutex::new(Some(rollout_recorder)),
|
||||
@@ -635,6 +592,18 @@ impl Session {
|
||||
for event in events {
|
||||
sess.send_event_raw(event).await;
|
||||
}
|
||||
sess.services
|
||||
.mcp_connection_manager
|
||||
.write()
|
||||
.await
|
||||
.initialize(
|
||||
config.mcp_servers.clone(),
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
auth_statuses.clone(),
|
||||
tx_event.clone(),
|
||||
sess.services.mcp_startup_cancellation_token.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
|
||||
sess.record_initial_history(initial_history).await;
|
||||
@@ -894,6 +863,7 @@ impl Session {
|
||||
let parsed_cmd = parse_command(&command);
|
||||
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command,
|
||||
cwd,
|
||||
reason,
|
||||
@@ -1258,6 +1228,8 @@ impl Session {
|
||||
) -> anyhow::Result<ListResourcesResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_resources(server, params)
|
||||
.await
|
||||
}
|
||||
@@ -1269,6 +1241,8 @@ impl Session {
|
||||
) -> anyhow::Result<ListResourceTemplatesResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_resource_templates(server, params)
|
||||
.await
|
||||
}
|
||||
@@ -1280,6 +1254,8 @@ impl Session {
|
||||
) -> anyhow::Result<ReadResourceResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.read_resource(server, params)
|
||||
.await
|
||||
}
|
||||
@@ -1292,19 +1268,29 @@ impl Session {
|
||||
) -> anyhow::Result<CallToolResult> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.call_tool(server, tool, arguments)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn parse_mcp_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
|
||||
pub(crate) async fn parse_mcp_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.parse_tool_name(tool_name)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn interrupt_task(self: &Arc<Self>) {
|
||||
info!("interrupt received: abort current task, if any");
|
||||
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
let has_active_turn = { self.active_turn.lock().await.is_some() };
|
||||
if has_active_turn {
|
||||
self.abort_all_tasks(TurnAbortReason::Interrupted).await;
|
||||
} else {
|
||||
self.cancel_mcp_startup().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn notifier(&self) -> &UserNotifier {
|
||||
@@ -1318,6 +1304,10 @@ impl Session {
|
||||
fn show_raw_agent_reasoning(&self) -> bool {
|
||||
self.services.show_raw_agent_reasoning
|
||||
}
|
||||
|
||||
async fn cancel_mcp_startup(&self) {
|
||||
self.services.mcp_startup_cancellation_token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiver<Submission>) {
|
||||
@@ -1575,17 +1565,15 @@ mod handlers {
|
||||
}
|
||||
|
||||
pub async fn list_mcp_tools(sess: &Session, config: &Arc<Config>, sub_id: String) {
|
||||
// This is a cheap lookup from the connection manager's cache.
|
||||
let tools = sess.services.mcp_connection_manager.list_all_tools();
|
||||
let (auth_status_entries, resources, resource_templates) = tokio::join!(
|
||||
let mcp_connection_manager = sess.services.mcp_connection_manager.read().await;
|
||||
let (tools, auth_status_entries, resources, resource_templates) = tokio::join!(
|
||||
mcp_connection_manager.list_all_tools(),
|
||||
compute_auth_statuses(
|
||||
config.mcp_servers.iter(),
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
),
|
||||
sess.services.mcp_connection_manager.list_all_resources(),
|
||||
sess.services
|
||||
.mcp_connection_manager
|
||||
.list_all_resource_templates()
|
||||
mcp_connection_manager.list_all_resources(),
|
||||
mcp_connection_manager.list_all_resource_templates(),
|
||||
);
|
||||
let auth_statuses = auth_status_entries
|
||||
.iter()
|
||||
@@ -1594,7 +1582,10 @@ mod handlers {
|
||||
let event = Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::McpListToolsResponse(crate::protocol::McpListToolsResponseEvent {
|
||||
tools,
|
||||
tools: tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
resources,
|
||||
resource_templates,
|
||||
auth_statuses,
|
||||
@@ -1924,10 +1915,22 @@ async fn run_turn(
|
||||
input: Vec<ResponseItem>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> CodexResult<TurnRunResult> {
|
||||
let mcp_tools = sess.services.mcp_connection_manager.list_all_tools();
|
||||
let mcp_tools = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.or_cancel(&cancellation_token)
|
||||
.await?;
|
||||
let router = Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
Some(mcp_tools),
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
));
|
||||
|
||||
let model_supports_parallel = turn_context
|
||||
@@ -2096,7 +2099,7 @@ async fn try_run_turn(
|
||||
ResponseEvent::Created => {}
|
||||
ResponseEvent::OutputItemDone(item) => {
|
||||
let previously_active_item = active_item.take();
|
||||
match ToolRouter::build_tool_call(sess.as_ref(), item.clone()) {
|
||||
match ToolRouter::build_tool_call(sess.as_ref(), item.clone()).await {
|
||||
Ok(Some(call)) => {
|
||||
let payload_preview = call.payload.log_payload().into_owned();
|
||||
tracing::info!("ToolCall: {} {}", call.tool_name, payload_preview);
|
||||
@@ -2307,59 +2310,6 @@ pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -
|
||||
})
|
||||
}
|
||||
|
||||
fn mcp_init_error_display(
|
||||
server_name: &str,
|
||||
entry: Option<&McpAuthStatusEntry>,
|
||||
err: &anyhow::Error,
|
||||
) -> String {
|
||||
if let Some(McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
bearer_token_env_var,
|
||||
http_headers,
|
||||
..
|
||||
}) = &entry.map(|entry| &entry.config.transport)
|
||||
&& url == "https://api.githubcopilot.com/mcp/"
|
||||
&& bearer_token_env_var.is_none()
|
||||
&& http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true)
|
||||
{
|
||||
// GitHub only supports OAUth for first party MCP clients.
|
||||
// That means that the user has to specify a personal access token either via bearer_token_env_var or http_headers.
|
||||
// https://github.com/github/github-mcp-server/issues/921#issuecomment-3221026448
|
||||
format!(
|
||||
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
)
|
||||
} else if is_mcp_client_auth_required_error(err) {
|
||||
format!(
|
||||
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
|
||||
)
|
||||
} else if is_mcp_client_startup_timeout_error(err) {
|
||||
let startup_timeout_secs = match entry {
|
||||
Some(entry) => match entry.config.startup_timeout_sec {
|
||||
Some(timeout) => timeout,
|
||||
None => DEFAULT_STARTUP_TIMEOUT,
|
||||
},
|
||||
None => DEFAULT_STARTUP_TIMEOUT,
|
||||
}
|
||||
.as_secs();
|
||||
format!(
|
||||
"MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX"
|
||||
)
|
||||
} else {
|
||||
format!("MCP client for `{server_name}` failed to start: {err:#}")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool {
|
||||
// StreamableHttpError::AuthRequired from the MCP SDK.
|
||||
error.to_string().contains("Auth required")
|
||||
}
|
||||
|
||||
fn is_mcp_client_startup_timeout_error(error: &anyhow::Error) -> bool {
|
||||
let error_message = error.to_string();
|
||||
error_message.contains("request timed out")
|
||||
|| error_message.contains("timed out handshaking with MCP server")
|
||||
}
|
||||
|
||||
use crate::features::Features;
|
||||
#[cfg(test)]
|
||||
pub(crate) use tests::make_session_and_context;
|
||||
@@ -2369,10 +2319,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::mcp::auth::McpAuthStatusEntry;
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
use crate::protocol::CompactedItem;
|
||||
@@ -2392,7 +2339,6 @@ mod tests {
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::McpAuthStatus;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
@@ -2606,7 +2552,8 @@ mod tests {
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: McpConnectionManager::default(),
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
unified_exec_manager: UnifiedExecSessionManager::default(),
|
||||
notifier: UserNotifier::new(None),
|
||||
rollout: Mutex::new(None),
|
||||
@@ -2682,7 +2629,8 @@ mod tests {
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
|
||||
let services = SessionServices {
|
||||
mcp_connection_manager: McpConnectionManager::default(),
|
||||
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
|
||||
mcp_startup_cancellation_token: CancellationToken::new(),
|
||||
unified_exec_manager: UnifiedExecSessionManager::default(),
|
||||
notifier: UserNotifier::new(None),
|
||||
rollout: Mutex::new(None),
|
||||
@@ -2863,9 +2811,23 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
let (session, turn_context, _rx) = make_session_and_context_with_rx();
|
||||
let tools = {
|
||||
session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await
|
||||
};
|
||||
let router = ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
Some(session.services.mcp_connection_manager.list_all_tools()),
|
||||
Some(
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
let item = ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
@@ -2876,6 +2838,7 @@ mod tests {
|
||||
};
|
||||
|
||||
let call = ToolRouter::build_tool_call(session.as_ref(), item.clone())
|
||||
.await
|
||||
.expect("build tool call")
|
||||
.expect("tool call present");
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
@@ -3125,7 +3088,6 @@ mod tests {
|
||||
pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 });
|
||||
assert!(exec_output.output.contains("hi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
use crate::protocol::AskForApproval;
|
||||
@@ -3167,89 +3129,4 @@ mod tests {
|
||||
|
||||
pretty_assertions::assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_prompts_for_github_pat() {
|
||||
let server_name = "github";
|
||||
let entry = McpAuthStatusEntry {
|
||||
config: McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://api.githubcopilot.com/mcp/".to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
let err = anyhow::anyhow!("OAuth is unsupported");
|
||||
|
||||
let display = mcp_init_error_display(server_name, Some(&entry), &err);
|
||||
|
||||
let expected = format!(
|
||||
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
);
|
||||
|
||||
assert_eq!(expected, display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_prompts_for_login_when_auth_required() {
|
||||
let server_name = "example";
|
||||
let err = anyhow::anyhow!("Auth required for server");
|
||||
|
||||
let display = mcp_init_error_display(server_name, None, &err);
|
||||
|
||||
let expected = format!(
|
||||
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
|
||||
);
|
||||
|
||||
assert_eq!(expected, display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_reports_generic_errors() {
|
||||
let server_name = "custom";
|
||||
let entry = McpAuthStatusEntry {
|
||||
config: McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com".to_string(),
|
||||
bearer_token_env_var: Some("TOKEN".to_string()),
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
let err = anyhow::anyhow!("boom");
|
||||
|
||||
let display = mcp_init_error_display(server_name, Some(&entry), &err);
|
||||
|
||||
let expected = format!("MCP client for `{server_name}` failed to start: {err:#}");
|
||||
|
||||
assert_eq!(expected, display);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_includes_startup_timeout_hint() {
|
||||
let server_name = "slow";
|
||||
let err = anyhow::anyhow!("request timed out");
|
||||
|
||||
let display = mcp_init_error_display(server_name, None, &err);
|
||||
|
||||
assert_eq!(
|
||||
"MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
|
||||
display
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::model_family::derive_default_model_family;
|
||||
use crate::model_family::find_family_for_model;
|
||||
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::ModelProviderInfo;
|
||||
use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
use crate::model_provider_info::built_in_model_providers;
|
||||
use crate::openai_model_info::get_model_info;
|
||||
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
@@ -466,6 +468,48 @@ pub fn set_project_trust_level(
|
||||
.apply_blocking()
|
||||
}
|
||||
|
||||
/// Save the default OSS provider preference to config.toml
|
||||
pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> {
|
||||
// Validate that the provider is one of the known OSS providers
|
||||
match provider {
|
||||
LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => {
|
||||
// Valid provider, continue
|
||||
}
|
||||
_ => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Read existing config or create empty string if file doesn't exist
|
||||
let content = match std::fs::read_to_string(&config_path) {
|
||||
Ok(content) => content,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
// Parse as DocumentMut for editing while preserving structure
|
||||
let mut doc = content.parse::<DocumentMut>().map_err(|e| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("failed to parse config.toml: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Set the default_oss_provider at root level
|
||||
use toml_edit::value;
|
||||
doc["oss_provider"] = value(provider);
|
||||
|
||||
// Write the modified document back
|
||||
std::fs::write(&config_path, doc.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a single dotted-path override onto a TOML value.
|
||||
fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
|
||||
use toml::value::Table;
|
||||
@@ -663,6 +707,8 @@ pub struct ConfigToml {
|
||||
pub experimental_use_rmcp_client: Option<bool>,
|
||||
pub experimental_use_freeform_apply_patch: Option<bool>,
|
||||
pub experimental_sandbox_command_assessment: Option<bool>,
|
||||
/// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigToml> for UserSavedConfig {
|
||||
@@ -851,6 +897,34 @@ pub struct ConfigOverrides {
|
||||
pub additional_writable_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Resolves the OSS provider from CLI override, profile config, or global config.
|
||||
/// Returns `None` if no provider is configured at any level.
|
||||
pub fn resolve_oss_provider(
|
||||
explicit_provider: Option<&str>,
|
||||
config_toml: &ConfigToml,
|
||||
config_profile: Option<String>,
|
||||
) -> Option<String> {
|
||||
if let Some(provider) = explicit_provider {
|
||||
// Explicit provider specified (e.g., via --local-provider)
|
||||
Some(provider.to_string())
|
||||
} else {
|
||||
// Check profile config first, then global config
|
||||
let profile = config_toml.get_config_profile(config_profile).ok();
|
||||
if let Some(profile) = &profile {
|
||||
// Check if profile has an oss provider
|
||||
if let Some(profile_oss_provider) = &profile.oss_provider {
|
||||
Some(profile_oss_provider.clone())
|
||||
}
|
||||
// If not then check if the toml has an oss provider
|
||||
else {
|
||||
config_toml.oss_provider.clone()
|
||||
}
|
||||
} else {
|
||||
config_toml.oss_provider.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Meant to be used exclusively for tests: `load_with_overrides()` should
|
||||
/// be used in all other cases.
|
||||
@@ -3265,6 +3339,41 @@ trust_level = "trusted"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_oss_provider() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let codex_home = temp_dir.path();
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
|
||||
// Test setting valid provider on empty config
|
||||
set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?;
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
assert!(content.contains("oss_provider = \"ollama\""));
|
||||
|
||||
// Test updating existing config
|
||||
std::fs::write(&config_path, "model = \"gpt-4\"\n")?;
|
||||
set_default_oss_provider(codex_home, LMSTUDIO_OSS_PROVIDER_ID)?;
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
assert!(content.contains("oss_provider = \"lmstudio\""));
|
||||
assert!(content.contains("model = \"gpt-4\""));
|
||||
|
||||
// Test overwriting existing oss_provider
|
||||
set_default_oss_provider(codex_home, OLLAMA_OSS_PROVIDER_ID)?;
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
assert!(content.contains("oss_provider = \"ollama\""));
|
||||
assert!(!content.contains("oss_provider = \"lmstudio\""));
|
||||
|
||||
// Test invalid provider
|
||||
let result = set_default_oss_provider(codex_home, "invalid_provider");
|
||||
assert!(result.is_err());
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(error.to_string().contains("Invalid OSS provider"));
|
||||
assert!(error.to_string().contains("invalid_provider"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> {
|
||||
let config_with_untrusted = r#"
|
||||
@@ -3295,6 +3404,85 @@ trust_level = "untrusted"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_explicit_override() {
|
||||
let config_toml = ConfigToml::default();
|
||||
let result = resolve_oss_provider(Some("custom-provider"), &config_toml, None);
|
||||
assert_eq!(result, Some("custom-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_from_profile() {
|
||||
let mut profiles = std::collections::HashMap::new();
|
||||
let profile = ConfigProfile {
|
||||
oss_provider: Some("profile-provider".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
profiles.insert("test-profile".to_string(), profile);
|
||||
let config_toml = ConfigToml {
|
||||
profiles,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string()));
|
||||
assert_eq!(result, Some("profile-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_from_global_config() {
|
||||
let config_toml = ConfigToml {
|
||||
oss_provider: Some("global-provider".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(None, &config_toml, None);
|
||||
assert_eq!(result, Some("global-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_profile_fallback_to_global() {
|
||||
let mut profiles = std::collections::HashMap::new();
|
||||
let profile = ConfigProfile::default(); // No oss_provider set
|
||||
profiles.insert("test-profile".to_string(), profile);
|
||||
let config_toml = ConfigToml {
|
||||
oss_provider: Some("global-provider".to_string()),
|
||||
profiles,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(None, &config_toml, Some("test-profile".to_string()));
|
||||
assert_eq!(result, Some("global-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_none_when_not_configured() {
|
||||
let config_toml = ConfigToml::default();
|
||||
let result = resolve_oss_provider(None, &config_toml, None);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_oss_provider_explicit_overrides_all() {
|
||||
let mut profiles = std::collections::HashMap::new();
|
||||
let profile = ConfigProfile {
|
||||
oss_provider: Some("profile-provider".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
profiles.insert("test-profile".to_string(), profile);
|
||||
let config_toml = ConfigToml {
|
||||
oss_provider: Some("global-provider".to_string()),
|
||||
profiles,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = resolve_oss_provider(
|
||||
Some("explicit-provider"),
|
||||
&config_toml,
|
||||
Some("test-profile".to_string()),
|
||||
);
|
||||
assert_eq!(result, Some("explicit-provider".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_untrusted_project_gets_unless_trusted_approval_policy() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -33,6 +33,7 @@ pub struct ConfigProfile {
|
||||
/// Optional feature toggles scoped to this profile.
|
||||
#[serde(default)]
|
||||
pub features: Option<crate::features::FeaturesToml>,
|
||||
pub oss_provider: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ConfigProfile> for codex_app_server_protocol::Profile {
|
||||
|
||||
@@ -189,16 +189,20 @@ async fn exec_windows_sandbox(
|
||||
};
|
||||
|
||||
let sandbox_cwd = cwd.clone();
|
||||
let logs_base_dir = find_codex_home().ok();
|
||||
let codex_home = find_codex_home().map_err(|err| {
|
||||
CodexErr::Io(io::Error::other(format!(
|
||||
"windows sandbox: failed to resolve codex_home: {err}"
|
||||
)))
|
||||
})?;
|
||||
let spawn_res = tokio::task::spawn_blocking(move || {
|
||||
run_windows_sandbox_capture(
|
||||
policy_str,
|
||||
&sandbox_cwd,
|
||||
codex_home.as_ref(),
|
||||
command,
|
||||
&cwd,
|
||||
env,
|
||||
timeout_ms,
|
||||
logs_base_dir.as_deref(),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -46,6 +46,8 @@ pub enum Feature {
|
||||
GhostCommit,
|
||||
/// Enable Windows sandbox (restricted token) on Windows.
|
||||
WindowsSandbox,
|
||||
/// Enable the default shell tool.
|
||||
ShellTool,
|
||||
}
|
||||
|
||||
impl Feature {
|
||||
@@ -301,4 +303,10 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellTool,
|
||||
key: "shell_tool",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -40,8 +40,11 @@ pub mod token_data;
|
||||
mod truncate;
|
||||
mod unified_exec;
|
||||
mod user_instructions;
|
||||
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
pub use model_provider_info::DEFAULT_LMSTUDIO_PORT;
|
||||
pub use model_provider_info::DEFAULT_OLLAMA_PORT;
|
||||
pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::ModelProviderInfo;
|
||||
pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID;
|
||||
pub use model_provider_info::WireApi;
|
||||
pub use model_provider_info::built_in_model_providers;
|
||||
pub use model_provider_info::create_oss_provider_with_base_url;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -258,9 +258,11 @@ impl ModelProviderInfo {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_OLLAMA_PORT: u32 = 11434;
|
||||
pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234;
|
||||
pub const DEFAULT_OLLAMA_PORT: u16 = 11434;
|
||||
|
||||
pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss";
|
||||
pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio";
|
||||
pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama";
|
||||
|
||||
/// Built-in default provider list.
|
||||
pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
@@ -311,14 +313,21 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
requires_openai_auth: true,
|
||||
},
|
||||
),
|
||||
(BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()),
|
||||
(
|
||||
OLLAMA_OSS_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat),
|
||||
),
|
||||
(
|
||||
LMSTUDIO_OSS_PROVIDER_ID,
|
||||
create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn create_oss_provider() -> ModelProviderInfo {
|
||||
pub fn create_oss_provider(default_provider_port: u16, wire_api: WireApi) -> ModelProviderInfo {
|
||||
// These CODEX_OSS_ environment variables are experimental: we may
|
||||
// switch to reading values from config.toml instead.
|
||||
let codex_oss_base_url = match std::env::var("CODEX_OSS_BASE_URL")
|
||||
@@ -331,22 +340,21 @@ pub fn create_oss_provider() -> ModelProviderInfo {
|
||||
port = std::env::var("CODEX_OSS_PORT")
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.and_then(|v| v.parse::<u32>().ok())
|
||||
.unwrap_or(DEFAULT_OLLAMA_PORT)
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(default_provider_port)
|
||||
),
|
||||
};
|
||||
|
||||
create_oss_provider_with_base_url(&codex_oss_base_url)
|
||||
create_oss_provider_with_base_url(&codex_oss_base_url, wire_api)
|
||||
}
|
||||
|
||||
pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo {
|
||||
pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> ModelProviderInfo {
|
||||
ModelProviderInfo {
|
||||
name: "gpt-oss".into(),
|
||||
base_url: Some(base_url.into()),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
wire_api: WireApi::Chat,
|
||||
wire_api,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
|
||||
@@ -6,7 +6,7 @@ use shlex::split as shlex_split;
|
||||
use shlex::try_join as shlex_try_join;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn shlex_join(tokens: &[String]) -> String {
|
||||
pub fn shlex_join(tokens: &[String]) -> String {
|
||||
shlex_try_join(tokens.iter().map(String::as_str))
|
||||
.unwrap_or_else(|_| "<command included NUL byte>".to_string())
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::UndoStarted(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
|
||||
@@ -8,9 +8,12 @@ use crate::unified_exec::UnifiedExecSessionManager;
|
||||
use crate::user_notification::UserNotifier;
|
||||
use codex_otel::otel_event_manager::OtelEventManager;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: McpConnectionManager,
|
||||
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
|
||||
pub(crate) mcp_startup_cancellation_token: CancellationToken,
|
||||
pub(crate) unified_exec_manager: UnifiedExecSessionManager,
|
||||
pub(crate) notifier: UserNotifier,
|
||||
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
|
||||
|
||||
@@ -65,22 +65,24 @@ impl SessionTask for UserShellCommandTask {
|
||||
// allows commands that use shell features (pipes, &&, redirects, etc.).
|
||||
// We do not source rc files or otherwise reformat the script.
|
||||
let use_login_shell = true;
|
||||
let shell_invocation = session
|
||||
let command = session
|
||||
.user_shell()
|
||||
.derive_exec_args(&self.command, use_login_shell);
|
||||
|
||||
let call_id = Uuid::new_v4().to_string();
|
||||
let raw_command = self.command.clone();
|
||||
let cwd = turn_context.cwd.clone();
|
||||
|
||||
let parsed_cmd = parse_command(&shell_invocation);
|
||||
let parsed_cmd = parse_command(&command);
|
||||
session
|
||||
.send_event(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
command: shell_invocation.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
parsed_cmd,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
}),
|
||||
@@ -88,8 +90,8 @@ impl SessionTask for UserShellCommandTask {
|
||||
.await;
|
||||
|
||||
let exec_env = ExecEnv {
|
||||
command: shell_invocation,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
env: create_env(&turn_context.shell_environment_policy),
|
||||
timeout_ms: None,
|
||||
sandbox: SandboxType::None,
|
||||
@@ -129,6 +131,12 @@ impl SessionTask for UserShellCommandTask {
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: aborted_message.clone(),
|
||||
aggregated_output: aborted_message.clone(),
|
||||
@@ -145,6 +153,12 @@ impl SessionTask for UserShellCommandTask {
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
stdout: output.stdout.text.clone(),
|
||||
stderr: output.stderr.text.clone(),
|
||||
aggregated_output: output.aggregated_output.text.clone(),
|
||||
@@ -176,6 +190,12 @@ impl SessionTask for UserShellCommandTask {
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
stdout: exec_output.stdout.text.clone(),
|
||||
stderr: exec_output.stderr.text.clone(),
|
||||
aggregated_output: exec_output.aggregated_output.text.clone(),
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::protocol::PatchApplyEndEvent;
|
||||
use crate::protocol::TurnDiffEvent;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -61,6 +62,7 @@ pub(crate) async fn emit_exec_command_begin(
|
||||
ctx: ToolEventCtx<'_>,
|
||||
command: &[String],
|
||||
cwd: &Path,
|
||||
parsed_cmd: &[ParsedCommand],
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
) {
|
||||
@@ -69,9 +71,10 @@ pub(crate) async fn emit_exec_command_begin(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
command: command.to_vec(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
parsed_cmd: parse_command(command),
|
||||
parsed_cmd: parsed_cmd.to_vec(),
|
||||
source,
|
||||
interaction_input,
|
||||
}),
|
||||
@@ -84,6 +87,7 @@ pub(crate) enum ToolEmitter {
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
source: ExecCommandSource,
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
},
|
||||
ApplyPatch {
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
@@ -94,15 +98,18 @@ pub(crate) enum ToolEmitter {
|
||||
cwd: PathBuf,
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
parsed_cmd: Vec<ParsedCommand>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolEmitter {
|
||||
pub fn shell(command: Vec<String>, cwd: PathBuf, source: ExecCommandSource) -> Self {
|
||||
let parsed_cmd = parse_command(&command);
|
||||
Self::Shell {
|
||||
command,
|
||||
cwd,
|
||||
source,
|
||||
parsed_cmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +126,13 @@ impl ToolEmitter {
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<String>,
|
||||
) -> Self {
|
||||
let parsed_cmd = parse_command(command);
|
||||
Self::UnifiedExec {
|
||||
command: command.to_vec(),
|
||||
cwd,
|
||||
source,
|
||||
interaction_input,
|
||||
parsed_cmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,44 +143,14 @@ impl ToolEmitter {
|
||||
command,
|
||||
cwd,
|
||||
source,
|
||||
parsed_cmd,
|
||||
},
|
||||
ToolEventStage::Begin,
|
||||
stage,
|
||||
) => {
|
||||
emit_exec_command_begin(ctx, command, cwd.as_path(), *source, None).await;
|
||||
}
|
||||
(Self::Shell { .. }, ToolEventStage::Success(output)) => {
|
||||
emit_exec_end(
|
||||
emit_exec_stage(
|
||||
ctx,
|
||||
output.stdout.text.clone(),
|
||||
output.stderr.text.clone(),
|
||||
output.aggregated_output.text.clone(),
|
||||
output.exit_code,
|
||||
output.duration,
|
||||
format_exec_output_str(&output),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(Self::Shell { .. }, ToolEventStage::Failure(ToolEventFailure::Output(output))) => {
|
||||
emit_exec_end(
|
||||
ctx,
|
||||
output.stdout.text.clone(),
|
||||
output.stderr.text.clone(),
|
||||
output.aggregated_output.text.clone(),
|
||||
output.exit_code,
|
||||
output.duration,
|
||||
format_exec_output_str(&output),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(Self::Shell { .. }, ToolEventStage::Failure(ToolEventFailure::Message(message))) => {
|
||||
emit_exec_end(
|
||||
ctx,
|
||||
String::new(),
|
||||
(*message).to_string(),
|
||||
(*message).to_string(),
|
||||
-1,
|
||||
Duration::ZERO,
|
||||
message.clone(),
|
||||
ExecCommandInput::new(command, cwd.as_path(), parsed_cmd, *source, None),
|
||||
stage,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -231,57 +210,20 @@ impl ToolEmitter {
|
||||
cwd,
|
||||
source,
|
||||
interaction_input,
|
||||
parsed_cmd,
|
||||
},
|
||||
ToolEventStage::Begin,
|
||||
stage,
|
||||
) => {
|
||||
emit_exec_command_begin(
|
||||
emit_exec_stage(
|
||||
ctx,
|
||||
command,
|
||||
cwd.as_path(),
|
||||
*source,
|
||||
interaction_input.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(Self::UnifiedExec { .. }, ToolEventStage::Success(output)) => {
|
||||
emit_exec_end(
|
||||
ctx,
|
||||
output.stdout.text.clone(),
|
||||
output.stderr.text.clone(),
|
||||
output.aggregated_output.text.clone(),
|
||||
output.exit_code,
|
||||
output.duration,
|
||||
format_exec_output_str(&output),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(
|
||||
Self::UnifiedExec { .. },
|
||||
ToolEventStage::Failure(ToolEventFailure::Output(output)),
|
||||
) => {
|
||||
emit_exec_end(
|
||||
ctx,
|
||||
output.stdout.text.clone(),
|
||||
output.stderr.text.clone(),
|
||||
output.aggregated_output.text.clone(),
|
||||
output.exit_code,
|
||||
output.duration,
|
||||
format_exec_output_str(&output),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
(
|
||||
Self::UnifiedExec { .. },
|
||||
ToolEventStage::Failure(ToolEventFailure::Message(message)),
|
||||
) => {
|
||||
emit_exec_end(
|
||||
ctx,
|
||||
String::new(),
|
||||
(*message).to_string(),
|
||||
(*message).to_string(),
|
||||
-1,
|
||||
Duration::ZERO,
|
||||
message.clone(),
|
||||
ExecCommandInput::new(
|
||||
command,
|
||||
cwd.as_path(),
|
||||
parsed_cmd,
|
||||
*source,
|
||||
interaction_input.as_deref(),
|
||||
),
|
||||
stage,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -340,26 +282,107 @@ impl ToolEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_exec_end(
|
||||
ctx: ToolEventCtx<'_>,
|
||||
struct ExecCommandInput<'a> {
|
||||
command: &'a [String],
|
||||
cwd: &'a Path,
|
||||
parsed_cmd: &'a [ParsedCommand],
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ExecCommandInput<'a> {
|
||||
fn new(
|
||||
command: &'a [String],
|
||||
cwd: &'a Path,
|
||||
parsed_cmd: &'a [ParsedCommand],
|
||||
source: ExecCommandSource,
|
||||
interaction_input: Option<&'a str>,
|
||||
) -> Self {
|
||||
Self {
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExecCommandResult {
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
aggregated_output: String,
|
||||
exit_code: i32,
|
||||
duration: Duration,
|
||||
formatted_output: String,
|
||||
}
|
||||
|
||||
async fn emit_exec_stage(
|
||||
ctx: ToolEventCtx<'_>,
|
||||
exec_input: ExecCommandInput<'_>,
|
||||
stage: ToolEventStage,
|
||||
) {
|
||||
match stage {
|
||||
ToolEventStage::Begin => {
|
||||
emit_exec_command_begin(
|
||||
ctx,
|
||||
exec_input.command,
|
||||
exec_input.cwd,
|
||||
exec_input.parsed_cmd,
|
||||
exec_input.source,
|
||||
exec_input.interaction_input.map(str::to_owned),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
ToolEventStage::Success(output)
|
||||
| ToolEventStage::Failure(ToolEventFailure::Output(output)) => {
|
||||
let exec_result = ExecCommandResult {
|
||||
stdout: output.stdout.text.clone(),
|
||||
stderr: output.stderr.text.clone(),
|
||||
aggregated_output: output.aggregated_output.text.clone(),
|
||||
exit_code: output.exit_code,
|
||||
duration: output.duration,
|
||||
formatted_output: format_exec_output_str(&output),
|
||||
};
|
||||
emit_exec_end(ctx, exec_input, exec_result).await;
|
||||
}
|
||||
ToolEventStage::Failure(ToolEventFailure::Message(message)) => {
|
||||
let text = message.to_string();
|
||||
let exec_result = ExecCommandResult {
|
||||
stdout: String::new(),
|
||||
stderr: text.clone(),
|
||||
aggregated_output: text.clone(),
|
||||
exit_code: -1,
|
||||
duration: Duration::ZERO,
|
||||
formatted_output: text,
|
||||
};
|
||||
emit_exec_end(ctx, exec_input, exec_result).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_exec_end(
|
||||
ctx: ToolEventCtx<'_>,
|
||||
exec_input: ExecCommandInput<'_>,
|
||||
exec_result: ExecCommandResult,
|
||||
) {
|
||||
ctx.session
|
||||
.send_event(
|
||||
ctx.turn,
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: ctx.call_id.to_string(),
|
||||
stdout,
|
||||
stderr,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
duration,
|
||||
formatted_output,
|
||||
turn_id: ctx.turn.sub_id.clone(),
|
||||
command: exec_input.command.to_vec(),
|
||||
cwd: exec_input.cwd.to_path_buf(),
|
||||
parsed_cmd: exec_input.parsed_cmd.to_vec(),
|
||||
source: exec_input.source,
|
||||
interaction_input: exec_input.interaction_input.map(str::to_owned),
|
||||
stdout: exec_result.stdout,
|
||||
stderr: exec_result.stderr,
|
||||
aggregated_output: exec_result.aggregated_output,
|
||||
exit_code: exec_result.exit_code,
|
||||
duration: exec_result.duration,
|
||||
formatted_output: exec_result.formatted_output,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -287,6 +287,8 @@ async fn handle_list_resources(
|
||||
let resources = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_resources()
|
||||
.await;
|
||||
Ok(ListResourcesPayload::from_all_servers(resources))
|
||||
@@ -396,6 +398,8 @@ async fn handle_list_resource_templates(
|
||||
let templates = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_resource_templates()
|
||||
.await;
|
||||
Ok(ListResourceTemplatesPayload::from_all_servers(templates))
|
||||
|
||||
@@ -54,7 +54,7 @@ impl ToolRouter {
|
||||
.any(|config| config.spec.name() == tool_name)
|
||||
}
|
||||
|
||||
pub fn build_tool_call(
|
||||
pub async fn build_tool_call(
|
||||
session: &Session,
|
||||
item: ResponseItem,
|
||||
) -> Result<Option<ToolCall>, FunctionCallError> {
|
||||
@@ -65,7 +65,7 @@ impl ToolRouter {
|
||||
call_id,
|
||||
..
|
||||
} => {
|
||||
if let Some((server, tool)) = session.parse_mcp_tool_name(&name) {
|
||||
if let Some((server, tool)) = session.parse_mcp_tool_name(&name).await {
|
||||
Ok(Some(ToolCall {
|
||||
tool_name: name,
|
||||
call_id,
|
||||
|
||||
@@ -20,6 +20,11 @@ pub enum ConfigShellToolType {
|
||||
Default,
|
||||
Local,
|
||||
UnifiedExec,
|
||||
/// Do not include a shell tool by default. Useful when using Codex
|
||||
/// with tools provided exclusively provided by MCP servers. Often used
|
||||
/// with `--config base_instructions=CUSTOM_INSTRUCTIONS`
|
||||
/// to customize agent behavior.
|
||||
Disabled,
|
||||
/// Takes a command as a single string to be run in the user's default shell.
|
||||
ShellCommand,
|
||||
}
|
||||
@@ -48,7 +53,9 @@ impl ToolsConfig {
|
||||
let include_web_search_request = features.enabled(Feature::WebSearchRequest);
|
||||
let include_view_image_tool = features.enabled(Feature::ViewImageTool);
|
||||
|
||||
let shell_type = if features.enabled(Feature::UnifiedExec) {
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
ConfigShellToolType::Disabled
|
||||
} else if features.enabled(Feature::UnifiedExec) {
|
||||
ConfigShellToolType::UnifiedExec
|
||||
} else if features.enabled(Feature::ShellCommandTool) {
|
||||
ConfigShellToolType::ShellCommand
|
||||
@@ -294,9 +301,26 @@ fn create_shell_tool() -> ToolSpec {
|
||||
},
|
||||
);
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
||||
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
||||
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
||||
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
|
||||
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
||||
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- 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."#
|
||||
}.to_string();
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell".to_string(),
|
||||
description: "Runs a shell command and returns its output.".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
@@ -341,9 +365,25 @@ fn create_shell_command_tool() -> ToolSpec {
|
||||
},
|
||||
);
|
||||
|
||||
let description = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output.
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): "Get-ChildItem -Force"
|
||||
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
|
||||
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
|
||||
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
|
||||
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
|
||||
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"#
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
|
||||
}.to_string();
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell_command".to_string(),
|
||||
description: "Runs a shell command string and returns its output.".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
@@ -973,16 +1013,21 @@ pub(crate) fn build_specs(
|
||||
builder.register_handler("exec_command", unified_exec_handler.clone());
|
||||
builder.register_handler("write_stdin", unified_exec_handler);
|
||||
}
|
||||
ConfigShellToolType::Disabled => {
|
||||
// Do nothing.
|
||||
}
|
||||
ConfigShellToolType::ShellCommand => {
|
||||
builder.push_spec(create_shell_command_tool());
|
||||
}
|
||||
}
|
||||
|
||||
// Always register shell aliases so older prompts remain compatible.
|
||||
builder.register_handler("shell", shell_handler.clone());
|
||||
builder.register_handler("container.exec", shell_handler.clone());
|
||||
builder.register_handler("local_shell", shell_handler);
|
||||
builder.register_handler("shell_command", shell_command_handler);
|
||||
if config.shell_type != ConfigShellToolType::Disabled {
|
||||
// Always register shell aliases so older prompts remain compatible.
|
||||
builder.register_handler("shell", shell_handler.clone());
|
||||
builder.register_handler("container.exec", shell_handler.clone());
|
||||
builder.register_handler("local_shell", shell_handler);
|
||||
builder.register_handler("shell_command", shell_command_handler);
|
||||
}
|
||||
|
||||
builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true);
|
||||
builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true);
|
||||
@@ -1118,6 +1163,7 @@ mod tests {
|
||||
ConfigShellToolType::Default => Some("shell"),
|
||||
ConfigShellToolType::Local => Some("local_shell"),
|
||||
ConfigShellToolType::UnifiedExec => None,
|
||||
ConfigShellToolType::Disabled => None,
|
||||
ConfigShellToolType::ShellCommand => Some("shell_command"),
|
||||
}
|
||||
}
|
||||
@@ -1873,8 +1919,23 @@ mod tests {
|
||||
};
|
||||
assert_eq!(name, "shell");
|
||||
|
||||
let expected = "Runs a shell command and returns its output.";
|
||||
assert_eq!(description, expected);
|
||||
let expected = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
||||
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
||||
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
||||
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
|
||||
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
||||
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- 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."#
|
||||
}.to_string();
|
||||
assert_eq!(description, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1888,8 +1949,22 @@ mod tests {
|
||||
};
|
||||
assert_eq!(name, "shell_command");
|
||||
|
||||
let expected = "Runs a shell command string and returns its output.";
|
||||
assert_eq!(description, expected);
|
||||
let expected = if cfg!(windows) {
|
||||
r#"Runs a Powershell command (Windows) and returns its output.
|
||||
|
||||
Examples of valid command strings:
|
||||
|
||||
- ls -a (show hidden): "Get-ChildItem -Force"
|
||||
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
|
||||
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
|
||||
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
|
||||
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
|
||||
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"#.to_string()
|
||||
} else {
|
||||
r#"Runs a shell command and returns its output.
|
||||
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#.to_string()
|
||||
};
|
||||
assert_eq!(description, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,7 +24,6 @@ codex-common = { workspace = true, features = [
|
||||
"sandbox_summary",
|
||||
] }
|
||||
codex-core = { workspace = true }
|
||||
codex-ollama = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
mcp-types = { workspace = true }
|
||||
opentelemetry-appender-tracing = { workspace = true }
|
||||
|
||||
@@ -18,9 +18,15 @@ pub struct Cli {
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Use open-source provider.
|
||||
#[arg(long = "oss", default_value_t = false)]
|
||||
pub oss: bool,
|
||||
|
||||
/// Specify which local provider to use (lmstudio or ollama).
|
||||
/// If not specified with --oss, will use config default or show selection.
|
||||
#[arg(long = "local-provider")]
|
||||
pub oss_provider: Option<String>,
|
||||
|
||||
/// Select the sandbox policy to use when executing model-generated shell
|
||||
/// commands.
|
||||
#[arg(long = "sandbox", short = 's', value_enum)]
|
||||
|
||||
@@ -182,6 +182,42 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
ts_msg!(self, " {}", details.style(self.dimmed));
|
||||
}
|
||||
}
|
||||
EventMsg::McpStartupUpdate(update) => {
|
||||
let status_text = match update.status {
|
||||
codex_core::protocol::McpStartupStatus::Starting => "starting".to_string(),
|
||||
codex_core::protocol::McpStartupStatus::Ready => "ready".to_string(),
|
||||
codex_core::protocol::McpStartupStatus::Cancelled => "cancelled".to_string(),
|
||||
codex_core::protocol::McpStartupStatus::Failed { ref error } => {
|
||||
format!("failed: {error}")
|
||||
}
|
||||
};
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {} {}",
|
||||
"mcp:".style(self.cyan),
|
||||
update.server,
|
||||
status_text
|
||||
);
|
||||
}
|
||||
EventMsg::McpStartupComplete(summary) => {
|
||||
let mut parts = Vec::new();
|
||||
if !summary.ready.is_empty() {
|
||||
parts.push(format!("ready: {}", summary.ready.join(", ")));
|
||||
}
|
||||
if !summary.failed.is_empty() {
|
||||
let servers: Vec<_> = summary.failed.iter().map(|f| f.server.clone()).collect();
|
||||
parts.push(format!("failed: {}", servers.join(", ")));
|
||||
}
|
||||
if !summary.cancelled.is_empty() {
|
||||
parts.push(format!("cancelled: {}", summary.cancelled.join(", ")));
|
||||
}
|
||||
let joined = if parts.is_empty() {
|
||||
"no servers".to_string()
|
||||
} else {
|
||||
parts.join("; ")
|
||||
};
|
||||
ts_msg!(self, "{} {}", "mcp startup:".style(self.cyan), joined);
|
||||
}
|
||||
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||||
ts_msg!(self, "{}", message.style(self.dimmed));
|
||||
}
|
||||
|
||||
+75
-23
@@ -11,20 +11,25 @@ pub mod event_processor_with_jsonl_output;
|
||||
pub mod exec_events;
|
||||
|
||||
pub use cli::Cli;
|
||||
use codex_common::oss::ensure_oss_provider_ready;
|
||||
use codex_common::oss::get_default_model_for_oss_provider;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_core::auth::enforce_login_restrictions;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
||||
use codex_core::config::resolve_oss_provider;
|
||||
use codex_core::git_info::get_git_repo_root;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use event_processor_with_human_output::EventProcessorWithHumanOutput;
|
||||
@@ -57,6 +62,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
images,
|
||||
model: model_cli_arg,
|
||||
oss,
|
||||
oss_provider,
|
||||
config_profile,
|
||||
full_auto,
|
||||
dangerously_bypass_approvals_and_sandbox,
|
||||
@@ -146,21 +152,64 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
sandbox_mode_cli_arg.map(Into::<SandboxMode>::into)
|
||||
};
|
||||
|
||||
// When using `--oss`, let the bootstrapper pick the model (defaulting to
|
||||
// gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
|
||||
// `oss` model provider.
|
||||
let model = if let Some(model) = model_cli_arg {
|
||||
Some(model)
|
||||
} else if oss {
|
||||
Some(DEFAULT_OSS_MODEL.to_owned())
|
||||
} else {
|
||||
None // No model specified, will use the default.
|
||||
// Parse `-c` overrides from the CLI.
|
||||
let cli_kv_overrides = match config_overrides.parse_overrides() {
|
||||
Ok(v) => v,
|
||||
#[allow(clippy::print_stderr)]
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing -c overrides: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// we load config.toml here to determine project state.
|
||||
#[allow(clippy::print_stderr)]
|
||||
let config_toml = {
|
||||
let codex_home = match find_codex_home() {
|
||||
Ok(codex_home) => codex_home,
|
||||
Err(err) => {
|
||||
eprintln!("Error finding codex home: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides.clone()).await {
|
||||
Ok(config_toml) => config_toml,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading config.toml: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let model_provider = if oss {
|
||||
Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_string())
|
||||
let resolved = resolve_oss_provider(
|
||||
oss_provider.as_deref(),
|
||||
&config_toml,
|
||||
config_profile.clone(),
|
||||
);
|
||||
|
||||
if let Some(provider) = resolved {
|
||||
Some(provider)
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"No default OSS provider configured. Use --local-provider=provider or set oss_provider to either {LMSTUDIO_OSS_PROVIDER_ID} or {OLLAMA_OSS_PROVIDER_ID} in config.toml"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
None // No specific model provider override.
|
||||
None // No OSS mode enabled
|
||||
};
|
||||
|
||||
// When using `--oss`, let the bootstrapper pick the model based on selected provider
|
||||
let model = if let Some(model) = model_cli_arg {
|
||||
Some(model)
|
||||
} else if oss {
|
||||
model_provider
|
||||
.as_ref()
|
||||
.and_then(|provider_id| get_default_model_for_oss_provider(provider_id))
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
} else {
|
||||
None // No model specified, will use the default.
|
||||
};
|
||||
|
||||
// Load configuration and determine approval policy
|
||||
@@ -172,7 +221,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_mode,
|
||||
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
|
||||
model_provider,
|
||||
model_provider: model_provider.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
@@ -183,14 +232,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
experimental_sandbox_command_assessment: None,
|
||||
additional_writable_roots: add_dir,
|
||||
};
|
||||
// Parse `-c` overrides.
|
||||
let cli_kv_overrides = match config_overrides.parse_overrides() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing -c overrides: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
|
||||
|
||||
@@ -233,7 +274,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
};
|
||||
|
||||
if oss {
|
||||
codex_ollama::ensure_oss_ready(&config)
|
||||
// We're in the oss section, so provider_id should be Some
|
||||
// Let's handle None case gracefully though just in case
|
||||
let provider_id = match model_provider.as_ref() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
error!("OSS provider unexpectedly not set when oss flag is used");
|
||||
return Err(anyhow::anyhow!(
|
||||
"OSS provider not set but oss flag was used"
|
||||
));
|
||||
}
|
||||
};
|
||||
ensure_oss_provider_ready(provider_id, &config)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("OSS setup failed: {e}"))?;
|
||||
}
|
||||
|
||||
@@ -618,15 +618,19 @@ fn error_followed_by_task_complete_produces_turn_failed() {
|
||||
#[test]
|
||||
fn exec_command_end_success_produces_completed_command_item() {
|
||||
let mut ep = EventProcessorWithJsonOutput::new(None);
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), "echo hi".to_string()];
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let parsed_cmd = Vec::new();
|
||||
|
||||
// Begin -> no output
|
||||
let begin = event(
|
||||
"c1",
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "1".to_string(),
|
||||
command: vec!["bash".to_string(), "-lc".to_string(), "echo hi".to_string()],
|
||||
cwd: std::env::current_dir().unwrap(),
|
||||
parsed_cmd: Vec::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
}),
|
||||
@@ -652,6 +656,12 @@ fn exec_command_end_success_produces_completed_command_item() {
|
||||
"c2",
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: "hi\n".to_string(),
|
||||
@@ -680,15 +690,19 @@ fn exec_command_end_success_produces_completed_command_item() {
|
||||
#[test]
|
||||
fn exec_command_end_failure_produces_failed_command_item() {
|
||||
let mut ep = EventProcessorWithJsonOutput::new(None);
|
||||
let command = vec!["sh".to_string(), "-c".to_string(), "exit 1".to_string()];
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let parsed_cmd = Vec::new();
|
||||
|
||||
// Begin -> no output
|
||||
let begin = event(
|
||||
"c1",
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "2".to_string(),
|
||||
command: vec!["sh".to_string(), "-c".to_string(), "exit 1".to_string()],
|
||||
cwd: std::env::current_dir().unwrap(),
|
||||
parsed_cmd: Vec::new(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
}),
|
||||
@@ -713,6 +727,12 @@ fn exec_command_end_failure_produces_failed_command_item() {
|
||||
"c2",
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "2".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: String::new(),
|
||||
@@ -747,6 +767,12 @@ fn exec_command_end_without_begin_is_ignored() {
|
||||
"c1",
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "no-begin".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command: Vec::new(),
|
||||
cwd: PathBuf::from("."),
|
||||
parsed_cmd: Vec::new(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: String::new(),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "codex-execpolicy2"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
description = "Codex exec policy v2: prefix-based Starlark rules for command decisions."
|
||||
|
||||
[lib]
|
||||
name = "codex_execpolicy2"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "codex-execpolicy2"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
multimap = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
starlark = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
@@ -0,0 +1,59 @@
|
||||
# codex-execpolicy2
|
||||
|
||||
## Overview
|
||||
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, match?, not_match?)`.
|
||||
- This release covers only the prefix-rule subset of the planned execpolicy v2 language; a richer language will follow.
|
||||
- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`.
|
||||
- `match` / `not_match` supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with `shlex`).
|
||||
- The CLI always prints the JSON serialization of the evaluation result (whether a match or not).
|
||||
|
||||
## Policy shapes
|
||||
- Prefix rules use Starlark syntax:
|
||||
```starlark
|
||||
prefix_rule(
|
||||
pattern = ["cmd", ["alt1", "alt2"]], # ordered tokens; list entries denote alternatives
|
||||
decision = "prompt", # allow | prompt | forbidden; defaults to allow
|
||||
match = [["cmd", "alt1"], "cmd alt2"], # examples that must match this rule
|
||||
not_match = [["cmd", "oops"], "cmd alt3"], # examples that must not match this rule
|
||||
)
|
||||
```
|
||||
|
||||
## Response shapes
|
||||
- Match:
|
||||
```json
|
||||
{
|
||||
"match": {
|
||||
"decision": "allow|prompt|forbidden",
|
||||
"matchedRules": [
|
||||
{
|
||||
"prefixRuleMatch": {
|
||||
"matchedPrefix": ["<token>", "..."],
|
||||
"decision": "allow|prompt|forbidden"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- No match:
|
||||
```json
|
||||
"noMatch"
|
||||
```
|
||||
|
||||
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
|
||||
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).
|
||||
|
||||
## CLI
|
||||
- Provide one or more policy files (for example `src/default.codexpolicy`) to check a command:
|
||||
```bash
|
||||
cargo run -p codex-execpolicy2 -- check --policy path/to/policy.codexpolicy git status
|
||||
```
|
||||
- Pass multiple `--policy` flags to merge rules, evaluated in the order provided:
|
||||
```bash
|
||||
cargo run -p codex-execpolicy2 -- check --policy base.codexpolicy --policy overrides.codexpolicy git status
|
||||
```
|
||||
- Output is newline-delimited JSON by default; pass `--pretty` for pretty-printed JSON if desired.
|
||||
- Example outcomes:
|
||||
- Match: `{"match": { ... "decision": "allow" ... }}`
|
||||
- No match: `"noMatch"`
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
# Example policy to illustrate syntax; not comprehensive and not recommended for actual use.
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["git", "reset", "--hard"],
|
||||
decision = "forbidden",
|
||||
match = [
|
||||
["git", "reset", "--hard"],
|
||||
],
|
||||
not_match = [
|
||||
["git", "reset", "--keep"],
|
||||
"git reset --merge",
|
||||
],
|
||||
)
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["ls"],
|
||||
match = [
|
||||
["ls"],
|
||||
["ls", "-l"],
|
||||
["ls", "-a", "."],
|
||||
],
|
||||
)
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["cat"],
|
||||
match = [
|
||||
["cat", "file.txt"],
|
||||
["cat", "-n", "README.md"],
|
||||
],
|
||||
)
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["cp"],
|
||||
decision = "prompt",
|
||||
match = [
|
||||
["cp", "foo", "bar"],
|
||||
"cp -r src dest",
|
||||
],
|
||||
)
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["head"],
|
||||
match = [
|
||||
["head", "README.md"],
|
||||
["head", "-n", "5", "CHANGELOG.md"],
|
||||
],
|
||||
not_match = [
|
||||
["hea", "-n", "1,5p", "CHANGELOG.md"],
|
||||
],
|
||||
)
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["printenv"],
|
||||
match = [
|
||||
["printenv"],
|
||||
["printenv", "PATH"],
|
||||
],
|
||||
not_match = [
|
||||
["print", "-0"],
|
||||
],
|
||||
)
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["pwd"],
|
||||
match = [
|
||||
["pwd"],
|
||||
],
|
||||
)
|
||||
|
||||
prefix_rule(
|
||||
pattern = ["which"],
|
||||
match = [
|
||||
["which", "python3"],
|
||||
["which", "-a", "python3"],
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Decision {
|
||||
/// Command may run without further approval.
|
||||
Allow,
|
||||
/// Request explicit user approval; rejected outright when running with `approval_policy="never"`.
|
||||
Prompt,
|
||||
/// Command is blocked without further consideration.
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
pub fn parse(raw: &str) -> Result<Self> {
|
||||
match raw {
|
||||
"allow" => Ok(Self::Allow),
|
||||
"prompt" => Ok(Self::Prompt),
|
||||
"forbidden" => Ok(Self::Forbidden),
|
||||
other => Err(Error::InvalidDecision(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use starlark::Error as StarlarkError;
|
||||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("invalid decision: {0}")]
|
||||
InvalidDecision(String),
|
||||
#[error("invalid pattern element: {0}")]
|
||||
InvalidPattern(String),
|
||||
#[error("invalid example: {0}")]
|
||||
InvalidExample(String),
|
||||
#[error(
|
||||
"expected every example to match at least one rule. rules: {rules:?}; unmatched examples: \
|
||||
{examples:?}"
|
||||
)]
|
||||
ExampleDidNotMatch {
|
||||
rules: Vec<String>,
|
||||
examples: Vec<String>,
|
||||
},
|
||||
#[error("expected example to not match rule `{rule}`: {example}")]
|
||||
ExampleDidMatch { rule: String, example: String },
|
||||
#[error("starlark error: {0}")]
|
||||
Starlark(StarlarkError),
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub mod decision;
|
||||
pub mod error;
|
||||
pub mod parser;
|
||||
pub mod policy;
|
||||
pub mod rule;
|
||||
|
||||
pub use decision::Decision;
|
||||
pub use error::Error;
|
||||
pub use error::Result;
|
||||
pub use parser::PolicyParser;
|
||||
pub use policy::Evaluation;
|
||||
pub use policy::Policy;
|
||||
pub use rule::Rule;
|
||||
pub use rule::RuleMatch;
|
||||
pub use rule::RuleRef;
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use codex_execpolicy2::PolicyParser;
|
||||
|
||||
/// CLI for evaluating exec policies
|
||||
#[derive(Parser)]
|
||||
#[command(name = "codex-execpolicy2")]
|
||||
enum Cli {
|
||||
/// Evaluate a command against a policy.
|
||||
Check {
|
||||
#[arg(short, long = "policy", value_name = "PATH", required = true)]
|
||||
policies: Vec<PathBuf>,
|
||||
|
||||
/// Pretty-print the JSON output.
|
||||
#[arg(long)]
|
||||
pretty: bool,
|
||||
|
||||
/// Command tokens to check.
|
||||
#[arg(
|
||||
value_name = "COMMAND",
|
||||
required = true,
|
||||
trailing_var_arg = true,
|
||||
allow_hyphen_values = true
|
||||
)]
|
||||
command: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli {
|
||||
Cli::Check {
|
||||
policies,
|
||||
command,
|
||||
pretty,
|
||||
} => cmd_check(policies, command, pretty),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_check(policy_paths: Vec<PathBuf>, args: Vec<String>, pretty: bool) -> Result<()> {
|
||||
let policy = load_policies(&policy_paths)?;
|
||||
|
||||
let eval = policy.check(&args);
|
||||
let json = if pretty {
|
||||
serde_json::to_string_pretty(&eval)?
|
||||
} else {
|
||||
serde_json::to_string(&eval)?
|
||||
};
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_policies(policy_paths: &[PathBuf]) -> Result<codex_execpolicy2::Policy> {
|
||||
let mut parser = PolicyParser::new();
|
||||
for policy_path in policy_paths {
|
||||
let policy_file_contents = fs::read_to_string(policy_path)
|
||||
.with_context(|| format!("failed to read policy at {}", policy_path.display()))?;
|
||||
let policy_identifier = policy_path.to_string_lossy().to_string();
|
||||
parser.parse(&policy_identifier, &policy_file_contents)?;
|
||||
}
|
||||
Ok(parser.build())
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use multimap::MultiMap;
|
||||
use shlex;
|
||||
use starlark::any::ProvidesStaticType;
|
||||
use starlark::environment::GlobalsBuilder;
|
||||
use starlark::environment::Module;
|
||||
use starlark::eval::Evaluator;
|
||||
use starlark::starlark_module;
|
||||
use starlark::syntax::AstModule;
|
||||
use starlark::syntax::Dialect;
|
||||
use starlark::values::Value;
|
||||
use starlark::values::list::ListRef;
|
||||
use starlark::values::list::UnpackList;
|
||||
use starlark::values::none::NoneType;
|
||||
use std::cell::RefCell;
|
||||
use std::cell::RefMut;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::decision::Decision;
|
||||
use crate::error::Error;
|
||||
use crate::error::Result;
|
||||
use crate::rule::PatternToken;
|
||||
use crate::rule::PrefixPattern;
|
||||
use crate::rule::PrefixRule;
|
||||
use crate::rule::RuleRef;
|
||||
use crate::rule::validate_match_examples;
|
||||
use crate::rule::validate_not_match_examples;
|
||||
|
||||
pub struct PolicyParser {
|
||||
builder: RefCell<PolicyBuilder>,
|
||||
}
|
||||
|
||||
impl Default for PolicyParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl PolicyParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
builder: RefCell::new(PolicyBuilder::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a policy, tagging parser errors with `policy_identifier` so failures include the
|
||||
/// identifier alongside line numbers.
|
||||
pub fn parse(&mut self, policy_identifier: &str, policy_file_contents: &str) -> Result<()> {
|
||||
let mut dialect = Dialect::Extended.clone();
|
||||
dialect.enable_f_strings = true;
|
||||
let ast = AstModule::parse(
|
||||
policy_identifier,
|
||||
policy_file_contents.to_string(),
|
||||
&dialect,
|
||||
)
|
||||
.map_err(Error::Starlark)?;
|
||||
let globals = GlobalsBuilder::standard().with(policy_builtins).build();
|
||||
let module = Module::new();
|
||||
{
|
||||
let mut eval = Evaluator::new(&module);
|
||||
eval.extra = Some(&self.builder);
|
||||
eval.eval_module(ast, &globals).map_err(Error::Starlark)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build(self) -> crate::policy::Policy {
|
||||
self.builder.into_inner().build()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, ProvidesStaticType)]
|
||||
struct PolicyBuilder {
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
}
|
||||
|
||||
impl PolicyBuilder {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rules_by_program: MultiMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_rule(&mut self, rule: RuleRef) {
|
||||
self.rules_by_program
|
||||
.insert(rule.program().to_string(), rule);
|
||||
}
|
||||
|
||||
fn build(self) -> crate::policy::Policy {
|
||||
crate::policy::Policy::new(self.rules_by_program)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pattern<'v>(pattern: UnpackList<Value<'v>>) -> Result<Vec<PatternToken>> {
|
||||
let tokens: Vec<PatternToken> = pattern
|
||||
.items
|
||||
.into_iter()
|
||||
.map(parse_pattern_token)
|
||||
.collect::<Result<_>>()?;
|
||||
if tokens.is_empty() {
|
||||
Err(Error::InvalidPattern("pattern cannot be empty".to_string()))
|
||||
} else {
|
||||
Ok(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_pattern_token<'v>(value: Value<'v>) -> Result<PatternToken> {
|
||||
if let Some(s) = value.unpack_str() {
|
||||
Ok(PatternToken::Single(s.to_string()))
|
||||
} else if let Some(list) = ListRef::from_value(value) {
|
||||
let tokens: Vec<String> = list
|
||||
.content()
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.unpack_str()
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidPattern(format!(
|
||||
"pattern alternative must be a string (got {})",
|
||||
value.get_type()
|
||||
))
|
||||
})
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
match tokens.as_slice() {
|
||||
[] => Err(Error::InvalidPattern(
|
||||
"pattern alternatives cannot be empty".to_string(),
|
||||
)),
|
||||
[single] => Ok(PatternToken::Single(single.clone())),
|
||||
_ => Ok(PatternToken::Alts(tokens)),
|
||||
}
|
||||
} else {
|
||||
Err(Error::InvalidPattern(format!(
|
||||
"pattern element must be a string or list of strings (got {})",
|
||||
value.get_type()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_examples<'v>(examples: UnpackList<Value<'v>>) -> Result<Vec<Vec<String>>> {
|
||||
examples.items.into_iter().map(parse_example).collect()
|
||||
}
|
||||
|
||||
fn parse_example<'v>(value: Value<'v>) -> Result<Vec<String>> {
|
||||
if let Some(raw) = value.unpack_str() {
|
||||
parse_string_example(raw)
|
||||
} else if let Some(list) = ListRef::from_value(value) {
|
||||
parse_list_example(list)
|
||||
} else {
|
||||
Err(Error::InvalidExample(format!(
|
||||
"example must be a string or list of strings (got {})",
|
||||
value.get_type()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_string_example(raw: &str) -> Result<Vec<String>> {
|
||||
let tokens = shlex::split(raw).ok_or_else(|| {
|
||||
Error::InvalidExample("example string has invalid shell syntax".to_string())
|
||||
})?;
|
||||
|
||||
if tokens.is_empty() {
|
||||
Err(Error::InvalidExample(
|
||||
"example cannot be an empty string".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_list_example(list: &ListRef) -> Result<Vec<String>> {
|
||||
let tokens: Vec<String> = list
|
||||
.content()
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.unpack_str()
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidExample(format!(
|
||||
"example tokens must be strings (got {})",
|
||||
value.get_type()
|
||||
))
|
||||
})
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
if tokens.is_empty() {
|
||||
Err(Error::InvalidExample(
|
||||
"example cannot be an empty list".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_builder<'v, 'a>(eval: &Evaluator<'v, 'a, '_>) -> RefMut<'a, PolicyBuilder> {
|
||||
#[expect(clippy::expect_used)]
|
||||
eval.extra
|
||||
.as_ref()
|
||||
.expect("policy_builder requires Evaluator.extra to be populated")
|
||||
.downcast_ref::<RefCell<PolicyBuilder>>()
|
||||
.expect("Evaluator.extra must contain a PolicyBuilder")
|
||||
.borrow_mut()
|
||||
}
|
||||
|
||||
#[starlark_module]
|
||||
fn policy_builtins(builder: &mut GlobalsBuilder) {
|
||||
fn prefix_rule<'v>(
|
||||
pattern: UnpackList<Value<'v>>,
|
||||
decision: Option<&'v str>,
|
||||
r#match: Option<UnpackList<Value<'v>>>,
|
||||
not_match: Option<UnpackList<Value<'v>>>,
|
||||
eval: &mut Evaluator<'v, '_, '_>,
|
||||
) -> anyhow::Result<NoneType> {
|
||||
let decision = match decision {
|
||||
Some(raw) => Decision::parse(raw)?,
|
||||
None => Decision::Allow,
|
||||
};
|
||||
|
||||
let pattern_tokens = parse_pattern(pattern)?;
|
||||
|
||||
let matches: Vec<Vec<String>> =
|
||||
r#match.map(parse_examples).transpose()?.unwrap_or_default();
|
||||
let not_matches: Vec<Vec<String>> = not_match
|
||||
.map(parse_examples)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut builder = policy_builder(eval);
|
||||
|
||||
let (first_token, remaining_tokens) = pattern_tokens
|
||||
.split_first()
|
||||
.ok_or_else(|| Error::InvalidPattern("pattern cannot be empty".to_string()))?;
|
||||
|
||||
let rest: Arc<[PatternToken]> = remaining_tokens.to_vec().into();
|
||||
|
||||
let rules: Vec<RuleRef> = first_token
|
||||
.alternatives()
|
||||
.iter()
|
||||
.map(|head| {
|
||||
Arc::new(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from(head.as_str()),
|
||||
rest: rest.clone(),
|
||||
},
|
||||
decision,
|
||||
}) as RuleRef
|
||||
})
|
||||
.collect();
|
||||
|
||||
validate_not_match_examples(&rules, ¬_matches)?;
|
||||
validate_match_examples(&rules, &matches)?;
|
||||
|
||||
rules.into_iter().for_each(|rule| builder.add_rule(rule));
|
||||
Ok(NoneType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use crate::decision::Decision;
|
||||
use crate::rule::RuleMatch;
|
||||
use crate::rule::RuleRef;
|
||||
use multimap::MultiMap;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Policy {
|
||||
rules_by_program: MultiMap<String, RuleRef>,
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
pub fn new(rules_by_program: MultiMap<String, RuleRef>) -> Self {
|
||||
Self { rules_by_program }
|
||||
}
|
||||
|
||||
pub fn rules(&self) -> &MultiMap<String, RuleRef> {
|
||||
&self.rules_by_program
|
||||
}
|
||||
|
||||
pub fn check(&self, cmd: &[String]) -> Evaluation {
|
||||
let rules = match cmd.first() {
|
||||
Some(first) => match self.rules_by_program.get_vec(first) {
|
||||
Some(rules) => rules,
|
||||
None => return Evaluation::NoMatch,
|
||||
},
|
||||
None => return Evaluation::NoMatch,
|
||||
};
|
||||
|
||||
let matched_rules: Vec<RuleMatch> =
|
||||
rules.iter().filter_map(|rule| rule.matches(cmd)).collect();
|
||||
match matched_rules.iter().map(RuleMatch::decision).max() {
|
||||
Some(decision) => Evaluation::Match {
|
||||
decision,
|
||||
matched_rules,
|
||||
},
|
||||
None => Evaluation::NoMatch,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_multiple<Commands>(&self, commands: Commands) -> Evaluation
|
||||
where
|
||||
Commands: IntoIterator,
|
||||
Commands::Item: AsRef<[String]>,
|
||||
{
|
||||
let matched_rules: Vec<RuleMatch> = commands
|
||||
.into_iter()
|
||||
.flat_map(|command| match self.check(command.as_ref()) {
|
||||
Evaluation::Match { matched_rules, .. } => matched_rules,
|
||||
Evaluation::NoMatch => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
match matched_rules.iter().map(RuleMatch::decision).max() {
|
||||
Some(decision) => Evaluation::Match {
|
||||
decision,
|
||||
matched_rules,
|
||||
},
|
||||
None => Evaluation::NoMatch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Evaluation {
|
||||
NoMatch,
|
||||
Match {
|
||||
decision: Decision,
|
||||
#[serde(rename = "matchedRules")]
|
||||
matched_rules: Vec<RuleMatch>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Evaluation {
|
||||
pub fn is_match(&self) -> bool {
|
||||
matches!(self, Self::Match { .. })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use crate::decision::Decision;
|
||||
use crate::error::Error;
|
||||
use crate::error::Result;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use shlex::try_join;
|
||||
use std::any::Any;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Matches a single command token, either a fixed string or one of several allowed alternatives.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PatternToken {
|
||||
Single(String),
|
||||
Alts(Vec<String>),
|
||||
}
|
||||
|
||||
impl PatternToken {
|
||||
fn matches(&self, token: &str) -> bool {
|
||||
match self {
|
||||
Self::Single(expected) => expected == token,
|
||||
Self::Alts(alternatives) => alternatives.iter().any(|alt| alt == token),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alternatives(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Single(expected) => std::slice::from_ref(expected),
|
||||
Self::Alts(alternatives) => alternatives,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix matcher for commands with support for alternative match tokens.
|
||||
/// First token is fixed since we key by the first token in policy.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PrefixPattern {
|
||||
pub first: Arc<str>,
|
||||
pub rest: Arc<[PatternToken]>,
|
||||
}
|
||||
|
||||
impl PrefixPattern {
|
||||
pub fn matches_prefix(&self, cmd: &[String]) -> Option<Vec<String>> {
|
||||
let pattern_length = self.rest.len() + 1;
|
||||
if cmd.len() < pattern_length || cmd[0] != self.first.as_ref() {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (pattern_token, cmd_token) in self.rest.iter().zip(&cmd[1..pattern_length]) {
|
||||
if !pattern_token.matches(cmd_token) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(cmd[..pattern_length].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RuleMatch {
|
||||
PrefixRuleMatch {
|
||||
#[serde(rename = "matchedPrefix")]
|
||||
matched_prefix: Vec<String>,
|
||||
decision: Decision,
|
||||
},
|
||||
}
|
||||
|
||||
impl RuleMatch {
|
||||
pub fn decision(&self) -> Decision {
|
||||
match self {
|
||||
Self::PrefixRuleMatch { decision, .. } => *decision,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PrefixRule {
|
||||
pub pattern: PrefixPattern,
|
||||
pub decision: Decision,
|
||||
}
|
||||
|
||||
pub trait Rule: Any + Debug + Send + Sync {
|
||||
fn program(&self) -> &str;
|
||||
|
||||
fn matches(&self, cmd: &[String]) -> Option<RuleMatch>;
|
||||
}
|
||||
|
||||
pub type RuleRef = Arc<dyn Rule>;
|
||||
|
||||
impl Rule for PrefixRule {
|
||||
fn program(&self) -> &str {
|
||||
self.pattern.first.as_ref()
|
||||
}
|
||||
|
||||
fn matches(&self, cmd: &[String]) -> Option<RuleMatch> {
|
||||
self.pattern
|
||||
.matches_prefix(cmd)
|
||||
.map(|matched_prefix| RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix,
|
||||
decision: self.decision,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Count how many rules match each provided example and error if any example is unmatched.
|
||||
pub(crate) fn validate_match_examples(rules: &[RuleRef], matches: &[Vec<String>]) -> Result<()> {
|
||||
let mut unmatched_examples = Vec::new();
|
||||
|
||||
for example in matches {
|
||||
if rules.iter().any(|rule| rule.matches(example).is_some()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unmatched_examples.push(
|
||||
try_join(example.iter().map(String::as_str))
|
||||
.unwrap_or_else(|_| "unable to render example".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
if unmatched_examples.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::ExampleDidNotMatch {
|
||||
rules: rules.iter().map(|rule| format!("{rule:?}")).collect(),
|
||||
examples: unmatched_examples,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure that no rule matches any provided negative example.
|
||||
pub(crate) fn validate_not_match_examples(
|
||||
rules: &[RuleRef],
|
||||
not_matches: &[Vec<String>],
|
||||
) -> Result<()> {
|
||||
for example in not_matches {
|
||||
if let Some(rule) = rules.iter().find(|rule| rule.matches(example).is_some()) {
|
||||
return Err(Error::ExampleDidMatch {
|
||||
rule: format!("{rule:?}"),
|
||||
example: try_join(example.iter().map(String::as_str))
|
||||
.unwrap_or_else(|_| "unable to render example".to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_execpolicy2::Decision;
|
||||
use codex_execpolicy2::Evaluation;
|
||||
use codex_execpolicy2::PolicyParser;
|
||||
use codex_execpolicy2::RuleMatch;
|
||||
use codex_execpolicy2::RuleRef;
|
||||
use codex_execpolicy2::rule::PatternToken;
|
||||
use codex_execpolicy2::rule::PrefixPattern;
|
||||
use codex_execpolicy2::rule::PrefixRule;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn tokens(cmd: &[&str]) -> Vec<String> {
|
||||
cmd.iter().map(std::string::ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum RuleSnapshot {
|
||||
Prefix(PrefixRule),
|
||||
}
|
||||
|
||||
fn rule_snapshots(rules: &[RuleRef]) -> Vec<RuleSnapshot> {
|
||||
rules
|
||||
.iter()
|
||||
.map(|rule| {
|
||||
let rule_any = rule.as_ref() as &dyn Any;
|
||||
if let Some(prefix_rule) = rule_any.downcast_ref::<PrefixRule>() {
|
||||
RuleSnapshot::Prefix(prefix_rule.clone())
|
||||
} else {
|
||||
panic!("unexpected rule type in RuleRef: {rule:?}");
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_match() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git", "status"],
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = parser.build();
|
||||
let cmd = tokens(&["git", "status"]);
|
||||
let evaluation = policy.check(&cmd);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_multiple_policy_files() {
|
||||
let first_policy = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git"],
|
||||
decision = "prompt",
|
||||
)
|
||||
"#;
|
||||
let second_policy = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git", "commit"],
|
||||
decision = "forbidden",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("first.codexpolicy", first_policy)
|
||||
.expect("parse policy");
|
||||
parser
|
||||
.parse("second.codexpolicy", second_policy)
|
||||
.expect("parse policy");
|
||||
let policy = parser.build();
|
||||
|
||||
let git_rules = rule_snapshots(policy.rules().get_vec("git").expect("git rules"));
|
||||
assert_eq!(
|
||||
vec![
|
||||
RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from("git"),
|
||||
rest: Vec::<PatternToken>::new().into(),
|
||||
},
|
||||
decision: Decision::Prompt,
|
||||
}),
|
||||
RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from("git"),
|
||||
rest: vec![PatternToken::Single("commit".to_string())].into(),
|
||||
},
|
||||
decision: Decision::Forbidden,
|
||||
}),
|
||||
],
|
||||
git_rules
|
||||
);
|
||||
|
||||
let status_eval = policy.check(&tokens(&["git", "status"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Prompt,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
}],
|
||||
},
|
||||
status_eval
|
||||
);
|
||||
|
||||
let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
},
|
||||
],
|
||||
},
|
||||
commit_eval
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_first_token_alias_expands_to_multiple_rules() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = [["bash", "sh"], ["-c", "-l"]],
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = parser.build();
|
||||
|
||||
let bash_rules = rule_snapshots(policy.rules().get_vec("bash").expect("bash rules"));
|
||||
let sh_rules = rule_snapshots(policy.rules().get_vec("sh").expect("sh rules"));
|
||||
assert_eq!(
|
||||
vec![RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from("bash"),
|
||||
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
|
||||
},
|
||||
decision: Decision::Allow,
|
||||
})],
|
||||
bash_rules
|
||||
);
|
||||
assert_eq!(
|
||||
vec![RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from("sh"),
|
||||
rest: vec![PatternToken::Alts(vec!["-c".to_string(), "-l".to_string()])].into(),
|
||||
},
|
||||
decision: Decision::Allow,
|
||||
})],
|
||||
sh_rules
|
||||
);
|
||||
|
||||
let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["bash", "-c"]),
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
},
|
||||
bash_eval
|
||||
);
|
||||
|
||||
let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["sh", "-l"]),
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
},
|
||||
sh_eval
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tail_aliases_are_not_cartesian_expanded() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["npm", ["i", "install"], ["--legacy-peer-deps", "--no-save"]],
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = parser.build();
|
||||
|
||||
let rules = rule_snapshots(policy.rules().get_vec("npm").expect("npm rules"));
|
||||
assert_eq!(
|
||||
vec![RuleSnapshot::Prefix(PrefixRule {
|
||||
pattern: PrefixPattern {
|
||||
first: Arc::from("npm"),
|
||||
rest: vec![
|
||||
PatternToken::Alts(vec!["i".to_string(), "install".to_string()]),
|
||||
PatternToken::Alts(vec![
|
||||
"--legacy-peer-deps".to_string(),
|
||||
"--no-save".to_string(),
|
||||
]),
|
||||
]
|
||||
.into(),
|
||||
},
|
||||
decision: Decision::Allow,
|
||||
})],
|
||||
rules
|
||||
);
|
||||
|
||||
let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
},
|
||||
npm_i
|
||||
);
|
||||
|
||||
let npm_install = policy.check(&tokens(&["npm", "install", "--no-save", "leftpad"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["npm", "install", "--no-save"]),
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
},
|
||||
npm_install
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_and_not_match_examples_are_enforced() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git", "status"],
|
||||
match = [["git", "status"], "git status"],
|
||||
not_match = [
|
||||
["git", "--config", "color.status=always", "status"],
|
||||
"git --config color.status=always status",
|
||||
],
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = parser.build();
|
||||
let match_eval = policy.check(&tokens(&["git", "status"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Allow,
|
||||
matched_rules: vec![RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "status"]),
|
||||
decision: Decision::Allow,
|
||||
}],
|
||||
},
|
||||
match_eval
|
||||
);
|
||||
|
||||
let no_match_eval = policy.check(&tokens(&[
|
||||
"git",
|
||||
"--config",
|
||||
"color.status=always",
|
||||
"status",
|
||||
]));
|
||||
assert_eq!(Evaluation::NoMatch, no_match_eval);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strictest_decision_wins_across_matches() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git"],
|
||||
decision = "prompt",
|
||||
)
|
||||
prefix_rule(
|
||||
pattern = ["git", "commit"],
|
||||
decision = "forbidden",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = parser.build();
|
||||
|
||||
let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]));
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
},
|
||||
],
|
||||
},
|
||||
commit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strictest_decision_across_multiple_commands() {
|
||||
let policy_src = r#"
|
||||
prefix_rule(
|
||||
pattern = ["git"],
|
||||
decision = "prompt",
|
||||
)
|
||||
prefix_rule(
|
||||
pattern = ["git", "commit"],
|
||||
decision = "forbidden",
|
||||
)
|
||||
"#;
|
||||
let mut parser = PolicyParser::new();
|
||||
parser
|
||||
.parse("test.codexpolicy", policy_src)
|
||||
.expect("parse policy");
|
||||
let policy = parser.build();
|
||||
|
||||
let commands = vec![
|
||||
tokens(&["git", "status"]),
|
||||
tokens(&["git", "commit", "-m", "hi"]),
|
||||
];
|
||||
|
||||
let evaluation = policy.check_multiple(&commands);
|
||||
assert_eq!(
|
||||
Evaluation::Match {
|
||||
decision: Decision::Forbidden,
|
||||
matched_rules: vec![
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git"]),
|
||||
decision: Decision::Prompt,
|
||||
},
|
||||
RuleMatch::PrefixRuleMatch {
|
||||
matched_prefix: tokens(&["git", "commit"]),
|
||||
decision: Decision::Forbidden,
|
||||
},
|
||||
],
|
||||
},
|
||||
evaluation
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "codex-lmstudio"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_lmstudio"
|
||||
path = "src/lib.rs"
|
||||
|
||||
|
||||
[dependencies]
|
||||
codex-core = { path = "../core" }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
which = "6.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.6"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,397 @@
|
||||
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_core::config::Config;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LMStudioClient {
|
||||
client: reqwest::Client,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
const LMSTUDIO_CONNECTION_ERROR: &str = "LM Studio is not responding. Install from https://lmstudio.ai/download and run 'lms server start'.";
|
||||
|
||||
impl LMStudioClient {
|
||||
pub async fn try_from_provider(config: &Config) -> std::io::Result<Self> {
|
||||
let provider = config
|
||||
.model_providers
|
||||
.get(LMSTUDIO_OSS_PROVIDER_ID)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Built-in provider {LMSTUDIO_OSS_PROVIDER_ID} not found",),
|
||||
)
|
||||
})?;
|
||||
let base_url = provider.base_url.as_ref().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"oss provider must have a base_url",
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
|
||||
let client = LMStudioClient {
|
||||
client,
|
||||
base_url: base_url.to_string(),
|
||||
};
|
||||
client.check_server().await?;
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
async fn check_server(&self) -> io::Result<()> {
|
||||
let url = format!("{}/models", self.base_url.trim_end_matches('/'));
|
||||
let response = self.client.get(&url).send().await;
|
||||
|
||||
if let Ok(resp) = response {
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"Server returned error: {} {LMSTUDIO_CONNECTION_ERROR}",
|
||||
resp.status()
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
Err(io::Error::other(LMSTUDIO_CONNECTION_ERROR))
|
||||
}
|
||||
}
|
||||
|
||||
// Load a model by sending an empty request with max_tokens 1
|
||||
pub async fn load_model(&self, model: &str) -> io::Result<()> {
|
||||
let url = format!("{}/responses", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"model": model,
|
||||
"input": "",
|
||||
"max_output_tokens": 1
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| io::Error::other(format!("Request failed: {e}")))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
tracing::info!("Successfully loaded model '{model}'");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"Failed to load model: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// Return the list of models available on the LM Studio server.
|
||||
pub async fn fetch_models(&self) -> io::Result<Vec<String>> {
|
||||
let url = format!("{}/models", self.base_url.trim_end_matches('/'));
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| io::Error::other(format!("Request failed: {e}")))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let json: serde_json::Value = response.json().await.map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("JSON parse error: {e}"))
|
||||
})?;
|
||||
let models = json["data"]
|
||||
.as_array()
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "No 'data' array in response")
|
||||
})?
|
||||
.iter()
|
||||
.filter_map(|model| model["id"].as_str())
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
Ok(models)
|
||||
} else {
|
||||
Err(io::Error::other(format!(
|
||||
"Failed to fetch models: {}",
|
||||
response.status()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// Find lms, checking fallback paths if not in PATH
|
||||
fn find_lms() -> std::io::Result<String> {
|
||||
Self::find_lms_with_home_dir(None)
|
||||
}
|
||||
|
||||
fn find_lms_with_home_dir(home_dir: Option<&str>) -> std::io::Result<String> {
|
||||
// First try 'lms' in PATH
|
||||
if which::which("lms").is_ok() {
|
||||
return Ok("lms".to_string());
|
||||
}
|
||||
|
||||
// Platform-specific fallback paths
|
||||
let home = match home_dir {
|
||||
Some(dir) => dir.to_string(),
|
||||
None => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::env::var("HOME").unwrap_or_default()
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("USERPROFILE").unwrap_or_default()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let fallback_path = format!("{home}/.lmstudio/bin/lms");
|
||||
|
||||
#[cfg(windows)]
|
||||
let fallback_path = format!("{home}/.lmstudio/bin/lms.exe");
|
||||
|
||||
if Path::new(&fallback_path).exists() {
|
||||
Ok(fallback_path)
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"LM Studio not found. Please install LM Studio from https://lmstudio.ai/",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_model(&self, model: &str) -> std::io::Result<()> {
|
||||
let lms = Self::find_lms()?;
|
||||
eprintln!("Downloading model: {model}");
|
||||
|
||||
let status = std::process::Command::new(&lms)
|
||||
.args(["get", "--yes", model])
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
std::io::Error::other(format!("Failed to execute '{lms} get --yes {model}': {e}"))
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Model download failed with exit code: {}",
|
||||
status.code().unwrap_or(-1)
|
||||
)));
|
||||
}
|
||||
|
||||
tracing::info!("Successfully downloaded model '{model}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Low-level constructor given a raw host root, e.g. "http://localhost:1234".
|
||||
#[cfg(test)]
|
||||
fn from_host_root(host_root: impl Into<String>) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new());
|
||||
Self {
|
||||
client,
|
||||
base_url: host_root.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_models_happy_path() {
|
||||
if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
tracing::info!(
|
||||
"{} is set; skipping test_fetch_models_happy_path",
|
||||
codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.and(wiremock::matchers::path("/models"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200).set_body_raw(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"id": "openai/gpt-oss-20b"},
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
"application/json",
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = LMStudioClient::from_host_root(server.uri());
|
||||
let models = client.fetch_models().await.expect("fetch models");
|
||||
assert!(models.contains(&"openai/gpt-oss-20b".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_models_no_data_array() {
|
||||
if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
tracing::info!(
|
||||
"{} is set; skipping test_fetch_models_no_data_array",
|
||||
codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.and(wiremock::matchers::path("/models"))
|
||||
.respond_with(
|
||||
wiremock::ResponseTemplate::new(200)
|
||||
.set_body_raw(serde_json::json!({}).to_string(), "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = LMStudioClient::from_host_root(server.uri());
|
||||
let result = client.fetch_models().await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No 'data' array in response")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_models_server_error() {
|
||||
if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
tracing::info!(
|
||||
"{} is set; skipping test_fetch_models_server_error",
|
||||
codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.and(wiremock::matchers::path("/models"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(500))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = LMStudioClient::from_host_root(server.uri());
|
||||
let result = client.fetch_models().await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Failed to fetch models: 500")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_server_happy_path() {
|
||||
if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
tracing::info!(
|
||||
"{} is set; skipping test_check_server_happy_path",
|
||||
codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.and(wiremock::matchers::path("/models"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = LMStudioClient::from_host_root(server.uri());
|
||||
client
|
||||
.check_server()
|
||||
.await
|
||||
.expect("server check should pass");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_check_server_error() {
|
||||
if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
tracing::info!(
|
||||
"{} is set; skipping test_check_server_error",
|
||||
codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let server = wiremock::MockServer::start().await;
|
||||
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||
.and(wiremock::matchers::path("/models"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let client = LMStudioClient::from_host_root(server.uri());
|
||||
let result = client.check_server().await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Server returned error: 404")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lms() {
|
||||
let result = LMStudioClient::find_lms();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// lms was found in PATH - that's fine
|
||||
}
|
||||
Err(e) => {
|
||||
// Expected error when LM Studio not installed
|
||||
assert!(e.to_string().contains("LM Studio not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_lms_with_mock_home() {
|
||||
// Test fallback path construction without touching env vars
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let result = LMStudioClient::find_lms_with_home_dir(Some("/test/home"));
|
||||
if let Err(e) = result {
|
||||
assert!(e.to_string().contains("LM Studio not found"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let result = LMStudioClient::find_lms_with_home_dir(Some("C:\\test\\home"));
|
||||
if let Err(e) = result {
|
||||
assert!(e.to_string().contains("LM Studio not found"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_host_root() {
|
||||
let client = LMStudioClient::from_host_root("http://localhost:1234");
|
||||
assert_eq!(client.base_url, "http://localhost:1234");
|
||||
|
||||
let client = LMStudioClient::from_host_root("https://example.com:8080/api");
|
||||
assert_eq!(client.base_url, "https://example.com:8080/api");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
mod client;
|
||||
|
||||
pub use client::LMStudioClient;
|
||||
use codex_core::config::Config;
|
||||
|
||||
/// Default OSS model to use when `--oss` is passed without an explicit `-m`.
|
||||
pub const DEFAULT_OSS_MODEL: &str = "openai/gpt-oss-20b";
|
||||
|
||||
/// Prepare the local OSS environment when `--oss` is selected.
|
||||
///
|
||||
/// - Ensures a local LM Studio server is reachable.
|
||||
/// - Checks if the model exists locally and downloads it if missing.
|
||||
pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> {
|
||||
let model: &str = config.model.as_ref();
|
||||
|
||||
// Verify local LM Studio is reachable.
|
||||
let lmstudio_client = LMStudioClient::try_from_provider(config).await?;
|
||||
|
||||
match lmstudio_client.fetch_models().await {
|
||||
Ok(models) => {
|
||||
if !models.iter().any(|m| m == model) {
|
||||
lmstudio_client.download_model(model).await?;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Not fatal; higher layers may still proceed and surface errors later.
|
||||
tracing::warn!("Failed to query local models from LM Studio: {}.", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load the model in the background
|
||||
tokio::spawn({
|
||||
let client = lmstudio_client.clone();
|
||||
let model = model.to_string();
|
||||
async move {
|
||||
if let Err(e) = client.load_model(&model).await {
|
||||
tracing::warn!("Failed to load model {}: {}", model, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -174,6 +174,7 @@ async fn run_codex_tool_session_inner(
|
||||
|
||||
match event.msg {
|
||||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
turn_id: _,
|
||||
command,
|
||||
cwd,
|
||||
call_id,
|
||||
@@ -258,6 +259,9 @@ async fn run_codex_tool_session_inner(
|
||||
EventMsg::AgentReasoningDelta(_) => {
|
||||
// TODO: think how we want to support this in the MCP
|
||||
}
|
||||
EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) => {
|
||||
// Ignored in MCP tool runner.
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { .. }) => {
|
||||
// TODO: think how we want to support this in the MCP
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::pull::PullEvent;
|
||||
use crate::pull::PullProgressReporter;
|
||||
use crate::url::base_url_to_host_root;
|
||||
use crate::url::is_openai_compatible_base_url;
|
||||
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use codex_core::ModelProviderInfo;
|
||||
use codex_core::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_core::WireApi;
|
||||
use codex_core::config::Config;
|
||||
|
||||
@@ -34,11 +34,11 @@ impl OllamaClient {
|
||||
// account.
|
||||
let provider = config
|
||||
.model_providers
|
||||
.get(BUILT_IN_OSS_MODEL_PROVIDER_ID)
|
||||
.get(OLLAMA_OSS_PROVIDER_ID)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Built-in provider {BUILT_IN_OSS_MODEL_PROVIDER_ID} not found",),
|
||||
format!("Built-in provider {OLLAMA_OSS_PROVIDER_ID} not found",),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -47,7 +47,8 @@ impl OllamaClient {
|
||||
|
||||
#[cfg(test)]
|
||||
async fn try_from_provider_with_base_url(base_url: &str) -> io::Result<Self> {
|
||||
let provider = codex_core::create_oss_provider_with_base_url(base_url);
|
||||
let provider =
|
||||
codex_core::create_oss_provider_with_base_url(base_url, codex_core::WireApi::Chat);
|
||||
Self::try_from_provider(&provider).await
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ impl SandboxRiskLevel {
|
||||
pub struct ExecApprovalRequestEvent {
|
||||
/// Identifier for the associated exec call, if available.
|
||||
pub call_id: String,
|
||||
/// Turn ID that this command belongs to.
|
||||
/// Uses `#[serde(default)]` for backwards compatibility.
|
||||
#[serde(default)]
|
||||
pub turn_id: String,
|
||||
/// The command to be executed.
|
||||
pub command: Vec<String>,
|
||||
/// The command's working directory.
|
||||
|
||||
@@ -478,6 +478,12 @@ pub enum EventMsg {
|
||||
/// Ack the client's configure message.
|
||||
SessionConfigured(SessionConfiguredEvent),
|
||||
|
||||
/// Incremental MCP startup progress updates.
|
||||
McpStartupUpdate(McpStartupUpdateEvent),
|
||||
|
||||
/// Aggregate MCP startup completion summary.
|
||||
McpStartupComplete(McpStartupCompleteEvent),
|
||||
|
||||
McpToolCallBegin(McpToolCallBeginEvent),
|
||||
|
||||
McpToolCallEnd(McpToolCallEndEvent),
|
||||
@@ -1235,6 +1241,8 @@ impl Default for ExecCommandSource {
|
||||
pub struct ExecCommandBeginEvent {
|
||||
/// Identifier so this can be paired with the ExecCommandEnd event.
|
||||
pub call_id: String,
|
||||
/// Turn ID that this command belongs to.
|
||||
pub turn_id: String,
|
||||
/// The command to be executed.
|
||||
pub command: Vec<String>,
|
||||
/// The command's working directory if not the default cwd for the agent.
|
||||
@@ -1253,6 +1261,21 @@ pub struct ExecCommandBeginEvent {
|
||||
pub struct ExecCommandEndEvent {
|
||||
/// Identifier for the ExecCommandBegin that finished.
|
||||
pub call_id: String,
|
||||
/// Turn ID that this command belongs to.
|
||||
pub turn_id: String,
|
||||
/// The command that was executed.
|
||||
pub command: Vec<String>,
|
||||
/// The command's working directory if not the default cwd for the agent.
|
||||
pub cwd: PathBuf,
|
||||
pub parsed_cmd: Vec<ParsedCommand>,
|
||||
/// Where the command originated. Defaults to Agent for backward compatibility.
|
||||
#[serde(default)]
|
||||
pub source: ExecCommandSource,
|
||||
/// Raw input sent to a unified exec session (if this is an interaction event).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub interaction_input: Option<String>,
|
||||
|
||||
/// Captured stdout
|
||||
pub stdout: String,
|
||||
/// Captured stderr
|
||||
@@ -1383,6 +1406,37 @@ pub struct McpListToolsResponseEvent {
|
||||
pub auth_statuses: std::collections::HashMap<String, McpAuthStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct McpStartupUpdateEvent {
|
||||
/// Server name being started.
|
||||
pub server: String,
|
||||
/// Current startup status.
|
||||
pub status: McpStartupStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "state")]
|
||||
#[ts(rename_all = "snake_case", tag = "state")]
|
||||
pub enum McpStartupStatus {
|
||||
Starting,
|
||||
Ready,
|
||||
Failed { error: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)]
|
||||
pub struct McpStartupCompleteEvent {
|
||||
pub ready: Vec<String>,
|
||||
pub failed: Vec<McpStartupFailure>,
|
||||
pub cancelled: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct McpStartupFailure {
|
||||
pub server: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
@@ -1589,4 +1643,47 @@ mod tests {
|
||||
assert_eq!(deserialized, event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_mcp_startup_update_event() -> Result<()> {
|
||||
let event = Event {
|
||||
id: "init".to_string(),
|
||||
msg: EventMsg::McpStartupUpdate(McpStartupUpdateEvent {
|
||||
server: "srv".to_string(),
|
||||
status: McpStartupStatus::Failed {
|
||||
error: "boom".to_string(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&event)?;
|
||||
assert_eq!(value["msg"]["type"], "mcp_startup_update");
|
||||
assert_eq!(value["msg"]["server"], "srv");
|
||||
assert_eq!(value["msg"]["status"]["state"], "failed");
|
||||
assert_eq!(value["msg"]["status"]["error"], "boom");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_mcp_startup_complete_event() -> Result<()> {
|
||||
let event = Event {
|
||||
id: "init".to_string(),
|
||||
msg: EventMsg::McpStartupComplete(McpStartupCompleteEvent {
|
||||
ready: vec!["a".to_string()],
|
||||
failed: vec![McpStartupFailure {
|
||||
server: "b".to_string(),
|
||||
error: "bad".to_string(),
|
||||
}],
|
||||
cancelled: vec!["c".to_string()],
|
||||
}),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&event)?;
|
||||
assert_eq!(value["msg"]["type"], "mcp_startup_complete");
|
||||
assert_eq!(value["msg"]["ready"][0], "a");
|
||||
assert_eq!(value["msg"]["failed"][0]["server"], "b");
|
||||
assert_eq!(value["msg"]["failed"][0]["error"], "bad");
|
||||
assert_eq!(value["msg"]["cancelled"][0], "c");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ clap = { workspace = true, features = ["derive"] }
|
||||
codex-ansi-escape = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-common = { workspace = true, features = [
|
||||
"cli",
|
||||
"elapsed",
|
||||
@@ -38,7 +39,6 @@ codex-core = { workspace = true }
|
||||
codex-feedback = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-ollama = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
color-eyre = { workspace = true }
|
||||
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
|
||||
@@ -62,6 +62,7 @@ ratatui = { workspace = true, features = [
|
||||
] }
|
||||
ratatui-macros = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["preserve_order"] }
|
||||
shlex = { workspace = true }
|
||||
|
||||
@@ -496,6 +496,9 @@ impl App {
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
self.chat_widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
AppEvent::RateLimitSnapshotFetched(snapshot) => {
|
||||
self.chat_widget.on_rate_limit_snapshot(Some(snapshot));
|
||||
}
|
||||
AppEvent::UpdateReasoningEffort(effort) => {
|
||||
self.on_update_reasoning_effort(effort);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_common::approval_presets::ApprovalPreset;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::RateLimitSnapshot;
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
@@ -41,6 +42,9 @@ pub(crate) enum AppEvent {
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
|
||||
/// Result of refreshing rate limits
|
||||
RateLimitSnapshotFetched(RateLimitSnapshot),
|
||||
|
||||
/// Result of computing a `/diff` command.
|
||||
DiffResult(String),
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
@@ -1103,8 +1104,7 @@ impl ChatComposer {
|
||||
..
|
||||
} = input
|
||||
{
|
||||
let has_ctrl_or_alt =
|
||||
modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT);
|
||||
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
|
||||
if !has_ctrl_or_alt {
|
||||
// Non-ASCII characters (e.g., from IMEs) can arrive in quick bursts and be
|
||||
// misclassified by paste heuristics. Flush any active burst buffer and insert
|
||||
@@ -1174,8 +1174,7 @@ impl ChatComposer {
|
||||
} = input;
|
||||
match code {
|
||||
KeyCode::Char(_) => {
|
||||
let has_ctrl_or_alt = modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| modifiers.contains(KeyModifiers::ALT);
|
||||
let has_ctrl_or_alt = has_ctrl_or_alt(modifiers);
|
||||
if has_ctrl_or_alt {
|
||||
self.paste_burst.clear_window_after_non_char();
|
||||
}
|
||||
|
||||
@@ -279,20 +279,23 @@ impl BottomPane {
|
||||
// esc_backtrack_hint_visible removed; hints are controlled internally.
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
let was_running = self.is_task_running;
|
||||
self.is_task_running = running;
|
||||
self.composer.set_task_running(running);
|
||||
|
||||
if running {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
));
|
||||
if !was_running {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(true);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(true);
|
||||
}
|
||||
self.request_redraw();
|
||||
} else {
|
||||
// Hide the status indicator when a task completes, but keep other modal views.
|
||||
self.hide_status_indicator();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::key_hint::is_altgr;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -247,16 +248,13 @@ impl TextArea {
|
||||
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
self.delete_backward_word()
|
||||
},
|
||||
// Windows AltGr generates ALT|CONTROL; treat as a plain character input unless
|
||||
// we match a specific Control+Alt binding above.
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::ALT)
|
||||
&& modifiers.contains(KeyModifiers::CONTROL) =>
|
||||
{
|
||||
// AltGr on many keyboards reports as Ctrl+Alt; treat it as a literal char.
|
||||
self.insert_str(&c.to_string());
|
||||
},
|
||||
} if is_altgr(modifiers) => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
@@ -1464,6 +1462,7 @@ mod tests {
|
||||
assert_eq!(t.cursor(), 3);
|
||||
}
|
||||
|
||||
#[cfg_attr(not(windows), ignore = "AltGr modifier only applies on Windows")]
|
||||
#[test]
|
||||
fn altgr_ctrl_alt_char_inserts_literal() {
|
||||
let mut t = ta_with("");
|
||||
|
||||
@@ -2,7 +2,10 @@ use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::types::Notifications;
|
||||
use codex_core::git_info::current_branch_name;
|
||||
@@ -27,6 +30,9 @@ use codex_core::protocol::ExecCommandSource;
|
||||
use codex_core::protocol::ExitedReviewModeEvent;
|
||||
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
||||
use codex_core::protocol::McpListToolsResponseEvent;
|
||||
use codex_core::protocol::McpStartupCompleteEvent;
|
||||
use codex_core::protocol::McpStartupStatus;
|
||||
use codex_core::protocol::McpStartupUpdateEvent;
|
||||
use codex_core::protocol::McpToolCallBeginEvent;
|
||||
use codex_core::protocol::McpToolCallEndEvent;
|
||||
use codex_core::protocol::Op;
|
||||
@@ -62,6 +68,7 @@ use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
@@ -116,6 +123,7 @@ use codex_common::approval_presets::builtin_approval_presets;
|
||||
use codex_common::model_presets::ModelPreset;
|
||||
use codex_common::model_presets::builtin_model_presets;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -255,10 +263,12 @@ pub(crate) struct ChatWidget {
|
||||
rate_limit_snapshot: Option<RateLimitSnapshotDisplay>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
rate_limit_poller: Option<JoinHandle<()>>,
|
||||
// Stream lifecycle controller
|
||||
stream_controller: Option<StreamController>,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
task_complete_pending: bool,
|
||||
mcp_startup_status: Option<HashMap<String, McpStartupStatus>>,
|
||||
// Queue of interruptive UI events deferred during an active write cycle
|
||||
interrupts: InterruptManager,
|
||||
// Accumulates the current reasoning block text to extract a header
|
||||
@@ -494,7 +504,7 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option<RateLimitSnapshot>) {
|
||||
if let Some(snapshot) = snapshot {
|
||||
let warnings = self.rate_limit_warnings.take_warnings(
|
||||
snapshot
|
||||
@@ -567,8 +577,76 @@ impl ChatWidget {
|
||||
self.maybe_send_next_queued_input();
|
||||
}
|
||||
|
||||
fn on_warning(&mut self, message: String) {
|
||||
self.add_to_history(history_cell::new_warning_event(message));
|
||||
fn on_warning(&mut self, message: impl Into<String>) {
|
||||
self.add_to_history(history_cell::new_warning_event(message.into()));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) {
|
||||
let mut status = self.mcp_startup_status.take().unwrap_or_default();
|
||||
if let McpStartupStatus::Failed { error } = &ev.status {
|
||||
self.on_warning(error);
|
||||
}
|
||||
status.insert(ev.server, ev.status);
|
||||
self.mcp_startup_status = Some(status);
|
||||
self.bottom_pane.set_task_running(true);
|
||||
if let Some(current) = &self.mcp_startup_status {
|
||||
let total = current.len();
|
||||
let mut starting: Vec<_> = current
|
||||
.iter()
|
||||
.filter_map(|(name, state)| {
|
||||
if matches!(state, McpStartupStatus::Starting) {
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
starting.sort();
|
||||
if let Some(first) = starting.first() {
|
||||
let completed = total.saturating_sub(starting.len());
|
||||
let max_to_show = 3;
|
||||
let mut to_show: Vec<String> = starting
|
||||
.iter()
|
||||
.take(max_to_show)
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
if starting.len() > max_to_show {
|
||||
to_show.push("…".to_string());
|
||||
}
|
||||
let header = if total > 1 {
|
||||
format!(
|
||||
"Starting MCP servers ({completed}/{total}): {}",
|
||||
to_show.join(", ")
|
||||
)
|
||||
} else {
|
||||
format!("Booting MCP server: {first}")
|
||||
};
|
||||
self.set_status_header(header);
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn on_mcp_startup_complete(&mut self, ev: McpStartupCompleteEvent) {
|
||||
let mut parts = Vec::new();
|
||||
if !ev.failed.is_empty() {
|
||||
let failed_servers: Vec<_> = ev.failed.iter().map(|f| f.server.clone()).collect();
|
||||
parts.push(format!("failed: {}", failed_servers.join(", ")));
|
||||
}
|
||||
if !ev.cancelled.is_empty() {
|
||||
self.on_warning(format!(
|
||||
"MCP startup interrupted. The following servers were not initialized: {}",
|
||||
ev.cancelled.join(", ")
|
||||
));
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
self.on_warning(format!("MCP startup incomplete ({})", parts.join("; ")));
|
||||
}
|
||||
|
||||
self.mcp_startup_status = None;
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.maybe_send_next_queued_input();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -1034,7 +1112,7 @@ impl ChatWidget {
|
||||
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
|
||||
let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager);
|
||||
|
||||
Self {
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
codex_op_tx,
|
||||
@@ -1058,9 +1136,11 @@ impl ChatWidget {
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
rate_limit_poller: None,
|
||||
stream_controller: None,
|
||||
running_commands: HashMap::new(),
|
||||
task_complete_pending: false,
|
||||
mcp_startup_status: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -1076,7 +1156,11 @@ impl ChatWidget {
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
feedback,
|
||||
current_rollout_path: None,
|
||||
}
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
|
||||
widget
|
||||
}
|
||||
|
||||
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
|
||||
@@ -1101,7 +1185,7 @@ impl ChatWidget {
|
||||
let codex_op_tx =
|
||||
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
|
||||
|
||||
Self {
|
||||
let mut widget = Self {
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
frame_requester: frame_requester.clone(),
|
||||
codex_op_tx,
|
||||
@@ -1125,9 +1209,11 @@ impl ChatWidget {
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
rate_limit_poller: None,
|
||||
stream_controller: None,
|
||||
running_commands: HashMap::new(),
|
||||
task_complete_pending: false,
|
||||
mcp_startup_status: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -1143,7 +1229,11 @@ impl ChatWidget {
|
||||
last_rendered_width: std::cell::Cell::new(None),
|
||||
feedback,
|
||||
current_rollout_path: None,
|
||||
}
|
||||
};
|
||||
|
||||
widget.prefetch_rate_limits();
|
||||
|
||||
widget
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
@@ -1540,6 +1630,8 @@ impl ChatWidget {
|
||||
}
|
||||
EventMsg::Warning(WarningEvent { message }) => self.on_warning(message),
|
||||
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
|
||||
EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev),
|
||||
EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev),
|
||||
EventMsg::TurnAborted(ev) => match ev.reason {
|
||||
TurnAbortReason::Interrupted => {
|
||||
self.on_interrupted_turn(ev.reason);
|
||||
@@ -1737,6 +1829,38 @@ impl ChatWidget {
|
||||
Local::now(),
|
||||
));
|
||||
}
|
||||
fn stop_rate_limit_poller(&mut self) {
|
||||
if let Some(handle) = self.rate_limit_poller.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn prefetch_rate_limits(&mut self) {
|
||||
self.stop_rate_limit_poller();
|
||||
|
||||
let Some(auth) = self.auth_manager.auth() else {
|
||||
return;
|
||||
};
|
||||
if auth.mode != AuthMode::ChatGPT {
|
||||
return;
|
||||
}
|
||||
|
||||
let base_url = self.config.chatgpt_base_url.clone();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
|
||||
loop {
|
||||
if let Some(snapshot) = fetch_rate_limits(base_url.clone(), auth.clone()).await {
|
||||
app_event_tx.send(AppEvent::RateLimitSnapshotFetched(snapshot));
|
||||
}
|
||||
interval.tick().await;
|
||||
}
|
||||
});
|
||||
|
||||
self.rate_limit_poller = Some(handle);
|
||||
}
|
||||
|
||||
fn lower_cost_preset(&self) -> Option<ModelPreset> {
|
||||
let auth_mode = self.auth_manager.auth().map(|auth| auth.mode);
|
||||
@@ -2774,6 +2898,12 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChatWidget {
|
||||
fn drop(&mut self) {
|
||||
self.stop_rate_limit_poller();
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ChatWidget {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.as_renderable().render(area, buf);
|
||||
@@ -2892,6 +3022,22 @@ fn extract_first_bold(s: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option<RateLimitSnapshot> {
|
||||
match BackendClient::from_auth(base_url, &auth).await {
|
||||
Ok(client) => match client.get_rate_limits().await {
|
||||
Ok(snapshot) => Some(snapshot),
|
||||
Err(err) => {
|
||||
debug!(error = ?err, "failed to fetch rate limits from /usage");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
debug!(error = ?err, "failed to construct backend client for rate limits");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn show_review_commit_picker_with_entries(
|
||||
chat: &mut ChatWidget,
|
||||
|
||||
@@ -12,7 +12,6 @@ use codex_core::CodexAuth;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::OPENAI_DEFAULT_MODEL;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
@@ -53,17 +52,12 @@ use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::File;
|
||||
use std::io::BufRead;
|
||||
use std::io::BufReader;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
use tempfile::tempdir;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.";
|
||||
|
||||
fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
Config::load_from_base_config_with_overrides(
|
||||
@@ -74,70 +68,6 @@ fn test_config() -> Config {
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
// Backward-compat shim for older session logs that predate the
|
||||
// `formatted_output` field on ExecCommandEnd events.
|
||||
fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json::Value {
|
||||
if let Some(obj) = payload.as_object_mut()
|
||||
&& let Some(msg) = obj.get_mut("msg")
|
||||
&& let Some(m) = msg.as_object_mut()
|
||||
{
|
||||
let ty = m.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if ty == "exec_command_end" {
|
||||
let stdout = m.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let stderr = m.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let aggregated = if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}{stderr}")
|
||||
};
|
||||
if !m.contains_key("formatted_output") {
|
||||
m.insert(
|
||||
"formatted_output".to_string(),
|
||||
serde_json::Value::String(aggregated.clone()),
|
||||
);
|
||||
}
|
||||
if !m.contains_key("aggregated_output") {
|
||||
m.insert(
|
||||
"aggregated_output".to_string(),
|
||||
serde_json::Value::String(aggregated),
|
||||
);
|
||||
}
|
||||
} else if ty == "patch_apply_begin"
|
||||
&& let Some(changes) = m.get_mut("changes").and_then(|v| v.as_object_mut())
|
||||
{
|
||||
for change in changes.values_mut() {
|
||||
if change.get("type").is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(change_obj) = change.as_object_mut()
|
||||
&& change_obj.len() == 1
|
||||
&& let Some((legacy_kind, legacy_value)) = change_obj
|
||||
.iter()
|
||||
.next()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
{
|
||||
change_obj.clear();
|
||||
change_obj.insert(
|
||||
"type".to_string(),
|
||||
serde_json::Value::String(legacy_kind.clone()),
|
||||
);
|
||||
match legacy_value {
|
||||
serde_json::Value::Object(payload) => {
|
||||
for (k, v) in payload {
|
||||
change_obj.insert(k, v);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
change_obj.insert("content".to_string(), other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
@@ -333,9 +263,11 @@ fn make_chatwidget_manual() -> (
|
||||
rate_limit_snapshot: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
rate_limit_poller: None,
|
||||
stream_controller: None,
|
||||
running_commands: HashMap::new(),
|
||||
task_complete_pending: false,
|
||||
mcp_startup_status: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
@@ -542,6 +474,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
|
||||
// Trigger an exec approval request with a short, single-line command
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-short".into(),
|
||||
turn_id: "turn-short".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: Some(
|
||||
@@ -585,6 +518,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
// Multiline command: modal should show full command, history records decision only
|
||||
let ev_multi = ExecApprovalRequestEvent {
|
||||
call_id: "call-multi".into(),
|
||||
turn_id: "turn-multi".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: Some(
|
||||
@@ -636,6 +570,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
let long = format!("echo {}", "a".repeat(200));
|
||||
let ev_long = ExecApprovalRequestEvent {
|
||||
call_id: "call-long".into(),
|
||||
turn_id: "turn-long".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), long],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: None,
|
||||
@@ -667,38 +602,64 @@ fn begin_exec_with_source(
|
||||
call_id: &str,
|
||||
raw_cmd: &str,
|
||||
source: ExecCommandSource,
|
||||
) {
|
||||
) -> ExecCommandBeginEvent {
|
||||
// Build the full command vec and parse it using core's parser,
|
||||
// then convert to protocol variants for the event payload.
|
||||
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
|
||||
let parsed_cmd: Vec<ParsedCommand> = codex_core::parse_command::parse_command(&command);
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let interaction_input = None;
|
||||
let event = ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
};
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
command,
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input: None,
|
||||
}),
|
||||
msg: EventMsg::ExecCommandBegin(event.clone()),
|
||||
});
|
||||
event
|
||||
}
|
||||
|
||||
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
|
||||
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent);
|
||||
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) -> ExecCommandBeginEvent {
|
||||
begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent)
|
||||
}
|
||||
|
||||
fn end_exec(chat: &mut ChatWidget, call_id: &str, stdout: &str, stderr: &str, exit_code: i32) {
|
||||
fn end_exec(
|
||||
chat: &mut ChatWidget,
|
||||
begin_event: ExecCommandBeginEvent,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
exit_code: i32,
|
||||
) {
|
||||
let aggregated = if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}{stderr}")
|
||||
};
|
||||
let ExecCommandBeginEvent {
|
||||
call_id,
|
||||
turn_id,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
} = begin_event;
|
||||
chat.handle_codex_event(Event {
|
||||
id: call_id.to_string(),
|
||||
id: call_id.clone(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.to_string(),
|
||||
call_id,
|
||||
turn_id,
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source,
|
||||
interaction_input,
|
||||
stdout: stdout.to_string(),
|
||||
stderr: stderr.to_string(),
|
||||
aggregated_output: aggregated.clone(),
|
||||
@@ -718,30 +679,6 @@ fn active_blob(chat: &ChatWidget) -> String {
|
||||
lines_to_single_string(&lines)
|
||||
}
|
||||
|
||||
fn open_fixture(name: &str) -> File {
|
||||
// 1) Prefer fixtures within this crate
|
||||
{
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.push("tests");
|
||||
p.push("fixtures");
|
||||
p.push(name);
|
||||
if let Ok(f) = File::open(&p) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
// 2) Fallback to parent (workspace root)
|
||||
{
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.push("..");
|
||||
p.push(name);
|
||||
if let Ok(f) = File::open(&p) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
// 3) Last resort: CWD
|
||||
File::open(name).expect("open fixture file")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_enter_during_task_does_not_queue() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
@@ -896,13 +833,13 @@ fn exec_history_cell_shows_working_then_completed() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
begin_exec(&mut chat, "call-1", "echo done");
|
||||
let begin = begin_exec(&mut chat, "call-1", "echo done");
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet");
|
||||
|
||||
// End command successfully
|
||||
end_exec(&mut chat, "call-1", "done", "", 0);
|
||||
end_exec(&mut chat, begin, "done", "", 0);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
// Exec end now finalizes and flushes the exec cell immediately.
|
||||
@@ -926,12 +863,12 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Begin command
|
||||
begin_exec(&mut chat, "call-2", "false");
|
||||
let begin = begin_exec(&mut chat, "call-2", "false");
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 0, "no exec cell should have been flushed yet");
|
||||
|
||||
// End command with failure
|
||||
end_exec(&mut chat, "call-2", "", "Bloop", 2);
|
||||
end_exec(&mut chat, begin, "", "Bloop", 2);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
// Exec end with failure should also flush immediately.
|
||||
@@ -949,7 +886,7 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
fn exec_history_shows_unified_exec_startup_commands() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
begin_exec_with_source(
|
||||
let begin = begin_exec_with_source(
|
||||
&mut chat,
|
||||
"call-startup",
|
||||
"echo unified exec startup",
|
||||
@@ -960,13 +897,7 @@ fn exec_history_shows_unified_exec_startup_commands() {
|
||||
"exec begin should not flush until completion"
|
||||
);
|
||||
|
||||
end_exec(
|
||||
&mut chat,
|
||||
"call-startup",
|
||||
"echo unified exec startup\n",
|
||||
"",
|
||||
0,
|
||||
);
|
||||
end_exec(&mut chat, begin, "echo unified exec startup\n", "", 0);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected finalized exec cell to flush");
|
||||
@@ -1681,29 +1612,29 @@ fn exec_history_extends_previous_when_consecutive() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// 1) Start "ls -la" (List)
|
||||
begin_exec(&mut chat, "call-ls", "ls -la");
|
||||
let begin_ls = begin_exec(&mut chat, "call-ls", "ls -la");
|
||||
assert_snapshot!("exploring_step1_start_ls", active_blob(&chat));
|
||||
|
||||
// 2) Finish "ls -la"
|
||||
end_exec(&mut chat, "call-ls", "", "", 0);
|
||||
end_exec(&mut chat, begin_ls, "", "", 0);
|
||||
assert_snapshot!("exploring_step2_finish_ls", active_blob(&chat));
|
||||
|
||||
// 3) Start "cat foo.txt" (Read)
|
||||
begin_exec(&mut chat, "call-cat-foo", "cat foo.txt");
|
||||
let begin_cat_foo = begin_exec(&mut chat, "call-cat-foo", "cat foo.txt");
|
||||
assert_snapshot!("exploring_step3_start_cat_foo", active_blob(&chat));
|
||||
|
||||
// 4) Complete "cat foo.txt"
|
||||
end_exec(&mut chat, "call-cat-foo", "hello from foo", "", 0);
|
||||
end_exec(&mut chat, begin_cat_foo, "hello from foo", "", 0);
|
||||
assert_snapshot!("exploring_step4_finish_cat_foo", active_blob(&chat));
|
||||
|
||||
// 5) Start & complete "sed -n 100,200p foo.txt" (treated as Read of foo.txt)
|
||||
begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt");
|
||||
end_exec(&mut chat, "call-sed-range", "chunk", "", 0);
|
||||
let begin_sed_range = begin_exec(&mut chat, "call-sed-range", "sed -n 100,200p foo.txt");
|
||||
end_exec(&mut chat, begin_sed_range, "chunk", "", 0);
|
||||
assert_snapshot!("exploring_step5_finish_sed_range", active_blob(&chat));
|
||||
|
||||
// 6) Start & complete "cat bar.txt"
|
||||
begin_exec(&mut chat, "call-cat-bar", "cat bar.txt");
|
||||
end_exec(&mut chat, "call-cat-bar", "hello from bar", "", 0);
|
||||
let begin_cat_bar = begin_exec(&mut chat, "call-cat-bar", "cat bar.txt");
|
||||
end_exec(&mut chat, begin_cat_bar, "hello from bar", "", 0);
|
||||
assert_snapshot!("exploring_step6_finish_cat_bar", active_blob(&chat));
|
||||
}
|
||||
|
||||
@@ -1726,167 +1657,6 @@ fn disabled_slash_command_while_task_running_snapshot() {
|
||||
assert_snapshot!(blob);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_size_transcript_snapshot() {
|
||||
// the snapshot in this test depends on gpt-5-codex. Skip for now. We will consider
|
||||
// creating snapshots for other models in the future.
|
||||
if OPENAI_DEFAULT_MODEL != "gpt-5-codex" {
|
||||
return;
|
||||
}
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Set up a VT100 test terminal to capture ANSI visual output
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 2000;
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
|
||||
.expect("failed to construct terminal");
|
||||
terminal.set_viewport_area(viewport);
|
||||
|
||||
// Replay the recorded session into the widget and collect transcript
|
||||
let file = open_fixture("binary-size-log.jsonl");
|
||||
let reader = BufReader::new(file);
|
||||
let mut transcript = String::new();
|
||||
let mut has_emitted_history = false;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line.expect("read line");
|
||||
if line.trim().is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Ok(v): Result<serde_json::Value, _> = serde_json::from_str(&line) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dir) = v.get("dir").and_then(|d| d.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if dir != "to_tui" {
|
||||
continue;
|
||||
}
|
||||
let Some(kind) = v.get("kind").and_then(|k| k.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match kind {
|
||||
"codex_event" => {
|
||||
if let Some(payload) = v.get("payload") {
|
||||
let ev: Event =
|
||||
serde_json::from_value(upgrade_event_payload_for_tests(payload.clone()))
|
||||
.expect("parse");
|
||||
let ev = match ev {
|
||||
Event {
|
||||
msg: EventMsg::ExecCommandBegin(e),
|
||||
..
|
||||
} => {
|
||||
// Re-parse the command
|
||||
let parsed_cmd = codex_core::parse_command::parse_command(&e.command);
|
||||
Event {
|
||||
id: ev.id,
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: e.call_id.clone(),
|
||||
command: e.command,
|
||||
cwd: e.cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: e.interaction_input.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
_ => ev,
|
||||
};
|
||||
chat.handle_codex_event(ev);
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = app_ev {
|
||||
let mut lines = cell.display_lines(width);
|
||||
if has_emitted_history
|
||||
&& !cell.is_stream_continuation()
|
||||
&& !lines.is_empty()
|
||||
{
|
||||
lines.insert(0, "".into());
|
||||
}
|
||||
has_emitted_history = true;
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines(&mut terminal, lines)
|
||||
.expect("Failed to insert history lines in test");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"app_event" => {
|
||||
if let Some(variant) = v.get("variant").and_then(|s| s.as_str())
|
||||
&& variant == "CommitTick"
|
||||
{
|
||||
chat.on_commit_tick();
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = app_ev {
|
||||
let mut lines = cell.display_lines(width);
|
||||
if has_emitted_history
|
||||
&& !cell.is_stream_continuation()
|
||||
&& !lines.is_empty()
|
||||
{
|
||||
lines.insert(0, "".into());
|
||||
}
|
||||
has_emitted_history = true;
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines(&mut terminal, lines)
|
||||
.expect("Failed to insert history lines in test");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
|
||||
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
|
||||
let screen = terminal.backend().vt100().screen();
|
||||
let mut lines: Vec<String> = Vec::with_capacity(height as usize);
|
||||
for row in 0..height {
|
||||
let mut s = String::with_capacity(width as usize);
|
||||
for col in 0..width {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
if let Some(ch) = cell.contents().chars().next() {
|
||||
s.push(ch);
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
} else {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
// Trim trailing spaces to match plain text fixture
|
||||
lines.push(s.trim_end().to_string());
|
||||
}
|
||||
while lines.last().is_some_and(std::string::String::is_empty) {
|
||||
lines.pop();
|
||||
}
|
||||
// Consider content only after the last session banner marker. Skip the transient
|
||||
// 'thinking' header if present, and start from the first non-empty content line
|
||||
// that follows. This keeps the snapshot stable across sessions.
|
||||
const MARKER_PREFIX: &str = "To get started, describe a task or try one of these commands:";
|
||||
let last_marker_line_idx = lines
|
||||
.iter()
|
||||
.rposition(|l| l.trim_start().starts_with(MARKER_PREFIX))
|
||||
.expect("marker not found in visible output");
|
||||
// Prefer the first assistant content line (blockquote '>' prefix) after the marker;
|
||||
// fallback to the first non-empty, non-'thinking' line.
|
||||
let start_idx = (last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| lines[idx].trim_start().starts_with('•'))
|
||||
.unwrap_or_else(|| {
|
||||
(last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| {
|
||||
let t = lines[idx].trim_start();
|
||||
!t.is_empty() && t != "thinking"
|
||||
})
|
||||
.expect("no content line found after marker")
|
||||
});
|
||||
|
||||
// Snapshot the normalized visible transcript following the banner.
|
||||
assert_snapshot!("binary_size_ideal_response", lines[start_idx..].join("\n"));
|
||||
}
|
||||
|
||||
//
|
||||
// Snapshot test: command approval modal
|
||||
//
|
||||
@@ -1901,6 +1671,7 @@ fn approval_modal_exec_snapshot() {
|
||||
// Inject an exec approval request to display the approval modal.
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd".into(),
|
||||
turn_id: "turn-approve-cmd".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: Some(
|
||||
@@ -1948,6 +1719,7 @@ fn approval_modal_exec_without_reason_snapshot() {
|
||||
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-cmd-noreason".into(),
|
||||
turn_id: "turn-approve-cmd-noreason".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
reason: None,
|
||||
@@ -2157,6 +1929,7 @@ fn status_widget_and_approval_modal_snapshot() {
|
||||
// Now show an approval modal (e.g. exec approval).
|
||||
let ev = ExecApprovalRequestEvent {
|
||||
call_id: "call-approve-exec".into(),
|
||||
turn_id: "turn-approve-exec".into(),
|
||||
command: vec!["echo".into(), "hello world".into()],
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
reason: Some(
|
||||
@@ -2692,7 +2465,7 @@ fn warning_event_adds_warning_history_cell() {
|
||||
chat.handle_codex_event(Event {
|
||||
id: "sub-1".into(),
|
||||
msg: EventMsg::Warning(WarningEvent {
|
||||
message: TEST_WARNING_MESSAGE.to_string(),
|
||||
message: "test warning message".to_string(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -2700,7 +2473,7 @@ fn warning_event_adds_warning_history_cell() {
|
||||
assert_eq!(cells.len(), 1, "expected one warning history cell");
|
||||
let rendered = lines_to_single_string(&cells[0]);
|
||||
assert!(
|
||||
rendered.contains(TEST_WARNING_MESSAGE),
|
||||
rendered.contains("test warning message"),
|
||||
"warning cell missing content: {rendered}"
|
||||
);
|
||||
}
|
||||
@@ -2858,24 +2631,28 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent { message: "I’m going to search the repo for where “Change Approved” is rendered to update that view.".into() }),
|
||||
});
|
||||
|
||||
let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()];
|
||||
let parsed_cmd = vec![
|
||||
ParsedCommand::Search {
|
||||
query: Some("Change Approved".into()),
|
||||
path: None,
|
||||
cmd: "rg \"Change Approved\"".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "diff_render.rs".into(),
|
||||
cmd: "cat diff_render.rs".into(),
|
||||
path: "diff_render.rs".into(),
|
||||
},
|
||||
];
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
chat.handle_codex_event(Event {
|
||||
id: "c1".into(),
|
||||
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: "c1".into(),
|
||||
command: vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()],
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
parsed_cmd: vec![
|
||||
ParsedCommand::Search {
|
||||
query: Some("Change Approved".into()),
|
||||
path: None,
|
||||
cmd: "rg \"Change Approved\"".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "diff_render.rs".into(),
|
||||
cmd: "cat diff_render.rs".into(),
|
||||
path: "diff_render.rs".into(),
|
||||
},
|
||||
],
|
||||
turn_id: "turn-1".into(),
|
||||
command: command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
}),
|
||||
@@ -2884,6 +2661,12 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() {
|
||||
id: "c1".into(),
|
||||
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: "c1".into(),
|
||||
turn_id: "turn-1".into(),
|
||||
command,
|
||||
cwd,
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: String::new(),
|
||||
|
||||
@@ -32,12 +32,16 @@ pub struct Cli {
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Convenience flag to select the local open source model provider.
|
||||
/// Equivalent to -c model_provider=oss; verifies a local Ollama server is
|
||||
/// running.
|
||||
/// Convenience flag to select the local open source model provider. Equivalent to -c
|
||||
/// model_provider=oss; verifies a local LM Studio or Ollama server is running.
|
||||
#[arg(long = "oss", default_value_t = false)]
|
||||
pub oss: bool,
|
||||
|
||||
/// Specify which local provider to use (lmstudio or ollama).
|
||||
/// If not specified with --oss, will use config default or show selection.
|
||||
#[arg(long = "local-provider")]
|
||||
pub oss_provider: Option<String>,
|
||||
|
||||
/// Configuration profile from config.toml to specify default options.
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<String>,
|
||||
|
||||
@@ -1018,10 +1018,8 @@ fn try_new_completed_mcp_tool_call_with_image_output(
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell {
|
||||
PlainHistoryCell {
|
||||
lines: vec![vec![format!("⚠ {message}").yellow()].into()],
|
||||
}
|
||||
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
|
||||
PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ")
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -89,3 +89,19 @@ impl From<&KeyBinding> for Span<'static> {
|
||||
fn key_hint_style() -> Style {
|
||||
Style::default().dim()
|
||||
}
|
||||
|
||||
pub(crate) fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {
|
||||
(mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[inline]
|
||||
pub(crate) fn is_altgr(mods: KeyModifiers) -> bool {
|
||||
mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[inline]
|
||||
pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
+81
-24
@@ -7,18 +7,21 @@ use additional_dirs::add_dir_warning_message;
|
||||
use app::App;
|
||||
pub use app::AppExitInfo;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_common::oss::ensure_oss_provider_ready;
|
||||
use codex_common::oss::get_default_model_for_oss_provider;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::INTERACTIVE_SESSION_SOURCES;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::auth::enforce_login_restrictions;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
||||
use codex_core::config::resolve_oss_provider;
|
||||
use codex_core::find_conversation_path_by_id_str;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -56,6 +59,7 @@ mod markdown_render;
|
||||
mod markdown_stream;
|
||||
mod model_migration;
|
||||
pub mod onboarding;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
mod render;
|
||||
@@ -124,21 +128,75 @@ pub async fn run_main(
|
||||
|
||||
// When using `--oss`, let the bootstrapper pick the model (defaulting to
|
||||
// gpt-oss:20b) and ensure it is present locally. Also, force the built‑in
|
||||
let raw_overrides = cli.config_overrides.raw_overrides.clone();
|
||||
// `oss` model provider.
|
||||
let overrides_cli = codex_common::CliConfigOverrides { raw_overrides };
|
||||
let cli_kv_overrides = match overrides_cli.parse_overrides() {
|
||||
// Parse `-c` overrides from the CLI.
|
||||
Ok(v) => v,
|
||||
#[allow(clippy::print_stderr)]
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing -c overrides: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// we load config.toml here to determine project state.
|
||||
#[allow(clippy::print_stderr)]
|
||||
let codex_home = match find_codex_home() {
|
||||
Ok(codex_home) => codex_home.to_path_buf(),
|
||||
Err(err) => {
|
||||
eprintln!("Error finding codex home: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
#[allow(clippy::print_stderr)]
|
||||
let config_toml =
|
||||
match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides.clone()).await {
|
||||
Ok(config_toml) => config_toml,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading config.toml: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let model_provider_override = if cli.oss {
|
||||
let resolved = resolve_oss_provider(
|
||||
cli.oss_provider.as_deref(),
|
||||
&config_toml,
|
||||
cli.config_profile.clone(),
|
||||
);
|
||||
|
||||
if let Some(provider) = resolved {
|
||||
Some(provider)
|
||||
} else {
|
||||
// No provider configured, prompt the user
|
||||
let provider = oss_selection::select_oss_provider(&codex_home).await?;
|
||||
if provider == "__CANCELLED__" {
|
||||
return Err(std::io::Error::other(
|
||||
"OSS provider selection was cancelled by user",
|
||||
));
|
||||
}
|
||||
Some(provider)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// When using `--oss`, let the bootstrapper pick the model based on selected provider
|
||||
let model = if let Some(model) = &cli.model {
|
||||
Some(model.clone())
|
||||
} else if cli.oss {
|
||||
Some(DEFAULT_OSS_MODEL.to_owned())
|
||||
// Use the provider from model_provider_override
|
||||
model_provider_override
|
||||
.as_ref()
|
||||
.and_then(|provider_id| get_default_model_for_oss_provider(provider_id))
|
||||
.map(std::borrow::ToOwned::to_owned)
|
||||
} else {
|
||||
None // No model specified, will use the default.
|
||||
};
|
||||
|
||||
let model_provider_override = if cli.oss {
|
||||
Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// canonicalize the cwd
|
||||
let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p));
|
||||
let additional_dirs = cli.add_dir.clone();
|
||||
@@ -149,7 +207,7 @@ pub async fn run_main(
|
||||
approval_policy,
|
||||
sandbox_mode,
|
||||
cwd,
|
||||
model_provider: model_provider_override,
|
||||
model_provider: model_provider_override.clone(),
|
||||
config_profile: cli.config_profile.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
base_instructions: None,
|
||||
@@ -161,16 +219,6 @@ pub async fn run_main(
|
||||
experimental_sandbox_command_assessment: None,
|
||||
additional_writable_roots: additional_dirs,
|
||||
};
|
||||
let raw_overrides = cli.config_overrides.raw_overrides.clone();
|
||||
let overrides_cli = codex_common::CliConfigOverrides { raw_overrides };
|
||||
let cli_kv_overrides = match overrides_cli.parse_overrides() {
|
||||
Ok(v) => v,
|
||||
#[allow(clippy::print_stderr)]
|
||||
Err(e) => {
|
||||
eprintln!("Error parsing -c overrides: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await;
|
||||
|
||||
@@ -232,10 +280,19 @@ pub async fn run_main(
|
||||
.with_target(false)
|
||||
.with_filter(targets);
|
||||
|
||||
if cli.oss {
|
||||
codex_ollama::ensure_oss_ready(&config)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?;
|
||||
if cli.oss && model_provider_override.is_some() {
|
||||
// We're in the oss section, so provider_id should be Some
|
||||
// Let's handle None case gracefully though just in case
|
||||
let provider_id = match model_provider_override.as_ref() {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
error!("OSS provider unexpectedly not set when oss flag is used");
|
||||
return Err(std::io::Error::other(
|
||||
"OSS provider not set but oss flag was used",
|
||||
));
|
||||
}
|
||||
};
|
||||
ensure_oss_provider_ready(provider_id, &config).await?;
|
||||
}
|
||||
|
||||
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::io;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use codex_core::DEFAULT_LMSTUDIO_PORT;
|
||||
use codex_core::DEFAULT_OLLAMA_PORT;
|
||||
use codex_core::LMSTUDIO_OSS_PROVIDER_ID;
|
||||
use codex_core::OLLAMA_OSS_PROVIDER_ID;
|
||||
use codex_core::config::set_default_oss_provider;
|
||||
use crossterm::event::Event;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::{self};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::EnterAlternateScreen;
|
||||
use crossterm::terminal::LeaveAlternateScreen;
|
||||
use crossterm::terminal::disable_raw_mode;
|
||||
use crossterm::terminal::enable_raw_mode;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Alignment;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Direction;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Margin;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProviderOption {
|
||||
name: String,
|
||||
status: ProviderStatus,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ProviderStatus {
|
||||
Running,
|
||||
NotRunning,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Options displayed in the *select* mode.
|
||||
///
|
||||
/// The `key` is matched case-insensitively.
|
||||
struct SelectOption {
|
||||
label: Line<'static>,
|
||||
description: &'static str,
|
||||
key: KeyCode,
|
||||
provider_id: &'static str,
|
||||
}
|
||||
|
||||
static OSS_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
vec![
|
||||
SelectOption {
|
||||
label: Line::from(vec!["L".underlined(), "M Studio".into()]),
|
||||
description: "Local LM Studio server (default port 1234)",
|
||||
key: KeyCode::Char('l'),
|
||||
provider_id: LMSTUDIO_OSS_PROVIDER_ID,
|
||||
},
|
||||
SelectOption {
|
||||
label: Line::from(vec!["O".underlined(), "llama".into()]),
|
||||
description: "Local Ollama server (default port 11434)",
|
||||
key: KeyCode::Char('o'),
|
||||
provider_id: OLLAMA_OSS_PROVIDER_ID,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
pub struct OssSelectionWidget<'a> {
|
||||
select_options: &'a Vec<SelectOption>,
|
||||
confirmation_prompt: Paragraph<'a>,
|
||||
|
||||
/// Currently selected index in *select* mode.
|
||||
selected_option: usize,
|
||||
|
||||
/// Set to `true` once a decision has been sent – the parent view can then
|
||||
/// remove this widget from its queue.
|
||||
done: bool,
|
||||
|
||||
selection: Option<String>,
|
||||
}
|
||||
|
||||
impl OssSelectionWidget<'_> {
|
||||
fn new(lmstudio_status: ProviderStatus, ollama_status: ProviderStatus) -> io::Result<Self> {
|
||||
let providers = vec![
|
||||
ProviderOption {
|
||||
name: "LM Studio".to_string(),
|
||||
status: lmstudio_status,
|
||||
},
|
||||
ProviderOption {
|
||||
name: "Ollama".to_string(),
|
||||
status: ollama_status,
|
||||
},
|
||||
];
|
||||
|
||||
let mut contents: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
"? ".fg(Color::Blue),
|
||||
"Select an open-source provider".bold(),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(" Choose which local AI server to use for your session."),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
// Add status indicators for each provider
|
||||
for provider in &providers {
|
||||
let (status_symbol, status_color) = get_status_symbol_and_color(&provider.status);
|
||||
contents.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(status_symbol, Style::default().fg(status_color)),
|
||||
Span::raw(format!(" {} ", provider.name)),
|
||||
]));
|
||||
}
|
||||
contents.push(Line::from(""));
|
||||
contents.push(Line::from(" ● Running ○ Not Running").add_modifier(Modifier::DIM));
|
||||
|
||||
contents.push(Line::from(""));
|
||||
contents.push(
|
||||
Line::from(" Press Enter to select • Ctrl+C to exit").add_modifier(Modifier::DIM),
|
||||
);
|
||||
|
||||
let confirmation_prompt = Paragraph::new(contents).wrap(Wrap { trim: false });
|
||||
|
||||
Ok(Self {
|
||||
select_options: &OSS_SELECT_OPTIONS,
|
||||
confirmation_prompt,
|
||||
selected_option: 0,
|
||||
done: false,
|
||||
selection: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
|
||||
// Should cache this for last value of width.
|
||||
self.confirmation_prompt.line_count(width) as u16
|
||||
}
|
||||
|
||||
/// Process a `KeyEvent` coming from crossterm. Always consumes the event
|
||||
/// while the modal is visible.
|
||||
/// Process a key event originating from crossterm. As the modal fully
|
||||
/// captures input while visible, we don't need to report whether the event
|
||||
/// was consumed—callers can assume it always is.
|
||||
pub fn handle_key_event(&mut self, key: KeyEvent) -> Option<String> {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
self.handle_select_key(key);
|
||||
}
|
||||
if self.done {
|
||||
self.selection.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a key for comparison.
|
||||
/// - For `KeyCode::Char`, converts to lowercase for case-insensitive matching.
|
||||
/// - Other key codes are returned unchanged.
|
||||
fn normalize_keycode(code: KeyCode) -> KeyCode {
|
||||
match code {
|
||||
KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_select_key(&mut self, key_event: KeyEvent) {
|
||||
match key_event.code {
|
||||
KeyCode::Char('c')
|
||||
if key_event
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
||||
{
|
||||
self.send_decision("__CANCELLED__".to_string());
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.selected_option = (self.selected_option + self.select_options.len() - 1)
|
||||
% self.select_options.len();
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.selected_option = (self.selected_option + 1) % self.select_options.len();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let opt = &self.select_options[self.selected_option];
|
||||
self.send_decision(opt.provider_id.to_string());
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.send_decision(LMSTUDIO_OSS_PROVIDER_ID.to_string());
|
||||
}
|
||||
other => {
|
||||
let normalized = Self::normalize_keycode(other);
|
||||
if let Some(opt) = self
|
||||
.select_options
|
||||
.iter()
|
||||
.find(|opt| Self::normalize_keycode(opt.key) == normalized)
|
||||
{
|
||||
self.send_decision(opt.provider_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send_decision(&mut self, selection: String) {
|
||||
self.selection = Some(selection);
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
/// Returns `true` once the user has made a decision and the widget no
|
||||
/// longer needs to be displayed.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
pub fn desired_height(&self, width: u16) -> u16 {
|
||||
self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &OssSelectionWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let prompt_height = self.get_confirmation_prompt_height(area.width);
|
||||
let [prompt_chunk, response_chunk] = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||||
.areas(area);
|
||||
|
||||
let lines: Vec<Line> = self
|
||||
.select_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, opt)| {
|
||||
let style = if idx == self.selected_option {
|
||||
Style::new().bg(Color::Cyan).fg(Color::Black)
|
||||
} else {
|
||||
Style::new().bg(Color::DarkGray)
|
||||
};
|
||||
opt.label.clone().alignment(Alignment::Center).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let [title_area, button_area, description_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(response_chunk.inner(Margin::new(1, 0)));
|
||||
|
||||
Line::from("Select provider?").render(title_area, buf);
|
||||
|
||||
self.confirmation_prompt.clone().render(prompt_chunk, buf);
|
||||
let areas = Layout::horizontal(
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| Constraint::Length(l.width() as u16 + 2)),
|
||||
)
|
||||
.spacing(1)
|
||||
.split(button_area);
|
||||
for (idx, area) in areas.iter().enumerate() {
|
||||
let line = &lines[idx];
|
||||
line.render(*area, buf);
|
||||
}
|
||||
|
||||
Line::from(self.select_options[self.selected_option].description)
|
||||
.style(Style::new().italic().fg(Color::DarkGray))
|
||||
.render(description_area.inner(Margin::new(1, 0)), buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn get_status_symbol_and_color(status: &ProviderStatus) -> (&'static str, Color) {
|
||||
match status {
|
||||
ProviderStatus::Running => ("●", Color::Green),
|
||||
ProviderStatus::NotRunning => ("○", Color::Red),
|
||||
ProviderStatus::Unknown => ("?", Color::Yellow),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn select_oss_provider(codex_home: &std::path::Path) -> io::Result<String> {
|
||||
// Check provider statuses first
|
||||
let lmstudio_status = check_lmstudio_status().await;
|
||||
let ollama_status = check_ollama_status().await;
|
||||
|
||||
// Autoselect if only one is running
|
||||
match (&lmstudio_status, &ollama_status) {
|
||||
(ProviderStatus::Running, ProviderStatus::NotRunning) => {
|
||||
let provider = LMSTUDIO_OSS_PROVIDER_ID.to_string();
|
||||
return Ok(provider);
|
||||
}
|
||||
(ProviderStatus::NotRunning, ProviderStatus::Running) => {
|
||||
let provider = OLLAMA_OSS_PROVIDER_ID.to_string();
|
||||
return Ok(provider);
|
||||
}
|
||||
_ => {
|
||||
// Both running or both not running - show UI
|
||||
}
|
||||
}
|
||||
|
||||
let mut widget = OssSelectionWidget::new(lmstudio_status, ollama_status)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let result = loop {
|
||||
terminal.draw(|f| {
|
||||
(&widget).render_ref(f.area(), f.buffer_mut());
|
||||
})?;
|
||||
|
||||
if let Event::Key(key_event) = event::read()?
|
||||
&& let Some(selection) = widget.handle_key_event(key_event)
|
||||
{
|
||||
break Ok(selection);
|
||||
}
|
||||
};
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
|
||||
// If the user manually selected an OSS provider, we save it as the
|
||||
// default one to use later.
|
||||
if let Ok(ref provider) = result
|
||||
&& let Err(e) = set_default_oss_provider(codex_home, provider)
|
||||
{
|
||||
tracing::warn!("Failed to save OSS provider preference: {e}");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn check_lmstudio_status() -> ProviderStatus {
|
||||
match check_port_status(DEFAULT_LMSTUDIO_PORT).await {
|
||||
Ok(true) => ProviderStatus::Running,
|
||||
Ok(false) => ProviderStatus::NotRunning,
|
||||
Err(_) => ProviderStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_ollama_status() -> ProviderStatus {
|
||||
match check_port_status(DEFAULT_OLLAMA_PORT).await {
|
||||
Ok(true) => ProviderStatus::Running,
|
||||
Ok(false) => ProviderStatus::NotRunning,
|
||||
Err(_) => ProviderStatus::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_port_status(port: u16) -> io::Result<bool> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.map_err(io::Error::other)?;
|
||||
|
||||
let url = format!("http://localhost:{port}");
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => Ok(response.status().is_success()),
|
||||
Err(_) => Ok(false), // Connection failed = not running
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,6 @@ impl Renderable for StatusIndicatorWidget {
|
||||
let elapsed_duration = self.elapsed_duration_at(now);
|
||||
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
|
||||
|
||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||
let mut spans = Vec::with_capacity(5);
|
||||
spans.push(spinner(Some(self.last_resume_at)));
|
||||
spans.push(" ".into());
|
||||
|
||||
-24109
File diff suppressed because one or more lines are too long
@@ -340,7 +340,13 @@ def main() -> int:
|
||||
add("WS: protected path case-variation denied", rc != 0 and assert_not_exists(git_variation), f"rc={rc}")
|
||||
|
||||
# 34. WS: policy tamper (.codex artifacts) denied
|
||||
rc, out, err = run_sbx("workspace-write", ["cmd", "/c", "echo tamper > .codex\\cap_sid"], WS_ROOT)
|
||||
codex_home = Path(os.environ["USERPROFILE"]) / ".codex"
|
||||
cap_sid_target = codex_home / "cap_sid"
|
||||
rc, out, err = run_sbx(
|
||||
"workspace-write",
|
||||
["cmd", "/c", f"echo tamper > \"{cap_sid_target}\""],
|
||||
WS_ROOT,
|
||||
)
|
||||
rc2, out2, err2 = run_sbx("workspace-write", ["cmd", "/c", "echo tamper > .codex\\policy.json"], WS_ROOT)
|
||||
add("WS: .codex cap_sid tamper denied", rc != 0, f"rc={rc}, err={err}")
|
||||
add("WS: .codex policy tamper denied", rc2 != 0, f"rc={rc2}, err={err2}")
|
||||
|
||||
@@ -13,8 +13,8 @@ pub struct CapSids {
|
||||
pub readonly: String,
|
||||
}
|
||||
|
||||
pub fn cap_sid_file(policy_cwd: &Path) -> PathBuf {
|
||||
policy_cwd.join(".codex").join("cap_sid")
|
||||
pub fn cap_sid_file(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("cap_sid")
|
||||
}
|
||||
|
||||
fn make_random_cap_sid_string() -> String {
|
||||
@@ -26,8 +26,8 @@ fn make_random_cap_sid_string() -> String {
|
||||
format!("S-1-5-21-{}-{}-{}-{}", a, b, c, d)
|
||||
}
|
||||
|
||||
pub fn load_or_create_cap_sids(policy_cwd: &Path) -> CapSids {
|
||||
let path = cap_sid_file(policy_cwd);
|
||||
pub fn load_or_create_cap_sids(codex_home: &Path) -> CapSids {
|
||||
let path = cap_sid_file(codex_home);
|
||||
if path.exists() {
|
||||
if let Ok(txt) = fs::read_to_string(&path) {
|
||||
let t = txt.trim();
|
||||
|
||||
@@ -73,6 +73,11 @@ mod windows_impl {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_codex_home_exists(p: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(p)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_env_block(env: &HashMap<String, String>) -> Vec<u16> {
|
||||
let mut items: Vec<(String, String)> =
|
||||
env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
@@ -179,40 +184,37 @@ mod windows_impl {
|
||||
pub fn run_windows_sandbox_capture(
|
||||
policy_json_or_preset: &str,
|
||||
sandbox_policy_cwd: &Path,
|
||||
codex_home: &Path,
|
||||
command: Vec<String>,
|
||||
cwd: &Path,
|
||||
mut env_map: HashMap<String, String>,
|
||||
timeout_ms: Option<u64>,
|
||||
logs_base_dir: Option<&Path>,
|
||||
) -> Result<CaptureResult> {
|
||||
let policy = SandboxPolicy::parse(policy_json_or_preset)?;
|
||||
normalize_null_device_env(&mut env_map);
|
||||
ensure_non_interactive_pager(&mut env_map);
|
||||
apply_no_network_to_env(&mut env_map)?;
|
||||
ensure_codex_home_exists(codex_home)?;
|
||||
|
||||
let current_dir = cwd.to_path_buf();
|
||||
// for now, don't fail if we detect world-writable directories
|
||||
// audit::audit_everyone_writable(¤t_dir, &env_map)?;
|
||||
let logs_base_dir = Some(codex_home);
|
||||
log_start(&command, logs_base_dir);
|
||||
let cap_sid_path = cap_sid_file(codex_home);
|
||||
let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe {
|
||||
match &policy.0 {
|
||||
SandboxMode::ReadOnly => {
|
||||
let caps = load_or_create_cap_sids(sandbox_policy_cwd);
|
||||
ensure_dir(&cap_sid_file(sandbox_policy_cwd))?;
|
||||
fs::write(
|
||||
cap_sid_file(sandbox_policy_cwd),
|
||||
serde_json::to_string(&caps)?,
|
||||
)?;
|
||||
let caps = load_or_create_cap_sids(codex_home);
|
||||
ensure_dir(&cap_sid_path)?;
|
||||
fs::write(&cap_sid_path, serde_json::to_string(&caps)?)?;
|
||||
let psid = convert_string_sid_to_sid(&caps.readonly).unwrap();
|
||||
super::token::create_readonly_token_with_cap(psid)?
|
||||
}
|
||||
SandboxMode::WorkspaceWrite => {
|
||||
let caps = load_or_create_cap_sids(sandbox_policy_cwd);
|
||||
ensure_dir(&cap_sid_file(sandbox_policy_cwd))?;
|
||||
fs::write(
|
||||
cap_sid_file(sandbox_policy_cwd),
|
||||
serde_json::to_string(&caps)?,
|
||||
)?;
|
||||
let caps = load_or_create_cap_sids(codex_home);
|
||||
ensure_dir(&cap_sid_path)?;
|
||||
fs::write(&cap_sid_path, serde_json::to_string(&caps)?)?;
|
||||
let psid = convert_string_sid_to_sid(&caps.workspace).unwrap();
|
||||
super::token::create_workspace_write_token_with_cap(psid)?
|
||||
}
|
||||
@@ -445,11 +447,11 @@ mod stub {
|
||||
pub fn run_windows_sandbox_capture(
|
||||
_policy_json_or_preset: &str,
|
||||
_sandbox_policy_cwd: &Path,
|
||||
_codex_home: &Path,
|
||||
_command: Vec<String>,
|
||||
_cwd: &Path,
|
||||
_env_map: HashMap<String, String>,
|
||||
_timeout_ms: Option<u64>,
|
||||
_logs_base_dir: Option<&Path>,
|
||||
) -> Result<CaptureResult> {
|
||||
bail!("Windows sandbox is only available on Windows")
|
||||
}
|
||||
|
||||
@@ -253,6 +253,20 @@ This is analogous to `model_context_window`, but for the maximum number of outpu
|
||||
|
||||
> See also [`codex exec`](./exec.md) to see how these model settings influence non-interactive runs.
|
||||
|
||||
### oss_provider
|
||||
|
||||
Specifies the default OSS provider to use when running Codex. This is used when the `--oss` flag is provided without a specific provider.
|
||||
|
||||
Valid values are:
|
||||
|
||||
- `"lmstudio"` - Use LM Studio as the local model provider
|
||||
- `"ollama"` - Use Ollama as the local model provider
|
||||
|
||||
```toml
|
||||
# Example: Set default OSS provider to LM Studio
|
||||
oss_provider = "lmstudio"
|
||||
```
|
||||
|
||||
## Execution environment
|
||||
|
||||
### approval_policy
|
||||
|
||||
Reference in New Issue
Block a user