Files
codex/codex-rs/tui/src/app_server_session.rs
T
Michael Bolin 4816b89204 permissions: make profiles represent enforcement (#19231)
## Why

`PermissionProfile` is becoming the canonical permissions abstraction,
but the old shape only carried optional filesystem and network fields.
It could describe allowed access, but not who is responsible for
enforcing it. That made `DangerFullAccess` and `ExternalSandbox` lossy
when profiles were exported, cached, or round-tripped through app-server
APIs.

The important model change is that active permissions are now a disjoint
union over the enforcement mode. Conceptually:

```rust
pub enum PermissionProfile {
    Managed {
        file_system: FileSystemSandboxPolicy,
        network: NetworkSandboxPolicy,
    },
    Disabled,
    External {
        network: NetworkSandboxPolicy,
    },
}
```

This distinction matters because `Disabled` means Codex should apply no
outer sandbox at all, while `External` means filesystem isolation is
owned by an outside caller. Those are not equivalent to a broad managed
sandbox. For example, macOS cannot nest Seatbelt inside Seatbelt, so an
inner sandbox may require the outer Codex layer to use no sandbox rather
than a permissive one.

## How Existing Modeling Maps

Legacy `SandboxPolicy` remains a boundary projection, but it now maps
into the higher-fidelity profile model:

- `ReadOnly` and `WorkspaceWrite` map to `PermissionProfile::Managed`
with restricted filesystem entries plus the corresponding network
policy.
- `DangerFullAccess` maps to `PermissionProfile::Disabled`, preserving
the “no outer sandbox” intent instead of treating it as a lax managed
sandbox.
- `ExternalSandbox { network_access }` maps to
`PermissionProfile::External { network }`, preserving external
filesystem enforcement while still carrying the active network policy.
- Split runtime policies that legacy `SandboxPolicy` cannot faithfully
express, such as managed unrestricted filesystem plus restricted
network, stay `Managed` instead of being collapsed into
`ExternalSandbox`.
- Per-command/session/turn grants remain partial overlays via
`AdditionalPermissionProfile`; full `PermissionProfile` is reserved for
complete active runtime permissions.

## What Changed

- Change active `PermissionProfile` into a tagged union: `managed`,
`disabled`, and `external`.
- Keep partial permission grants separate with
`AdditionalPermissionProfile` for command/session/turn overlays.
- Represent managed filesystem permissions as either `restricted`
entries or `unrestricted`; `glob_scan_max_depth` is non-zero when
present.
- Preserve old rollout compatibility by accepting the pre-tagged `{
network, file_system }` profile shape during deserialization.
- Preserve fidelity for important edge cases: `DangerFullAccess`
round-trips as `disabled`, `ExternalSandbox` round-trips as `external`,
and managed unrestricted filesystem + restricted network stays managed
instead of being mistaken for external enforcement.
- Preserve configured deny-read entries and bounded glob scan depth when
full profiles are projected back into runtime policies, including
unrestricted replacements that now become `:root = write` plus deny
entries.
- Regenerate the experimental app-server v2 JSON/TypeScript schema and
update the `command/exec` README example for the tagged
`permissionProfile` shape.

## Compatibility

Legacy `SandboxPolicy` remains available at config/API boundaries as the
compatibility projection. Existing rollout lines with the old
`PermissionProfile` shape continue to load. The app-server
`permissionProfile` field is experimental, so its v2 wire shape is
intentionally updated to match the higher-fidelity model.

## Verification

- `just write-app-server-schema`
- `cargo check --tests`
- `cargo test -p codex-protocol permission_profile`
- `cargo test -p codex-protocol
preserving_deny_entries_keeps_unrestricted_policy_enforceable`
- `cargo test -p codex-app-server-protocol
permission_profile_file_system_permissions`
- `cargo test -p codex-app-server-protocol serialize_client_response`
- `cargo test -p codex-core
session_configured_reports_permission_profile_for_external_sandbox`
- `just fix`
- `just fix -p codex-protocol`
- `just fix -p codex-app-server-protocol`
- `just fix -p codex-core`
- `just fix -p codex-app-server`
2026-04-23 23:02:18 -07:00

1798 lines
65 KiB
Rust

use crate::bottom_pane::FeedbackAudience;
#[cfg(test)]
use crate::legacy_core::append_message_history_entry;
use crate::legacy_core::config::Config;
use crate::legacy_core::message_history_metadata;
use crate::status::StatusAccountDisplay;
use crate::status::plan_type_display_name;
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MemoryResetResponse;
use codex_app_server_protocol::Model as ApiModel;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams;
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStartResponse;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadRealtimeStopResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartSource;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionNetworkProxyRuntime;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
/// Data collected during the TUI bootstrap phase that the main event loop
/// needs to configure the UI, telemetry, and initial rate-limit prefetch.
///
/// Rate-limit snapshots are intentionally **not** included here; they are
/// fetched asynchronously after bootstrap returns so that the TUI can render
/// its first frame without waiting for the rate-limit round-trip.
pub(crate) struct AppServerBootstrap {
pub(crate) account_email: Option<String>,
pub(crate) auth_mode: Option<TelemetryAuthMode>,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
/// Whether the configured model provider needs OpenAI-style auth. Combined
/// with `has_chatgpt_account` to decide if a startup rate-limit prefetch
/// should be fired.
pub(crate) requires_openai_auth: bool,
pub(crate) default_model: String,
pub(crate) feedback_audience: FeedbackAudience,
pub(crate) has_chatgpt_account: bool,
pub(crate) available_models: Vec<ModelPreset>,
}
pub(crate) struct AppServerSession {
client: AppServerClient,
next_request_id: i64,
remote_cwd_override: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadSessionState {
pub(crate) thread_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
pub(crate) fork_parent_title: Option<String>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<codex_protocol::config_types::ServiceTier>,
pub(crate) approval_policy: AskForApproval,
pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
/// Legacy sandbox projection kept for compatibility. Use this only when
/// `permission_profile` is `None`.
pub(crate) sandbox_policy: SandboxPolicy,
/// Canonical active permissions when available. Consumers should prefer
/// this over `sandbox_policy`; `None` means the session only has a legacy
/// sandbox projection.
pub(crate) permission_profile: Option<PermissionProfile>,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) instruction_source_paths: Vec<AbsolutePathBuf>,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: u64,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
#[derive(Clone, Copy)]
enum ThreadParamsMode {
Embedded,
Remote,
}
impl ThreadParamsMode {
fn model_provider_from_config(self, config: &Config) -> Option<String> {
match self {
Self::Embedded => Some(config.model_provider_id.clone()),
Self::Remote => None,
}
}
}
pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
}
impl AppServerSession {
pub(crate) fn new(client: AppServerClient) -> Self {
Self {
client,
next_request_id: 1,
remote_cwd_override: None,
}
}
pub(crate) fn with_remote_cwd_override(mut self, remote_cwd_override: Option<PathBuf>) -> Self {
self.remote_cwd_override = remote_cwd_override;
self
}
pub(crate) fn remote_cwd_override(&self) -> Option<&std::path::Path> {
self.remote_cwd_override.as_deref()
}
pub(crate) fn is_remote(&self) -> bool {
matches!(self.client, AppServerClient::Remote(_))
}
pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result<AppServerBootstrap> {
let account = self.read_account().await?;
let model_request_id = self.next_request_id();
let models: ModelListResponse = self
.client
.request_typed(ClientRequest::ModelList {
request_id: model_request_id,
params: ModelListParams {
cursor: None,
limit: None,
include_hidden: Some(true),
},
})
.await
.wrap_err("model/list failed during TUI bootstrap")?;
let available_models = models
.data
.into_iter()
.map(model_preset_from_api_model)
.collect::<Vec<_>>();
let default_model = config
.model
.clone()
.or_else(|| {
available_models
.iter()
.find(|model| model.is_default)
.map(|model| model.model.clone())
})
.or_else(|| available_models.first().map(|model| model.model.clone()))
.wrap_err("model/list returned no models for TUI bootstrap")?;
let (
account_email,
auth_mode,
status_account_display,
plan_type,
feedback_audience,
has_chatgpt_account,
) = match account.account {
Some(Account::ApiKey {}) => (
None,
Some(TelemetryAuthMode::ApiKey),
Some(StatusAccountDisplay::ApiKey),
None,
FeedbackAudience::External,
false,
),
Some(Account::Chatgpt { email, plan_type }) => {
let feedback_audience = if email.ends_with("@openai.com") {
FeedbackAudience::OpenAiEmployee
} else {
FeedbackAudience::External
};
(
Some(email.clone()),
Some(TelemetryAuthMode::Chatgpt),
Some(StatusAccountDisplay::ChatGpt {
email: Some(email),
plan: Some(plan_type_display_name(plan_type)),
}),
Some(plan_type),
feedback_audience,
true,
)
}
Some(Account::AmazonBedrock {}) => {
(None, None, None, None, FeedbackAudience::External, false)
}
None => (None, None, None, None, FeedbackAudience::External, false),
};
Ok(AppServerBootstrap {
account_email,
auth_mode,
status_account_display,
plan_type,
requires_openai_auth: account.requires_openai_auth,
default_model,
feedback_audience,
has_chatgpt_account,
available_models,
})
}
/// Fetches the current account info without refreshing the auth token.
///
/// Used by both `bootstrap` (to populate the initial UI) and `get_login_status`
/// (to check auth mode without the overhead of a full bootstrap).
pub(crate) async fn read_account(&mut self) -> Result<GetAccountResponse> {
let account_request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::GetAccount {
request_id: account_request_id,
params: GetAccountParams {
refresh_token: false,
},
})
.await
.wrap_err("account/read failed during TUI bootstrap")
}
pub(crate) async fn external_agent_config_detect(
&mut self,
params: ExternalAgentConfigDetectParams,
) -> Result<ExternalAgentConfigDetectResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
.await
.wrap_err("externalAgentConfig/detect failed during TUI startup")
}
pub(crate) async fn external_agent_config_import(
&mut self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> Result<ExternalAgentConfigImportResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ExternalAgentConfigImport {
request_id,
params: ExternalAgentConfigImportParams { migration_items },
})
.await
.wrap_err("externalAgentConfig/import failed during TUI startup")
}
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
self.client.next_event().await
}
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
self.start_thread_with_session_start_source(config, /*session_start_source*/ None)
.await
}
pub(crate) async fn start_thread_with_session_start_source(
&mut self,
config: &Config,
session_start_source: Option<ThreadStartSource>,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let response: ThreadStartResponse = self
.client
.request_typed(ClientRequest::ThreadStart {
request_id,
params: thread_start_params_from_config(
config,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
session_start_source,
),
})
.await
.wrap_err("thread/start failed during TUI bootstrap")?;
started_thread_from_start_response(response, config).await
}
pub(crate) async fn resume_thread(
&mut self,
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let response: ThreadResumeResponse = self
.client
.request_typed(ClientRequest::ThreadResume {
request_id,
params: thread_resume_params_from_config(
config.clone(),
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
),
})
.await
.wrap_err("thread/resume failed during TUI bootstrap")?;
let fork_parent_title = self
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
.await;
let mut started = started_thread_from_resume_response(response, &config).await?;
started.session.fork_parent_title = fork_parent_title;
Ok(started)
}
pub(crate) async fn fork_thread(
&mut self,
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let response: ThreadForkResponse = self
.client
.request_typed(ClientRequest::ThreadFork {
request_id,
params: thread_fork_params_from_config(
config.clone(),
thread_id,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
),
})
.await
.wrap_err("thread/fork failed during TUI bootstrap")?;
let fork_parent_title = self
.fork_parent_title_from_app_server(response.thread.forked_from_id.as_deref())
.await;
let mut started = started_thread_from_fork_response(response, &config).await?;
started.session.fork_parent_title = fork_parent_title;
Ok(started)
}
fn thread_params_mode(&self) -> ThreadParamsMode {
match &self.client {
AppServerClient::InProcess(_) => ThreadParamsMode::Embedded,
AppServerClient::Remote(_) => ThreadParamsMode::Remote,
}
}
async fn fork_parent_title_from_app_server(
&mut self,
forked_from_id: Option<&str>,
) -> Option<String> {
let forked_from_id = forked_from_id?;
let forked_from_id = match ThreadId::from_string(forked_from_id) {
Ok(thread_id) => thread_id,
Err(err) => {
tracing::warn!("Failed to parse fork parent thread id from app server: {err}");
return None;
}
};
match self
.thread_read(forked_from_id, /*include_turns*/ false)
.await
{
Ok(thread) => thread.name,
Err(err) => {
tracing::warn!("Failed to read fork parent metadata from app server: {err}");
None
}
}
}
pub(crate) async fn thread_list(
&mut self,
params: ThreadListParams,
) -> Result<ThreadListResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadList { request_id, params })
.await
.wrap_err("thread/list failed during TUI session lookup")
}
/// Lists thread ids that the app server currently holds in memory.
///
/// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were
/// spawned before the TUI connected. The caller then fetches full metadata per thread via
/// `thread_read` and walks the spawn tree.
pub(crate) async fn thread_loaded_list(
&mut self,
params: ThreadLoadedListParams,
) -> Result<ThreadLoadedListResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadLoadedList { request_id, params })
.await
.wrap_err("failed to list loaded threads from app server")
}
pub(crate) async fn thread_read(
&mut self,
thread_id: ThreadId,
include_turns: bool,
) -> Result<Thread> {
let request_id = self.next_request_id();
let response: ThreadReadResponse = self
.client
.request_typed(ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: thread_id.to_string(),
include_turns,
},
})
.await
.wrap_err("thread/read failed during TUI session lookup")?;
Ok(response.thread)
}
pub(crate) async fn thread_inject_items(
&mut self,
thread_id: ThreadId,
items: Vec<ResponseItem>,
) -> Result<ThreadInjectItemsResponse> {
let items = items
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()
.wrap_err("failed to encode thread/inject_items payload")?;
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadInjectItems {
request_id,
params: ThreadInjectItemsParams {
thread_id: thread_id.to_string(),
items,
},
})
.await
.wrap_err("thread/inject_items failed during TUI side conversation setup")
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn turn_start(
&mut self,
thread_id: ThreadId,
items: Vec<codex_protocol::user_input::UserInput>,
cwd: PathBuf,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
sandbox_policy: SandboxPolicy,
permission_profile: Option<PermissionProfile>,
model: String,
effort: Option<codex_protocol::openai_models::ReasoningEffort>,
summary: Option<codex_protocol::config_types::ReasoningSummary>,
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
personality: Option<codex_protocol::config_types::Personality>,
output_schema: Option<serde_json::Value>,
) -> Result<TurnStartResponse> {
let request_id = self.next_request_id();
let (sandbox_policy, permission_profile) = turn_start_permission_overrides(
self.thread_params_mode(),
sandbox_policy,
permission_profile,
);
self.client
.request_typed(ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread_id.to_string(),
input: items.into_iter().map(Into::into).collect(),
responsesapi_client_metadata: None,
environments: None,
cwd: Some(cwd),
approval_policy: Some(approval_policy.into()),
approvals_reviewer: Some(approvals_reviewer.into()),
sandbox_policy,
permission_profile,
model: Some(model),
service_tier,
effort,
summary,
personality,
output_schema,
collaboration_mode,
},
})
.await
.wrap_err("turn/start failed in TUI")
}
pub(crate) async fn turn_interrupt(
&mut self,
thread_id: ThreadId,
turn_id: String,
) -> Result<()> {
let request_id = self.next_request_id();
let _: TurnInterruptResponse = self
.client
.request_typed(ClientRequest::TurnInterrupt {
request_id,
params: TurnInterruptParams {
thread_id: thread_id.to_string(),
turn_id,
},
})
.await
.wrap_err("turn/interrupt failed in TUI")?;
Ok(())
}
pub(crate) async fn startup_interrupt(&mut self, thread_id: ThreadId) -> Result<()> {
self.turn_interrupt(thread_id, String::new()).await
}
pub(crate) async fn turn_steer(
&mut self,
thread_id: ThreadId,
turn_id: String,
items: Vec<codex_protocol::user_input::UserInput>,
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::TurnSteer {
request_id,
params: TurnSteerParams {
thread_id: thread_id.to_string(),
input: items.into_iter().map(Into::into).collect(),
responsesapi_client_metadata: None,
expected_turn_id: turn_id,
},
})
.await
}
pub(crate) async fn thread_set_name(
&mut self,
thread_id: ThreadId,
name: String,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadSetNameResponse = self
.client
.request_typed(ClientRequest::ThreadSetName {
request_id,
params: ThreadSetNameParams {
thread_id: thread_id.to_string(),
name,
},
})
.await
.wrap_err("thread/name/set failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_memory_mode_set(
&mut self,
thread_id: ThreadId,
mode: ThreadMemoryMode,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadMemoryModeSetResponse = self
.client
.request_typed(ClientRequest::ThreadMemoryModeSet {
request_id,
params: ThreadMemoryModeSetParams {
thread_id: thread_id.to_string(),
mode,
},
})
.await
.wrap_err("thread/memoryMode/set failed in TUI")?;
Ok(())
}
pub(crate) async fn memory_reset(&mut self) -> Result<()> {
let request_id = self.next_request_id();
let _: MemoryResetResponse = self
.client
.request_typed(ClientRequest::MemoryReset {
request_id,
params: None,
})
.await
.wrap_err("memory/reset failed in TUI")?;
Ok(())
}
pub(crate) async fn logout_account(&mut self) -> Result<()> {
let request_id = self.next_request_id();
let _: LogoutAccountResponse = self
.client
.request_typed(ClientRequest::LogoutAccount {
request_id,
params: None,
})
.await
.wrap_err("account/logout failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadUnsubscribeResponse = self
.client
.request_typed(ClientRequest::ThreadUnsubscribe {
request_id,
params: ThreadUnsubscribeParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/unsubscribe failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_compact_start(&mut self, thread_id: ThreadId) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadCompactStartResponse = self
.client
.request_typed(ClientRequest::ThreadCompactStart {
request_id,
params: ThreadCompactStartParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/compact/start failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_shell_command(
&mut self,
thread_id: ThreadId,
command: String,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadShellCommandResponse = self
.client
.request_typed(ClientRequest::ThreadShellCommand {
request_id,
params: ThreadShellCommandParams {
thread_id: thread_id.to_string(),
command,
},
})
.await
.wrap_err("thread/shellCommand failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_approve_guardian_denied_action(
&mut self,
thread_id: ThreadId,
event: &GuardianAssessmentEvent,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadApproveGuardianDeniedActionResponse = self
.client
.request_typed(ClientRequest::ThreadApproveGuardianDeniedAction {
request_id,
params: ThreadApproveGuardianDeniedActionParams {
thread_id: thread_id.to_string(),
event: serde_json::to_value(event)
.wrap_err("failed to serialize Guardian denial event")?,
},
})
.await
.wrap_err("thread/approveGuardianDeniedAction failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_background_terminals_clean(
&mut self,
thread_id: ThreadId,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadBackgroundTerminalsCleanResponse = self
.client
.request_typed(ClientRequest::ThreadBackgroundTerminalsClean {
request_id,
params: ThreadBackgroundTerminalsCleanParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/backgroundTerminals/clean failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_rollback(
&mut self,
thread_id: ThreadId,
num_turns: u32,
) -> Result<ThreadRollbackResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadRollback {
request_id,
params: ThreadRollbackParams {
thread_id: thread_id.to_string(),
num_turns,
},
})
.await
.wrap_err("thread/rollback failed in TUI")
}
pub(crate) async fn review_start(
&mut self,
thread_id: ThreadId,
review_request: ReviewRequest,
) -> Result<ReviewStartResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ReviewStart {
request_id,
params: ReviewStartParams {
thread_id: thread_id.to_string(),
target: review_target_to_app_server(review_request.target),
delivery: Some(ReviewDelivery::Inline),
},
})
.await
.wrap_err("review/start failed in TUI")
}
pub(crate) async fn skills_list(
&mut self,
params: SkillsListParams,
) -> Result<SkillsListResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::SkillsList { request_id, params })
.await
.wrap_err("skills/list failed in TUI")
}
pub(crate) async fn reload_user_config(&mut self) -> Result<()> {
let request_id = self.next_request_id();
let _: ConfigWriteResponse = self
.client
.request_typed(ClientRequest::ConfigBatchWrite {
request_id,
params: ConfigBatchWriteParams {
edits: Vec::new(),
file_path: None,
expected_version: None,
reload_user_config: true,
},
})
.await
.wrap_err("config/batchWrite failed while reloading user config in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_start(
&mut self,
thread_id: ThreadId,
params: ConversationStartParams,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeStartResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeStart {
request_id,
params: ThreadRealtimeStartParams {
thread_id: thread_id.to_string(),
output_modality: params.output_modality,
prompt: params.prompt,
session_id: params.session_id,
voice: params.voice,
transport: params.transport.map(|transport| match transport {
ConversationStartTransport::Websocket => {
ThreadRealtimeStartTransport::Websocket
}
ConversationStartTransport::Webrtc { sdp } => {
ThreadRealtimeStartTransport::Webrtc { sdp }
}
}),
},
})
.await
.wrap_err("thread/realtime/start failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_audio(
&mut self,
thread_id: ThreadId,
params: ConversationAudioParams,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeAppendAudioResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeAppendAudio {
request_id,
params: ThreadRealtimeAppendAudioParams {
thread_id: thread_id.to_string(),
audio: params.frame.into(),
},
})
.await
.wrap_err("thread/realtime/appendAudio failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_text(
&mut self,
thread_id: ThreadId,
params: ConversationTextParams,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeAppendTextResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeAppendText {
request_id,
params: ThreadRealtimeAppendTextParams {
thread_id: thread_id.to_string(),
text: params.text,
},
})
.await
.wrap_err("thread/realtime/appendText failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_stop(&mut self, thread_id: ThreadId) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadRealtimeStopResponse = self
.client
.request_typed(ClientRequest::ThreadRealtimeStop {
request_id,
params: ThreadRealtimeStopParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/realtime/stop failed in TUI")?;
Ok(())
}
pub(crate) async fn reject_server_request(
&self,
request_id: RequestId,
error: JSONRPCErrorError,
) -> std::io::Result<()> {
self.client.reject_server_request(request_id, error).await
}
pub(crate) async fn resolve_server_request(
&self,
request_id: RequestId,
result: serde_json::Value,
) -> std::io::Result<()> {
self.client.resolve_server_request(request_id, result).await
}
pub(crate) async fn shutdown(self) -> std::io::Result<()> {
self.client.shutdown().await
}
pub(crate) fn request_handle(&self) -> AppServerRequestHandle {
self.client.request_handle()
}
fn next_request_id(&mut self) -> RequestId {
let request_id = self.next_request_id;
self.next_request_id += 1;
RequestId::Integer(request_id)
}
}
pub(crate) fn status_account_display_from_auth_mode(
auth_mode: Option<AuthMode>,
plan_type: Option<codex_protocol::account::PlanType>,
) -> Option<StatusAccountDisplay> {
match auth_mode {
Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey),
Some(AuthMode::Chatgpt)
| Some(AuthMode::ChatgptAuthTokens)
| Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: plan_type.map(plan_type_display_name),
}),
None => None,
}
}
fn model_preset_from_api_model(model: ApiModel) -> ModelPreset {
let upgrade = model.upgrade.map(|upgrade_id| {
let upgrade_info = model.upgrade_info.clone();
ModelUpgrade {
id: upgrade_id,
reasoning_effort_mapping: None,
migration_config_key: model.model.clone(),
model_link: upgrade_info
.as_ref()
.and_then(|info| info.model_link.clone()),
upgrade_copy: upgrade_info
.as_ref()
.and_then(|info| info.upgrade_copy.clone()),
migration_markdown: upgrade_info.and_then(|info| info.migration_markdown),
}
});
ModelPreset {
id: model.id,
model: model.model,
display_name: model.display_name,
description: model.description,
default_reasoning_effort: model.default_reasoning_effort,
supported_reasoning_efforts: model
.supported_reasoning_efforts
.into_iter()
.map(|effort| ReasoningEffortPreset {
effort: effort.reasoning_effort,
description: effort.description,
})
.collect(),
supports_personality: model.supports_personality,
additional_speed_tiers: model.additional_speed_tiers,
is_default: model.is_default,
upgrade,
show_in_picker: !model.hidden,
availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux {
message: nux.message,
}),
// `model/list` already returns models filtered for the active client/auth context.
supported_in_api: true,
input_modalities: model.input_modalities,
}
}
fn approvals_reviewer_override_from_config(
config: &Config,
) -> Option<codex_app_server_protocol::ApprovalsReviewer> {
Some(config.approvals_reviewer.into())
}
fn config_request_overrides_from_config(
config: &Config,
) -> Option<HashMap<String, serde_json::Value>> {
config.active_profile.as_ref().map(|profile| {
HashMap::from([(
"profile".to_string(),
serde_json::Value::String(profile.clone()),
)])
})
}
fn sandbox_mode_from_policy(
policy: SandboxPolicy,
) -> Option<codex_app_server_protocol::SandboxMode> {
match policy {
SandboxPolicy::DangerFullAccess => {
Some(codex_app_server_protocol::SandboxMode::DangerFullAccess)
}
SandboxPolicy::ReadOnly { .. } => Some(codex_app_server_protocol::SandboxMode::ReadOnly),
SandboxPolicy::WorkspaceWrite { .. } => {
Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite)
}
SandboxPolicy::ExternalSandbox { .. } => None,
}
}
fn turn_start_permission_overrides(
mode: ThreadParamsMode,
sandbox_policy: SandboxPolicy,
permission_profile: Option<PermissionProfile>,
) -> (
Option<codex_app_server_protocol::SandboxPolicy>,
Option<codex_app_server_protocol::PermissionProfile>,
) {
match (mode, permission_profile) {
(ThreadParamsMode::Embedded, Some(permission_profile)) => {
(None, Some(permission_profile.into()))
}
(ThreadParamsMode::Embedded, None) => (None, None),
(ThreadParamsMode::Remote, _) => (Some(sandbox_policy.into()), None),
}
}
fn permission_profile_override_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
) -> Option<codex_app_server_protocol::PermissionProfile> {
if matches!(thread_params_mode, ThreadParamsMode::Remote) {
return None;
}
Some(config.permissions.permission_profile().into())
}
fn thread_start_params_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
session_start_source: Option<ThreadStartSource>,
) -> ThreadStartParams {
let permission_profile = permission_profile_override_from_config(config, thread_params_mode);
let sandbox = permission_profile
.is_none()
.then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()))
.flatten();
ThreadStartParams {
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(config),
cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(config),
sandbox,
permission_profile,
config: config_request_overrides_from_config(config),
ephemeral: Some(config.ephemeral),
session_start_source,
persist_extended_history: true,
..ThreadStartParams::default()
}
}
fn thread_resume_params_from_config(
config: Config,
thread_id: ThreadId,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
) -> ThreadResumeParams {
let permission_profile = permission_profile_override_from_config(&config, thread_params_mode);
let sandbox = permission_profile
.is_none()
.then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()))
.flatten();
ThreadResumeParams {
thread_id: thread_id.to_string(),
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(&config),
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
sandbox,
permission_profile,
config: config_request_overrides_from_config(&config),
persist_extended_history: true,
..ThreadResumeParams::default()
}
}
fn thread_fork_params_from_config(
config: Config,
thread_id: ThreadId,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
) -> ThreadForkParams {
let permission_profile = permission_profile_override_from_config(&config, thread_params_mode);
let sandbox = permission_profile
.is_none()
.then(|| sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()))
.flatten();
ThreadForkParams {
thread_id: thread_id.to_string(),
model: config.model.clone(),
model_provider: thread_params_mode.model_provider_from_config(&config),
cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override),
approval_policy: Some(config.permissions.approval_policy.value().into()),
approvals_reviewer: approvals_reviewer_override_from_config(&config),
sandbox,
permission_profile,
config: config_request_overrides_from_config(&config),
base_instructions: config.base_instructions.clone(),
developer_instructions: config.developer_instructions.clone(),
ephemeral: config.ephemeral,
persist_extended_history: true,
..ThreadForkParams::default()
}
}
fn thread_cwd_from_config(
config: &Config,
thread_params_mode: ThreadParamsMode,
remote_cwd_override: Option<&std::path::Path>,
) -> Option<String> {
match thread_params_mode {
ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()),
ThreadParamsMode::Remote => {
remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string())
}
}
}
async fn started_thread_from_start_response(
response: ThreadStartResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_start_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
})
}
async fn started_thread_from_resume_response(
response: ThreadResumeResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_resume_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
})
}
async fn started_thread_from_fork_response(
response: ThreadForkResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_fork_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
})
}
async fn thread_session_state_from_thread_start_response(
response: &ThreadStartResponse,
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.forked_from_id.clone(),
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.permission_profile.clone().map(Into::into),
response.cwd.clone(),
response.instruction_sources.clone(),
response.reasoning_effort,
config,
)
.await
}
async fn thread_session_state_from_thread_resume_response(
response: &ThreadResumeResponse,
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.forked_from_id.clone(),
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.permission_profile.clone().map(Into::into),
response.cwd.clone(),
response.instruction_sources.clone(),
response.reasoning_effort,
config,
)
.await
}
async fn thread_session_state_from_thread_fork_response(
response: &ThreadForkResponse,
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.forked_from_id.clone(),
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.permission_profile.clone().map(Into::into),
response.cwd.clone(),
response.instruction_sources.clone(),
response.reasoning_effort,
config,
)
.await
}
fn review_target_to_app_server(
target: CoreReviewTarget,
) -> codex_app_server_protocol::ReviewTarget {
match target {
CoreReviewTarget::UncommittedChanges => {
codex_app_server_protocol::ReviewTarget::UncommittedChanges
}
CoreReviewTarget::BaseBranch { branch } => {
codex_app_server_protocol::ReviewTarget::BaseBranch { branch }
}
CoreReviewTarget::Commit { sha, title } => {
codex_app_server_protocol::ReviewTarget::Commit { sha, title }
}
CoreReviewTarget::Custom { instructions } => {
codex_app_server_protocol::ReviewTarget::Custom { instructions }
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "session mapping keeps explicit fields"
)]
async fn thread_session_state_from_thread_response(
thread_id: &str,
forked_from_id: Option<String>,
thread_name: Option<String>,
rollout_path: Option<PathBuf>,
model: String,
model_provider_id: String,
service_tier: Option<codex_protocol::config_types::ServiceTier>,
approval_policy: AskForApproval,
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
sandbox_policy: SandboxPolicy,
permission_profile: Option<PermissionProfile>,
cwd: AbsolutePathBuf,
instruction_source_paths: Vec<AbsolutePathBuf>,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
config: &Config,
) -> Result<ThreadSessionState, String> {
let thread_id = ThreadId::from_string(thread_id)
.map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?;
let forked_from_id = forked_from_id
.as_deref()
.map(ThreadId::from_string)
.transpose()
.map_err(|err| format!("forked_from_id is invalid: {err}"))?;
let (history_log_id, history_entry_count) = message_history_metadata(config).await;
let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX);
Ok(ThreadSessionState {
thread_id,
forked_from_id,
fork_parent_title: None,
thread_name,
model,
model_provider_id,
service_tier,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
cwd,
instruction_source_paths,
reasoning_effort,
history_log_id,
history_entry_count,
network_proxy: None,
rollout_path,
})
}
pub(crate) fn app_server_rate_limit_snapshots_to_core(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
let mut snapshots = Vec::new();
snapshots.push(app_server_rate_limit_snapshot_to_core(response.rate_limits));
if let Some(by_limit_id) = response.rate_limits_by_limit_id {
snapshots.extend(
by_limit_id
.into_values()
.map(app_server_rate_limit_snapshot_to_core),
);
}
snapshots
}
pub(crate) fn app_server_rate_limit_snapshot_to_core(
snapshot: codex_app_server_protocol::RateLimitSnapshot,
) -> RateLimitSnapshot {
RateLimitSnapshot {
limit_id: snapshot.limit_id,
limit_name: snapshot.limit_name,
primary: snapshot.primary.map(app_server_rate_limit_window_to_core),
secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core),
credits: snapshot.credits.map(app_server_credits_snapshot_to_core),
plan_type: snapshot.plan_type,
rate_limit_reached_type: snapshot.rate_limit_reached_type.map(Into::into),
}
}
fn app_server_rate_limit_window_to_core(
window: codex_app_server_protocol::RateLimitWindow,
) -> RateLimitWindow {
RateLimitWindow {
used_percent: window.used_percent as f64,
window_minutes: window.window_duration_mins,
resets_at: window.resets_at,
}
}
fn app_server_credits_snapshot_to_core(
snapshot: codex_app_server_protocol::CreditsSnapshot,
) -> CreditsSnapshot {
CreditsSnapshot {
has_credits: snapshot.has_credits,
unlimited: snapshot.unlimited,
balance: snapshot.balance,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
async fn build_config(temp_dir: &TempDir) -> Config {
ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
.build()
.await
.expect("config should build")
}
#[tokio::test]
async fn thread_start_params_include_cwd_for_embedded_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let params = thread_start_params_from_config(
&config,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string()));
assert_eq!(params.sandbox, None);
assert_eq!(
params.permission_profile,
Some(config.permissions.permission_profile().into())
);
assert_eq!(params.model_provider, Some(config.model_provider_id));
}
#[tokio::test]
async fn thread_start_params_can_mark_clear_source() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let params = thread_start_params_from_config(
&config,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
Some(ThreadStartSource::Clear),
);
assert_eq!(params.session_start_source, Some(ThreadStartSource::Clear));
}
#[tokio::test]
async fn thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let expected_sandbox =
sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone());
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Remote,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
let resume = thread_resume_params_from_config(
config.clone(),
thread_id,
ThreadParamsMode::Remote,
/*remote_cwd_override*/ None,
);
let fork = thread_fork_params_from_config(
config,
thread_id,
ThreadParamsMode::Remote,
/*remote_cwd_override*/ None,
);
assert_eq!(start.cwd, None);
assert_eq!(resume.cwd, None);
assert_eq!(fork.cwd, None);
assert_eq!(start.model_provider, None);
assert_eq!(resume.model_provider, None);
assert_eq!(fork.model_provider, None);
assert_eq!(start.sandbox, expected_sandbox);
assert_eq!(resume.sandbox, expected_sandbox);
assert_eq!(fork.sandbox, expected_sandbox);
assert_eq!(start.permission_profile, None);
assert_eq!(resume.permission_profile, None);
assert_eq!(fork.permission_profile, None);
}
#[tokio::test]
async fn thread_lifecycle_params_forward_explicit_remote_cwd_override_for_remote_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let remote_cwd = PathBuf::from("repo/on/server");
let expected_sandbox =
sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone());
let start = thread_start_params_from_config(
&config,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
/*session_start_source*/ None,
);
let resume = thread_resume_params_from_config(
config.clone(),
thread_id,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
);
let fork = thread_fork_params_from_config(
config,
thread_id,
ThreadParamsMode::Remote,
Some(remote_cwd.as_path()),
);
assert_eq!(start.cwd.as_deref(), Some("repo/on/server"));
assert_eq!(resume.cwd.as_deref(), Some("repo/on/server"));
assert_eq!(fork.cwd.as_deref(), Some("repo/on/server"));
assert_eq!(start.model_provider, None);
assert_eq!(resume.model_provider, None);
assert_eq!(fork.model_provider, None);
assert_eq!(start.sandbox, expected_sandbox);
assert_eq!(resume.sandbox, expected_sandbox);
assert_eq!(fork.sandbox, expected_sandbox);
assert_eq!(start.permission_profile, None);
assert_eq!(resume.permission_profile, None);
assert_eq!(fork.permission_profile, None);
}
#[test]
fn turn_start_permission_overrides_send_profiles_only_for_embedded_runtime_overrides() {
let cwd = test_path_buf("/tmp/project");
let workspace_write = SandboxPolicy::new_workspace_write_policy();
let workspace_write_profile =
PermissionProfile::from_legacy_sandbox_policy(&workspace_write, &cwd);
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Embedded,
workspace_write.clone(),
Some(workspace_write_profile.clone()),
);
assert_eq!(sandbox, None);
assert_eq!(profile, Some(workspace_write_profile.into()));
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Embedded,
workspace_write.clone(),
/*permission_profile*/ None,
);
assert_eq!(sandbox, None);
assert_eq!(profile, None);
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Remote,
workspace_write.clone(),
Some(PermissionProfile::from_legacy_sandbox_policy(
&workspace_write,
&cwd,
)),
);
assert_eq!(sandbox, Some(workspace_write.into()));
assert_eq!(profile, None);
let external_sandbox = SandboxPolicy::ExternalSandbox {
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
};
let (sandbox, profile) = turn_start_permission_overrides(
ThreadParamsMode::Embedded,
external_sandbox.clone(),
Some(PermissionProfile::from_legacy_sandbox_policy(
&external_sandbox,
&cwd,
)),
);
assert_eq!(sandbox, None);
assert_eq!(
profile,
Some(PermissionProfile::from_legacy_sandbox_policy(&external_sandbox, &cwd).into())
);
}
#[tokio::test]
async fn thread_fork_params_forward_instruction_overrides() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config = build_config(&temp_dir).await;
config.base_instructions = Some("Base override.".to_string());
config.developer_instructions = Some("Developer override.".to_string());
let thread_id = ThreadId::new();
let params = thread_fork_params_from_config(
config,
thread_id,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
);
assert_eq!(params.base_instructions.as_deref(), Some("Base override."));
assert_eq!(
params.developer_instructions.as_deref(),
Some("Developer override.")
);
}
#[tokio::test]
async fn resume_response_restores_turns_from_thread_items() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let forked_from_id = ThreadId::new();
let response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
id: thread_id.to_string(),
forked_from_id: Some(forked_from_id.to_string()),
preview: "hello".to_string(),
ephemeral: false,
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
status: ThreadStatus::Idle,
path: None,
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: codex_protocol::protocol::SessionSource::Cli.into(),
agent_nickname: None,
agent_role: None,
git_info: None,
name: None,
turns: vec![Turn {
id: "turn-1".to_string(),
items: vec![
codex_app_server_protocol::ThreadItem::UserMessage {
id: "user-1".to_string(),
content: vec![codex_app_server_protocol::UserInput::Text {
text: "hello from history".to_string(),
text_elements: Vec::new(),
}],
},
codex_app_server_protocol::ThreadItem::AgentMessage {
id: "assistant-1".to_string(),
text: "assistant reply".to_string(),
phase: None,
memory_citation: None,
},
],
status: TurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}],
},
model: "gpt-5.4".to_string(),
model_provider: "openai".to_string(),
service_tier: None,
cwd: test_path_buf("/tmp/project").abs(),
instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()],
approval_policy: codex_protocol::protocol::AskForApproval::Never.into(),
approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User,
sandbox: codex_protocol::protocol::SandboxPolicy::new_read_only_policy().into(),
permission_profile: Some(
codex_protocol::models::PermissionProfile::from_legacy_sandbox_policy(
&codex_protocol::protocol::SandboxPolicy::new_read_only_policy(),
&test_path_buf("/tmp/project"),
)
.into(),
),
reasoning_effort: None,
};
let started = started_thread_from_resume_response(response.clone(), &config)
.await
.expect("resume response should map");
assert_eq!(started.session.forked_from_id, Some(forked_from_id));
assert_eq!(
started.session.instruction_source_paths,
response.instruction_sources
);
assert_eq!(
started.session.permission_profile,
response.permission_profile.clone().map(Into::into)
);
assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]);
}
#[tokio::test]
async fn session_configured_populates_history_metadata() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
append_message_history_entry("older", &thread_id, &config)
.await
.expect("history append should succeed");
append_message_history_entry("newer", &thread_id, &config)
.await
.expect("history append should succeed");
let session = thread_session_state_from_thread_response(
&thread_id.to_string(),
/*forked_from_id*/ None,
Some("restore".to_string()),
/*rollout_path*/ None,
"gpt-5.4".to_string(),
"openai".to_string(),
/*service_tier*/ None,
AskForApproval::Never,
codex_protocol::config_types::ApprovalsReviewer::User,
SandboxPolicy::new_read_only_policy(),
Some(PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
std::path::Path::new("/tmp/project"),
)),
test_path_buf("/tmp/project").abs(),
Vec::new(),
/*reasoning_effort*/ None,
&config,
)
.await
.expect("session should map");
assert_ne!(session.history_log_id, 0);
assert_eq!(session.history_entry_count, 2);
}
#[tokio::test]
async fn session_configured_preserves_fork_source_thread_id() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
let forked_from_id = ThreadId::new();
let session = thread_session_state_from_thread_response(
&thread_id.to_string(),
Some(forked_from_id.to_string()),
Some("restore".to_string()),
/*rollout_path*/ None,
"gpt-5.4".to_string(),
"openai".to_string(),
/*service_tier*/ None,
AskForApproval::Never,
codex_protocol::config_types::ApprovalsReviewer::User,
SandboxPolicy::new_read_only_policy(),
Some(PermissionProfile::from_legacy_sandbox_policy(
&SandboxPolicy::new_read_only_policy(),
std::path::Path::new("/tmp/project"),
)),
test_path_buf("/tmp/project").abs(),
Vec::new(),
/*reasoning_effort*/ None,
&config,
)
.await
.expect("session should map");
assert_eq!(session.forked_from_id, Some(forked_from_id));
}
#[test]
fn status_account_display_from_auth_mode_uses_remapped_plan_labels() {
let business = status_account_display_from_auth_mode(
Some(AuthMode::Chatgpt),
Some(codex_protocol::account::PlanType::EnterpriseCbpUsageBased),
);
assert!(matches!(
business,
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: Some(ref plan),
}) if plan == "Enterprise"
));
let team = status_account_display_from_auth_mode(
Some(AuthMode::Chatgpt),
Some(codex_protocol::account::PlanType::SelfServeBusinessUsageBased),
);
assert!(matches!(
team,
Some(StatusAccountDisplay::ChatGpt {
email: None,
plan: Some(ref plan),
}) if plan == "Business"
));
}
}