mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
chore: merge origin/main into symlink hardening
Co-authored-by: Codex noreply@openai.com
This commit is contained in:
@@ -21,7 +21,6 @@ codex-network-proxy = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-image = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
codex-utils-template = { workspace = true }
|
||||
encoding_rs = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
icu_decimal = { workspace = true }
|
||||
@@ -60,5 +59,4 @@ tempfile = { workspace = true }
|
||||
[package.metadata.cargo-shear]
|
||||
# Required because:
|
||||
# `icu_provider`: contains a required `sync` feature for `icu_decimal`
|
||||
# `strum`: as strum_macros in non-nightly builds
|
||||
ignored = ["icu_provider", "strum"]
|
||||
ignored = ["icu_provider"]
|
||||
|
||||
@@ -9,7 +9,10 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::num::NonZeroU64;
|
||||
use std::ops::Deref;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use strum_macros::Display;
|
||||
use strum_macros::EnumIter;
|
||||
@@ -18,6 +21,21 @@ use wildmatch::WildMatchPattern;
|
||||
|
||||
use crate::openai_models::ReasoningEffort;
|
||||
|
||||
/// Selects which part of the active context is charged against
|
||||
/// `model_auto_compact_token_limit`.
|
||||
#[derive(
|
||||
Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum AutoCompactTokenLimitScope {
|
||||
/// Count the full active context against the limit.
|
||||
#[default]
|
||||
Total,
|
||||
/// Count sampled output and later growth after the carried window prefix.
|
||||
BodyAfterPrefix,
|
||||
}
|
||||
|
||||
/// A summary of the reasoning performed by the model. This can be useful for
|
||||
/// debugging and understanding the model's reasoning process.
|
||||
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries
|
||||
@@ -77,6 +95,65 @@ pub enum SandboxMode {
|
||||
DangerFullAccess,
|
||||
}
|
||||
|
||||
/// Validated plain profile-v2 name used to select `$CODEX_HOME/<name>.config.toml`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProfileV2Name(String);
|
||||
|
||||
impl ProfileV2Name {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ProfileV2NameParseError {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileV2NameParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"invalid --profile value `{}`; pass a plain name such as `work`",
|
||||
self.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProfileV2NameParseError {}
|
||||
|
||||
impl FromStr for ProfileV2Name {
|
||||
type Err = ProfileV2NameParseError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
if value.is_empty()
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(ProfileV2NameParseError {
|
||||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(value.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for ProfileV2Name {
|
||||
type Target = str;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileV2Name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, TS)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
#[ts(type = r#""user" | "auto_review" | "guardian_subagent""#)]
|
||||
@@ -355,6 +432,12 @@ pub enum ServiceTier {
|
||||
Flex,
|
||||
}
|
||||
|
||||
/// Request/config sentinel for explicit standard routing.
|
||||
///
|
||||
/// This is not a catalog service tier id. It means the user intentionally
|
||||
/// selected no service tier, so model catalog defaults should not apply.
|
||||
pub const SERVICE_TIER_DEFAULT_REQUEST_VALUE: &str = "default";
|
||||
|
||||
impl ServiceTier {
|
||||
pub const fn request_value(self) -> &'static str {
|
||||
match self {
|
||||
@@ -465,22 +548,9 @@ pub enum TrustLevel {
|
||||
|
||||
/// Controls whether the TUI uses the terminal's alternate screen buffer.
|
||||
///
|
||||
/// **Background:** The alternate screen buffer provides a cleaner fullscreen experience
|
||||
/// without polluting the terminal's scrollback history. However, it conflicts with terminal
|
||||
/// multiplexers like Zellij that strictly follow the xterm specification, which defines
|
||||
/// that alternate screen buffers should not have scrollback.
|
||||
///
|
||||
/// **Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode
|
||||
/// (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This
|
||||
/// is by design and not configurable in Zellij—there is no option to enable scrollback in
|
||||
/// alternate screen mode.
|
||||
///
|
||||
/// **Solution:** This setting provides a pragmatic workaround:
|
||||
/// - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij,
|
||||
/// disable alternate screen to preserve scrollback. Enable it everywhere else.
|
||||
/// - `always`: Always use alternate screen mode (original behavior before this fix).
|
||||
/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback
|
||||
/// in all multiplexers.
|
||||
/// - `auto` (default): Use alternate screen mode.
|
||||
/// - `always`: Always use alternate screen mode.
|
||||
/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback.
|
||||
///
|
||||
/// The CLI flag `--no-alt-screen` can override this setting at runtime.
|
||||
#[derive(
|
||||
@@ -489,10 +559,10 @@ pub enum TrustLevel {
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[strum(serialize_all = "lowercase")]
|
||||
pub enum AltScreenMode {
|
||||
/// Auto-detect: disable alternate screen in Zellij, enable elsewhere.
|
||||
/// Use alternate screen mode.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Always use alternate screen (original behavior).
|
||||
/// Always use alternate screen mode.
|
||||
Always,
|
||||
/// Never use alternate screen (inline mode only).
|
||||
Never,
|
||||
@@ -703,6 +773,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_v2_name_rejects_paths_and_empty_names() {
|
||||
assert_eq!(
|
||||
ProfileV2Name::from_str("../foo"),
|
||||
Err(ProfileV2NameParseError {
|
||||
value: "../foo".to_string(),
|
||||
}),
|
||||
"dots and slashes are disallowed to prevent reading arbitrary files"
|
||||
);
|
||||
assert_eq!(
|
||||
ProfileV2Name::from_str(""),
|
||||
Err(ProfileV2NameParseError {
|
||||
value: String::new(),
|
||||
}),
|
||||
"profile name cannot be empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tui_visible_collaboration_modes_match_mode_kind_visibility() {
|
||||
let expected = [ModeKind::Default, ModeKind::Plan];
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::exec_output::ExecToolCallOutput;
|
||||
use crate::network_policy::NetworkPolicyDecisionPayload;
|
||||
use crate::protocol::CodexErrorInfo;
|
||||
use crate::protocol::ErrorEvent;
|
||||
use crate::protocol::RateLimitReachedType;
|
||||
use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::TruncationPolicy;
|
||||
use chrono::DateTime;
|
||||
@@ -89,6 +90,8 @@ pub enum CodexErr {
|
||||
/// Returned by run_command_stream when the spawned child process timed out (10s).
|
||||
#[error("timeout waiting for child process to exit")]
|
||||
Timeout,
|
||||
#[error("request timed out")]
|
||||
RequestTimeout,
|
||||
/// Returned by run_command_stream when the child could not be spawned (its stdout/stderr pipes
|
||||
/// could not be captured). Analogous to the previous `CodexError::Spawn` variant.
|
||||
#[error("spawn failed: child stdout/stderr not captured")]
|
||||
@@ -192,6 +195,7 @@ impl CodexErr {
|
||||
| CodexErr::CyberPolicy { .. } => false,
|
||||
CodexErr::Stream(..)
|
||||
| CodexErr::Timeout
|
||||
| CodexErr::RequestTimeout
|
||||
| CodexErr::UnexpectedStatus(_)
|
||||
| CodexErr::ResponseStreamFailed(_)
|
||||
| CodexErr::ConnectionFailed(_)
|
||||
@@ -448,6 +452,7 @@ pub struct UsageLimitReachedError {
|
||||
pub resets_at: Option<DateTime<Utc>>,
|
||||
pub rate_limits: Option<Box<RateLimitSnapshot>>,
|
||||
pub promo_message: Option<String>,
|
||||
pub rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UsageLimitReachedError {
|
||||
@@ -467,6 +472,38 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(rate_limit_reached_type) = self.rate_limit_reached_type {
|
||||
match rate_limit_reached_type {
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted => {
|
||||
return write!(
|
||||
f,
|
||||
"Your workspace is out of credits. Add credits to continue."
|
||||
);
|
||||
}
|
||||
RateLimitReachedType::WorkspaceMemberCreditsDepleted => {
|
||||
return write!(
|
||||
f,
|
||||
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue."
|
||||
);
|
||||
}
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached => {
|
||||
return write!(
|
||||
f,
|
||||
"You hit your spend cap set in your workspace. Increase your spend cap to continue."
|
||||
);
|
||||
}
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached => {
|
||||
return write!(
|
||||
f,
|
||||
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue."
|
||||
);
|
||||
}
|
||||
RateLimitReachedType::RateLimitReached => {
|
||||
// Generic limits intentionally use the existing promo or plan copy below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(promo_message) = &self.promo_message {
|
||||
return write!(
|
||||
f,
|
||||
|
||||
@@ -56,6 +56,7 @@ fn usage_limit_reached_error_formats_plus_plan() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -63,6 +64,44 @@ fn usage_limit_reached_error_formats_plus_plan() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_rate_limit_reached_types() {
|
||||
let cases = [
|
||||
(
|
||||
RateLimitReachedType::RateLimitReached,
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again later.",
|
||||
),
|
||||
(
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
|
||||
"Your workspace is out of credits. Add credits to continue.",
|
||||
),
|
||||
(
|
||||
RateLimitReachedType::WorkspaceMemberCreditsDepleted,
|
||||
"Your workspace is out of credits. Ask your workspace owner to refill in order to continue.",
|
||||
),
|
||||
(
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
|
||||
"You hit your spend cap set in your workspace. Increase your spend cap to continue.",
|
||||
),
|
||||
(
|
||||
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
|
||||
"You hit your spend cap set by the owner of your workspace. Ask an owner to increase your spend cap to continue.",
|
||||
),
|
||||
];
|
||||
|
||||
for (rate_limit_reached_type, expected) in cases {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::Plus)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: Some(rate_limit_reached_type),
|
||||
};
|
||||
|
||||
assert_eq!(err.to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_overloaded_maps_to_protocol() {
|
||||
let err = CodexErr::ServerOverloaded;
|
||||
@@ -177,6 +216,7 @@ fn usage_limit_reached_error_formats_free_plan() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -191,6 +231,7 @@ fn usage_limit_reached_error_formats_go_plan() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -205,6 +246,7 @@ fn usage_limit_reached_error_formats_default_when_none() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -223,6 +265,7 @@ fn usage_limit_reached_error_formats_team_plan() {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again at {expected_time}."
|
||||
@@ -238,6 +281,7 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -252,6 +296,7 @@ fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -266,6 +311,7 @@ fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -280,6 +326,7 @@ fn usage_limit_reached_error_formats_default_for_other_plans() {
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -298,6 +345,7 @@ fn usage_limit_reached_error_formats_pro_plan_with_reset() {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
@@ -324,6 +372,7 @@ fn usage_limit_reached_error_hides_upsell_for_non_codex_limit_name() {
|
||||
"Visit https://chatgpt.com/codex/settings/usage to purchase more credits"
|
||||
.to_string(),
|
||||
),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit for codex_other. Switch to another model now, or try again at {expected_time}."
|
||||
@@ -343,6 +392,7 @@ fn usage_limit_reached_includes_minutes_when_available() {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -482,6 +532,7 @@ fn usage_limit_reached_includes_hours_and_minutes() {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at {expected_time}."
|
||||
@@ -502,6 +553,7 @@ fn usage_limit_reached_includes_days_hours_minutes() {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -519,6 +571,7 @@ fn usage_limit_reached_less_than_minute() {
|
||||
resets_at: Some(resets_at),
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!("You've hit your usage limit. Try again at {expected_time}.");
|
||||
assert_eq!(err.to_string(), expected);
|
||||
@@ -538,6 +591,7 @@ fn usage_limit_reached_with_promo_message() {
|
||||
promo_message: Some(
|
||||
"To continue using Codex, start a free trial of <PLAN> today".to_string(),
|
||||
),
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let expected = format!(
|
||||
"You've hit your usage limit. To continue using Codex, start a free trial of <PLAN> today, or try again at {expected_time}."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::mcp::CallToolResult;
|
||||
use crate::memory_citation::MemoryCitation;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::ImageDetail;
|
||||
use crate::models::MessagePhase;
|
||||
use crate::models::ResponseItem;
|
||||
use crate::models::WebSearchAction;
|
||||
@@ -55,6 +56,9 @@ pub enum TurnItem {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
|
||||
pub struct UserMessageItem {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub client_id: Option<String>,
|
||||
pub content: Vec<UserInput>,
|
||||
}
|
||||
|
||||
@@ -178,6 +182,9 @@ pub struct McpToolCallItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mcp_app_resource_uri: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub plugin_id: Option<String>,
|
||||
pub status: McpToolCallStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
@@ -233,6 +240,7 @@ impl UserMessageItem {
|
||||
pub fn new(content: &[UserInput]) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
client_id: None,
|
||||
content: content.to_vec(),
|
||||
}
|
||||
}
|
||||
@@ -241,9 +249,12 @@ impl UserMessageItem {
|
||||
// Legacy user-message events flatten only text inputs into `message` and
|
||||
// rebase text element ranges onto that concatenated text.
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
client_id: self.client_id.clone(),
|
||||
message: self.message(),
|
||||
images: Some(self.image_urls()),
|
||||
image_details: self.image_details(),
|
||||
local_images: self.local_image_paths(),
|
||||
local_image_details: self.local_image_details(),
|
||||
text_elements: self.text_elements(),
|
||||
})
|
||||
}
|
||||
@@ -266,6 +277,7 @@ impl UserMessageItem {
|
||||
if let UserInput::Text {
|
||||
text,
|
||||
text_elements,
|
||||
..
|
||||
} = input
|
||||
{
|
||||
// Text element ranges are relative to each text chunk; offset them so they align
|
||||
@@ -290,21 +302,54 @@ impl UserMessageItem {
|
||||
self.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
UserInput::Image { image_url } => Some(image_url.clone()),
|
||||
UserInput::Image { image_url, .. } => Some(image_url.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn image_details(&self) -> Vec<Option<ImageDetail>> {
|
||||
trim_trailing_default_image_details(
|
||||
self.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
UserInput::Image { detail, .. } => Some(*detail),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn local_image_paths(&self) -> Vec<std::path::PathBuf> {
|
||||
self.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
UserInput::LocalImage { path } => Some(path.clone()),
|
||||
UserInput::LocalImage { path, .. } => Some(path.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn local_image_details(&self) -> Vec<Option<ImageDetail>> {
|
||||
trim_trailing_default_image_details(
|
||||
self.content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
UserInput::LocalImage { detail, .. } => Some(*detail),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_trailing_default_image_details(
|
||||
mut details: Vec<Option<ImageDetail>>,
|
||||
) -> Vec<Option<ImageDetail>> {
|
||||
while matches!(details.last(), Some(None)) {
|
||||
details.pop();
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
impl HookPromptItem {
|
||||
@@ -494,6 +539,7 @@ impl McpToolCallItem {
|
||||
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
|
||||
},
|
||||
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
|
||||
plugin_id: self.plugin_id.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -512,6 +558,7 @@ impl McpToolCallItem {
|
||||
arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()),
|
||||
},
|
||||
mcp_app_resource_uri: self.mcp_app_resource_uri.clone(),
|
||||
plugin_id: self.plugin_id.clone(),
|
||||
duration: self.duration?,
|
||||
result,
|
||||
}))
|
||||
|
||||
@@ -26,6 +26,18 @@ impl std::fmt::Display for RequestId {
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation metadata advertised by an initialized MCP server.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct McpServerInfo {
|
||||
pub name: String,
|
||||
pub title: Option<String>,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
pub icons: Option<Vec<serde_json::Value>>,
|
||||
pub website_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Definition for a tool the client can call.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
+249
-136
@@ -126,7 +126,7 @@ impl FileSystemPermissions {
|
||||
match entry.access {
|
||||
FileSystemAccessMode::Read => read.push(path.clone()),
|
||||
FileSystemAccessMode::Write => write.push(path.clone()),
|
||||
FileSystemAccessMode::None => return None,
|
||||
FileSystemAccessMode::Deny => return None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +297,15 @@ impl ManagedFileSystemPermissions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserved identifier for the built-in read-only permission profile.
|
||||
pub const BUILT_IN_PERMISSION_PROFILE_READ_ONLY: &str = ":read-only";
|
||||
|
||||
/// Reserved identifier for the built-in workspace-write permission profile.
|
||||
pub const BUILT_IN_PERMISSION_PROFILE_WORKSPACE: &str = ":workspace";
|
||||
|
||||
/// Reserved identifier for the built-in full-access permission profile.
|
||||
pub const BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS: &str = ":danger-full-access";
|
||||
|
||||
/// Canonical active runtime permissions for a conversation, turn, or command.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Serialize, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -330,26 +339,11 @@ pub struct ActivePermissionProfile {
|
||||
/// profile.
|
||||
pub id: String,
|
||||
|
||||
/// Optional parent profile identifier once permissions profiles support
|
||||
/// inheritance. This is always `None` until that config feature exists.
|
||||
/// Optional parent profile identifier from the selected permissions
|
||||
/// profile's `extends` setting.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub extends: Option<String>,
|
||||
|
||||
/// Bounded user-requested modifications applied on top of the named
|
||||
/// profile, if any.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub modifications: Vec<ActivePermissionProfileModification>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[ts(tag = "type")]
|
||||
pub enum ActivePermissionProfileModification {
|
||||
/// Additional concrete directory that should be writable.
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
AdditionalWritableRoot { path: AbsolutePathBuf },
|
||||
}
|
||||
|
||||
impl ActivePermissionProfile {
|
||||
@@ -357,16 +351,11 @@ impl ActivePermissionProfile {
|
||||
Self {
|
||||
id: id.into(),
|
||||
extends: None,
|
||||
modifications: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_modifications(
|
||||
mut self,
|
||||
modifications: Vec<ActivePermissionProfileModification>,
|
||||
) -> Self {
|
||||
self.modifications = modifications;
|
||||
self
|
||||
pub fn read_only() -> Self {
|
||||
Self::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +391,7 @@ impl PermissionProfile {
|
||||
/// Managed workspace-write filesystem access with restricted network
|
||||
/// access.
|
||||
///
|
||||
/// The returned profile contains symbolic `:project_roots` entries that
|
||||
/// The returned profile contains symbolic `:workspace_roots` entries that
|
||||
/// must be resolved against the active permission root before enforcement.
|
||||
pub fn workspace_write() -> Self {
|
||||
Self::workspace_write_with(
|
||||
@@ -416,7 +405,7 @@ impl PermissionProfile {
|
||||
/// Managed workspace-write filesystem access with the legacy
|
||||
/// `sandbox_workspace_write` knobs applied directly to the profile.
|
||||
///
|
||||
/// The returned profile contains symbolic `:project_roots` entries that
|
||||
/// The returned profile contains symbolic `:workspace_roots` entries that
|
||||
/// must be resolved against the active permission root before enforcement.
|
||||
pub fn workspace_write_with(
|
||||
writable_roots: &[AbsolutePathBuf],
|
||||
@@ -435,6 +424,28 @@ impl PermissionProfile {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn materialize_project_roots_with_workspace_roots(
|
||||
self,
|
||||
workspace_roots: &[AbsolutePathBuf],
|
||||
) -> Self {
|
||||
match self {
|
||||
Self::Managed {
|
||||
file_system,
|
||||
network,
|
||||
} => {
|
||||
let file_system = file_system
|
||||
.to_sandbox_policy()
|
||||
.materialize_project_roots_with_workspace_roots(workspace_roots);
|
||||
Self::Managed {
|
||||
file_system: ManagedFileSystemPermissions::from_sandbox_policy(&file_system),
|
||||
network,
|
||||
}
|
||||
}
|
||||
Self::Disabled => Self::Disabled,
|
||||
Self::External { network } => Self::External { network },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_runtime_permissions(
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
@@ -880,7 +891,10 @@ pub enum ResponseItem {
|
||||
result: String,
|
||||
},
|
||||
#[serde(alias = "compaction_summary")]
|
||||
Compaction { encrypted_content: String },
|
||||
Compaction {
|
||||
encrypted_content: String,
|
||||
},
|
||||
CompactionTrigger,
|
||||
ContextCompaction {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
@@ -890,6 +904,13 @@ pub enum ResponseItem {
|
||||
Other,
|
||||
}
|
||||
|
||||
impl ResponseItem {
|
||||
/// Returns whether this item is an ordinary user-role message.
|
||||
pub fn is_user_message(&self) -> bool {
|
||||
matches!(self, Self::Message { role, .. } if role == "user")
|
||||
}
|
||||
}
|
||||
|
||||
pub const BASE_INSTRUCTIONS_DEFAULT: &str = include_str!("prompts/base_instructions/default.md");
|
||||
|
||||
/// Base instructions for the model in a thread. Corresponds to the `instructions` field in the ResponsesAPI.
|
||||
@@ -1054,8 +1075,13 @@ pub fn local_image_content_items_with_label_number(
|
||||
path: &std::path::Path,
|
||||
file_bytes: Vec<u8>,
|
||||
label_number: Option<usize>,
|
||||
mode: PromptImageMode,
|
||||
detail: ImageDetail,
|
||||
) -> Vec<ContentItem> {
|
||||
let mode = match detail {
|
||||
ImageDetail::Original => PromptImageMode::Original,
|
||||
ImageDetail::Auto | ImageDetail::Low | ImageDetail::High => PromptImageMode::ResizeToFit,
|
||||
};
|
||||
|
||||
match load_for_prompt_bytes(path, file_bytes, mode) {
|
||||
Ok(image) => {
|
||||
let mut items = Vec::with_capacity(3);
|
||||
@@ -1066,7 +1092,7 @@ pub fn local_image_content_items_with_label_number(
|
||||
}
|
||||
items.push(ContentItem::InputImage {
|
||||
image_url: image.into_data_url(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
detail: Some(detail),
|
||||
});
|
||||
if label_number.is_some() {
|
||||
items.push(ContentItem::InputText {
|
||||
@@ -1211,29 +1237,25 @@ impl From<Vec<UserInput>> for ResponseInputItem {
|
||||
.into_iter()
|
||||
.flat_map(|c| match c {
|
||||
UserInput::Text { text, .. } => vec![ContentItem::InputText { text }],
|
||||
UserInput::Image { image_url } => {
|
||||
UserInput::Image {
|
||||
image_url, detail, ..
|
||||
} => {
|
||||
image_index += 1;
|
||||
vec![
|
||||
ContentItem::InputText {
|
||||
text: image_open_tag_text(),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: image_close_tag_text(),
|
||||
},
|
||||
]
|
||||
let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
|
||||
vec![ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(detail),
|
||||
}]
|
||||
}
|
||||
UserInput::LocalImage { path } => {
|
||||
UserInput::LocalImage { path, detail, .. } => {
|
||||
image_index += 1;
|
||||
let detail = detail.unwrap_or(DEFAULT_IMAGE_DETAIL);
|
||||
match std::fs::read(&path) {
|
||||
Ok(file_bytes) => local_image_content_items_with_label_number(
|
||||
&path,
|
||||
file_bytes,
|
||||
Some(image_index),
|
||||
PromptImageMode::ResizeToFit,
|
||||
detail,
|
||||
),
|
||||
Err(err) => vec![local_image_error_placeholder(&path, err)],
|
||||
}
|
||||
@@ -1253,30 +1275,6 @@ pub struct SearchToolCallParams {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
|
||||
/// or `shell`, the `arguments` field should deserialize to this struct.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
pub struct ShellToolCallParams {
|
||||
pub command: Vec<String>,
|
||||
pub workdir: Option<String>,
|
||||
|
||||
/// This is the maximum time in milliseconds that the command is allowed to run.
|
||||
#[serde(alias = "timeout")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub sandbox_permissions: Option<SandboxPermissions>,
|
||||
/// Suggests a command prefix to persist for future sessions
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub prefix_rule: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub additional_permissions: Option<AdditionalPermissionProfile>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
|
||||
/// `arguments` field should deserialize to this struct.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -1319,6 +1317,9 @@ pub enum FunctionCallOutputContentItem {
|
||||
#[ts(optional)]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
EncryptedContent {
|
||||
encrypted_content: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Converts structured function-call output content into plain text for
|
||||
@@ -1342,7 +1343,8 @@ pub fn function_call_output_content_items_to_text(
|
||||
Some(text.as_str())
|
||||
}
|
||||
FunctionCallOutputContentItem::InputText { .. }
|
||||
| FunctionCallOutputContentItem::InputImage { .. } => None,
|
||||
| FunctionCallOutputContentItem::InputImage { .. }
|
||||
| FunctionCallOutputContentItem::EncryptedContent { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -1647,6 +1649,14 @@ mod tests {
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
// A tiny valid PNG (1x1) so image conversion tests don't depend on cross-crate
|
||||
// file paths, which break under Bazel sandboxing.
|
||||
const TINY_PNG_BYTES: &[u8] = &[
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6,
|
||||
0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0,
|
||||
1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn response_input_message_conversion_preserves_phase() {
|
||||
let item = ResponseItem::from(ResponseInputItem::Message {
|
||||
@@ -1670,6 +1680,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_detail_roundtrips_all_wire_values() -> Result<()> {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ImageDetail>("\"auto\"")?,
|
||||
ImageDetail::Auto
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<ImageDetail>("\"low\"")?,
|
||||
ImageDetail::Low
|
||||
);
|
||||
assert_eq!(serde_json::to_string(&ImageDetail::Auto)?, "\"auto\"");
|
||||
assert_eq!(serde_json::to_string(&ImageDetail::Low)?, "\"low\"");
|
||||
|
||||
let content_item: ContentItem = serde_json::from_value(serde_json::json!({
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,abc",
|
||||
"detail": "auto",
|
||||
}))?;
|
||||
|
||||
assert_eq!(
|
||||
content_item,
|
||||
ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abc".to_string(),
|
||||
detail: Some(ImageDetail::Auto),
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_permissions_helpers_match_documented_semantics() {
|
||||
let cases = [
|
||||
@@ -1787,7 +1827,7 @@ mod tests {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: "**/*.env".to_string(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}]);
|
||||
file_system_sandbox_policy.glob_scan_max_depth = Some(2);
|
||||
|
||||
@@ -2062,6 +2102,9 @@ mod tests {
|
||||
image_url: "data:image/png;base64,AAA".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: "enc_opaque".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let text = function_call_output_content_items_to_text(&content_items);
|
||||
@@ -2269,6 +2312,35 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_encrypted_function_output_content_as_array() -> Result<()> {
|
||||
let item = ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call1".into(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: "enc_opaque".into(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(&item)?;
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": "call1",
|
||||
"output": [
|
||||
{
|
||||
"type": "encrypted_content",
|
||||
"encrypted_content": "enc_opaque",
|
||||
}
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_existing_image_data_urls() -> Result<()> {
|
||||
let call_tool_result = CallToolResult {
|
||||
@@ -2393,6 +2465,30 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_encrypted_array_payload_into_items() -> Result<()> {
|
||||
let json = r#"[
|
||||
{"type": "encrypted_content", "encrypted_content": "enc_opaque"}
|
||||
]"#;
|
||||
|
||||
let payload: FunctionCallOutputPayload = serde_json::from_str(json)?;
|
||||
let expected_items = vec![FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: "enc_opaque".into(),
|
||||
}];
|
||||
|
||||
assert_eq!(payload.success, None);
|
||||
assert_eq!(
|
||||
payload.body,
|
||||
FunctionCallOutputBody::ContentItems(expected_items.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&payload)?,
|
||||
serde_json::to_string(&expected_items)?
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_compaction_alias() -> Result<()> {
|
||||
let json = r#"{"type":"compaction_summary","encrypted_content":"abc"}"#;
|
||||
@@ -2424,20 +2520,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_context_compaction_trigger_without_payload() -> Result<()> {
|
||||
let item = ResponseItem::ContextCompaction {
|
||||
encrypted_content: None,
|
||||
};
|
||||
fn serializes_compaction_trigger_without_payload() -> Result<()> {
|
||||
let item = ResponseItem::CompactionTrigger;
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(item)?,
|
||||
serde_json::json!({
|
||||
"type": "context_compaction",
|
||||
"type": "compaction_trigger",
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_compaction_trigger_without_payload() -> Result<()> {
|
||||
let json = r#"{"type":"compaction_trigger"}"#;
|
||||
|
||||
let item: ResponseItem = serde_json::from_str(json)?;
|
||||
|
||||
assert_eq!(item, ResponseItem::CompactionTrigger);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_legacy_ghost_snapshot_as_other() -> Result<()> {
|
||||
let json = r#"{
|
||||
@@ -2546,51 +2650,20 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_shell_tool_call_params() -> Result<()> {
|
||||
let json = r#"{
|
||||
"command": ["ls", "-l"],
|
||||
"workdir": "/tmp",
|
||||
"timeout": 1000
|
||||
}"#;
|
||||
|
||||
let params: ShellToolCallParams = serde_json::from_str(json)?;
|
||||
assert_eq!(
|
||||
ShellToolCallParams {
|
||||
command: vec!["ls".to_string(), "-l".to_string()],
|
||||
workdir: Some("/tmp".to_string()),
|
||||
timeout_ms: Some(1000),
|
||||
sandbox_permissions: None,
|
||||
prefix_rule: None,
|
||||
additional_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
params
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_image_user_input_with_tags() -> Result<()> {
|
||||
fn serializes_image_user_input_without_tags() -> Result<()> {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
ResponseInputItem::Message { content, .. } => {
|
||||
let expected = vec![
|
||||
ContentItem::InputText {
|
||||
text: image_open_tag_text(),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: image_close_tag_text(),
|
||||
},
|
||||
];
|
||||
let expected = vec![ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}];
|
||||
assert_eq!(content, expected);
|
||||
}
|
||||
other => panic!("expected message response but got {other:?}"),
|
||||
@@ -2599,6 +2672,31 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_user_input_preserves_requested_detail() -> Result<()> {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
detail: Some(ImageDetail::Original),
|
||||
}]);
|
||||
|
||||
match item {
|
||||
ResponseInputItem::Message { content, .. } => {
|
||||
assert_eq!(
|
||||
content.first(),
|
||||
Some(&ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(ImageDetail::Original),
|
||||
})
|
||||
);
|
||||
}
|
||||
other => panic!("expected message response but got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_call_roundtrips() -> Result<()> {
|
||||
let parsed: ResponseItem = serde_json::from_str(
|
||||
@@ -2767,55 +2865,40 @@ mod tests {
|
||||
let image_url = "data:image/png;base64,abc".to_string();
|
||||
let dir = tempdir()?;
|
||||
let local_path = dir.path().join("local.png");
|
||||
// A tiny valid PNG (1x1) so this test doesn't depend on cross-crate file paths, which
|
||||
// break under Bazel sandboxing.
|
||||
const TINY_PNG_BYTES: &[u8] = &[
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
|
||||
8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2,
|
||||
0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
];
|
||||
std::fs::write(&local_path, TINY_PNG_BYTES)?;
|
||||
|
||||
let item = ResponseInputItem::from(vec![
|
||||
UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
detail: None,
|
||||
},
|
||||
UserInput::LocalImage {
|
||||
path: local_path,
|
||||
detail: None,
|
||||
},
|
||||
UserInput::LocalImage { path: local_path },
|
||||
]);
|
||||
|
||||
match item {
|
||||
ResponseInputItem::Message { content, .. } => {
|
||||
assert_eq!(
|
||||
content.first(),
|
||||
Some(&ContentItem::InputText {
|
||||
text: image_open_tag_text(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
content.get(1),
|
||||
Some(&ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
content.get(2),
|
||||
Some(&ContentItem::InputText {
|
||||
text: image_close_tag_text(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
content.get(3),
|
||||
content.get(1),
|
||||
Some(&ContentItem::InputText {
|
||||
text: local_image_open_tag_text(/*label_number*/ 2),
|
||||
})
|
||||
);
|
||||
assert!(matches!(
|
||||
content.get(4),
|
||||
content.get(2),
|
||||
Some(ContentItem::InputImage { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
content.get(5),
|
||||
content.get(3),
|
||||
Some(&ContentItem::InputText {
|
||||
text: image_close_tag_text(),
|
||||
})
|
||||
@@ -2827,6 +2910,33 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_image_user_input_preserves_requested_detail() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
let local_path = dir.path().join("local.png");
|
||||
std::fs::write(&local_path, TINY_PNG_BYTES)?;
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
|
||||
path: local_path,
|
||||
detail: Some(ImageDetail::Original),
|
||||
}]);
|
||||
|
||||
match item {
|
||||
ResponseInputItem::Message { content, .. } => {
|
||||
assert!(matches!(
|
||||
content.get(1),
|
||||
Some(ContentItem::InputImage {
|
||||
detail: Some(ImageDetail::Original),
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
other => panic!("expected message response but got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_image_read_error_adds_placeholder() -> Result<()> {
|
||||
let dir = tempdir()?;
|
||||
@@ -2834,6 +2944,7 @@ mod tests {
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
|
||||
path: missing_path.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
@@ -2868,6 +2979,7 @@ mod tests {
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
|
||||
path: json_path.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
@@ -2905,6 +3017,7 @@ mod tests {
|
||||
|
||||
let item = ResponseInputItem::from(vec![UserInput::LocalImage {
|
||||
path: svg_path.clone(),
|
||||
detail: None,
|
||||
}]);
|
||||
|
||||
match item {
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::str::FromStr;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::Display;
|
||||
use strum_macros::EnumIter;
|
||||
@@ -17,6 +19,8 @@ use ts_rs::TS;
|
||||
|
||||
use crate::config_types::Personality;
|
||||
use crate::config_types::ReasoningSummary;
|
||||
use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
|
||||
use crate::config_types::ServiceTier;
|
||||
use crate::config_types::Verbosity;
|
||||
|
||||
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
|
||||
@@ -146,6 +150,9 @@ pub struct ModelPreset {
|
||||
/// Service tiers this model can run with.
|
||||
#[serde(default)]
|
||||
pub service_tiers: Vec<ModelServiceTier>,
|
||||
/// Catalog default service tier id for this model.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_service_tier: Option<String>,
|
||||
/// Whether this is the default model for new users.
|
||||
pub is_default: bool,
|
||||
/// recommended upgrade model
|
||||
@@ -202,7 +209,6 @@ pub enum ConfigShellToolType {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApplyPatchToolType {
|
||||
Freeform,
|
||||
Function,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
@@ -223,6 +229,25 @@ pub enum TruncationMode {
|
||||
Tokens,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolMode {
|
||||
Direct,
|
||||
CodeMode,
|
||||
CodeModeOnly,
|
||||
}
|
||||
|
||||
fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let Some(value) = Option::<String>::deserialize(deserializer)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(serde_json::from_value(serde_json::Value::String(value)).ok())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)]
|
||||
pub struct TruncationPolicyConfig {
|
||||
pub mode: TruncationMode,
|
||||
@@ -270,6 +295,8 @@ pub struct ModelInfo {
|
||||
pub additional_speed_tiers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub service_tiers: Vec<ModelServiceTier>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_service_tier: Option<String>,
|
||||
pub availability_nux: Option<ModelAvailabilityNux>,
|
||||
pub upgrade: Option<ModelInfoUpgrade>,
|
||||
pub base_instructions: String,
|
||||
@@ -312,6 +339,14 @@ pub struct ModelInfo {
|
||||
pub used_fallback_model_metadata: bool,
|
||||
#[serde(default)]
|
||||
pub supports_search_tool: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_review_model_override: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
deserialize_with = "deserialize_optional_model_selector"
|
||||
)]
|
||||
pub tool_mode: Option<ToolMode>,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
@@ -455,6 +490,7 @@ impl From<ModelInfo> for ModelPreset {
|
||||
supports_personality,
|
||||
additional_speed_tiers: info.additional_speed_tiers,
|
||||
service_tiers: info.service_tiers,
|
||||
default_service_tier: info.default_service_tier,
|
||||
is_default: false, // default is the highest priority available model
|
||||
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
|
||||
id: upgrade.model.clone(),
|
||||
@@ -479,13 +515,30 @@ impl ModelPreset {
|
||||
pub fn supports_fast_mode(&self) -> bool {
|
||||
self.service_tiers
|
||||
.iter()
|
||||
.any(|tier| tier.id == SPEED_TIER_FAST)
|
||||
.any(|tier| tier.id == ServiceTier::Fast.request_value())
|
||||
|| self
|
||||
.additional_speed_tiers
|
||||
.iter()
|
||||
.any(|tier| tier == SPEED_TIER_FAST)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
pub fn supports_service_tier(&self, service_tier: &str) -> bool {
|
||||
self.service_tiers
|
||||
.iter()
|
||||
.any(|tier| tier.id == service_tier)
|
||||
}
|
||||
|
||||
pub fn service_tier_for_request(&self, service_tier: Option<String>) -> Option<String> {
|
||||
service_tier.filter(|service_tier| {
|
||||
service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE
|
||||
&& self.supports_service_tier(service_tier)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelPreset {
|
||||
/// Filter models based on authentication mode.
|
||||
///
|
||||
/// In ChatGPT mode, all models are visible. Otherwise, only API-supported models are shown.
|
||||
@@ -566,6 +619,7 @@ mod tests {
|
||||
priority: 1,
|
||||
additional_speed_tiers: Vec::new(),
|
||||
service_tiers: Vec::new(),
|
||||
default_service_tier: None,
|
||||
availability_nux: None,
|
||||
upgrade: None,
|
||||
base_instructions: "base".to_string(),
|
||||
@@ -587,6 +641,8 @@ mod tests {
|
||||
input_modalities: default_input_modalities(),
|
||||
used_fallback_model_metadata: false,
|
||||
supports_search_tool: false,
|
||||
auto_review_model_override: None,
|
||||
tool_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +860,45 @@ mod tests {
|
||||
assert!(!model.supports_image_detail_original);
|
||||
assert_eq!(model.web_search_tool_type, WebSearchToolType::Text);
|
||||
assert!(!model.supports_search_tool);
|
||||
assert_eq!(model.auto_review_model_override, None);
|
||||
assert_eq!(model.tool_mode, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_info_deserializes_known_tool_mode() {
|
||||
let mut value =
|
||||
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.expect("model info should be an object");
|
||||
object.insert(
|
||||
"tool_mode".to_string(),
|
||||
serde_json::Value::String("code_mode_only".to_string()),
|
||||
);
|
||||
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
||||
|
||||
assert_eq!(model.tool_mode, Some(ToolMode::CodeModeOnly));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_info_treats_unknown_tool_mode_as_omitted() {
|
||||
let mut value =
|
||||
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.expect("model info should be an object");
|
||||
object.insert(
|
||||
"tool_mode".to_string(),
|
||||
serde_json::Value::String("future_tool_mode".to_string()),
|
||||
);
|
||||
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
|
||||
|
||||
assert_eq!(model.tool_mode, None);
|
||||
let serialized = serde_json::to_value(model).expect("serialize model info");
|
||||
let object = serialized
|
||||
.as_object()
|
||||
.expect("model info should be an object");
|
||||
assert!(!object.contains_key("tool_mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -836,6 +931,7 @@ mod tests {
|
||||
message: "Try Spark.".to_string(),
|
||||
}),
|
||||
additional_speed_tiers: vec![SPEED_TIER_FAST.to_string()],
|
||||
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
||||
service_tiers: Vec::new(),
|
||||
..test_model(/*spec*/ None)
|
||||
});
|
||||
@@ -847,13 +943,17 @@ mod tests {
|
||||
})
|
||||
);
|
||||
assert!(preset.supports_fast_mode());
|
||||
assert_eq!(
|
||||
preset.default_service_tier,
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_preset_supports_fast_mode_from_service_tiers() {
|
||||
let preset = ModelPreset::from(ModelInfo {
|
||||
service_tiers: vec![ModelServiceTier {
|
||||
id: SPEED_TIER_FAST.to_string(),
|
||||
id: ServiceTier::Fast.request_value().to_string(),
|
||||
name: "Fast".to_string(),
|
||||
description: "Priority processing.".to_string(),
|
||||
}],
|
||||
@@ -862,4 +962,60 @@ mod tests {
|
||||
|
||||
assert!(preset.supports_fast_mode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_tier_for_request_omits_explicit_default_tier() {
|
||||
let model = ModelInfo {
|
||||
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
||||
service_tiers: vec![ModelServiceTier {
|
||||
id: ServiceTier::Fast.request_value().to_string(),
|
||||
name: "Fast".to_string(),
|
||||
description: "Priority processing.".to_string(),
|
||||
}],
|
||||
..test_model(/*spec*/ None)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
model.service_tier_for_request(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_tier_for_request_filters_unsupported_tiers() {
|
||||
let model = ModelInfo {
|
||||
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
||||
service_tiers: vec![ModelServiceTier {
|
||||
id: ServiceTier::Fast.request_value().to_string(),
|
||||
name: "Fast".to_string(),
|
||||
description: "Priority processing.".to_string(),
|
||||
}],
|
||||
..test_model(/*spec*/ None)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
model.service_tier_for_request(Some(ServiceTier::Fast.request_value().to_string())),
|
||||
Some(ServiceTier::Fast.request_value().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
model.service_tier_for_request(Some("unsupported".to_string())),
|
||||
None
|
||||
);
|
||||
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_tier_for_request_does_not_apply_catalog_default() {
|
||||
let model = ModelInfo {
|
||||
default_service_tier: Some(ServiceTier::Fast.request_value().to_string()),
|
||||
service_tiers: vec![ModelServiceTier {
|
||||
id: ServiceTier::Fast.request_value().to_string(),
|
||||
name: "Fast".to_string(),
|
||||
description: "Priority processing.".to_string(),
|
||||
}],
|
||||
..test_model(/*spec*/ None)
|
||||
};
|
||||
|
||||
assert_eq!(model.service_tier_for_request(/*service_tier*/ None), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ impl NetworkSandboxPolicy {
|
||||
/// Access mode for a filesystem entry.
|
||||
///
|
||||
/// When two equally specific entries target the same path, we compare these by
|
||||
/// conflict precedence rather than by capability breadth: `none` beats
|
||||
/// conflict precedence rather than by capability breadth: `deny` beats
|
||||
/// `write`, and `write` beats `read`.
|
||||
#[derive(
|
||||
Debug,
|
||||
@@ -117,12 +117,14 @@ impl NetworkSandboxPolicy {
|
||||
pub enum FileSystemAccessMode {
|
||||
Read,
|
||||
Write,
|
||||
None,
|
||||
/// `none` is a legacy input alias retained temporarily for compatibility.
|
||||
#[serde(alias = "none")]
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl FileSystemAccessMode {
|
||||
pub fn can_read(self) -> bool {
|
||||
!matches!(self, FileSystemAccessMode::None)
|
||||
!matches!(self, FileSystemAccessMode::Deny)
|
||||
}
|
||||
|
||||
pub fn can_write(self) -> bool {
|
||||
@@ -232,8 +234,39 @@ impl ReadDenyMatcher {
|
||||
/// can skip read-deny checks without allocating matcher state. The `cwd`
|
||||
/// resolves cwd-relative policy paths and special paths before matching.
|
||||
pub fn new(file_system_sandbox_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<Self> {
|
||||
match Self::build(
|
||||
file_system_sandbox_policy,
|
||||
cwd,
|
||||
InvalidDenyReadGlobBehavior::FailClosed,
|
||||
) {
|
||||
Ok(matcher) => matcher,
|
||||
Err(_) => unreachable!("fail-closed glob handling does not return errors"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a matcher for callers that must reject malformed glob patterns.
|
||||
///
|
||||
/// Runtime read checks intentionally fail closed on malformed deny patterns.
|
||||
/// Host-side expansion work should use this constructor instead so a typo
|
||||
/// cannot broaden the set of paths it mutates before execution starts.
|
||||
pub fn try_new(
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
cwd: &Path,
|
||||
) -> Result<Option<Self>, String> {
|
||||
Self::build(
|
||||
file_system_sandbox_policy,
|
||||
cwd,
|
||||
InvalidDenyReadGlobBehavior::ReturnError,
|
||||
)
|
||||
}
|
||||
|
||||
fn build(
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
cwd: &Path,
|
||||
invalid_glob_behavior: InvalidDenyReadGlobBehavior,
|
||||
) -> Result<Option<Self>, String> {
|
||||
if !file_system_sandbox_policy.has_denied_read_restrictions() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Exact roots are stored as all meaningful path spellings we can derive
|
||||
@@ -247,22 +280,23 @@ impl ReadDenyMatcher {
|
||||
// Pattern entries stay as policy-level globs. They are matched at read
|
||||
// time here instead of being snapshotted to startup filesystem state.
|
||||
let mut invalid_pattern = false;
|
||||
let deny_read_matchers = file_system_sandbox_policy
|
||||
.get_unreadable_globs_with_cwd(cwd)
|
||||
.into_iter()
|
||||
.filter_map(|pattern| match build_glob_matcher(&pattern) {
|
||||
Some(matcher) => Some(matcher),
|
||||
None => {
|
||||
invalid_pattern = true;
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Some(Self {
|
||||
let mut deny_read_matchers = Vec::new();
|
||||
for pattern in file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd) {
|
||||
match build_glob_matcher(&pattern) {
|
||||
Ok(matcher) => deny_read_matchers.push(matcher),
|
||||
Err(err) => match invalid_glob_behavior {
|
||||
InvalidDenyReadGlobBehavior::FailClosed => invalid_pattern = true,
|
||||
InvalidDenyReadGlobBehavior::ReturnError => {
|
||||
return Err(format!("invalid deny-read glob pattern `{pattern}`: {err}"));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(Some(Self {
|
||||
denied_candidates,
|
||||
deny_read_matchers,
|
||||
invalid_pattern,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// Returns whether `path` is denied by the policy used to build this matcher.
|
||||
@@ -295,6 +329,12 @@ impl ReadDenyMatcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum InvalidDenyReadGlobBehavior {
|
||||
FailClosed,
|
||||
ReturnError,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
#[ts(tag = "type")]
|
||||
@@ -303,7 +343,7 @@ pub enum FileSystemPath {
|
||||
path: AbsolutePathBuf,
|
||||
},
|
||||
/// A git-style glob pattern. Pattern entries currently support
|
||||
/// FileSystemAccessMode::None only.
|
||||
/// FileSystemAccessMode::Deny only.
|
||||
GlobPattern {
|
||||
pattern: String,
|
||||
},
|
||||
@@ -312,6 +352,12 @@ pub enum FileSystemPath {
|
||||
},
|
||||
}
|
||||
|
||||
const PROJECT_ROOTS_GLOB_PATTERN_PREFIX: &str = "codex-project-roots://";
|
||||
|
||||
pub fn project_roots_glob_pattern(subpath: &Path) -> String {
|
||||
format!("{PROJECT_ROOTS_GLOB_PATTERN_PREFIX}{}", subpath.display())
|
||||
}
|
||||
|
||||
impl Default for FileSystemSandboxPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -368,7 +414,7 @@ impl FileSystemSandboxPolicy {
|
||||
&& self
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.access == FileSystemAccessMode::None)
|
||||
.any(|entry| entry.access == FileSystemAccessMode::Deny)
|
||||
}
|
||||
|
||||
pub fn from_legacy_sandbox_policy_preserving_deny_entries(
|
||||
@@ -385,7 +431,7 @@ impl FileSystemSandboxPolicy {
|
||||
for deny_entry in existing
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
|
||||
{
|
||||
if !rebuilt.entries.iter().any(|entry| entry == deny_entry) {
|
||||
rebuilt.entries.push(deny_entry.clone());
|
||||
@@ -401,7 +447,7 @@ impl FileSystemSandboxPolicy {
|
||||
let has_deny_read_entries = existing
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.access == FileSystemAccessMode::None);
|
||||
.any(|entry| entry.access == FileSystemAccessMode::Deny);
|
||||
if matches!(self.kind, FileSystemSandboxKind::Unrestricted) && has_deny_read_entries {
|
||||
*self = Self::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
@@ -422,7 +468,7 @@ impl FileSystemSandboxPolicy {
|
||||
for deny_entry in existing
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
|
||||
{
|
||||
if !self.entries.iter().any(|entry| entry == deny_entry) {
|
||||
self.entries.push(deny_entry.clone());
|
||||
@@ -448,7 +494,7 @@ impl FileSystemSandboxPolicy {
|
||||
FileSystemPath::Path { .. } => !self.has_same_target_write_override(entry),
|
||||
FileSystemPath::GlobPattern { .. } => true,
|
||||
FileSystemPath::Special { value } => match value {
|
||||
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::None,
|
||||
FileSystemSpecialPath::Root => entry.access == FileSystemAccessMode::Deny,
|
||||
FileSystemSpecialPath::Minimal | FileSystemSpecialPath::Unknown { .. } => {
|
||||
false
|
||||
}
|
||||
@@ -610,7 +656,7 @@ impl FileSystemSandboxPolicy {
|
||||
}
|
||||
|
||||
let Some(path) = resolve_candidate_path(path, cwd) else {
|
||||
return FileSystemAccessMode::None;
|
||||
return FileSystemAccessMode::Deny;
|
||||
};
|
||||
|
||||
self.resolved_entries_with_cwd(cwd)
|
||||
@@ -618,7 +664,7 @@ impl FileSystemSandboxPolicy {
|
||||
.filter(|entry| path.as_path().starts_with(entry.path.as_path()))
|
||||
.max_by_key(resolved_entry_precedence)
|
||||
.map(|entry| entry.access)
|
||||
.unwrap_or(FileSystemAccessMode::None)
|
||||
.unwrap_or(FileSystemAccessMode::Deny)
|
||||
}
|
||||
|
||||
pub fn can_read_path_with_cwd(&self, path: &Path, cwd: &Path) -> bool {
|
||||
@@ -657,7 +703,7 @@ impl FileSystemSandboxPolicy {
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces symbolic `:project_roots` entries with absolute paths resolved
|
||||
/// Replaces symbolic `:workspace_roots` entries with absolute paths resolved
|
||||
/// against `cwd`.
|
||||
///
|
||||
/// Use this when a durable permission profile must survive a cwd-only
|
||||
@@ -665,15 +711,100 @@ impl FileSystemSandboxPolicy {
|
||||
pub fn materialize_project_roots_with_cwd(mut self, cwd: &Path) -> Self {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(cwd).ok();
|
||||
for entry in &mut self.entries {
|
||||
let FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::ProjectRoots { .. },
|
||||
} = &entry.path
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
match &entry.path {
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::ProjectRoots { .. },
|
||||
} => {
|
||||
if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) {
|
||||
entry.path = FileSystemPath::Path { path };
|
||||
}
|
||||
}
|
||||
FileSystemPath::GlobPattern { pattern } => {
|
||||
if let (Some(cwd), Some(subpath)) =
|
||||
(cwd.as_ref(), parse_project_roots_glob_pattern(pattern))
|
||||
{
|
||||
entry.path = FileSystemPath::GlobPattern {
|
||||
pattern: resolve_project_roots_glob_pattern(subpath, cwd),
|
||||
};
|
||||
}
|
||||
}
|
||||
FileSystemPath::Special { value: _ } => {}
|
||||
FileSystemPath::Path { .. } => {}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
if let Some(path) = resolve_file_system_path(&entry.path, cwd.as_ref()) {
|
||||
entry.path = FileSystemPath::Path { path };
|
||||
/// Replaces symbolic `:workspace_roots` entries with concrete entries for
|
||||
/// each workspace root.
|
||||
pub fn materialize_project_roots_with_workspace_roots(
|
||||
mut self,
|
||||
workspace_roots: &[AbsolutePathBuf],
|
||||
) -> Self {
|
||||
let mut entries = Vec::with_capacity(self.entries.len());
|
||||
for entry in self.entries {
|
||||
match entry.path {
|
||||
FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::ProjectRoots { subpath },
|
||||
} => {
|
||||
entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: match subpath.as_ref() {
|
||||
Some(subpath) => AbsolutePathBuf::resolve_path_against_base(
|
||||
subpath,
|
||||
root.as_path(),
|
||||
),
|
||||
None => root.clone(),
|
||||
},
|
||||
},
|
||||
access: entry.access,
|
||||
}));
|
||||
}
|
||||
FileSystemPath::GlobPattern { pattern } => {
|
||||
if let Some(subpath) = parse_project_roots_glob_pattern(&pattern) {
|
||||
entries.extend(workspace_roots.iter().map(|root| FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: resolve_project_roots_glob_pattern(subpath, root),
|
||||
},
|
||||
access: entry.access,
|
||||
}));
|
||||
} else {
|
||||
entries.push(FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern { pattern },
|
||||
access: entry.access,
|
||||
});
|
||||
}
|
||||
}
|
||||
FileSystemPath::Path { path } => {
|
||||
entries.push(FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path },
|
||||
access: entry.access,
|
||||
});
|
||||
}
|
||||
FileSystemPath::Special { value } => {
|
||||
entries.push(FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special { value },
|
||||
access: entry.access,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.entries = entries;
|
||||
self
|
||||
}
|
||||
|
||||
/// Preserves symbolic `:workspace_roots` entries while also adding concrete
|
||||
/// entries for each provided workspace root.
|
||||
pub fn with_materialized_project_roots_for_workspace_roots(
|
||||
mut self,
|
||||
workspace_roots: &[AbsolutePathBuf],
|
||||
) -> Self {
|
||||
let materialized = self
|
||||
.clone()
|
||||
.materialize_project_roots_with_workspace_roots(workspace_roots);
|
||||
for entry in materialized.entries {
|
||||
if !self.entries.contains(&entry) {
|
||||
self.entries.push(entry);
|
||||
}
|
||||
}
|
||||
self
|
||||
@@ -725,7 +856,7 @@ impl FileSystemSandboxPolicy {
|
||||
///
|
||||
/// Unlike [`Self::with_additional_writable_roots`], this mirrors legacy
|
||||
/// writable-roots semantics by adding exact roots even when they are
|
||||
/// already writable through `:project_roots`, and by adding the default
|
||||
/// already writable through `:workspace_roots`, and by adding the default
|
||||
/// read-only protected subpaths for each new root.
|
||||
pub fn with_additional_legacy_workspace_writable_roots(
|
||||
mut self,
|
||||
@@ -933,7 +1064,7 @@ impl FileSystemSandboxPolicy {
|
||||
dedup_absolute_paths(
|
||||
self.resolved_entries_with_cwd(cwd)
|
||||
.iter()
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
|
||||
.filter(|entry| !self.can_read_path_with_cwd(entry.path.as_path(), cwd))
|
||||
// Restricted policies already deny reads outside explicit allow roots,
|
||||
// so materializing the filesystem root here would erase narrower
|
||||
@@ -954,7 +1085,7 @@ impl FileSystemSandboxPolicy {
|
||||
let mut patterns = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::None)
|
||||
.filter(|entry| entry.access == FileSystemAccessMode::Deny)
|
||||
.filter_map(|entry| match &entry.path {
|
||||
FileSystemPath::GlobPattern { pattern } => {
|
||||
Some(AbsolutePathBuf::resolve_path_against_base(pattern, cwd))
|
||||
@@ -1013,7 +1144,7 @@ impl FileSystemSandboxPolicy {
|
||||
}
|
||||
FileSystemPath::Special { value } => match value {
|
||||
FileSystemSpecialPath::Root => match entry.access {
|
||||
FileSystemAccessMode::None => {}
|
||||
FileSystemAccessMode::Deny => {}
|
||||
FileSystemAccessMode::Read => {}
|
||||
FileSystemAccessMode::Write => {
|
||||
unbridgeable_root_write = true;
|
||||
@@ -1171,6 +1302,18 @@ fn resolve_entry_path(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_project_roots_glob_pattern(pattern: &str) -> Option<&Path> {
|
||||
pattern
|
||||
.strip_prefix(PROJECT_ROOTS_GLOB_PATTERN_PREFIX)
|
||||
.map(Path::new)
|
||||
}
|
||||
|
||||
fn resolve_project_roots_glob_pattern(subpath: &Path, root: &AbsolutePathBuf) -> String {
|
||||
AbsolutePathBuf::resolve_path_against_base(subpath, root.as_path())
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn resolve_candidate_path(path: &Path, cwd: &Path) -> Option<AbsolutePathBuf> {
|
||||
if path.is_absolute() {
|
||||
AbsolutePathBuf::from_absolute_path(path).ok()
|
||||
@@ -1291,15 +1434,15 @@ fn push_unique(candidates: &mut Vec<PathBuf>, candidate: PathBuf) {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_glob_matcher(pattern: &str) -> Option<GlobMatcher> {
|
||||
fn build_glob_matcher(pattern: &str) -> Result<GlobMatcher, String> {
|
||||
// Keep `*` and `?` within a single path component and preserve an unclosed
|
||||
// `[` as a literal so matcher behavior stays aligned with config parsing.
|
||||
GlobBuilder::new(pattern)
|
||||
.literal_separator(true)
|
||||
.allow_unclosed_class(true)
|
||||
.build()
|
||||
.ok()
|
||||
.map(|glob| glob.compile_matcher())
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn resolve_file_system_special_path(
|
||||
@@ -2063,7 +2206,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: link_blocked },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2125,7 +2268,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: link_blocked },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2264,7 +2407,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: link_private },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2311,7 +2454,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: link_private },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2353,7 +2496,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: alias },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2413,7 +2556,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: link_blocked },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -2459,7 +2602,7 @@ mod tests {
|
||||
path: FileSystemPath::Path {
|
||||
path: docs_private.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
@@ -2479,7 +2622,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs_private.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::None
|
||||
FileSystemAccessMode::Deny
|
||||
);
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(docs_private_public.as_path(), cwd.path()),
|
||||
@@ -2620,7 +2763,7 @@ mod tests {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: docs.clone() },
|
||||
@@ -2656,14 +2799,14 @@ mod tests {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(!policy.has_full_disk_write_access());
|
||||
assert_eq!(
|
||||
policy.resolve_access_with_cwd(root.as_path(), cwd.path()),
|
||||
FileSystemAccessMode::None
|
||||
FileSystemAccessMode::Deny
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2763,6 +2906,115 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_project_roots_with_workspace_roots_expands_exact_and_glob_entries() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let first = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("first"))
|
||||
.expect("resolve first root");
|
||||
let second = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("second"))
|
||||
.expect("resolve second root");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(Some(".git".into())),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: project_roots_glob_pattern(Path::new("**/*.env")),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
let actual =
|
||||
policy.materialize_project_roots_with_workspace_roots(&[first.clone(), second.clone()]);
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: first.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: second.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::Write,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: first.join(".git"),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: second.join(".git"),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: AbsolutePathBuf::resolve_path_against_base(
|
||||
"**/*.env",
|
||||
first.as_path(),
|
||||
)
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: AbsolutePathBuf::resolve_path_against_base(
|
||||
"**/*.env",
|
||||
second.as_path(),
|
||||
)
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_project_roots_with_cwd_expands_symbolic_glob_entries() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: project_roots_glob_pattern(Path::new("**/*.env")),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}]);
|
||||
|
||||
let actual = policy.materialize_project_roots_with_cwd(cwd.path());
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path())
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
},
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_additional_legacy_workspace_writable_roots_protects_metadata() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
@@ -2807,7 +3059,7 @@ mod tests {
|
||||
#[test]
|
||||
fn file_system_access_mode_orders_by_conflict_precedence() {
|
||||
assert!(FileSystemAccessMode::Write > FileSystemAccessMode::Read);
|
||||
assert!(FileSystemAccessMode::None > FileSystemAccessMode::Write);
|
||||
assert!(FileSystemAccessMode::Deny > FileSystemAccessMode::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2817,7 +3069,7 @@ mod tests {
|
||||
path: FileSystemPath::Path {
|
||||
path: denied.clone(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}]);
|
||||
|
||||
let rebuilt = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
|
||||
@@ -2832,7 +3084,7 @@ mod tests {
|
||||
== FileSystemPath::Path {
|
||||
path: denied.clone(),
|
||||
}
|
||||
&& entry.access == FileSystemAccessMode::None
|
||||
&& entry.access == FileSystemAccessMode::Deny
|
||||
}),
|
||||
"expected explicit deny entry to be preserved"
|
||||
);
|
||||
@@ -2865,14 +3117,14 @@ mod tests {
|
||||
path: FileSystemPath::Path {
|
||||
path: AbsolutePathBuf::try_from(path).expect("absolute deny path"),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}])
|
||||
}
|
||||
|
||||
fn unreadable_glob_entry(pattern: String) -> FileSystemSandboxEntry {
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern { pattern },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+333
-241
@@ -3,6 +3,7 @@
|
||||
//! Uses a SQ (Submission Queue) / EQ (Event Queue) pattern to asynchronously communicate
|
||||
//! between user and agent.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::ops::Mul;
|
||||
@@ -34,6 +35,7 @@ use crate::memory_citation::MemoryCitation;
|
||||
use crate::models::ActivePermissionProfile;
|
||||
use crate::models::BaseInstructions;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::ImageDetail;
|
||||
use crate::models::MessagePhase;
|
||||
use crate::models::PermissionProfile;
|
||||
use crate::models::ResponseInputItem;
|
||||
@@ -127,6 +129,9 @@ pub struct Submission {
|
||||
pub id: String,
|
||||
/// Payload
|
||||
pub op: Op,
|
||||
/// Client-provided id for the user message represented by `Op::UserInput`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_user_message_id: Option<String>,
|
||||
/// Optional W3C trace carrier propagated across async submission handoffs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trace: Option<W3cTraceContext>,
|
||||
@@ -395,6 +400,96 @@ pub struct ConversationTextParams {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Persistent thread-settings overrides that can be applied before user input or
|
||||
/// on their own.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
pub struct ThreadSettingsOverrides {
|
||||
/// Updated `cwd` for sandbox/tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Updated runtime workspace roots used to materialize symbolic
|
||||
/// `:workspace_roots` filesystem permissions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
|
||||
/// Updated profile-defined workspace roots for status summaries and
|
||||
/// per-turn config reconstruction.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub profile_workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
|
||||
/// Updated command approval policy.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
|
||||
/// Updated approval reviewer for future approval prompts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
|
||||
/// Updated sandbox policy for tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
|
||||
/// Updated permissions profile for tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission_profile: Option<PermissionProfile>,
|
||||
|
||||
/// Named or built-in profile that produced `permission_profile`, if the
|
||||
/// update selected a profile rather than supplying raw permissions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub active_permission_profile: Option<ActivePermissionProfile>,
|
||||
|
||||
/// Updated Windows sandbox mode for tool execution.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub windows_sandbox_level: Option<WindowsSandboxLevel>,
|
||||
|
||||
/// Updated model slug. When set, the model info is derived automatically.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Updated reasoning effort (honored only for reasoning-capable models).
|
||||
///
|
||||
/// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the
|
||||
/// effort, or `None` to leave the existing value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<Option<ReasoningEffortConfig>>,
|
||||
|
||||
/// Updated reasoning summary preference (honored only for reasoning-capable models).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<ReasoningSummaryConfig>,
|
||||
|
||||
/// Updated service tier preference for future turns.
|
||||
///
|
||||
/// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
|
||||
/// preference, or `None` to leave the existing value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_tier: Option<Option<String>>,
|
||||
|
||||
/// EXPERIMENTAL - set a pre-set collaboration mode.
|
||||
/// Takes precedence over model, effort, and developer instructions if set.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub collaboration_mode: Option<CollaborationMode>,
|
||||
|
||||
/// Updated personality preference.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<Personality>,
|
||||
}
|
||||
|
||||
/// Source classification for client-supplied context.
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AdditionalContextKind {
|
||||
Untrusted,
|
||||
Application,
|
||||
}
|
||||
|
||||
/// Client-supplied context keyed by an opaque source identifier.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
|
||||
pub struct AdditionalContextEntry {
|
||||
pub value: String,
|
||||
pub kind: AdditionalContextKind,
|
||||
}
|
||||
|
||||
/// Submission operation
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -424,10 +519,7 @@ pub enum Op {
|
||||
/// Request the list of voices supported by realtime conversation streams.
|
||||
RealtimeConversationListVoices,
|
||||
|
||||
/// Legacy user input.
|
||||
///
|
||||
/// Prefer [`Op::UserTurn`] so the caller provides full turn context
|
||||
/// (cwd/approval/sandbox/model/etc.) for each turn.
|
||||
/// User input, optionally with thread-settings overrides applied first.
|
||||
UserInput {
|
||||
/// User input items, see `InputItem`
|
||||
items: Vec<UserInput>,
|
||||
@@ -440,153 +532,23 @@ pub enum Op {
|
||||
/// Optional turn-scoped Responses API `client_metadata`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
responsesapi_client_metadata: Option<HashMap<String, String>>,
|
||||
/// Client-supplied context fragments keyed by an opaque source identifier.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
additional_context: BTreeMap<String, AdditionalContextEntry>,
|
||||
|
||||
/// Persistent thread-settings overrides to apply before the input.
|
||||
#[serde(default, flatten)]
|
||||
thread_settings: ThreadSettingsOverrides,
|
||||
},
|
||||
|
||||
/// Similar to [`Op::UserInput`], but first applies persistent turn-context
|
||||
/// overrides in the same queued operation. This preserves submission order
|
||||
/// and prevents the input from starting if the overrides are rejected.
|
||||
UserInputWithTurnContext {
|
||||
/// User input items, see `InputItem`
|
||||
items: Vec<UserInput>,
|
||||
/// Optional turn-scoped environment selections.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
environments: Option<Vec<TurnEnvironmentSelection>>,
|
||||
/// Optional JSON Schema used to constrain the final assistant message for this turn.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
final_output_json_schema: Option<Value>,
|
||||
/// Optional turn-scoped Responses API `client_metadata`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
responsesapi_client_metadata: Option<HashMap<String, String>>,
|
||||
|
||||
/// Updated `cwd` for sandbox/tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<PathBuf>,
|
||||
|
||||
/// Updated command approval policy.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approval_policy: Option<AskForApproval>,
|
||||
|
||||
/// Updated approval reviewer for future approval prompts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
|
||||
/// Updated sandbox policy for tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
|
||||
/// Updated permissions profile for tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
permission_profile: Option<PermissionProfile>,
|
||||
|
||||
/// Named or built-in profile that produced `permission_profile`, if
|
||||
/// the update selected a profile rather than supplying raw
|
||||
/// permissions.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
active_permission_profile: Option<ActivePermissionProfile>,
|
||||
|
||||
/// Updated Windows sandbox mode for tool execution.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
windows_sandbox_level: Option<WindowsSandboxLevel>,
|
||||
|
||||
/// Updated model slug. When set, the model info is derived
|
||||
/// automatically.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
|
||||
/// Updated reasoning effort (honored only for reasoning-capable models).
|
||||
///
|
||||
/// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear
|
||||
/// the effort, or `None` to leave the existing value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
effort: Option<Option<ReasoningEffortConfig>>,
|
||||
|
||||
/// Updated reasoning summary preference (honored only for reasoning-capable models).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<ReasoningSummaryConfig>,
|
||||
|
||||
/// Updated service tier preference for future turns.
|
||||
///
|
||||
/// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
|
||||
/// preference, or `None` to leave the existing value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
service_tier: Option<Option<String>>,
|
||||
|
||||
/// EXPERIMENTAL - set a pre-set collaboration mode.
|
||||
/// Takes precedence over model, effort, and developer instructions if set.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
collaboration_mode: Option<CollaborationMode>,
|
||||
|
||||
/// Updated personality preference.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
personality: Option<Personality>,
|
||||
},
|
||||
|
||||
/// Similar to [`Op::UserInput`], but contains additional context required
|
||||
/// for a turn of a [`crate::codex_thread::CodexThread`].
|
||||
UserTurn {
|
||||
/// User input items, see `InputItem`
|
||||
items: Vec<UserInput>,
|
||||
|
||||
/// `cwd` to use with the [`SandboxPolicy`] and potentially tool calls
|
||||
/// such as `local_shell`.
|
||||
cwd: PathBuf,
|
||||
|
||||
/// Policy to use for command approval.
|
||||
approval_policy: AskForApproval,
|
||||
|
||||
/// Reviewer to use for approval requests raised during this turn.
|
||||
///
|
||||
/// When omitted, the session keeps the current setting
|
||||
approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
|
||||
/// Policy to use for tool calls such as `local_shell`.
|
||||
sandbox_policy: SandboxPolicy,
|
||||
|
||||
/// Full permissions profile to use for tool calls such as `local_shell`.
|
||||
///
|
||||
/// When omitted, `sandbox_policy` is used as a legacy compatibility
|
||||
/// projection.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
permission_profile: Option<PermissionProfile>,
|
||||
|
||||
/// Must be a valid model slug for the configured client session
|
||||
/// associated with this conversation.
|
||||
model: String,
|
||||
|
||||
/// Will only be honored if the model is configured to use reasoning.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
effort: Option<ReasoningEffortConfig>,
|
||||
|
||||
/// Will only be honored if the model is configured to use reasoning.
|
||||
///
|
||||
/// When omitted, the session keeps the current setting (which allows core to
|
||||
/// fall back to the selected model's default on new sessions).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<ReasoningSummaryConfig>,
|
||||
|
||||
/// Optional service tier override for this turn.
|
||||
///
|
||||
/// Use `Some(Some(_))` to set a specific tier for this turn, `Some(None)` to
|
||||
/// explicitly clear the tier for this turn, or `None` to keep the existing
|
||||
/// session preference.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
service_tier: Option<Option<String>>,
|
||||
|
||||
// The JSON schema to use for the final assistant message
|
||||
final_output_json_schema: Option<Value>,
|
||||
|
||||
/// EXPERIMENTAL - set a pre-set collaboration mode.
|
||||
/// Takes precedence over model, effort, and developer instructions if set.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
collaboration_mode: Option<CollaborationMode>,
|
||||
|
||||
/// Optional personality override for this turn.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
personality: Option<Personality>,
|
||||
|
||||
/// Optional turn-scoped environments.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
environments: Option<Vec<TurnEnvironmentSelection>>,
|
||||
/// Apply persistent thread-settings overrides without starting a turn.
|
||||
///
|
||||
/// This uses the same submission queue as turn starts so app-server can
|
||||
/// preserve caller order between both kinds of mutation.
|
||||
ThreadSettings {
|
||||
/// Persistent thread-settings overrides to apply.
|
||||
#[serde(flatten)]
|
||||
thread_settings: ThreadSettingsOverrides,
|
||||
},
|
||||
|
||||
/// Inter-agent communication that should be recorded as assistant history
|
||||
@@ -595,70 +557,6 @@ pub enum Op {
|
||||
communication: InterAgentCommunication,
|
||||
},
|
||||
|
||||
/// Override parts of the persistent turn context for subsequent turns.
|
||||
///
|
||||
/// All fields are optional; when omitted, the existing value is preserved.
|
||||
/// This does not enqueue any input – it only updates defaults used for
|
||||
/// turns that rely on persistent session-level context (for example,
|
||||
/// [`Op::UserInput`]).
|
||||
OverrideTurnContext {
|
||||
/// Updated `cwd` for sandbox/tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<PathBuf>,
|
||||
|
||||
/// Updated command approval policy.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approval_policy: Option<AskForApproval>,
|
||||
|
||||
/// Updated approval reviewer for future approval prompts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
|
||||
/// Updated sandbox policy for tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sandbox_policy: Option<SandboxPolicy>,
|
||||
|
||||
/// Updated permissions profile for tool calls.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
permission_profile: Option<PermissionProfile>,
|
||||
|
||||
/// Updated Windows sandbox mode for tool execution.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
windows_sandbox_level: Option<WindowsSandboxLevel>,
|
||||
|
||||
/// Updated model slug. When set, the model info is derived
|
||||
/// automatically.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
|
||||
/// Updated reasoning effort (honored only for reasoning-capable models).
|
||||
///
|
||||
/// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear
|
||||
/// the effort, or `None` to leave the existing value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
effort: Option<Option<ReasoningEffortConfig>>,
|
||||
|
||||
/// Updated reasoning summary preference (honored only for reasoning-capable models).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
summary: Option<ReasoningSummaryConfig>,
|
||||
|
||||
/// Updated service tier preference for future turns.
|
||||
///
|
||||
/// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the
|
||||
/// preference, or `None` to leave the existing value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
service_tier: Option<Option<String>>,
|
||||
|
||||
/// EXPERIMENTAL - set a pre-set collaboration mode.
|
||||
/// Takes precedence over model, effort, and developer instructions if set.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
collaboration_mode: Option<CollaborationMode>,
|
||||
|
||||
/// Updated personality preference.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
personality: Option<Personality>,
|
||||
},
|
||||
|
||||
/// Approve a command execution
|
||||
ExecApproval {
|
||||
/// The id of the submission we are approving
|
||||
@@ -779,6 +677,8 @@ impl From<Vec<UserInput>> for Op {
|
||||
items: value,
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: ThreadSettingsOverrides::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -845,10 +745,8 @@ impl Op {
|
||||
Self::RealtimeConversationClose => "realtime_conversation_close",
|
||||
Self::RealtimeConversationListVoices => "realtime_conversation_list_voices",
|
||||
Self::UserInput { .. } => "user_input",
|
||||
Self::UserInputWithTurnContext { .. } => "user_input_with_turn_context",
|
||||
Self::UserTurn { .. } => "user_turn",
|
||||
Self::ThreadSettings { .. } => "thread_settings",
|
||||
Self::InterAgentCommunication { .. } => "inter_agent_communication",
|
||||
Self::OverrideTurnContext { .. } => "override_turn_context",
|
||||
Self::ExecApproval { .. } => "exec_approval",
|
||||
Self::PatchApproval { .. } => "patch_approval",
|
||||
Self::ResolveElicitation { .. } => "resolve_elicitation",
|
||||
@@ -1299,6 +1197,10 @@ pub enum EventMsg {
|
||||
#[serde(rename = "task_started", alias = "turn_started")]
|
||||
TurnStarted(TurnStartedEvent),
|
||||
|
||||
/// Persistent thread-settings overrides from the correlated submission have
|
||||
/// been applied to the session configuration.
|
||||
ThreadSettingsApplied(ThreadSettingsAppliedEvent),
|
||||
|
||||
/// Agent has completed all actions.
|
||||
/// v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.
|
||||
#[serde(rename = "task_complete", alias = "turn_complete")]
|
||||
@@ -1401,9 +1303,6 @@ pub enum EventMsg {
|
||||
/// List of voices supported by realtime conversation streams.
|
||||
RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent),
|
||||
|
||||
/// Notification that skill data may have been updated and clients may want to reload.
|
||||
SkillsUpdateAvailable,
|
||||
|
||||
PlanUpdate(UpdatePlanArgs),
|
||||
|
||||
TurnAborted(TurnAbortedEvent),
|
||||
@@ -1461,6 +1360,8 @@ pub enum HookEventName {
|
||||
PostCompact,
|
||||
SessionStart,
|
||||
UserPromptSubmit,
|
||||
SubagentStart,
|
||||
SubagentStop,
|
||||
Stop,
|
||||
}
|
||||
|
||||
@@ -1496,6 +1397,7 @@ pub enum HookSource {
|
||||
SessionFlags,
|
||||
Plugin,
|
||||
CloudRequirements,
|
||||
CloudManagedConfig,
|
||||
LegacyManagedConfigFile,
|
||||
LegacyManagedConfigMdm,
|
||||
#[default]
|
||||
@@ -1972,6 +1874,10 @@ pub struct TurnCompleteEvent {
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct TurnStartedEvent {
|
||||
pub turn_id: String,
|
||||
// Persist for rollout consumers that correlate turns with telemetry traces.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub trace_id: Option<String>,
|
||||
/// Unix timestamp (in seconds) when the turn started.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(type = "number | null", optional)]
|
||||
@@ -1982,6 +1888,33 @@ pub struct TurnStartedEvent {
|
||||
pub collaboration_mode_kind: ModeKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ThreadSettingsAppliedEvent {
|
||||
pub thread_settings: ThreadSettingsSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct ThreadSettingsSnapshot {
|
||||
pub model: String,
|
||||
pub model_provider_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_tier: Option<String>,
|
||||
pub approval_policy: AskForApproval,
|
||||
pub approvals_reviewer: ApprovalsReviewer,
|
||||
pub permission_profile: PermissionProfile,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub active_permission_profile: Option<ActivePermissionProfile>,
|
||||
pub cwd: AbsolutePathBuf,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_summary: Option<ReasoningSummaryConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub personality: Option<Personality>,
|
||||
pub collaboration_mode: CollaborationMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)]
|
||||
pub struct TokenUsage {
|
||||
#[ts(type = "number")]
|
||||
@@ -2091,6 +2024,21 @@ pub enum RateLimitReachedType {
|
||||
WorkspaceMemberUsageLimitReached,
|
||||
}
|
||||
|
||||
impl FromStr for RateLimitReachedType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value {
|
||||
"rate_limit_reached" => Ok(Self::RateLimitReached),
|
||||
"workspace_owner_credits_depleted" => Ok(Self::WorkspaceOwnerCreditsDepleted),
|
||||
"workspace_member_credits_depleted" => Ok(Self::WorkspaceMemberCreditsDepleted),
|
||||
"workspace_owner_usage_limit_reached" => Ok(Self::WorkspaceOwnerUsageLimitReached),
|
||||
"workspace_member_usage_limit_reached" => Ok(Self::WorkspaceMemberUsageLimitReached),
|
||||
other => Err(format!("unknown rate limit reached type: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct RateLimitWindow {
|
||||
/// Percentage (0-100) of the window that has been consumed.
|
||||
@@ -2218,19 +2166,29 @@ pub struct AgentMessageEvent {
|
||||
pub memory_citation: Option<MemoryCitation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)]
|
||||
pub struct UserMessageEvent {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_id: Option<String>,
|
||||
pub message: String,
|
||||
/// Image URLs sourced from `UserInput::Image`. These are safe
|
||||
/// to replay in legacy UI history events and correspond to images sent to
|
||||
/// the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub images: Option<Vec<String>>,
|
||||
/// Detail hints for `images`, indexed in parallel. Missing entries imply
|
||||
/// default image detail behavior.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub image_details: Vec<Option<ImageDetail>>,
|
||||
/// Local file paths sourced from `UserInput::LocalImage`. These are kept so
|
||||
/// the UI can reattach images when editing history, and should not be sent
|
||||
/// to the model or treated as API-ready URLs.
|
||||
#[serde(default)]
|
||||
pub local_images: Vec<std::path::PathBuf>,
|
||||
/// Detail hints for `local_images`, indexed in parallel. Missing entries
|
||||
/// imply default image detail behavior.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub local_image_details: Vec<Option<ImageDetail>>,
|
||||
/// UI-defined spans within `message` used to render or persist special elements.
|
||||
#[serde(default)]
|
||||
pub text_elements: Vec<crate::user_input::TextElement>,
|
||||
@@ -2273,6 +2231,9 @@ pub struct McpToolCallBeginEvent {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mcp_app_resource_uri: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub plugin_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, PartialEq)]
|
||||
@@ -2283,6 +2244,9 @@ pub struct McpToolCallEndEvent {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub mcp_app_resource_uri: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
pub plugin_id: Option<String>,
|
||||
#[ts(type = "string")]
|
||||
pub duration: Duration,
|
||||
/// Result of the tool call. Note this could be an error.
|
||||
@@ -2477,12 +2441,27 @@ impl InitialHistory {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option<ThreadSource>)> {
|
||||
let meta = self.get_resumed_session_meta()?;
|
||||
Some((meta.source.clone(), meta.thread_source))
|
||||
}
|
||||
|
||||
pub fn get_resumed_thread_source(&self) -> Option<ThreadSource> {
|
||||
self.get_resumed_session_meta()
|
||||
.and_then(|meta| meta.thread_source)
|
||||
}
|
||||
|
||||
pub fn get_resumed_parent_thread_id(&self) -> Option<ThreadId> {
|
||||
self.get_resumed_session_meta()
|
||||
.and_then(|meta| meta.parent_thread_id)
|
||||
}
|
||||
|
||||
fn get_resumed_session_meta(&self) -> Option<&SessionMeta> {
|
||||
match self {
|
||||
InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None,
|
||||
InitialHistory::Resumed(resumed) => {
|
||||
resumed.history.iter().find_map(|item| match item {
|
||||
RolloutItem::SessionMeta(meta_line) => meta_line.meta.thread_source,
|
||||
RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
@@ -2667,6 +2646,19 @@ impl SessionSource {
|
||||
.restriction_product()
|
||||
.is_some_and(|product| product.matches_product_restriction(products))
|
||||
}
|
||||
|
||||
pub fn parent_thread_id(&self) -> Option<ThreadId> {
|
||||
match self {
|
||||
SessionSource::SubAgent(subagent_source) => subagent_source.parent_thread_id(),
|
||||
SessionSource::Cli
|
||||
| SessionSource::VSCode
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::Internal(_)
|
||||
| SessionSource::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SubAgentSource {
|
||||
@@ -2687,6 +2679,30 @@ impl fmt::Display for SubAgentSource {
|
||||
}
|
||||
}
|
||||
|
||||
impl SubAgentSource {
|
||||
pub fn kind(&self) -> &str {
|
||||
match self {
|
||||
SubAgentSource::Review => "review",
|
||||
SubAgentSource::Compact => "compact",
|
||||
SubAgentSource::ThreadSpawn { .. } => "thread_spawn",
|
||||
SubAgentSource::MemoryConsolidation => "memory_consolidation",
|
||||
SubAgentSource::Other(other) => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parent_thread_id(&self) -> Option<ThreadId> {
|
||||
match self {
|
||||
SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
} => Some(*parent_thread_id),
|
||||
SubAgentSource::Review
|
||||
| SubAgentSource::Compact
|
||||
| SubAgentSource::MemoryConsolidation
|
||||
| SubAgentSource::Other(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for InternalSessionSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -2705,6 +2721,8 @@ pub struct SessionMeta {
|
||||
pub id: ThreadId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from_id: Option<ThreadId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_thread_id: Option<ThreadId>,
|
||||
pub timestamp: String,
|
||||
pub cwd: PathBuf,
|
||||
pub originator: String,
|
||||
@@ -2739,6 +2757,7 @@ impl Default for SessionMeta {
|
||||
SessionMeta {
|
||||
id: ThreadId::default(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
timestamp: String::new(),
|
||||
cwd: PathBuf::new(),
|
||||
originator: String::new(),
|
||||
@@ -2808,9 +2827,11 @@ pub struct TurnContextNetworkItem {
|
||||
pub struct TurnContextItem {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub turn_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trace_id: Option<String>,
|
||||
pub cwd: PathBuf,
|
||||
/// Effective workspace roots used to materialize symbolic
|
||||
/// `:workspace_roots` filesystem permissions in `permission_profile`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_roots: Option<Vec<AbsolutePathBuf>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_date: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -2832,15 +2853,11 @@ pub struct TurnContextItem {
|
||||
pub realtime_active: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<ReasoningEffortConfig>,
|
||||
// Compatibility-only field written with a default value so older Codex
|
||||
// versions can deserialize turn-context rollout items. It is no longer
|
||||
// read by context reconstruction and should be removed in a future schema
|
||||
// cleanup.
|
||||
pub summary: ReasoningSummaryConfig,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub developer_instructions: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub final_output_json_schema: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub truncation_policy: Option<TruncationPolicy>,
|
||||
}
|
||||
|
||||
impl TurnContextItem {
|
||||
@@ -3412,6 +3429,8 @@ pub struct SessionConfiguredEvent {
|
||||
pub thread_id: ThreadId,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub forked_from_id: Option<ThreadId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_thread_id: Option<ThreadId>,
|
||||
/// Optional analytics source classification for this thread.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thread_source: Option<ThreadSource>,
|
||||
@@ -3481,6 +3500,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
|
||||
#[serde(default)]
|
||||
thread_id: Option<ThreadId>,
|
||||
forked_from_id: Option<ThreadId>,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
#[serde(default)]
|
||||
thread_source: Option<ThreadSource>,
|
||||
#[serde(default)]
|
||||
@@ -3521,6 +3541,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
|
||||
session_id: wire.session_id,
|
||||
thread_id: wire.thread_id.unwrap_or_else(|| wire.session_id.into()),
|
||||
forked_from_id: wire.forked_from_id,
|
||||
parent_thread_id: wire.parent_thread_id,
|
||||
thread_source: wire.thread_source,
|
||||
thread_name: wire.thread_name,
|
||||
model: wire.model,
|
||||
@@ -3545,6 +3566,8 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent {
|
||||
pub enum ThreadGoalStatus {
|
||||
Active,
|
||||
Paused,
|
||||
Blocked,
|
||||
UsageLimited,
|
||||
BudgetLimited,
|
||||
Complete,
|
||||
}
|
||||
@@ -4304,7 +4327,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: blocked },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -4365,7 +4388,7 @@ mod tests {
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path { path: secret },
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -4637,6 +4660,7 @@ mod tests {
|
||||
tool: "tool".into(),
|
||||
arguments: json!({"arg": "value"}),
|
||||
mcp_app_resource_uri: Some("app://connector".into()),
|
||||
plugin_id: Some("sample@test".into()),
|
||||
status: McpToolCallStatus::InProgress,
|
||||
result: None,
|
||||
error: None,
|
||||
@@ -4655,6 +4679,7 @@ mod tests {
|
||||
event.mcp_app_resource_uri.as_deref(),
|
||||
Some("app://connector")
|
||||
);
|
||||
assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
|
||||
}
|
||||
_ => panic!("expected McpToolCallBegin event"),
|
||||
}
|
||||
@@ -4742,6 +4767,7 @@ mod tests {
|
||||
tool: "tool".into(),
|
||||
arguments: json!({"arg": "value"}),
|
||||
mcp_app_resource_uri: Some("app://connector".into()),
|
||||
plugin_id: Some("sample@test".into()),
|
||||
status: McpToolCallStatus::Completed,
|
||||
result: Some(CallToolResult {
|
||||
content: vec![json!({"type": "text", "text": "ok"})],
|
||||
@@ -4765,6 +4791,7 @@ mod tests {
|
||||
event.mcp_app_resource_uri.as_deref(),
|
||||
Some("app://connector")
|
||||
);
|
||||
assert_eq!(event.plugin_id.as_deref(), Some("sample@test"));
|
||||
assert_eq!(event.duration, Duration::from_millis(42));
|
||||
assert!(event.is_success());
|
||||
}
|
||||
@@ -5026,6 +5053,8 @@ mod tests {
|
||||
items: Vec::new(),
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
};
|
||||
|
||||
let json_op = serde_json::to_value(op)?;
|
||||
@@ -5045,6 +5074,8 @@ mod tests {
|
||||
items: Vec::new(),
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5066,6 +5097,8 @@ mod tests {
|
||||
items: Vec::new(),
|
||||
final_output_json_schema: Some(schema.clone()),
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
};
|
||||
|
||||
let json_op = serde_json::to_value(op)?;
|
||||
@@ -5091,6 +5124,8 @@ mod tests {
|
||||
"fiber_run_id".to_string(),
|
||||
"fiber-123".to_string(),
|
||||
)])),
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
};
|
||||
|
||||
let json_op = serde_json::to_value(&op)?;
|
||||
@@ -5132,10 +5167,12 @@ mod tests {
|
||||
#[test]
|
||||
fn user_message_event_serializes_empty_metadata_vectors() -> Result<()> {
|
||||
let event = UserMessageEvent {
|
||||
client_id: None,
|
||||
message: "hello".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let json_event = serde_json::to_value(event)?;
|
||||
@@ -5151,6 +5188,64 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_event_deserializes_without_image_detail_fields() -> Result<()> {
|
||||
let event: UserMessageEvent = serde_json::from_value(json!({
|
||||
"message": "hello",
|
||||
"images": ["https://example.com/image.png"],
|
||||
"local_images": ["/tmp/local.png"],
|
||||
"text_elements": [],
|
||||
}))?;
|
||||
|
||||
assert_eq!(event.message, "hello");
|
||||
assert_eq!(
|
||||
event.images,
|
||||
Some(vec!["https://example.com/image.png".to_string()])
|
||||
);
|
||||
assert_eq!(event.image_details, Vec::<Option<ImageDetail>>::new());
|
||||
assert_eq!(event.local_images, vec![PathBuf::from("/tmp/local.png")]);
|
||||
assert_eq!(event.local_image_details, Vec::<Option<ImageDetail>>::new());
|
||||
assert_eq!(event.text_elements, Vec::new());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_item_legacy_event_preserves_image_details() {
|
||||
let local_path = PathBuf::from("/tmp/local.png");
|
||||
let mut item = UserMessageItem::new(&[
|
||||
crate::user_input::UserInput::Image {
|
||||
image_url: "https://example.com/first.png".to_string(),
|
||||
detail: Some(ImageDetail::Original),
|
||||
},
|
||||
crate::user_input::UserInput::Image {
|
||||
image_url: "https://example.com/second.png".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
crate::user_input::UserInput::LocalImage {
|
||||
path: local_path.clone(),
|
||||
detail: Some(ImageDetail::Original),
|
||||
},
|
||||
]);
|
||||
item.client_id = Some("client-message-1".to_string());
|
||||
|
||||
let EventMsg::UserMessage(event) = item.as_legacy_event() else {
|
||||
panic!("expected user message event");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
event.images,
|
||||
Some(vec![
|
||||
"https://example.com/first.png".to_string(),
|
||||
"https://example.com/second.png".to_string(),
|
||||
])
|
||||
);
|
||||
assert_eq!(event.client_id, Some("client-message-1".to_string()));
|
||||
assert_eq!(event.image_details, vec![Some(ImageDetail::Original)]);
|
||||
assert_eq!(event.local_images, vec![local_path]);
|
||||
assert_eq!(event.local_image_details, vec![Some(ImageDetail::Original)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_aborted_event_deserializes_without_turn_id() -> Result<()> {
|
||||
let event: EventMsg = serde_json::from_value(json!({
|
||||
@@ -5181,7 +5276,6 @@ mod tests {
|
||||
"summary": "auto",
|
||||
}))?;
|
||||
|
||||
assert_eq!(item.trace_id, None);
|
||||
assert_eq!(item.network, None);
|
||||
assert_eq!(item.file_system_sandbox_policy, None);
|
||||
Ok(())
|
||||
@@ -5191,8 +5285,8 @@ mod tests {
|
||||
fn turn_context_item_serializes_network_when_present() -> Result<()> {
|
||||
let item = TurnContextItem {
|
||||
turn_id: None,
|
||||
trace_id: None,
|
||||
cwd: test_path_buf("/tmp"),
|
||||
workspace_roots: None,
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
@@ -5207,7 +5301,7 @@ mod tests {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: "/tmp/private/**/*.txt".to_string(),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
access: FileSystemAccessMode::Deny,
|
||||
},
|
||||
])),
|
||||
model: "gpt-5".to_string(),
|
||||
@@ -5216,10 +5310,6 @@ mod tests {
|
||||
realtime_active: None,
|
||||
effort: None,
|
||||
summary: ReasoningSummaryConfig::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(item)?;
|
||||
@@ -5239,10 +5329,11 @@ mod tests {
|
||||
"type": "glob_pattern",
|
||||
"pattern": "/tmp/private/**/*.txt"
|
||||
},
|
||||
"access": "none"
|
||||
"access": "deny"
|
||||
}]
|
||||
})
|
||||
);
|
||||
assert_eq!(value["summary"], json!("auto"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5260,6 +5351,7 @@ mod tests {
|
||||
session_id,
|
||||
thread_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
thread_source: None,
|
||||
thread_name: None,
|
||||
model: "codex-mini-latest".to_string(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
|
||||
/// Identifies a callable tool, preserving the namespace split when the model
|
||||
@@ -31,13 +32,6 @@ impl ToolName {
|
||||
namespace: Some(namespace.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display(&self) -> String {
|
||||
match &self.namespace {
|
||||
Some(namespace) => format!("{namespace}{}", self.name),
|
||||
None => self.name.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ToolName {
|
||||
@@ -49,6 +43,26 @@ impl fmt::Display for ToolName {
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for ToolName {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
let lhs = match &self.namespace {
|
||||
Some(namespace) => (namespace.as_str(), Some(self.name.as_str())),
|
||||
None => (self.name.as_str(), None),
|
||||
};
|
||||
let rhs = match &other.namespace {
|
||||
Some(namespace) => (namespace.as_str(), Some(other.name.as_str())),
|
||||
None => (other.name.as_str(), None),
|
||||
};
|
||||
lhs.cmp(&rhs)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for ToolName {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolName {
|
||||
fn from(name: String) -> Self {
|
||||
Self::plain(name)
|
||||
|
||||
@@ -3,6 +3,8 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::models::ImageDetail;
|
||||
|
||||
/// Conservative cap so one user message cannot monopolize a large context window.
|
||||
pub const MAX_USER_INPUT_TEXT_CHARS: usize = 1 << 20;
|
||||
|
||||
@@ -21,11 +23,21 @@ pub enum UserInput {
|
||||
text_elements: Vec<TextElement>,
|
||||
},
|
||||
/// Pre‑encoded data: URI image.
|
||||
Image { image_url: String },
|
||||
Image {
|
||||
image_url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
|
||||
/// Local image path provided by the user. This will be converted to an
|
||||
/// `Image` variant (base64 data URL) during request serialization.
|
||||
LocalImage { path: std::path::PathBuf },
|
||||
LocalImage {
|
||||
path: std::path::PathBuf,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional)]
|
||||
detail: Option<ImageDetail>,
|
||||
},
|
||||
|
||||
/// Skill selected by the user (name + path to SKILL.md).
|
||||
Skill {
|
||||
|
||||
Reference in New Issue
Block a user