Merge cf7d1ec0cf into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-04-28 13:37:50 -07:00
committed by GitHub
51 changed files with 2132 additions and 115 deletions
+1
View File
@@ -2816,6 +2816,7 @@ dependencies = [
"cc",
"clap",
"codex-core",
"codex-process-hardening",
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",
+28 -5
View File
@@ -8,7 +8,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::auth::PlanType as AuthPlanType;
use codex_protocol::protocol::SessionSource;
use crypto_box::SecretKey as Curve25519SecretKey;
use ed25519_dalek::Signer as _;
@@ -73,7 +73,7 @@ pub struct AgentIdentityJwtClaims {
pub account_id: String,
pub chatgpt_user_id: String,
pub email: String,
pub plan_type: AccountPlanType,
pub plan_type: AuthPlanType,
pub chatgpt_account_is_fedramp: bool,
}
@@ -408,6 +408,8 @@ mod tests {
use jsonwebtoken::Header;
use pretty_assertions::assert_eq;
use codex_protocol::auth::KnownPlan;
use super::*;
#[test]
@@ -517,12 +519,33 @@ mod tests {
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
plan_type: AuthPlanType::Known(KnownPlan::Pro),
chatgpt_account_is_fedramp: false,
}
);
}
#[test]
fn decode_agent_identity_jwt_maps_raw_plan_aliases() {
let jwt = jwt_with_payload(serde_json::json!({
"iss": AGENT_IDENTITY_JWT_ISSUER,
"aud": AGENT_IDENTITY_JWT_AUDIENCE,
"iat": 1_700_000_000usize,
"exp": 4_000_000_000usize,
"agent_runtime_id": "agent-runtime-id",
"agent_private_key": "private-key",
"account_id": "account-id",
"chatgpt_user_id": "user-id",
"email": "user@example.com",
"plan_type": "hc",
"chatgpt_account_is_fedramp": false,
}));
let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode");
assert_eq!(claims.plan_type, AuthPlanType::Known(KnownPlan::Enterprise));
}
#[test]
fn decode_agent_identity_jwt_verifies_when_jwks_is_present() {
let jwks = test_jwks("test-key");
@@ -536,7 +559,7 @@ mod tests {
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
plan_type: AuthPlanType::Known(KnownPlan::Pro),
chatgpt_account_is_fedramp: false,
};
let jwt = jsonwebtoken::encode(
@@ -568,7 +591,7 @@ mod tests {
account_id: "account-id".to_string(),
chatgpt_user_id: "user-id".to_string(),
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
plan_type: AuthPlanType::Known(KnownPlan::Pro),
chatgpt_account_is_fedramp: false,
};
assert_eq!(
@@ -1,4 +1,5 @@
use std::path::Path;
use std::path::PathBuf;
use crate::JSONRPCNotification;
use crate::JSONRPCRequest;
@@ -73,6 +74,76 @@ macro_rules! experimental_type_entry {
};
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientRequestSerializationScope {
Global(&'static str),
Thread { thread_id: String },
ThreadPath { path: PathBuf },
CommandExecProcess { process_id: String },
FuzzyFileSearchSession { session_id: String },
FsWatch { watch_id: String },
McpOauth { server_name: String },
}
macro_rules! serialization_scope_expr {
($actual_params:ident, None) => {
None
};
($actual_params:ident, global($key:literal)) => {
Some(ClientRequestSerializationScope::Global($key))
};
($actual_params:ident, thread_id($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::Thread {
thread_id: $actual_params.$field.clone(),
})
};
($actual_params:ident, optional_thread_id($params:ident . $field:ident)) => {
$actual_params
.$field
.clone()
.map(|thread_id| ClientRequestSerializationScope::Thread { thread_id })
};
($actual_params:ident, thread_or_path($params:ident . $thread_field:ident, $params2:ident . $path_field:ident)) => {
if !$actual_params.$thread_field.is_empty() {
Some(ClientRequestSerializationScope::Thread {
thread_id: $actual_params.$thread_field.clone(),
})
} else if let Some(path) = $actual_params.$path_field.clone() {
Some(ClientRequestSerializationScope::ThreadPath { path })
} else {
Some(ClientRequestSerializationScope::Thread {
thread_id: $actual_params.$thread_field.clone(),
})
}
};
($actual_params:ident, optional_command_process_id($params:ident . $field:ident)) => {
$actual_params
.$field
.clone()
.map(|process_id| ClientRequestSerializationScope::CommandExecProcess { process_id })
};
($actual_params:ident, command_process_id($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::CommandExecProcess {
process_id: $actual_params.$field.clone(),
})
};
($actual_params:ident, fuzzy_session_id($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::FuzzyFileSearchSession {
session_id: $actual_params.$field.clone(),
})
};
($actual_params:ident, fs_watch_id($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::FsWatch {
watch_id: $actual_params.$field.clone(),
})
};
($actual_params:ident, mcp_oauth_server($params:ident . $field:ident)) => {
Some(ClientRequestSerializationScope::McpOauth {
server_name: $actual_params.$field.clone(),
})
};
}
/// Generates an `enum ClientRequest` where each variant is a request that the
/// client can send to the server. Each variant has associated `params` and
/// `response` types. Also generates a `export_client_responses()` function to
@@ -85,6 +156,7 @@ macro_rules! client_request_definitions {
$variant:ident $(=> $wire:literal)? {
params: $(#[$params_meta:meta])* $params:ty,
$(inspect_params: $inspect_params:tt,)?
serialization: $serialization:ident $( ( $($serialization_args:tt)* ) )?,
response: $response:ty,
}
),* $(,)?
@@ -123,6 +195,19 @@ macro_rules! client_request_definitions {
})
.unwrap_or_else(|| "<unknown>".to_string())
}
pub fn serialization_scope(&self) -> Option<ClientRequestSerializationScope> {
match self {
$(
Self::$variant { params, .. } => {
let _ = params;
serialization_scope_expr!(
params, $serialization $( ( $($serialization_args)* ) )?
)
}
)*
}
}
}
/// Typed response from the server to the client.
@@ -235,6 +320,7 @@ macro_rules! client_request_definitions {
client_request_definitions! {
Initialize {
params: v1::InitializeParams,
serialization: None,
response: v1::InitializeResponse,
},
@@ -244,24 +330,29 @@ client_request_definitions! {
ThreadStart => "thread/start" {
params: v2::ThreadStartParams,
inspect_params: true,
serialization: None,
response: v2::ThreadStartResponse,
},
ThreadResume => "thread/resume" {
params: v2::ThreadResumeParams,
inspect_params: true,
serialization: thread_or_path(params.thread_id, params.path),
response: v2::ThreadResumeResponse,
},
ThreadFork => "thread/fork" {
params: v2::ThreadForkParams,
inspect_params: true,
serialization: thread_or_path(params.thread_id, params.path),
response: v2::ThreadForkResponse,
},
ThreadArchive => "thread/archive" {
params: v2::ThreadArchiveParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadArchiveResponse,
},
ThreadUnsubscribe => "thread/unsubscribe" {
params: v2::ThreadUnsubscribeParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadUnsubscribeResponse,
},
#[experimental("thread/increment_elicitation")]
@@ -271,6 +362,7 @@ client_request_definitions! {
/// approval or other elicitation is pending outside the app-server request flow.
ThreadIncrementElicitation => "thread/increment_elicitation" {
params: v2::ThreadIncrementElicitationParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadIncrementElicitationResponse,
},
#[experimental("thread/decrement_elicitation")]
@@ -279,302 +371,372 @@ client_request_definitions! {
/// When the count reaches zero, timeout accounting resumes for the thread.
ThreadDecrementElicitation => "thread/decrement_elicitation" {
params: v2::ThreadDecrementElicitationParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadDecrementElicitationResponse,
},
ThreadSetName => "thread/name/set" {
params: v2::ThreadSetNameParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadSetNameResponse,
},
#[experimental("thread/goal/set")]
ThreadGoalSet => "thread/goal/set" {
params: v2::ThreadGoalSetParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadGoalSetResponse,
},
#[experimental("thread/goal/get")]
ThreadGoalGet => "thread/goal/get" {
params: v2::ThreadGoalGetParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadGoalGetResponse,
},
#[experimental("thread/goal/clear")]
ThreadGoalClear => "thread/goal/clear" {
params: v2::ThreadGoalClearParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadGoalClearResponse,
},
ThreadMetadataUpdate => "thread/metadata/update" {
params: v2::ThreadMetadataUpdateParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadMetadataUpdateResponse,
},
#[experimental("thread/memoryMode/set")]
ThreadMemoryModeSet => "thread/memoryMode/set" {
params: v2::ThreadMemoryModeSetParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadMemoryModeSetResponse,
},
#[experimental("memory/reset")]
MemoryReset => "memory/reset" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("memory"),
response: v2::MemoryResetResponse,
},
ThreadUnarchive => "thread/unarchive" {
params: v2::ThreadUnarchiveParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadUnarchiveResponse,
},
ThreadCompactStart => "thread/compact/start" {
params: v2::ThreadCompactStartParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadCompactStartResponse,
},
ThreadShellCommand => "thread/shellCommand" {
params: v2::ThreadShellCommandParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadShellCommandResponse,
},
ThreadApproveGuardianDeniedAction => "thread/approveGuardianDeniedAction" {
params: v2::ThreadApproveGuardianDeniedActionParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadApproveGuardianDeniedActionResponse,
},
#[experimental("thread/backgroundTerminals/clean")]
ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" {
params: v2::ThreadBackgroundTerminalsCleanParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadBackgroundTerminalsCleanResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadRollbackResponse,
},
ThreadList => "thread/list" {
params: v2::ThreadListParams,
serialization: None,
response: v2::ThreadListResponse,
},
ThreadLoadedList => "thread/loaded/list" {
params: v2::ThreadLoadedListParams,
serialization: None,
response: v2::ThreadLoadedListResponse,
},
ThreadRead => "thread/read" {
params: v2::ThreadReadParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadReadResponse,
},
ThreadTurnsList => "thread/turns/list" {
params: v2::ThreadTurnsListParams,
// Explicitly concurrent: this primarily reads append-only rollout storage.
serialization: None,
response: v2::ThreadTurnsListResponse,
},
/// Append raw Responses API items to the thread history without starting a user turn.
ThreadInjectItems => "thread/inject_items" {
params: v2::ThreadInjectItemsParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadInjectItemsResponse,
},
SkillsList => "skills/list" {
params: v2::SkillsListParams,
serialization: global("config"),
response: v2::SkillsListResponse,
},
MarketplaceAdd => "marketplace/add" {
params: v2::MarketplaceAddParams,
serialization: global("config"),
response: v2::MarketplaceAddResponse,
},
MarketplaceRemove => "marketplace/remove" {
params: v2::MarketplaceRemoveParams,
serialization: global("config"),
response: v2::MarketplaceRemoveResponse,
},
MarketplaceUpgrade => "marketplace/upgrade" {
params: v2::MarketplaceUpgradeParams,
serialization: global("config"),
response: v2::MarketplaceUpgradeResponse,
},
PluginList => "plugin/list" {
params: v2::PluginListParams,
serialization: global("config"),
response: v2::PluginListResponse,
},
PluginRead => "plugin/read" {
params: v2::PluginReadParams,
serialization: global("config"),
response: v2::PluginReadResponse,
},
AppsList => "app/list" {
params: v2::AppsListParams,
serialization: None,
response: v2::AppsListResponse,
},
DeviceKeyCreate => "device/key/create" {
params: v2::DeviceKeyCreateParams,
serialization: global("device-key"),
response: v2::DeviceKeyCreateResponse,
},
DeviceKeyPublic => "device/key/public" {
params: v2::DeviceKeyPublicParams,
serialization: global("device-key"),
response: v2::DeviceKeyPublicResponse,
},
DeviceKeySign => "device/key/sign" {
params: v2::DeviceKeySignParams,
serialization: global("device-key"),
response: v2::DeviceKeySignResponse,
},
// File system requests are intentionally concurrent. Desktop already treats local
// file system operations as concurrent, and app-server remote fs mirrors that model.
FsReadFile => "fs/readFile" {
params: v2::FsReadFileParams,
serialization: None,
response: v2::FsReadFileResponse,
},
FsWriteFile => "fs/writeFile" {
params: v2::FsWriteFileParams,
serialization: None,
response: v2::FsWriteFileResponse,
},
FsCreateDirectory => "fs/createDirectory" {
params: v2::FsCreateDirectoryParams,
serialization: None,
response: v2::FsCreateDirectoryResponse,
},
FsGetMetadata => "fs/getMetadata" {
params: v2::FsGetMetadataParams,
serialization: None,
response: v2::FsGetMetadataResponse,
},
FsReadDirectory => "fs/readDirectory" {
params: v2::FsReadDirectoryParams,
serialization: None,
response: v2::FsReadDirectoryResponse,
},
FsRemove => "fs/remove" {
params: v2::FsRemoveParams,
serialization: None,
response: v2::FsRemoveResponse,
},
FsCopy => "fs/copy" {
params: v2::FsCopyParams,
serialization: None,
response: v2::FsCopyResponse,
},
FsWatch => "fs/watch" {
params: v2::FsWatchParams,
serialization: fs_watch_id(params.watch_id),
response: v2::FsWatchResponse,
},
FsUnwatch => "fs/unwatch" {
params: v2::FsUnwatchParams,
serialization: fs_watch_id(params.watch_id),
response: v2::FsUnwatchResponse,
},
SkillsConfigWrite => "skills/config/write" {
params: v2::SkillsConfigWriteParams,
serialization: global("config"),
response: v2::SkillsConfigWriteResponse,
},
PluginInstall => "plugin/install" {
params: v2::PluginInstallParams,
serialization: global("config"),
response: v2::PluginInstallResponse,
},
PluginUninstall => "plugin/uninstall" {
params: v2::PluginUninstallParams,
serialization: global("config"),
response: v2::PluginUninstallResponse,
},
TurnStart => "turn/start" {
params: v2::TurnStartParams,
inspect_params: true,
serialization: thread_id(params.thread_id),
response: v2::TurnStartResponse,
},
TurnSteer => "turn/steer" {
params: v2::TurnSteerParams,
inspect_params: true,
serialization: thread_id(params.thread_id),
response: v2::TurnSteerResponse,
},
TurnInterrupt => "turn/interrupt" {
params: v2::TurnInterruptParams,
serialization: thread_id(params.thread_id),
response: v2::TurnInterruptResponse,
},
#[experimental("thread/realtime/start")]
ThreadRealtimeStart => "thread/realtime/start" {
params: v2::ThreadRealtimeStartParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadRealtimeStartResponse,
},
#[experimental("thread/realtime/appendAudio")]
ThreadRealtimeAppendAudio => "thread/realtime/appendAudio" {
params: v2::ThreadRealtimeAppendAudioParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadRealtimeAppendAudioResponse,
},
#[experimental("thread/realtime/appendText")]
ThreadRealtimeAppendText => "thread/realtime/appendText" {
params: v2::ThreadRealtimeAppendTextParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadRealtimeAppendTextResponse,
},
#[experimental("thread/realtime/stop")]
ThreadRealtimeStop => "thread/realtime/stop" {
params: v2::ThreadRealtimeStopParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadRealtimeStopResponse,
},
#[experimental("thread/realtime/listVoices")]
ThreadRealtimeListVoices => "thread/realtime/listVoices" {
params: v2::ThreadRealtimeListVoicesParams,
serialization: None,
response: v2::ThreadRealtimeListVoicesResponse,
},
ReviewStart => "review/start" {
params: v2::ReviewStartParams,
serialization: thread_id(params.thread_id),
response: v2::ReviewStartResponse,
},
ModelList => "model/list" {
params: v2::ModelListParams,
serialization: None,
response: v2::ModelListResponse,
},
ExperimentalFeatureList => "experimentalFeature/list" {
params: v2::ExperimentalFeatureListParams,
serialization: global("config"),
response: v2::ExperimentalFeatureListResponse,
},
ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" {
params: v2::ExperimentalFeatureEnablementSetParams,
serialization: global("config"),
response: v2::ExperimentalFeatureEnablementSetResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
params: v2::CollaborationModeListParams,
serialization: None,
response: v2::CollaborationModeListResponse,
},
#[experimental("mock/experimentalMethod")]
/// Test-only method used to validate experimental gating.
MockExperimentalMethod => "mock/experimentalMethod" {
params: v2::MockExperimentalMethodParams,
serialization: None,
response: v2::MockExperimentalMethodResponse,
},
McpServerOauthLogin => "mcpServer/oauth/login" {
params: v2::McpServerOauthLoginParams,
serialization: mcp_oauth_server(params.name),
response: v2::McpServerOauthLoginResponse,
},
McpServerRefresh => "config/mcpServer/reload" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("mcp-registry"),
response: v2::McpServerRefreshResponse,
},
McpServerStatusList => "mcpServerStatus/list" {
params: v2::ListMcpServerStatusParams,
serialization: global("mcp-registry"),
response: v2::ListMcpServerStatusResponse,
},
McpResourceRead => "mcpServer/resource/read" {
params: v2::McpResourceReadParams,
serialization: optional_thread_id(params.thread_id),
response: v2::McpResourceReadResponse,
},
McpServerToolCall => "mcpServer/tool/call" {
params: v2::McpServerToolCallParams,
serialization: thread_id(params.thread_id),
response: v2::McpServerToolCallResponse,
},
WindowsSandboxSetupStart => "windowsSandbox/setupStart" {
params: v2::WindowsSandboxSetupStartParams,
serialization: global("windows-sandbox-setup"),
response: v2::WindowsSandboxSetupStartResponse,
},
LoginAccount => "account/login/start" {
params: v2::LoginAccountParams,
inspect_params: true,
serialization: global("account-auth"),
response: v2::LoginAccountResponse,
},
CancelLoginAccount => "account/login/cancel" {
params: v2::CancelLoginAccountParams,
serialization: global("account-auth"),
response: v2::CancelLoginAccountResponse,
},
LogoutAccount => "account/logout" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("account-auth"),
response: v2::LogoutAccountResponse,
},
GetAccountRateLimits => "account/rateLimits/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: None,
response: v2::GetAccountRateLimitsResponse,
},
SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" {
params: v2::SendAddCreditsNudgeEmailParams,
serialization: global("account-auth"),
response: v2::SendAddCreditsNudgeEmailResponse,
},
FeedbackUpload => "feedback/upload" {
params: v2::FeedbackUploadParams,
serialization: None,
response: v2::FeedbackUploadResponse,
},
@@ -582,86 +744,106 @@ client_request_definitions! {
OneOffCommandExec => "command/exec" {
params: v2::CommandExecParams,
inspect_params: true,
serialization: optional_command_process_id(params.process_id),
response: v2::CommandExecResponse,
},
/// Write stdin bytes to a running `command/exec` session or close stdin.
CommandExecWrite => "command/exec/write" {
params: v2::CommandExecWriteParams,
serialization: command_process_id(params.process_id),
response: v2::CommandExecWriteResponse,
},
/// Terminate a running `command/exec` session by client-supplied `processId`.
CommandExecTerminate => "command/exec/terminate" {
params: v2::CommandExecTerminateParams,
serialization: command_process_id(params.process_id),
response: v2::CommandExecTerminateResponse,
},
/// Resize a running PTY-backed `command/exec` session by client-supplied `processId`.
CommandExecResize => "command/exec/resize" {
params: v2::CommandExecResizeParams,
serialization: command_process_id(params.process_id),
response: v2::CommandExecResizeResponse,
},
ConfigRead => "config/read" {
params: v2::ConfigReadParams,
serialization: global("config"),
response: v2::ConfigReadResponse,
},
ExternalAgentConfigDetect => "externalAgentConfig/detect" {
params: v2::ExternalAgentConfigDetectParams,
serialization: global("config"),
response: v2::ExternalAgentConfigDetectResponse,
},
ExternalAgentConfigImport => "externalAgentConfig/import" {
params: v2::ExternalAgentConfigImportParams,
serialization: global("config"),
response: v2::ExternalAgentConfigImportResponse,
},
ConfigValueWrite => "config/value/write" {
params: v2::ConfigValueWriteParams,
serialization: global("config"),
response: v2::ConfigWriteResponse,
},
ConfigBatchWrite => "config/batchWrite" {
params: v2::ConfigBatchWriteParams,
serialization: global("config"),
response: v2::ConfigWriteResponse,
},
ConfigRequirementsRead => "configRequirements/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global("config"),
response: v2::ConfigRequirementsReadResponse,
},
GetAccount => "account/read" {
params: v2::GetAccountParams,
serialization: global("account-auth"),
response: v2::GetAccountResponse,
},
/// DEPRECATED APIs below
GetConversationSummary {
params: v1::GetConversationSummaryParams,
serialization: None,
response: v1::GetConversationSummaryResponse,
},
GitDiffToRemote {
params: v1::GitDiffToRemoteParams,
serialization: None,
response: v1::GitDiffToRemoteResponse,
},
/// DEPRECATED in favor of GetAccount
GetAuthStatus {
params: v1::GetAuthStatusParams,
serialization: global("account-auth"),
response: v1::GetAuthStatusResponse,
},
// Legacy fuzzy search cancellation is intentionally concurrent: clients reuse a
// cancellation token so a newer request can cancel an older in-flight search.
FuzzyFileSearch {
params: FuzzyFileSearchParams,
serialization: None,
response: FuzzyFileSearchResponse,
},
#[experimental("fuzzyFileSearch/sessionStart")]
FuzzyFileSearchSessionStart => "fuzzyFileSearch/sessionStart" {
params: FuzzyFileSearchSessionStartParams,
serialization: fuzzy_session_id(params.session_id),
response: FuzzyFileSearchSessionStartResponse,
},
#[experimental("fuzzyFileSearch/sessionUpdate")]
FuzzyFileSearchSessionUpdate => "fuzzyFileSearch/sessionUpdate" {
params: FuzzyFileSearchSessionUpdateParams,
serialization: fuzzy_session_id(params.session_id),
response: FuzzyFileSearchSessionUpdateResponse,
},
#[experimental("fuzzyFileSearch/sessionStop")]
FuzzyFileSearchSessionStop => "fuzzyFileSearch/sessionStop" {
params: FuzzyFileSearchSessionStopParams,
serialization: fuzzy_session_id(params.session_id),
response: FuzzyFileSearchSessionStopResponse,
},
}
@@ -1150,6 +1332,325 @@ mod tests {
test_path_buf(&path).abs()
}
fn request_id() -> RequestId {
const REQUEST_ID: i64 = 1;
RequestId::Integer(REQUEST_ID)
}
#[test]
fn client_request_serialization_scope_covers_keyed_families() {
let thread_id = "thread-1".to_string();
let thread_resume = ClientRequest::ThreadResume {
request_id: request_id(),
params: v2::ThreadResumeParams {
thread_id: thread_id.clone(),
..Default::default()
},
};
assert_eq!(
thread_resume.serialization_scope(),
Some(ClientRequestSerializationScope::Thread {
thread_id: thread_id.clone()
})
);
let thread_resume_with_path = ClientRequest::ThreadResume {
request_id: request_id(),
params: v2::ThreadResumeParams {
thread_id: thread_id.clone(),
path: Some(PathBuf::from("/tmp/resume-thread.jsonl")),
..Default::default()
},
};
assert_eq!(
thread_resume_with_path.serialization_scope(),
Some(ClientRequestSerializationScope::Thread {
thread_id: thread_id.clone()
})
);
let thread_fork = ClientRequest::ThreadFork {
request_id: request_id(),
params: v2::ThreadForkParams {
thread_id: thread_id.clone(),
path: Some(PathBuf::from("/tmp/source-thread.jsonl")),
..Default::default()
},
};
assert_eq!(
thread_fork.serialization_scope(),
Some(ClientRequestSerializationScope::Thread { thread_id })
);
let command_exec = ClientRequest::OneOffCommandExec {
request_id: request_id(),
params: v2::CommandExecParams {
command: vec!["sleep".to_string(), "10".to_string()],
process_id: Some("proc-1".to_string()),
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
permission_profile: None,
},
};
assert_eq!(
command_exec.serialization_scope(),
Some(ClientRequestSerializationScope::CommandExecProcess {
process_id: "proc-1".to_string()
})
);
let fuzzy_update = ClientRequest::FuzzyFileSearchSessionUpdate {
request_id: request_id(),
params: FuzzyFileSearchSessionUpdateParams {
session_id: "search-1".to_string(),
query: "lib".to_string(),
},
};
assert_eq!(
fuzzy_update.serialization_scope(),
Some(ClientRequestSerializationScope::FuzzyFileSearchSession {
session_id: "search-1".to_string()
})
);
let fs_watch = ClientRequest::FsWatch {
request_id: request_id(),
params: v2::FsWatchParams {
watch_id: "watch-1".to_string(),
path: absolute_path("/tmp/repo"),
},
};
assert_eq!(
fs_watch.serialization_scope(),
Some(ClientRequestSerializationScope::FsWatch {
watch_id: "watch-1".to_string()
})
);
let plugin_install = ClientRequest::PluginInstall {
request_id: request_id(),
params: v2::PluginInstallParams {
marketplace_path: Some(absolute_path("/tmp/marketplace")),
remote_marketplace_name: None,
plugin_name: "plugin-a".to_string(),
},
};
assert_eq!(
plugin_install.serialization_scope(),
Some(ClientRequestSerializationScope::Global("config"))
);
let plugin_uninstall = ClientRequest::PluginUninstall {
request_id: request_id(),
params: v2::PluginUninstallParams {
plugin_id: "plugin-a".to_string(),
},
};
assert_eq!(
plugin_uninstall.serialization_scope(),
Some(ClientRequestSerializationScope::Global("config"))
);
let mcp_oauth = ClientRequest::McpServerOauthLogin {
request_id: request_id(),
params: v2::McpServerOauthLoginParams {
name: "server-a".to_string(),
scopes: None,
timeout_secs: None,
},
};
assert_eq!(
mcp_oauth.serialization_scope(),
Some(ClientRequestSerializationScope::McpOauth {
server_name: "server-a".to_string()
})
);
let mcp_resource_read = ClientRequest::McpResourceRead {
request_id: request_id(),
params: v2::McpResourceReadParams {
thread_id: Some("thread-1".to_string()),
server: "server-a".to_string(),
uri: "file:///tmp/resource".to_string(),
},
};
assert_eq!(
mcp_resource_read.serialization_scope(),
Some(ClientRequestSerializationScope::Thread {
thread_id: "thread-1".to_string()
})
);
let config_read = ClientRequest::ConfigRead {
request_id: request_id(),
params: v2::ConfigReadParams {
include_layers: false,
cwd: None,
},
};
assert_eq!(
config_read.serialization_scope(),
Some(ClientRequestSerializationScope::Global("config"))
);
let account_read = ClientRequest::GetAccount {
request_id: request_id(),
params: v2::GetAccountParams {
refresh_token: false,
},
};
assert_eq!(
account_read.serialization_scope(),
Some(ClientRequestSerializationScope::Global("account-auth"))
);
let thread_goal_set = ClientRequest::ThreadGoalSet {
request_id: request_id(),
params: v2::ThreadGoalSetParams {
thread_id: "goal-thread".to_string(),
objective: Some("ship it".to_string()),
status: None,
token_budget: None,
},
};
assert_eq!(
thread_goal_set.serialization_scope(),
Some(ClientRequestSerializationScope::Thread {
thread_id: "goal-thread".to_string()
})
);
let guardian_approval = ClientRequest::ThreadApproveGuardianDeniedAction {
request_id: request_id(),
params: v2::ThreadApproveGuardianDeniedActionParams {
thread_id: "guardian-thread".to_string(),
event: json!({ "type": "guardian" }),
},
};
assert_eq!(
guardian_approval.serialization_scope(),
Some(ClientRequestSerializationScope::Thread {
thread_id: "guardian-thread".to_string()
})
);
let marketplace_remove = ClientRequest::MarketplaceRemove {
request_id: request_id(),
params: v2::MarketplaceRemoveParams {
marketplace_name: "marketplace".to_string(),
},
};
assert_eq!(
marketplace_remove.serialization_scope(),
Some(ClientRequestSerializationScope::Global("config"))
);
let device_key_create = ClientRequest::DeviceKeyCreate {
request_id: request_id(),
params: v2::DeviceKeyCreateParams {
protection_policy: None,
account_user_id: "user".to_string(),
client_id: "client".to_string(),
},
};
assert_eq!(
device_key_create.serialization_scope(),
Some(ClientRequestSerializationScope::Global("device-key"))
);
let add_credits_nudge = ClientRequest::SendAddCreditsNudgeEmail {
request_id: request_id(),
params: v2::SendAddCreditsNudgeEmailParams {
credit_type: v2::AddCreditsNudgeCreditType::Credits,
},
};
assert_eq!(
add_credits_nudge.serialization_scope(),
Some(ClientRequestSerializationScope::Global("account-auth"))
);
}
#[test]
fn client_request_serialization_scope_covers_unkeyed_representatives() {
let initialize = ClientRequest::Initialize {
request_id: request_id(),
params: v1::InitializeParams {
client_info: v1::ClientInfo {
name: "test".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: None,
},
};
assert_eq!(initialize.serialization_scope(), None);
let thread_start = ClientRequest::ThreadStart {
request_id: request_id(),
params: v2::ThreadStartParams::default(),
};
assert_eq!(thread_start.serialization_scope(), None);
let command_exec = ClientRequest::OneOffCommandExec {
request_id: request_id(),
params: v2::CommandExecParams {
command: vec!["true".to_string()],
process_id: None,
tty: false,
stream_stdin: false,
stream_stdout_stderr: false,
output_bytes_cap: None,
disable_output_cap: false,
disable_timeout: false,
timeout_ms: None,
cwd: None,
env: None,
size: None,
sandbox_policy: None,
permission_profile: None,
},
};
assert_eq!(command_exec.serialization_scope(), None);
let fs_read = ClientRequest::FsReadFile {
request_id: request_id(),
params: v2::FsReadFileParams {
path: absolute_path("/tmp/file.txt"),
},
};
assert_eq!(fs_read.serialization_scope(), None);
let thread_turns_list = ClientRequest::ThreadTurnsList {
request_id: request_id(),
params: v2::ThreadTurnsListParams {
thread_id: "thread-1".to_string(),
cursor: None,
limit: None,
sort_direction: None,
},
};
assert_eq!(thread_turns_list.serialization_scope(), None);
let mcp_resource_read = ClientRequest::McpResourceRead {
request_id: request_id(),
params: v2::McpResourceReadParams {
thread_id: None,
server: "server-a".to_string(),
uri: "file:///tmp/resource".to_string(),
},
};
assert_eq!(mcp_resource_read.serialization_scope(), None);
}
#[test]
fn serialize_get_conversation_summary() -> Result<()> {
let request = ClientRequest::GetConversationSummary {
+1 -1
View File
@@ -192,7 +192,7 @@ Example with notification opt-out:
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
@@ -178,6 +178,7 @@ pub(crate) async fn apply_bespoke_event_handling(
outgoing: ThreadScopedOutgoingMessageSender,
thread_state: Arc<tokio::sync::Mutex<ThreadState>>,
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<tokio::sync::Semaphore>,
api_version: ApiVersion,
fallback_model_provider: String,
codex_home: &Path,
@@ -1874,6 +1875,20 @@ pub(crate) async fn apply_bespoke_event_handling(
};
if let Some(request_id) = pending {
let _thread_list_state_permit = match thread_list_state_permit.acquire().await {
Ok(permit) => permit,
Err(err) => {
outgoing
.send_error(
request_id,
internal_error(format!(
"failed to acquire thread list state permit: {err}"
)),
)
.await;
return;
}
};
let Some(rollout_path) = conversation.rollout_path() else {
outgoing
.send_error(
@@ -3271,6 +3286,7 @@ mod tests {
self.outgoing.clone(),
self.thread_state.clone(),
self.thread_watch_manager.clone(),
Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
ApiVersion::V2,
"test-provider".to_string(),
&self.codex_home,
@@ -393,6 +393,8 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::Mutex;
use tokio::sync::Semaphore;
use tokio::sync::SemaphorePermit;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::watch;
@@ -524,6 +526,10 @@ pub(crate) struct CodexMessageProcessor {
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
thread_state_manager: ThreadStateManager,
thread_watch_manager: ThreadWatchManager,
/// Serializes mutations of list membership or fields rendered from list
/// results. `thread/list` is intentionally not serialized so it can run
/// concurrently against mostly append-only storage.
thread_list_state_permit: Arc<Semaphore>,
command_exec_manager: CommandExecManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
@@ -549,6 +555,7 @@ struct ListenerTaskContext {
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
analytics_events_client: AnalyticsEventsClient,
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<Semaphore>,
fallback_model_provider: String,
codex_home: PathBuf,
}
@@ -790,6 +797,7 @@ impl CodexMessageProcessor {
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
thread_state_manager: ThreadStateManager::new(),
thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing),
thread_list_state_permit: Arc::new(Semaphore::new(/*permits*/ 1)),
command_exec_manager: CommandExecManager::default(),
workspace_settings_cache: Arc::new(
workspace_settings::WorkspaceSettingsCache::default(),
@@ -1326,6 +1334,17 @@ impl CodexMessageProcessor {
}
}
async fn acquire_thread_list_state_permit(
&self,
) -> Result<SemaphorePermit<'_>, JSONRPCErrorError> {
self.thread_list_state_permit
.acquire()
.await
.map_err(|err| {
internal_error(format!("failed to acquire thread list state permit: {err}"))
})
}
async fn login_api_key_common(
&self,
params: &LoginApiKeyParams,
@@ -2421,6 +2440,7 @@ impl CodexMessageProcessor {
pending_thread_unloads: Arc::clone(&self.pending_thread_unloads),
analytics_events_client: self.analytics_events_client.clone(),
thread_watch_manager: self.thread_watch_manager.clone(),
thread_list_state_permit: self.thread_list_state_permit.clone(),
fallback_model_provider: self.config.model_provider_id.clone(),
codex_home: self.config.codex_home.to_path_buf(),
};
@@ -2871,6 +2891,13 @@ impl CodexMessageProcessor {
}
async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) {
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
Ok(permit) => permit,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let result = self.thread_archive_response(params).await;
let archived_thread_ids = result
.as_ref()
@@ -3077,6 +3104,7 @@ impl CodexMessageProcessor {
return Err(invalid_request("thread name must not be empty"));
};
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
self.submit_core_op(request_id, thread.as_ref(), Op::SetThreadName { name })
.await
@@ -3218,6 +3246,7 @@ impl CodexMessageProcessor {
return Err(invalid_request("gitInfo must include at least one field"));
}
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok();
let mut state_db_ctx = loaded_thread.as_ref().and_then(|thread| thread.state_db());
if state_db_ctx.is_none() {
@@ -3414,6 +3443,13 @@ impl CodexMessageProcessor {
request_id: ConnectionRequestId,
params: ThreadUnarchiveParams,
) {
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
Ok(permit) => permit,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let result = self.thread_unarchive_response(params).await;
let notification =
result
@@ -4143,6 +4179,13 @@ impl CodexMessageProcessor {
return;
}
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
Ok(permit) => permit,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self.resume_running_thread(&request_id, &params).await {
Ok(true) => return,
Ok(false) => {}
@@ -7237,6 +7280,7 @@ impl CodexMessageProcessor {
pending_thread_unloads: Arc::clone(&self.pending_thread_unloads),
analytics_events_client: self.analytics_events_client.clone(),
thread_watch_manager: self.thread_watch_manager.clone(),
thread_list_state_permit: self.thread_list_state_permit.clone(),
fallback_model_provider: self.config.model_provider_id.clone(),
codex_home: self.config.codex_home.to_path_buf(),
},
@@ -7354,6 +7398,7 @@ impl CodexMessageProcessor {
pending_thread_unloads: Arc::clone(&self.pending_thread_unloads),
analytics_events_client: self.analytics_events_client.clone(),
thread_watch_manager: self.thread_watch_manager.clone(),
thread_list_state_permit: self.thread_list_state_permit.clone(),
fallback_model_provider: self.config.model_provider_id.clone(),
codex_home: self.config.codex_home.to_path_buf(),
},
@@ -7402,6 +7447,7 @@ impl CodexMessageProcessor {
pending_thread_unloads,
analytics_events_client: _,
thread_watch_manager,
thread_list_state_permit,
fallback_model_provider,
codex_home,
} = listener_task_context;
@@ -7480,6 +7526,7 @@ impl CodexMessageProcessor {
thread_outgoing,
thread_state.clone(),
thread_watch_manager.clone(),
thread_list_state_permit.clone(),
api_version,
fallback_model_provider.clone(),
codex_home.as_path(),
+1
View File
@@ -48,6 +48,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"memories",
"plugins",
"remote_control",
"tool_search",
"tool_suggest",
"tool_call_mcp_elicitation",
@@ -0,0 +1,209 @@
use std::future::Future;
use tokio::sync::Mutex;
use tokio_util::task::TaskTracker;
/// Per-connection gate for initialized RPC handler execution.
///
/// Closing the gate prevents queued handlers from starting while allowing
/// handlers that already acquired a token to finish.
#[derive(Debug)]
pub(crate) struct ConnectionRpcGate {
accepting: Mutex<bool>,
tasks: TaskTracker,
}
impl ConnectionRpcGate {
pub(crate) fn new() -> Self {
let accepting = true;
Self {
accepting: Mutex::new(accepting),
tasks: TaskTracker::new(),
}
}
pub(crate) async fn run<F>(&self, future: F)
where
F: Future<Output = ()>,
{
let token = {
let accepting = self.accepting.lock().await;
if !*accepting {
return;
}
self.tasks.token()
};
future.await;
drop(token);
}
pub(crate) async fn shutdown(&self) {
{
let mut accepting = self.accepting.lock().await;
*accepting = false;
self.tasks.close();
}
self.tasks.wait().await;
}
#[cfg(test)]
async fn is_accepting(&self) -> bool {
*self.accepting.lock().await
}
#[cfg(test)]
fn inflight_count(&self) -> usize {
self.tasks.len()
}
}
impl Default for ConnectionRpcGate {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tokio::sync::oneshot;
use tokio::time::Duration;
use tokio::time::timeout;
#[tokio::test]
async fn run_executes_while_open() {
let gate = ConnectionRpcGate::new();
let ran = Arc::new(AtomicBool::new(/*v*/ false));
let ran_clone = Arc::clone(&ran);
gate.run(async move {
ran_clone.store(/*val*/ true, Ordering::Release);
})
.await;
assert!(ran.load(Ordering::Acquire));
}
#[tokio::test]
async fn run_drops_future_without_polling_after_shutdown() {
let gate = ConnectionRpcGate::new();
gate.shutdown().await;
let polled = Arc::new(AtomicBool::new(/*v*/ false));
let polled_clone = Arc::clone(&polled);
gate.run(async move {
polled_clone.store(/*val*/ true, Ordering::Release);
})
.await;
assert!(!polled.load(Ordering::Acquire));
assert!(!gate.is_accepting().await);
}
#[tokio::test]
async fn shutdown_waits_for_started_run_to_finish() {
let gate = Arc::new(ConnectionRpcGate::new());
let (started_tx, started_rx) = oneshot::channel();
let (finish_tx, finish_rx) = oneshot::channel();
let gate_for_run = Arc::clone(&gate);
let run_task = tokio::spawn(async move {
gate_for_run
.run(async move {
started_tx.send(()).expect("receiver should be open");
let _ = finish_rx.await;
})
.await;
});
started_rx.await.expect("run should start");
assert_eq!(gate.inflight_count(), 1);
let gate_for_shutdown = Arc::clone(&gate);
let shutdown_task = tokio::spawn(async move {
gate_for_shutdown.shutdown().await;
});
timeout(Duration::from_millis(/*millis*/ 50), shutdown_task)
.await
.expect_err("shutdown should wait for the running future");
finish_tx
.send(())
.expect("running future should be waiting");
run_task.await.expect("run task should complete");
gate.shutdown().await;
assert_eq!(gate.inflight_count(), 0);
}
#[tokio::test]
async fn shutdown_drops_late_runs_while_waiting_for_inflight_work() {
let gate = Arc::new(ConnectionRpcGate::new());
let (started_tx, started_rx) = oneshot::channel();
let (finish_tx, finish_rx) = oneshot::channel();
let gate_for_run = Arc::clone(&gate);
let run_task = tokio::spawn(async move {
gate_for_run
.run(async move {
started_tx.send(()).expect("receiver should be open");
let _ = finish_rx.await;
})
.await;
});
started_rx.await.expect("run should start");
let gate_for_shutdown = Arc::clone(&gate);
let shutdown_task = tokio::spawn(async move {
gate_for_shutdown.shutdown().await;
});
timeout(Duration::from_millis(/*millis*/ 50), shutdown_task)
.await
.expect_err("shutdown should wait for the running future");
let late_polled = Arc::new(AtomicBool::new(/*v*/ false));
let late_polled_clone = Arc::clone(&late_polled);
gate.run(async move {
late_polled_clone.store(/*val*/ true, Ordering::Release);
})
.await;
assert!(!late_polled.load(Ordering::Acquire));
finish_tx
.send(())
.expect("running future should still be waiting");
run_task.await.expect("run task should complete");
gate.shutdown().await;
assert_eq!(gate.inflight_count(), 0);
}
#[tokio::test]
async fn run_is_counted_before_handler_body_continues() {
let gate = Arc::new(ConnectionRpcGate::new());
let (entered_tx, entered_rx) = oneshot::channel();
let (continue_tx, continue_rx) = oneshot::channel();
let gate_for_run = Arc::clone(&gate);
let run_task = tokio::spawn(async move {
gate_for_run
.run(async move {
entered_tx.send(()).expect("receiver should be open");
let _ = continue_rx.await;
})
.await;
});
entered_rx.await.expect("handler body should be entered");
assert_eq!(gate.inflight_count(), 1);
continue_tx
.send(())
.expect("handler body should still be waiting");
run_task.await.expect("run task should complete");
assert_eq!(gate.inflight_count(), 0);
}
}
+3 -1
View File
@@ -490,7 +490,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
processor.clear_runtime_references();
processor.cancel_active_login().await;
processor.connection_closed(IN_PROCESS_CONNECTION_ID).await;
processor
.connection_closed(IN_PROCESS_CONNECTION_ID, &session)
.await;
processor.clear_all_thread_listeners().await;
processor.drain_background_tasks().await;
processor.shutdown_threads().await;
+11 -3
View File
@@ -75,6 +75,7 @@ mod config;
mod config_api;
mod config_manager;
mod config_manager_service;
mod connection_rpc_gate;
mod device_key_api;
mod dynamic_tools;
mod error_code;
@@ -87,6 +88,7 @@ pub mod in_process;
mod message_processor;
mod models;
mod outgoing_message;
mod request_serialization;
mod server_request_error;
mod thread_state;
mod thread_status;
@@ -809,9 +811,9 @@ pub async fn run_main_with_transport_options(
);
}
TransportEvent::ConnectionClosed { connection_id } => {
if connections.remove(&connection_id).is_none() {
let Some(connection_state) = connections.remove(&connection_id) else {
continue;
}
};
if outbound_control_tx
.send(OutboundControlEvent::Closed { connection_id })
.await
@@ -819,7 +821,7 @@ pub async fn run_main_with_transport_options(
{
break;
}
processor.connection_closed(connection_id).await;
processor.connection_closed(connection_id, &connection_state.session).await;
if shutdown_when_no_connections && connections.is_empty() {
break;
}
@@ -933,6 +935,12 @@ pub async fn run_main_with_transport_options(
}
if !shutdown_state.forced() {
futures::future::join_all(
connections
.values()
.map(|connection_state| connection_state.session.rpc_gate.shutdown()),
)
.await;
processor.drain_background_tasks().await;
processor.shutdown_threads().await;
}
+51 -11
View File
@@ -9,6 +9,7 @@ use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::device_key_api::DeviceKeyApi;
use crate::error_code::invalid_request;
use crate::external_agent_config_api::ExternalAgentConfigApi;
@@ -18,6 +19,9 @@ use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::RequestContext;
use crate::request_serialization::QueuedInitializedRequest;
use crate::request_serialization::RequestSerializationQueueKey;
use crate::request_serialization::RequestSerializationQueues;
use crate::transport::AppServerTransport;
use crate::transport::ConnectionOrigin;
use crate::transport::RemoteControlHandle;
@@ -167,11 +171,13 @@ pub(crate) struct MessageProcessor {
config_warnings: Arc<Vec<ConfigWarningNotification>>,
rpc_transport: AppServerRpcTransport,
remote_control_handle: Option<RemoteControlHandle>,
request_serialization_queues: RequestSerializationQueues,
}
#[derive(Debug)]
pub(crate) struct ConnectionSessionState {
origin: ConnectionOrigin,
pub(crate) rpc_gate: Arc<ConnectionRpcGate>,
initialized: OnceLock<InitializedConnectionSessionState>,
}
@@ -193,6 +199,7 @@ impl ConnectionSessionState {
pub(crate) fn new(origin: ConnectionOrigin) -> Self {
Self {
origin,
rpc_gate: Arc::new(ConnectionRpcGate::new()),
initialized: OnceLock::new(),
}
}
@@ -344,6 +351,7 @@ impl MessageProcessor {
config_warnings: Arc::new(config_warnings),
rpc_transport,
remote_control_handle,
request_serialization_queues: RequestSerializationQueues::default(),
}
}
@@ -540,7 +548,12 @@ impl MessageProcessor {
self.codex_message_processor.shutdown_threads().await;
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
pub(crate) async fn connection_closed(
&self,
connection_id: ConnectionId,
session_state: &ConnectionSessionState,
) {
session_state.rpc_gate.shutdown().await;
self.outgoing.connection_closed(connection_id).await;
self.fs_watch_manager.connection_closed(connection_id).await;
self.codex_message_processor
@@ -724,19 +737,46 @@ impl MessageProcessor {
);
}
let serialization_scope = codex_request.serialization_scope();
let app_server_client_name = session.app_server_client_name().map(str::to_string);
let client_version = session.client_version().map(str::to_string);
let device_key_requests_allowed = session.allows_device_key_requests();
Arc::clone(self)
.handle_initialized_client_request(
connection_request_id,
codex_request,
request_context,
app_server_client_name,
client_version,
device_key_requests_allowed,
)
.await
let error_request_id = connection_request_id.clone();
let rpc_gate = Arc::clone(&session.rpc_gate);
let processor = Arc::clone(self);
let span = request_context.span();
let request = QueuedInitializedRequest::new(
rpc_gate,
async move {
let processor_for_request = Arc::clone(&processor);
let result = processor_for_request
.handle_initialized_client_request(
connection_request_id,
codex_request,
request_context,
app_server_client_name,
client_version,
device_key_requests_allowed,
)
.await;
if let Err(error) = result {
processor.outgoing.send_error(error_request_id, error).await;
}
}
.instrument(span),
);
if let Some(scope) = serialization_scope {
let key = RequestSerializationQueueKey::from_scope(connection_id, scope);
self.request_serialization_queues
.enqueue(key, request)
.await;
} else {
tokio::spawn(async move {
request.run().await;
});
}
Ok(())
}
async fn handle_initialized_client_request(
@@ -0,0 +1,380 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use codex_app_server_protocol::ClientRequestSerializationScope;
use tokio::sync::Mutex;
use tracing::Instrument;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::outgoing_message::ConnectionId;
type BoxFutureUnit = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) enum RequestSerializationQueueKey {
Global(&'static str),
Thread {
thread_id: String,
},
ThreadPath {
path: PathBuf,
},
CommandExecProcess {
connection_id: ConnectionId,
process_id: String,
},
FuzzyFileSearchSession {
session_id: String,
},
FsWatch {
connection_id: ConnectionId,
watch_id: String,
},
McpOauth {
server_name: String,
},
}
impl RequestSerializationQueueKey {
pub(crate) fn from_scope(
connection_id: ConnectionId,
scope: ClientRequestSerializationScope,
) -> Self {
match scope {
ClientRequestSerializationScope::Global(name) => Self::Global(name),
ClientRequestSerializationScope::Thread { thread_id } => Self::Thread { thread_id },
ClientRequestSerializationScope::ThreadPath { path } => Self::ThreadPath { path },
ClientRequestSerializationScope::CommandExecProcess { process_id } => {
Self::CommandExecProcess {
connection_id,
process_id,
}
}
ClientRequestSerializationScope::FuzzyFileSearchSession { session_id } => {
Self::FuzzyFileSearchSession { session_id }
}
ClientRequestSerializationScope::FsWatch { watch_id } => Self::FsWatch {
connection_id,
watch_id,
},
ClientRequestSerializationScope::McpOauth { server_name } => {
Self::McpOauth { server_name }
}
}
}
}
pub(crate) struct QueuedInitializedRequest {
gate: Arc<ConnectionRpcGate>,
future: BoxFutureUnit,
}
impl QueuedInitializedRequest {
pub(crate) fn new(
gate: Arc<ConnectionRpcGate>,
future: impl Future<Output = ()> + Send + 'static,
) -> Self {
Self {
gate,
future: Box::pin(future),
}
}
pub(crate) async fn run(self) {
let Self { gate, future } = self;
gate.run(future).await;
}
}
#[derive(Clone, Default)]
pub(crate) struct RequestSerializationQueues {
inner: Arc<Mutex<HashMap<RequestSerializationQueueKey, VecDeque<QueuedInitializedRequest>>>>,
}
impl RequestSerializationQueues {
pub(crate) async fn enqueue(
&self,
key: RequestSerializationQueueKey,
request: QueuedInitializedRequest,
) {
let should_spawn = {
let mut queues = self.inner.lock().await;
match queues.get_mut(&key) {
Some(queue) => {
queue.push_back(request);
false
}
None => {
let mut queue = VecDeque::new();
queue.push_back(request);
queues.insert(key.clone(), queue);
true
}
}
};
if should_spawn {
let queues = self.clone();
let span = tracing::debug_span!("app_server.serialized_request_queue", ?key);
tokio::spawn(async move { queues.drain(key).await }.instrument(span));
}
}
async fn drain(self, key: RequestSerializationQueueKey) {
loop {
let request = {
let mut queues = self.inner.lock().await;
let Some(queue) = queues.get_mut(&key) else {
return;
};
match queue.pop_front() {
Some(request) => request,
None => {
queues.remove(&key);
return;
}
}
};
request.run().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::Duration;
use tokio::time::timeout;
const FIRST_REQUEST_VALUE: i32 = 1;
const SECOND_REQUEST_VALUE: i32 = 2;
const THIRD_REQUEST_VALUE: i32 = 3;
fn gate() -> Arc<ConnectionRpcGate> {
Arc::new(ConnectionRpcGate::new())
}
fn queue_drain_timeout() -> Duration {
Duration::from_secs(/*secs*/ 1)
}
fn shutdown_wait_timeout() -> Duration {
Duration::from_millis(/*millis*/ 50)
}
#[tokio::test]
async fn same_key_requests_run_fifo() {
let queues = RequestSerializationQueues::default();
let key = RequestSerializationQueueKey::Global("test");
let gate = gate();
let (tx, mut rx) = mpsc::unbounded_channel();
for value in [
FIRST_REQUEST_VALUE,
SECOND_REQUEST_VALUE,
THIRD_REQUEST_VALUE,
] {
let tx = tx.clone();
queues
.enqueue(
key.clone(),
QueuedInitializedRequest::new(Arc::clone(&gate), async move {
tx.send(value).expect("receiver should be open");
}),
)
.await;
}
drop(tx);
let mut values = Vec::new();
while let Some(value) = timeout(queue_drain_timeout(), rx.recv())
.await
.expect("timed out waiting for queued request")
{
values.push(value);
}
assert_eq!(
values,
vec![
FIRST_REQUEST_VALUE,
SECOND_REQUEST_VALUE,
THIRD_REQUEST_VALUE
]
);
}
#[tokio::test]
async fn different_keys_run_concurrently() {
let queues = RequestSerializationQueues::default();
let (blocked_tx, blocked_rx) = oneshot::channel::<()>();
let (ran_tx, ran_rx) = oneshot::channel::<()>();
queues
.enqueue(
RequestSerializationQueueKey::Global("blocked"),
QueuedInitializedRequest::new(gate(), async move {
let _ = blocked_rx.await;
}),
)
.await;
queues
.enqueue(
RequestSerializationQueueKey::Global("other"),
QueuedInitializedRequest::new(gate(), async move {
ran_tx.send(()).expect("receiver should be open");
}),
)
.await;
timeout(queue_drain_timeout(), ran_rx)
.await
.expect("other key should not be blocked")
.expect("sender should be open");
blocked_tx
.send(())
.expect("blocked request should be waiting");
}
#[tokio::test]
async fn closed_gate_request_is_skipped_and_following_requests_continue() {
let queues = RequestSerializationQueues::default();
let key = RequestSerializationQueueKey::Global("test");
let live_gate = gate();
let closed_gate = gate();
closed_gate.shutdown().await;
let (tx, mut rx) = mpsc::unbounded_channel();
let (blocked_tx, blocked_rx) = oneshot::channel::<()>();
{
let tx = tx.clone();
queues
.enqueue(
key.clone(),
QueuedInitializedRequest::new(Arc::clone(&live_gate), async move {
tx.send(FIRST_REQUEST_VALUE)
.expect("receiver should be open");
let _ = blocked_rx.await;
}),
)
.await;
}
{
let tx = tx.clone();
queues
.enqueue(
key.clone(),
QueuedInitializedRequest::new(closed_gate, async move {
tx.send(SECOND_REQUEST_VALUE)
.expect("receiver should be open");
}),
)
.await;
}
{
let tx = tx.clone();
queues
.enqueue(
key,
QueuedInitializedRequest::new(live_gate, async move {
tx.send(THIRD_REQUEST_VALUE)
.expect("receiver should be open");
}),
)
.await;
}
drop(tx);
assert_eq!(
timeout(queue_drain_timeout(), rx.recv())
.await
.expect("timed out waiting for first request"),
Some(FIRST_REQUEST_VALUE)
);
blocked_tx
.send(())
.expect("blocked request should be waiting");
let mut values = Vec::new();
while let Some(value) = timeout(queue_drain_timeout(), rx.recv())
.await
.expect("timed out waiting for queue to drain")
{
values.push(value);
}
assert_eq!(values, vec![THIRD_REQUEST_VALUE]);
}
#[tokio::test]
async fn shutdown_of_live_gate_skips_already_queued_requests() {
let queues = RequestSerializationQueues::default();
let key = RequestSerializationQueueKey::Global("test");
let live_gate = gate();
let (tx, mut rx) = mpsc::unbounded_channel();
let (blocked_tx, blocked_rx) = oneshot::channel::<()>();
{
let tx = tx.clone();
queues
.enqueue(
key.clone(),
QueuedInitializedRequest::new(Arc::clone(&live_gate), async move {
tx.send(FIRST_REQUEST_VALUE)
.expect("receiver should be open");
let _ = blocked_rx.await;
}),
)
.await;
}
{
let tx = tx.clone();
queues
.enqueue(
key,
QueuedInitializedRequest::new(live_gate.clone(), async move {
tx.send(SECOND_REQUEST_VALUE)
.expect("receiver should be open");
}),
)
.await;
}
drop(tx);
assert_eq!(
timeout(queue_drain_timeout(), rx.recv())
.await
.expect("timed out waiting for first request"),
Some(FIRST_REQUEST_VALUE)
);
let gate_for_shutdown = Arc::clone(&live_gate);
let shutdown_task = tokio::spawn(async move {
gate_for_shutdown.shutdown().await;
});
timeout(shutdown_wait_timeout(), shutdown_task)
.await
.expect_err("shutdown should wait for the running request");
blocked_tx
.send(())
.expect("blocked request should still be waiting");
assert_eq!(
timeout(queue_drain_timeout(), rx.recv())
.await
.expect("timed out waiting for queue to drain"),
None
);
}
}
@@ -306,6 +306,24 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let remote_control_enabled = false;
let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]);
let actual = set_experimental_feature_enablement(&mut mcp, enablement.clone()).await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse { enablement }
);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -363,7 +381,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -
);
assert!(
error.message.contains(
"apps, memories, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"
"apps, memories, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation"
),
"{}",
error.message
+82 -17
View File
@@ -179,6 +179,14 @@ fn auth_identity(auth: &CodexAuth) -> (Option<String>, Option<String>) {
(auth.get_chatgpt_user_id(), auth.get_account_id())
}
fn cloud_requirements_eligible_auth(auth: &CodexAuth) -> bool {
let Some(plan_type) = auth.account_plan_type() else {
return false;
};
auth.uses_codex_backend()
&& (plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
}
fn cache_payload_bytes(payload: &CloudRequirementsCacheSignedPayload) -> Option<Vec<u8>> {
serde_json::to_vec(&payload).ok()
}
@@ -329,17 +337,7 @@ impl CloudRequirementsService {
let Some(auth) = self.auth_manager.auth().await else {
return Ok(None);
};
if matches!(auth, CodexAuth::AgentIdentity(_)) {
// AgentIdentity does not carry a human bearer token, and identity-edge
// only allowlists task-scoped AgentAssertion calls for the Codex runtime.
return Ok(None);
}
let Some(plan_type) = auth.account_plan_type() else {
return Ok(None);
};
if !auth.uses_codex_backend()
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
{
if !cloud_requirements_eligible_auth(&auth) {
return Ok(None);
}
let (chatgpt_user_id, account_id) = auth_identity(&auth);
@@ -554,12 +552,7 @@ impl CloudRequirementsService {
let Some(auth) = self.auth_manager.auth().await else {
return false;
};
let Some(plan_type) = auth.account_plan_type() else {
return false;
};
if !auth.uses_codex_backend()
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
{
if !cloud_requirements_eligible_auth(&auth) {
return false;
}
@@ -837,18 +830,41 @@ mod tests {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::auth::AgentIdentityAuth;
use codex_login::auth::AgentIdentityAuthRecord;
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::VecDeque;
use std::ffi::OsString;
use std::future::pending;
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::path::Path;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::thread;
use tempfile::TempDir;
use tempfile::tempdir;
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> {
std::fs::write(codex_home.join("auth.json"), serde_json::to_string(&value)?)?;
Ok(())
@@ -1200,6 +1216,55 @@ mod tests {
);
}
#[tokio::test]
async fn cloud_requirements_eligible_auth_allows_agent_identity_business_plan() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind task registration server");
let addr = listener
.local_addr()
.expect("task registration server addr");
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept task registration request");
let mut request = [0; 4096];
let _ = stream
.read(&mut request)
.expect("read task registration request");
let body = r#"{"task_id":"task-123"}"#;
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write task registration response");
});
let record = AgentIdentityAuthRecord {
agent_runtime_id: "agent-runtime-123".to_string(),
agent_private_key: "MC4CAQAwBQYDK2VwBCIEIDQg14jybCLydjHQwXeBzsDM7oB6BSAenodx6oCovQ/D"
.to_string(),
account_id: "account-12345".to_string(),
chatgpt_user_id: "user-12345".to_string(),
email: "user@example.com".to_string(),
plan_type: PlanType::Business,
chatgpt_account_is_fedramp: false,
};
let authapi_base_url = format!("http://{addr}/backend-api");
let original_authapi_base_url = std::env::var_os("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL");
unsafe {
std::env::set_var("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &authapi_base_url);
}
let _authapi_guard = EnvVarGuard {
key: "CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL",
original: original_authapi_base_url,
};
let auth = AgentIdentityAuth::load(record)
.await
.map(CodexAuth::AgentIdentity)
.expect("agent identity auth");
server.join().expect("task registration server joined");
assert!(cloud_requirements_eligible_auth(&auth));
}
#[tokio::test]
async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() {
let codex_home = tempdir().expect("tempdir");
+6
View File
@@ -1336,6 +1336,12 @@
"minimum": 1.0,
"type": "integer"
},
"min_wait_timeout_ms": {
"format": "int64",
"maximum": 3600000.0,
"minimum": 1.0,
"type": "integer"
},
"root_agent_usage_hint_text": {
"type": "string"
},
+54
View File
@@ -7804,6 +7804,7 @@ async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> {
r#"[features.multi_agent_v2]
enabled = true
max_concurrent_threads_per_session = 5
min_wait_timeout_ms = 2500
usage_hint_enabled = false
usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
@@ -7820,6 +7821,7 @@ hide_spawn_agent_metadata = true
assert!(config.features.enabled(Feature::MultiAgentV2));
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 5);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 2500);
assert_eq!(config.agent_max_threads, Some(4));
assert!(!config.multi_agent_v2.usage_hint_enabled);
assert_eq!(
@@ -7848,6 +7850,7 @@ async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> {
[features.multi_agent_v2]
max_concurrent_threads_per_session = 4
min_wait_timeout_ms = 3000
usage_hint_enabled = true
usage_hint_text = "base hint"
root_agent_usage_hint_text = "base root hint"
@@ -7856,6 +7859,7 @@ hide_spawn_agent_metadata = true
[profiles.no_hint.features.multi_agent_v2]
max_concurrent_threads_per_session = 6
min_wait_timeout_ms = 1500
usage_hint_enabled = false
usage_hint_text = "profile hint"
root_agent_usage_hint_text = "profile root hint"
@@ -7871,6 +7875,7 @@ hide_spawn_agent_metadata = false
.await?;
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500);
assert!(!config.multi_agent_v2.usage_hint_enabled);
assert_eq!(
config.multi_agent_v2.usage_hint_text.as_deref(),
@@ -7906,6 +7911,7 @@ enabled = true
.await?;
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4);
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000);
assert_eq!(config.agent_max_threads, Some(3));
Ok(())
@@ -7940,6 +7946,54 @@ max_threads = 3
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_rejects_invalid_min_wait_timeout() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 0
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("zero min_wait_timeout_ms should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.min_wait_timeout_ms must be at least 1"
);
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
min_wait_timeout_ms = 3600001
"#,
)?;
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("too large min_wait_timeout_ms should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
err.to_string(),
"features.multi_agent_v2.min_wait_timeout_ms must be at most 3600000"
);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+23
View File
@@ -156,6 +156,8 @@ impl Default for GhostSnapshotConfig {
pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usize = 4;
pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
pub(crate) const MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS: i64 = 3600 * 1000;
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0";
@@ -753,6 +755,7 @@ pub struct Config {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultiAgentV2Config {
pub max_concurrent_threads_per_session: usize,
pub min_wait_timeout_ms: i64,
pub usage_hint_enabled: bool,
pub usage_hint_text: Option<String>,
pub root_agent_usage_hint_text: Option<String>,
@@ -765,6 +768,7 @@ impl Default for MultiAgentV2Config {
Self {
max_concurrent_threads_per_session:
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS,
usage_hint_enabled: true,
usage_hint_text: None,
root_agent_usage_hint_text: None,
@@ -1638,6 +1642,10 @@ fn resolve_multi_agent_v2_config(
.and_then(|config| config.max_concurrent_threads_per_session)
.or_else(|| base.and_then(|config| config.max_concurrent_threads_per_session))
.unwrap_or(default.max_concurrent_threads_per_session);
let min_wait_timeout_ms = profile
.and_then(|config| config.min_wait_timeout_ms)
.or_else(|| base.and_then(|config| config.min_wait_timeout_ms))
.unwrap_or(default.min_wait_timeout_ms);
let usage_hint_enabled = profile
.and_then(|config| config.usage_hint_enabled)
.or_else(|| base.and_then(|config| config.usage_hint_enabled))
@@ -1664,6 +1672,7 @@ fn resolve_multi_agent_v2_config(
MultiAgentV2Config {
max_concurrent_threads_per_session,
min_wait_timeout_ms,
usage_hint_enabled,
usage_hint_text,
root_agent_usage_hint_text,
@@ -2186,6 +2195,20 @@ impl Config {
"features.multi_agent_v2.max_concurrent_threads_per_session must be at least 1",
));
}
if multi_agent_v2.min_wait_timeout_ms <= 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"features.multi_agent_v2.min_wait_timeout_ms must be at least 1",
));
}
if multi_agent_v2.min_wait_timeout_ms > MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"features.multi_agent_v2.min_wait_timeout_ms must be at most {MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS}"
),
));
}
let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads);
let agent_max_threads = if features.enabled(Feature::MultiAgentV2) {
if agent_max_threads_from_config.is_some() {
+5
View File
@@ -54,6 +54,11 @@ pub(super) async fn spawn_review_thread(
.with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata)
.with_goal_tools_allowed(goal_tools_supported)
.with_max_concurrent_threads_per_session(config.agent_max_threads)
.with_wait_agent_min_timeout_ms(
review_features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.min_wait_timeout_ms),
)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
+5
View File
@@ -1864,6 +1864,11 @@ async fn try_run_sampling_request(
otel.name = field::Empty,
tool_name = field::Empty,
from = field::Empty,
gen_ai.usage.input_tokens = field::Empty,
gen_ai.usage.cache_read.input_tokens = field::Empty,
gen_ai.usage.output_tokens = field::Empty,
codex.usage.reasoning_output_tokens = field::Empty,
codex.usage.total_tokens = field::Empty,
);
let event = match stream
+12
View File
@@ -201,6 +201,12 @@ impl TurnContext {
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.max_concurrent_threads_per_session),
)
.with_wait_agent_min_timeout_ms(
config
.features
.enabled(Feature::MultiAgentV2)
.then_some(config.multi_agent_v2.min_wait_timeout_ms),
)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
@@ -475,6 +481,12 @@ impl Session {
.max_concurrent_threads_per_session,
),
)
.with_wait_agent_min_timeout_ms(
per_turn_config
.features
.enabled(Feature::MultiAgentV2)
.then_some(per_turn_config.multi_agent_v2.min_wait_timeout_ms),
)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&per_turn_config.agent_roles,
));
+13 -5
View File
@@ -79,17 +79,25 @@ pub(crate) struct RunningTask {
pub(crate) _timer: Option<codex_otel::Timer>,
}
pub(crate) struct RemovedTask {
pub(crate) records_turn_token_usage_on_span: bool,
pub(crate) active_turn_is_empty: bool,
}
impl ActiveTurn {
pub(crate) fn add_task(&mut self, task: RunningTask) {
let sub_id = task.turn_context.sub_id.clone();
self.tasks.insert(sub_id, task);
}
pub(crate) fn remove_task(&mut self, sub_id: &str) -> bool {
if let Some(task) = self.tasks.swap_remove(sub_id) {
task.handle.detach();
}
self.tasks.is_empty()
pub(crate) fn remove_task(&mut self, sub_id: &str) -> Option<RemovedTask> {
let task = self.tasks.swap_remove(sub_id)?;
let records_turn_token_usage_on_span = task.task.records_turn_token_usage_on_span();
task.handle.detach();
Some(RemovedTask {
records_turn_token_usage_on_span,
active_turn_is_empty: self.tasks.is_empty(),
})
}
pub(crate) fn drain_tasks(&mut self) -> Vec<RunningTask> {
+56 -4
View File
@@ -14,6 +14,8 @@ use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
use tracing::Instrument;
use tracing::Span;
use tracing::field;
use tracing::info_span;
use tracing::trace;
use tracing::warn;
@@ -188,6 +190,11 @@ pub(crate) trait SessionTask: Send + Sync + 'static {
/// Returns the tracing name for a spawned task span.
fn span_name(&self) -> &'static str;
/// Returns whether turn token usage should be recorded on this task's turn span.
fn records_turn_token_usage_on_span(&self) -> bool {
false
}
/// Executes the task until completion or cancellation.
///
/// Implementations typically stream protocol events using `session` and
@@ -225,6 +232,8 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static {
fn span_name(&self) -> &'static str;
fn records_turn_token_usage_on_span(&self) -> bool;
fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
@@ -252,6 +261,10 @@ where
SessionTask::span_name(self)
}
fn records_turn_token_usage_on_span(&self) -> bool {
SessionTask::records_turn_token_usage_on_span(self)
}
fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
@@ -361,6 +374,12 @@ impl Session {
thread.id = %self.conversation_id,
turn.id = %turn_context.sub_id,
model = %turn_context.model_info.slug,
codex.turn.token_usage.input_tokens = field::Empty,
codex.turn.token_usage.cached_input_tokens = field::Empty,
codex.turn.token_usage.non_cached_input_tokens = field::Empty,
codex.turn.token_usage.output_tokens = field::Empty,
codex.turn.token_usage.reasoning_output_tokens = field::Empty,
codex.turn.token_usage.total_tokens = field::Empty,
);
let handle = tokio::spawn(
async move {
@@ -548,14 +567,20 @@ impl Session {
let mut token_usage_at_turn_start = None;
let mut turn_had_memory_citation = false;
let mut turn_tool_calls = 0_u64;
let mut records_turn_token_usage_on_span = false;
let turn_state = {
let mut active = self.active_turn.lock().await;
if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
&& let Some(removed_task) = at.remove_task(&turn_context.sub_id)
{
should_clear_active_turn = true;
let turn_state = Arc::clone(&at.turn_state);
Some(turn_state)
records_turn_token_usage_on_span = removed_task.records_turn_token_usage_on_span;
if removed_task.active_turn_is_empty {
should_clear_active_turn = true;
let turn_state = Arc::clone(&at.turn_state);
Some(turn_state)
} else {
None
}
} else {
None
}
@@ -634,6 +659,33 @@ impl Session {
- token_usage_at_turn_start.total_tokens)
.max(0),
};
if records_turn_token_usage_on_span {
let current_span = Span::current();
current_span.record(
"codex.turn.token_usage.input_tokens",
turn_token_usage.input_tokens,
);
current_span.record(
"codex.turn.token_usage.cached_input_tokens",
turn_token_usage.cached_input(),
);
current_span.record(
"codex.turn.token_usage.non_cached_input_tokens",
turn_token_usage.non_cached_input(),
);
current_span.record(
"codex.turn.token_usage.output_tokens",
turn_token_usage.output_tokens,
);
current_span.record(
"codex.turn.token_usage.reasoning_output_tokens",
turn_token_usage.reasoning_output_tokens,
);
current_span.record(
"codex.turn.token_usage.total_tokens",
turn_token_usage.total_tokens,
);
}
self.services
.analytics_events_client
.track_turn_token_usage(TurnTokenUsageFact {
+4
View File
@@ -33,6 +33,10 @@ impl SessionTask for RegularTask {
"session_task.turn"
}
fn records_turn_token_usage_on_span(&self) -> bool {
true
}
async fn run(
self: Arc<Self>,
session: Arc<SessionTaskContext>,
@@ -1,5 +1,7 @@
use crate::agent::AgentStatus;
use crate::config::Config;
use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
use crate::config::MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS;
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
@@ -26,9 +28,9 @@ use serde_json::Value as JsonValue;
use std::collections::HashMap;
/// Minimum wait timeout to prevent tight polling loops from burning CPU.
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000;
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = MAX_MULTI_AGENT_V2_WAIT_TIMEOUT_MS;
pub(crate) fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
match payload {
@@ -2742,6 +2742,59 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() {
assert_eq!(success, None);
}
#[tokio::test]
async fn multi_agent_v2_wait_agent_uses_configured_min_timeout() {
let (session, mut turn) = make_session_and_context().await;
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
config.multi_agent_v2.min_wait_timeout_ms = 50;
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let early = timeout(
Duration::from_millis(/*millis*/ 20),
WaitAgentHandlerV2.handle(invocation(
session.clone(),
turn.clone(),
"wait_agent",
function_payload(json!({"timeout_ms": 1})),
)),
)
.await;
assert!(
early.is_err(),
"wait_agent should not return before the configured minimum timeout"
);
let output = timeout(
Duration::from_secs(/*secs*/ 1),
WaitAgentHandlerV2.handle(invocation(
session,
turn,
"wait_agent",
function_payload(json!({"timeout_ms": 1})),
)),
)
.await
.expect("configured minimum should be shorter than the test timeout")
.expect("wait_agent should succeed");
let (content, success) = expect_text_output(output);
let result: crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult =
serde_json::from_str(&content).expect("wait_agent result should be json");
assert_eq!(
result,
crate::tools::handlers::multi_agents_v2::wait::WaitAgentResult {
message: "Wait timed out.".to_string(),
timed_out: true,
}
);
assert_eq!(success, None);
}
#[tokio::test]
async fn wait_agent_returns_not_found_for_missing_agents() {
let (mut session, turn) = make_session_and_context().await;
@@ -28,13 +28,18 @@ impl ToolHandler for Handler {
let arguments = function_arguments(payload)?;
let args: WaitArgs = parse_arguments(&arguments)?;
let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
let min_timeout_ms = turn
.config
.multi_agent_v2
.min_wait_timeout_ms
.clamp(1, MAX_WAIT_TIMEOUT_MS);
let timeout_ms = match timeout_ms {
ms if ms <= 0 => {
return Err(FunctionCallError::RespondToModel(
"timeout_ms must be greater than zero".to_owned(),
));
}
ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS),
ms => ms.clamp(min_timeout_ms, MAX_WAIT_TIMEOUT_MS),
};
let mut mailbox_seq_rx = session.subscribe_mailbox_seq();
+12 -2
View File
@@ -124,6 +124,16 @@ pub(crate) fn build_specs_with_discoverable_tools(
});
let default_agent_type_description =
crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new());
let min_wait_timeout_ms = if config.multi_agent_v2 {
config
.wait_agent_min_timeout_ms
.unwrap_or(MIN_WAIT_TIMEOUT_MS)
.clamp(1, MAX_WAIT_TIMEOUT_MS)
} else {
MIN_WAIT_TIMEOUT_MS
};
let default_wait_timeout_ms =
DEFAULT_WAIT_TIMEOUT_MS.clamp(min_wait_timeout_ms, MAX_WAIT_TIMEOUT_MS);
let plan = build_tool_registry_plan(
config,
ToolRegistryPlanParams {
@@ -138,8 +148,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
dynamic_tools,
default_agent_type_description: &default_agent_type_description,
wait_agent_timeouts: WaitAgentTimeoutOptions {
default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS,
min_timeout_ms: MIN_WAIT_TIMEOUT_MS,
default_timeout_ms: default_wait_timeout_ms,
min_timeout_ms: min_wait_timeout_ms,
max_timeout_ms: MAX_WAIT_TIMEOUT_MS,
},
},
+29
View File
@@ -784,6 +784,35 @@ async fn spawn_agent_description_uses_configured_usage_hint_text() {
);
}
#[tokio::test]
async fn multi_agent_v2_wait_agent_schema_uses_configured_min_timeout() {
let wait_agent_min_timeout_ms = Some(60_000);
let tools_config = multi_agent_v2_tools_config()
.await
.with_wait_agent_min_timeout_ms(wait_agent_min_timeout_ms);
let (tools, _) = build_specs(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[],
)
.build();
let wait_agent = find_tool(&tools, "wait_agent");
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &wait_agent.spec else {
panic!("wait_agent should be a function tool");
};
let timeout_description = parameters
.properties
.as_ref()
.and_then(|properties| properties.get("timeout_ms"))
.and_then(|schema| schema.description.as_deref());
assert_eq!(
timeout_description,
Some("Optional timeout in milliseconds. Defaults to 60000, min 60000, max 3600000.")
);
}
#[tokio::test]
async fn tool_suggest_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info().await;
+85
View File
@@ -563,6 +563,91 @@ async fn process_sse_emits_completed_telemetry() {
});
}
#[tokio::test(flavor = "current_thread")]
async fn turn_and_completed_response_spans_record_token_usage() {
let buffer: &'static Mutex<Vec<u8>> = Box::leak(Box::new(Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::TRACE)
.with_span_events(FmtSpan::FULL)
.with_writer(MockWriter::new(buffer))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let server = start_mock_server().await;
mount_sse_once(
&server,
sse(vec![serde_json::json!({
"type": "response.completed",
"response": {
"id": "resp1",
"usage": {
"input_tokens": 3,
"input_tokens_details": { "cached_tokens": 1 },
"output_tokens": 5,
"output_tokens_details": { "reasoning_tokens": 2 },
"total_tokens": 9
}
}
})]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config
.features
.disable(Feature::GhostCommit)
.expect("test config should allow feature update");
})
.build(&server)
.await
.unwrap();
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let logs = String::from_utf8(buffer.lock().unwrap().clone()).unwrap();
assert!(
logs.lines().any(|line| {
line.contains("handle_responses{otel.name=\"completed\"")
&& line.contains("gen_ai.usage.input_tokens=3")
&& line.contains("gen_ai.usage.cache_read.input_tokens=1")
&& line.contains("gen_ai.usage.output_tokens=5")
&& line.contains("codex.usage.reasoning_output_tokens=2")
&& line.contains("codex.usage.total_tokens=9")
}),
"missing completed response span token usage\nlogs:\n{logs}"
);
assert!(
logs.lines().any(|line| {
line.contains("turn{otel.name=\"session_task.turn\"")
&& line.contains("codex.turn.token_usage.input_tokens=3")
&& line.contains("codex.turn.token_usage.cached_input_tokens=1")
&& line.contains("codex.turn.token_usage.non_cached_input_tokens=2")
&& line.contains("codex.turn.token_usage.output_tokens=5")
&& line.contains("codex.turn.token_usage.reasoning_output_tokens=2")
&& line.contains("codex.turn.token_usage.total_tokens=9")
}),
"missing regular turn span token usage\nlogs:\n{logs}"
);
}
#[tokio::test]
async fn handle_responses_span_records_response_kind_and_tool_name() {
let buffer: &'static Mutex<Vec<u8>> = Box::leak(Box::new(Mutex::new(Vec::new())));
+3
View File
@@ -12,6 +12,9 @@ pub struct MultiAgentV2ConfigToml {
#[schemars(range(min = 1))]
pub max_concurrent_threads_per_session: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 3600000))]
pub min_wait_timeout_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_hint_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_hint_text: Option<String>,
+3
View File
@@ -413,6 +413,7 @@ fn multi_agent_v2_feature_config_deserializes_table() {
[multi_agent_v2]
enabled = true
max_concurrent_threads_per_session = 4
min_wait_timeout_ms = 2500
usage_hint_enabled = false
usage_hint_text = "Custom delegation guidance."
root_agent_usage_hint_text = "Root guidance."
@@ -431,6 +432,7 @@ hide_spawn_agent_metadata = true
Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml {
enabled: Some(true),
max_concurrent_threads_per_session: Some(4),
min_wait_timeout_ms: Some(2500),
usage_hint_enabled: Some(false),
usage_hint_text: Some("Custom delegation guidance.".to_string()),
root_agent_usage_hint_text: Some("Root guidance.".to_string()),
@@ -465,6 +467,7 @@ usage_hint_enabled = false
Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml {
enabled: None,
max_concurrent_threads_per_session: None,
min_wait_timeout_ms: None,
usage_hint_enabled: Some(false),
usage_hint_text: None,
root_agent_usage_hint_text: None,
+1
View File
@@ -17,6 +17,7 @@ workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
clap = { workspace = true, features = ["derive"] }
codex-process-hardening = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-utils-absolute-path = { workspace = true }
+2
View File
@@ -176,6 +176,8 @@ fn install_network_seccomp_filter_on_current_thread(
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
deny_syscall(&mut rules, libc::SYS_ptrace);
deny_syscall(&mut rules, libc::SYS_process_vm_readv);
deny_syscall(&mut rules, libc::SYS_process_vm_writev);
deny_syscall(&mut rules, libc::SYS_io_uring_setup);
deny_syscall(&mut rules, libc::SYS_io_uring_enter);
deny_syscall(&mut rules, libc::SYS_io_uring_register);
+7 -2
View File
@@ -450,7 +450,7 @@ fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result<libc::
}
fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
set_parent_death_signal()?;
harden_bridge_process()?;
if uds_path.exists() {
std::fs::remove_file(uds_path)?;
}
@@ -501,7 +501,7 @@ fn spawn_local_bridge(uds_path: &Path) -> io::Result<u16> {
}
fn run_local_bridge(uds_path: &Path, ready_fd: libc::c_int) -> io::Result<()> {
set_parent_death_signal()?;
harden_bridge_process()?;
let listener = bind_local_loopback_listener()?;
let port = listener.local_addr()?.port();
@@ -614,6 +614,11 @@ fn set_parent_death_signal() -> io::Result<()> {
}
}
fn harden_bridge_process() -> io::Result<()> {
set_parent_death_signal()?;
codex_process_hardening::disable_process_dumping()
}
fn proxy_bidirectional(mut tcp_stream: TcpStream, mut unix_stream: UnixStream) -> io::Result<()> {
let mut tcp_reader = tcp_stream.try_clone()?;
let mut unix_writer = unix_stream.try_clone()?;
+58 -4
View File
@@ -88,7 +88,8 @@ async fn login_with_agent_identity_writes_only_token() {
let dir = tempdir().unwrap();
let auth_path = dir.path().join("auth.json");
let record = agent_identity_record("account-123");
let agent_identity = signed_agent_identity_jwt(&record).expect("signed agent identity");
let agent_identity =
signed_agent_identity_jwt(&record, json!(record.plan_type)).expect("signed agent identity");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/agent-identities/jwks"))
@@ -709,7 +710,8 @@ async fn load_auth_reads_agent_identity_from_env() {
let codex_home = tempdir().unwrap();
let expected_record = agent_identity_record("account-123");
let agent_identity =
signed_agent_identity_jwt(&expected_record).expect("signed agent identity");
signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type))
.expect("signed agent identity");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/agent-identities/jwks"))
@@ -925,6 +927,13 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
}
fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<String> {
fake_agent_identity_jwt_with_plan_type(record, serde_json::to_value(record.plan_type)?)
}
fn fake_agent_identity_jwt_with_plan_type(
record: &AgentIdentityAuthRecord,
plan_type: serde_json::Value,
) -> std::io::Result<String> {
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#);
let payload = json!({
@@ -937,7 +946,7 @@ fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<
"account_id": record.account_id,
"chatgpt_user_id": record.chatgpt_user_id,
"email": record.email,
"plan_type": record.plan_type,
"plan_type": plan_type,
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
});
let payload_b64 = encode(&serde_json::to_vec(&payload)?);
@@ -947,6 +956,7 @@ fn fake_agent_identity_jwt(record: &AgentIdentityAuthRecord) -> std::io::Result<
fn signed_agent_identity_jwt(
record: &AgentIdentityAuthRecord,
plan_type: serde_json::Value,
) -> jsonwebtoken::errors::Result<String> {
let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
header.kid = Some("test-key".to_string());
@@ -962,7 +972,7 @@ fn signed_agent_identity_jwt(
"account_id": record.account_id,
"chatgpt_user_id": record.chatgpt_user_id,
"email": record.email,
"plan_type": record.plan_type,
"plan_type": plan_type,
"chatgpt_account_is_fedramp": record.chatgpt_account_is_fedramp,
}),
&jsonwebtoken::EncodingKey::from_rsa_pem(TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM)?,
@@ -1011,6 +1021,50 @@ J1bwkqKZTB5dHolX9A58e/xXnfZ5P8f3Z83+Izap3FwqQulk7b1WO1MQcHuVg2NN
8U4M2TSWCKUY/A6sT4W8+mT9
-----END PRIVATE KEY-----"#;
#[tokio::test]
#[serial(codex_auth_env)]
async fn agent_identity_plan_type_maps_raw_enterprise_alias() {
assert_agent_identity_plan_alias(json!("hc"), AccountPlanType::Enterprise).await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn agent_identity_plan_type_maps_raw_education_alias() {
assert_agent_identity_plan_alias(json!("education"), AccountPlanType::Edu).await;
}
async fn assert_agent_identity_plan_alias(
plan_type: serde_json::Value,
expected_plan_type: AccountPlanType,
) {
let record = agent_identity_record("account-id");
let jwt = signed_agent_identity_jwt(&record, plan_type).expect("agent identity jwt");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/agent-identities/jwks"))
.respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body()))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/v1/agent/agent-runtime-id/task/register"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"task_id": "task-123",
})))
.expect(1)
.mount(&server)
.await;
let chatgpt_base_url = format!("{}/backend-api", server.uri());
let _authapi_guard =
EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url);
let auth = CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url))
.await
.expect("agent identity auth");
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type));
server.verify().await;
}
#[tokio::test]
#[serial(codex_auth_env)]
async fn plan_type_maps_known_plan() {
+1 -21
View File
@@ -39,7 +39,6 @@ use crate::token_data::parse_jwt_expiration;
use codex_client::CodexHttpClient;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::auth::KnownPlan as InternalKnownPlan;
use codex_protocol::auth::PlanType as InternalPlanType;
use codex_protocol::auth::RefreshTokenFailedError;
use codex_protocol::auth::RefreshTokenFailedReason;
@@ -380,29 +379,10 @@ impl CodexAuth {
return Some(auth.plan_type());
}
let map_known = |kp: &InternalKnownPlan| match kp {
InternalKnownPlan::Free => AccountPlanType::Free,
InternalKnownPlan::Go => AccountPlanType::Go,
InternalKnownPlan::Plus => AccountPlanType::Plus,
InternalKnownPlan::Pro => AccountPlanType::Pro,
InternalKnownPlan::ProLite => AccountPlanType::ProLite,
InternalKnownPlan::Team => AccountPlanType::Team,
InternalKnownPlan::SelfServeBusinessUsageBased => {
AccountPlanType::SelfServeBusinessUsageBased
}
InternalKnownPlan::Business => AccountPlanType::Business,
InternalKnownPlan::EnterpriseCbpUsageBased => AccountPlanType::EnterpriseCbpUsageBased,
InternalKnownPlan::Enterprise => AccountPlanType::Enterprise,
InternalKnownPlan::Edu => AccountPlanType::Edu,
};
self.get_current_token_data().map(|t| {
t.id_token
.chatgpt_plan_type
.map(|pt| match pt {
InternalPlanType::Known(k) => map_known(&k),
InternalPlanType::Unknown(_) => AccountPlanType::Unknown,
})
.map(AccountPlanType::from)
.unwrap_or(AccountPlanType::Unknown)
})
}
+1 -1
View File
@@ -75,7 +75,7 @@ impl From<AgentIdentityJwtClaims> for AgentIdentityAuthRecord {
account_id: claims.account_id,
chatgpt_user_id: claims.chatgpt_user_id,
email: claims.email,
plan_type: claims.plan_type,
plan_type: claims.plan_type.into(),
chatgpt_account_is_fedramp: claims.chatgpt_account_is_fedramp,
}
}
@@ -0,0 +1,157 @@
use crate::policy::is_non_public_ip;
use crate::state::NetworkProxyState;
use rama_core::Service;
use rama_core::error::BoxError;
use rama_core::error::ErrorExt as _;
use rama_core::error::OpaqueError;
use rama_core::extensions::ExtensionsMut;
use rama_net::address::ProxyAddress;
use rama_net::client::EstablishedClientConnection;
use rama_net::transport::TryRefIntoTransportContext;
use rama_tcp::TcpStream;
use rama_tcp::client::TcpStreamConnector;
use rama_tcp::client::service::TcpConnector;
use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
#[derive(Clone)]
pub(crate) struct TargetCheckedTcpConnector {
policy: TargetPolicy,
}
impl TargetCheckedTcpConnector {
pub(crate) fn new(state: Arc<NetworkProxyState>) -> Self {
Self {
policy: TargetPolicy::State(state),
}
}
pub(crate) fn from_allow_local_binding(allow_local_binding: bool) -> Self {
Self {
policy: TargetPolicy::Config {
allow_local_binding,
},
}
}
}
impl<Input> Service<Input> for TargetCheckedTcpConnector
where
Input: TryRefIntoTransportContext + Send + ExtensionsMut + 'static,
Input::Error: Into<BoxError> + Send + Sync + 'static,
{
type Output = EstablishedClientConnection<TcpStream, Input>;
type Error = BoxError;
async fn serve(&self, input: Input) -> Result<Self::Output, Self::Error> {
if input.extensions().get::<ProxyAddress>().is_some() {
return TcpConnector::new().serve(input).await;
}
TcpConnector::new()
.with_connector(TargetCheckedStreamConnector {
policy: self.policy.clone(),
})
.serve(input)
.await
}
}
#[derive(Clone)]
struct TargetCheckedStreamConnector {
policy: TargetPolicy,
}
impl TcpStreamConnector for TargetCheckedStreamConnector {
type Error = BoxError;
async fn connect(&self, addr: SocketAddr) -> Result<TcpStream, Self::Error> {
if !self.policy.allow_local_binding().await? && is_non_public_ip(addr.ip()) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"network target rejected by policy",
)
.into());
}
tokio::net::TcpStream::connect(addr)
.await
.map(TcpStream::from)
.map_err(Into::into)
}
}
#[derive(Clone)]
enum TargetPolicy {
Config { allow_local_binding: bool },
State(Arc<NetworkProxyState>),
}
impl TargetPolicy {
async fn allow_local_binding(&self) -> Result<bool, BoxError> {
match self {
Self::Config {
allow_local_binding,
} => Ok(*allow_local_binding),
Self::State(state) => state.allow_local_binding().await.map_err(|err| {
let err: BoxError = err.into();
OpaqueError::from_boxed(err)
.context("read network proxy config")
.into_boxed()
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NetworkProxySettings;
use crate::state::network_proxy_state_for_policy;
use rama_net::address::HostWithPort;
use std::net::Ipv4Addr;
use tokio::net::TcpListener;
#[tokio::test(flavor = "current_thread")]
async fn direct_connector_rejects_non_public_target_when_local_binding_disabled() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.expect("bind local listener");
let target = listener.local_addr().expect("local addr");
let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy(
NetworkProxySettings::default(),
)));
let request: rama_tcp::client::Request =
rama_tcp::client::Request::new(HostWithPort::from(target));
let err = Service::serve(&connector, request)
.await
.expect_err("local target should be rejected");
assert!(
format!("{err:?}").contains("network target rejected by policy"),
"unexpected error: {err:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn direct_connector_allows_non_public_target_when_local_binding_enabled() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
.await
.expect("bind local listener");
let target = listener.local_addr().expect("local addr");
let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy(
NetworkProxySettings {
allow_local_binding: true,
..NetworkProxySettings::default()
},
)));
let request: rama_tcp::client::Request =
rama_tcp::client::Request::new(HostWithPort::from(target));
let result = Service::serve(&connector, request).await;
assert!(result.is_ok(), "local target should be allowed: {result:?}");
}
}
+16 -13
View File
@@ -1,4 +1,5 @@
use crate::config::NetworkMode;
use crate::connect_policy::TargetCheckedTcpConnector;
use crate::mitm;
use crate::network_policy::BlockDecisionAuditEventArgs;
use crate::network_policy::NetworkDecision;
@@ -66,7 +67,6 @@ use rama_net::proxy::ProxyTarget;
use rama_net::proxy::StreamForwardService;
use rama_net::stream::SocketInfo;
use rama_tcp::client::Request as TcpRequest;
use rama_tcp::client::service::TcpConnector;
use rama_tcp::server::TcpListener;
use rama_tls_rustls::client::TlsConnectorDataBuilder;
use rama_tls_rustls::client::TlsConnectorLayer;
@@ -345,20 +345,22 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
return Ok(());
}
let allow_upstream_proxy = match upgraded
let app_state = match upgraded
.extensions()
.get::<Arc<NetworkProxyState>>()
.cloned()
{
Some(state) => match state.allow_upstream_proxy().await {
Ok(allowed) => allowed,
Err(err) => {
error!("failed to read upstream proxy setting: {err}");
false
}
},
Some(state) => state,
None => {
error!("missing app state");
return Ok(());
}
};
let allow_upstream_proxy = match app_state.allow_upstream_proxy().await {
Ok(allowed) => allowed,
Err(err) => {
error!("failed to read upstream proxy setting: {err}");
false
}
};
@@ -369,7 +371,7 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
None
};
if let Err(err) = forward_connect_tunnel(upgraded, proxy).await {
if let Err(err) = forward_connect_tunnel(upgraded, proxy, app_state).await {
warn!("tunnel error: {err}");
}
Ok(())
@@ -378,6 +380,7 @@ async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> {
async fn forward_connect_tunnel(
upgraded: Upgraded,
proxy: Option<ProxyAddress>,
app_state: Arc<NetworkProxyState>,
) -> Result<(), BoxError> {
let authority = upgraded
.extensions()
@@ -392,7 +395,7 @@ async fn forward_connect_tunnel(
let req = TcpRequest::new_with_extensions(authority.clone(), extensions)
.with_protocol(Protocol::HTTPS);
let proxy_connector = HttpProxyConnector::optional(TcpConnector::new());
let proxy_connector = HttpProxyConnector::optional(TargetCheckedTcpConnector::new(app_state));
let tls_config = TlsConnectorDataBuilder::new()
.with_alpn_protocols_http_auto()
.build();
@@ -730,9 +733,9 @@ async fn http_plain_proxy(
Err(resp) => return Ok(resp),
};
let client = if allow_upstream_proxy {
UpstreamClient::from_env_proxy()
UpstreamClient::from_env_proxy(app_state.clone())
} else {
UpstreamClient::direct()
UpstreamClient::direct(app_state.clone())
};
// Strip hop-by-hop headers only after extracting metadata used for policy correlation.
+1
View File
@@ -2,6 +2,7 @@
mod certs;
mod config;
mod connect_policy;
mod http_proxy;
mod mitm;
mod network_policy;
+9 -4
View File
@@ -52,6 +52,11 @@ pub struct MitmState {
max_body_bytes: usize,
}
pub(crate) struct MitmUpstreamConfig {
pub(crate) allow_upstream_proxy: bool,
pub(crate) allow_local_binding: bool,
}
#[derive(Clone)]
struct MitmPolicyContext {
target_host: String,
@@ -80,16 +85,16 @@ impl std::fmt::Debug for MitmState {
}
impl MitmState {
pub(crate) fn new(allow_upstream_proxy: bool) -> Result<Self> {
pub(crate) fn new(config: MitmUpstreamConfig) -> Result<Self> {
// MITM exists to make limited-mode HTTPS enforceable: once CONNECT is established, plain
// proxying would lose visibility into the inner HTTP request. We generate/load a local CA
// and issue per-host leaf certs so we can terminate TLS and apply policy.
let ca = ManagedMitmCa::load_or_create()?;
let upstream = if allow_upstream_proxy {
UpstreamClient::from_env_proxy()
let upstream = if config.allow_upstream_proxy {
UpstreamClient::from_env_proxy_with_allow_local_binding(config.allow_local_binding)
} else {
UpstreamClient::direct()
UpstreamClient::direct_with_allow_local_binding(config.allow_local_binding)
};
Ok(Self {
+6
View File
@@ -532,6 +532,12 @@ impl NetworkProxyState {
Ok(guard.config.network.allow_upstream_proxy)
}
pub async fn allow_local_binding(&self) -> Result<bool> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
Ok(guard.config.network.allow_local_binding)
}
pub async fn network_mode(&self) -> Result<NetworkMode> {
self.reload_if_needed().await?;
let guard = self.state.read().await;
+4 -4
View File
@@ -1,4 +1,5 @@
use crate::config::NetworkMode;
use crate::connect_policy::TargetCheckedTcpConnector;
use crate::network_policy::BlockDecisionAuditEventArgs;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
@@ -34,7 +35,6 @@ use rama_socks5::server::udp::RelayRequest;
use rama_socks5::server::udp::RelayResponse;
use rama_tcp::TcpStream;
use rama_tcp::client::Request as TcpRequest;
use rama_tcp::client::service::TcpConnector;
use rama_tcp::server::TcpListener;
use std::io;
use std::net::SocketAddr;
@@ -94,7 +94,7 @@ async fn run_socks5_with_listener(
}
}
let tcp_connector = TcpConnector::default();
let tcp_connector = TargetCheckedTcpConnector::new(state.clone());
let policy_tcp_connector = service_fn({
let policy_decider = policy_decider.clone();
move |req: TcpRequest| {
@@ -131,7 +131,7 @@ async fn run_socks5_with_listener(
async fn handle_socks5_tcp(
req: TcpRequest,
tcp_connector: TcpConnector,
tcp_connector: TargetCheckedTcpConnector,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
) -> Result<EstablishedClientConnection<TcpStream, TcpRequest>, BoxError> {
let app_state = req
@@ -548,7 +548,7 @@ mod tests {
let (result, events) = capture_events(|| async {
handle_socks5_tcp(
request,
TcpConnector::default(),
TargetCheckedTcpConnector::new(state.clone()),
/*policy_decider*/ None,
)
.await
+5 -3
View File
@@ -3,6 +3,7 @@ use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::config::NetworkUnixSocketPermissions;
use crate::mitm::MitmState;
use crate::mitm::MitmUpstreamConfig;
use crate::policy::DomainPattern;
use crate::policy::compile_allowlist_globset;
use crate::policy::compile_denylist_globset;
@@ -66,9 +67,10 @@ pub fn build_config_state(
let deny_set = compile_denylist_globset(&denied_domains)?;
let allow_set = compile_allowlist_globset(&allowed_domains)?;
let mitm = if config.network.mitm {
Some(Arc::new(MitmState::new(
config.network.allow_upstream_proxy,
)?))
Some(Arc::new(MitmState::new(MitmUpstreamConfig {
allow_upstream_proxy: config.network.allow_upstream_proxy,
allow_local_binding: config.network.allow_local_binding,
})?))
} else {
None
};
+32 -9
View File
@@ -1,3 +1,5 @@
use crate::connect_policy::TargetCheckedTcpConnector;
use crate::state::NetworkProxyState;
use rama_core::Layer;
use rama_core::Service;
use rama_core::error::BoxError;
@@ -16,9 +18,9 @@ use rama_http_backend::client::proxy::layer::HttpProxyConnectorLayer;
use rama_net::address::ProxyAddress;
use rama_net::client::EstablishedClientConnection;
use rama_net::http::RequestContext;
use rama_tcp::client::service::TcpConnector;
use rama_tls_rustls::client::TlsConnectorDataBuilder;
use rama_tls_rustls::client::TlsConnectorLayer;
use std::sync::Arc;
use tracing::warn;
#[cfg(target_os = "macos")]
@@ -102,12 +104,32 @@ pub(crate) struct UpstreamClient {
}
impl UpstreamClient {
pub(crate) fn direct() -> Self {
Self::new(ProxyConfig::default())
pub(crate) fn direct(state: Arc<NetworkProxyState>) -> Self {
Self::new(
ProxyConfig::default(),
TargetCheckedTcpConnector::new(state),
)
}
pub(crate) fn from_env_proxy() -> Self {
Self::new(ProxyConfig::from_env())
pub(crate) fn from_env_proxy(state: Arc<NetworkProxyState>) -> Self {
Self::new(
ProxyConfig::from_env(),
TargetCheckedTcpConnector::new(state),
)
}
pub(crate) fn direct_with_allow_local_binding(allow_local_binding: bool) -> Self {
Self::new(
ProxyConfig::default(),
TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding),
)
}
pub(crate) fn from_env_proxy_with_allow_local_binding(allow_local_binding: bool) -> Self {
Self::new(
ProxyConfig::from_env(),
TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding),
)
}
#[cfg(target_os = "macos")]
@@ -119,8 +141,8 @@ impl UpstreamClient {
}
}
fn new(proxy_config: ProxyConfig) -> Self {
let connector = build_http_connector();
fn new(proxy_config: ProxyConfig, transport: TargetCheckedTcpConnector) -> Self {
let connector = build_http_connector(transport);
Self {
connector,
proxy_config,
@@ -158,12 +180,13 @@ impl Service<Request<Body>> for UpstreamClient {
}
}
fn build_http_connector() -> BoxService<
fn build_http_connector(
transport: TargetCheckedTcpConnector,
) -> BoxService<
Request<Body>,
EstablishedClientConnection<HttpClientService<Body>, Request<Body>>,
BoxError,
> {
let transport = TcpConnector::default();
let proxy = HttpProxyConnectorLayer::optional().into_layer(transport);
let tls_config = TlsConnectorDataBuilder::new()
.with_alpn_protocols_http_auto()
@@ -305,6 +305,23 @@ impl SessionTelemetry {
handle_responses_span.record("tool_name", name.as_str());
}
}
ResponseEvent::Completed {
token_usage: Some(token_usage),
..
} => {
handle_responses_span.record("gen_ai.usage.input_tokens", token_usage.input_tokens);
handle_responses_span.record(
"gen_ai.usage.cache_read.input_tokens",
token_usage.cached_input(),
);
handle_responses_span
.record("gen_ai.usage.output_tokens", token_usage.output_tokens);
handle_responses_span.record(
"codex.usage.reasoning_output_tokens",
token_usage.reasoning_output_tokens,
);
handle_responses_span.record("codex.usage.total_tokens", token_usage.total_tokens);
}
_ => {}
}
}
+11
View File
@@ -61,6 +61,17 @@ pub(crate) fn pre_main_hardening_linux() {
remove_env_vars_with_prefix(b"LD_");
}
/// Mark the current Linux process non-dumpable so same-user processes cannot attach with ptrace.
#[cfg(target_os = "linux")]
pub fn disable_process_dumping() -> std::io::Result<()> {
let ret_code = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
if ret_code == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(any(target_os = "freebsd", target_os = "openbsd"))]
pub(crate) fn pre_main_hardening_bsd() {
// FreeBSD/OpenBSD: set RLIMIT_CORE to 0 and clear LD_* env vars
+48
View File
@@ -3,6 +3,9 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
use crate::auth::KnownPlan;
use crate::auth::PlanType as AuthPlanType;
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
@@ -57,9 +60,38 @@ impl PlanType {
}
}
impl From<AuthPlanType> for PlanType {
fn from(plan_type: AuthPlanType) -> Self {
match plan_type {
AuthPlanType::Known(plan) => plan.into(),
AuthPlanType::Unknown(_) => Self::Unknown,
}
}
}
impl From<KnownPlan> for PlanType {
fn from(plan: KnownPlan) -> Self {
match plan {
KnownPlan::Free => Self::Free,
KnownPlan::Go => Self::Go,
KnownPlan::Plus => Self::Plus,
KnownPlan::Pro => Self::Pro,
KnownPlan::ProLite => Self::ProLite,
KnownPlan::Team => Self::Team,
KnownPlan::SelfServeBusinessUsageBased => Self::SelfServeBusinessUsageBased,
KnownPlan::Business => Self::Business,
KnownPlan::EnterpriseCbpUsageBased => Self::EnterpriseCbpUsageBased,
KnownPlan::Enterprise => Self::Enterprise,
KnownPlan::Edu => Self::Edu,
}
}
}
#[cfg(test)]
mod tests {
use super::PlanType;
use crate::auth::KnownPlan;
use crate::auth::PlanType as AuthPlanType;
use pretty_assertions::assert_eq;
#[test]
@@ -121,4 +153,20 @@ mod tests {
assert_eq!(PlanType::Edu.is_workspace_account(), true);
assert_eq!(PlanType::Pro.is_workspace_account(), false);
}
#[test]
fn auth_plan_type_converts_to_account_plan_type() {
assert_eq!(
PlanType::from(AuthPlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
PlanType::EnterpriseCbpUsageBased
);
assert_eq!(
PlanType::from(AuthPlanType::Known(KnownPlan::Enterprise)),
PlanType::Enterprise
);
assert_eq!(
PlanType::from(AuthPlanType::Unknown("mystery-tier".to_string())),
PlanType::Unknown
);
}
}
+21
View File
@@ -46,6 +46,7 @@ pub enum KnownPlan {
EnterpriseCbpUsageBased,
#[serde(alias = "hc")]
Enterprise,
#[serde(alias = "education")]
Edu,
}
@@ -118,3 +119,23 @@ pub enum RefreshTokenFailedReason {
Revoked,
Other,
}
#[cfg(test)]
mod tests {
use super::KnownPlan;
use super::PlanType;
use pretty_assertions::assert_eq;
#[test]
fn plan_type_deserializes_raw_aliases() {
assert_eq!(
serde_json::from_str::<PlanType>("\"hc\"").expect("hc should deserialize"),
PlanType::Known(KnownPlan::Enterprise)
);
assert_eq!(
serde_json::from_str::<PlanType>("\"education\"")
.expect("education should deserialize"),
PlanType::Known(KnownPlan::Edu)
);
}
}
+2 -1
View File
@@ -367,6 +367,8 @@ async fn run_inserter(
) {
let mut buffer = Vec::with_capacity(config.batch_size);
let mut ticker = tokio::time::interval(config.flush_interval);
// Consume the immediate startup tick so entries flush after the interval.
ticker.tick().await;
loop {
tokio::select! {
maybe_command = receiver.recv() => {
@@ -645,7 +647,6 @@ mod tests {
flush_interval: std::time::Duration::from_secs(60),
},
);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let guard = tracing_subscriber::registry()
.with(
+10
View File
@@ -107,6 +107,7 @@ pub struct ToolsConfig {
pub spawn_agent_usage_hint: bool,
pub spawn_agent_usage_hint_text: Option<String>,
pub max_concurrent_threads_per_session: Option<usize>,
pub wait_agent_min_timeout_ms: Option<i64>,
pub default_mode_request_user_input: bool,
pub experimental_supported_tools: Vec<String>,
pub agent_jobs_tools: bool,
@@ -226,6 +227,7 @@ impl ToolsConfig {
spawn_agent_usage_hint: true,
spawn_agent_usage_hint_text: None,
max_concurrent_threads_per_session: None,
wait_agent_min_timeout_ms: None,
default_mode_request_user_input: include_default_mode_request_user_input,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
agent_jobs_tools: include_agent_jobs,
@@ -270,6 +272,14 @@ impl ToolsConfig {
self
}
pub fn with_wait_agent_min_timeout_ms(
mut self,
wait_agent_min_timeout_ms: Option<i64>,
) -> Self {
self.wait_agent_min_timeout_ms = wait_agent_min_timeout_ms;
self
}
pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self {
self.allow_login_shell = allow_login_shell;
self