Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Bolin
40ba3162cc core: route view_image through a sandbox-backed fs helper 2026-03-19 09:18:57 -07:00
Michael Bolin
392347d436 fix: try to fix "Stage npm package" step in ci.yml (#15092)
Fix the CI job by updating it to use artifacts from a more recent
release (`0.115.0`) instead of the existing one (`0.74.0`).

This step in our CI job on PRs started failing today:


334164a6f7/.github/workflows/ci.yml (L33-L47)

I believe it's because this test verifies that the "package npm" script
works, but we want it to be fast and not wait for binaries to be built,
so it uses a GitHub workflow that's already done. Because it was using a
GitHub workflow associated with `0.74.0`, it seems likely that
workflow's history has been reaped, so we need to use a newer one.
2026-03-18 13:52:33 -07:00
Felipe Coury
334164a6f7 feat(tui): restore composer history in app-server tui (#14945)
## Problem

The app-server TUI (`tui_app_server`) lacked composer history support.
Pressing Up/Down to recall previous prompts hit a stub that logged a
warning and displayed "Not available in app-server TUI yet." New
submissions were silently dropped from the shared history file, so
nothing persisted for future sessions.

## Mental model

Codex maintains a single, append-only history file
(`$CODEX_HOME/history.jsonl`) shared across all TUI processes on the
same machine. The legacy (in-process) TUI already reads/writes this file
through `codex_core::message_history`. The app-server TUI delegates most
operations to a separate process over RPC, but history is intentionally
*not* an RPC concern — it's a client-local file.

This PR makes the app-server TUI access the same history file directly,
bypassing the app-server process entirely. The composer's Up/Down
navigation and submit-time persistence now follow the same code paths as
the legacy TUI, with the only difference being *where* the call is
dispatched (locally in `App`, rather than inside `CodexThread`).

The branch is rebuilt directly on top of `upstream/main`, so it keeps
the
existing app-server restore architecture intact.
`AppServerStartedThread`
still restores transcript history from the server `Thread` snapshot via
`thread_snapshot_events`; this PR only adds composer-history support.

## Non-goals

- Adding history support to the app-server protocol. History remains
client-local.
- Changing the on-disk format or location of `history.jsonl`.
- Surfacing history I/O errors to the user (failures are logged and
silently swallowed, matching the legacy TUI).

## Tradeoffs

| Decision | Why | Risk |
|----------|-----|------|
| Widen `message_history` from `pub(crate)` to `pub` | Avoids
duplicating file I/O logic; the module already has a clean, minimal API
surface. | Other workspace crates can now call these functions — the
contract is no longer crate-private. However, this is consistent with
recent precedent: `590cfa617` exposed `mention_syntax` for TUI
consumption, `752402c4f` exposed plugin APIs (`PluginsManager`), and
`14fcb6645`/`edacbf7b6` widened internal core APIs for other crates.
These were all narrow, intentional exposures of specific APIs — not
broad "make internals public" moves. `1af2a37ad` even went the other
direction, reducing broad re-exports to tighten boundaries. This change
follows the same pattern: a small, deliberate API surface (3 functions)
rather than a wholesale visibility change. |
| Intercept `AddToHistory` / `GetHistoryEntryRequest` in `App` before
RPC fallback | Keeps history ops out of the "unsupported op" error path
without changing app-server protocol. | This now routes through a single
`submit_thread_op` entry point, which is safer than the original
duplicated dispatch. The remaining risk is organizational: future
thread-op submission paths need to keep using that shared entry point. |
| `session_configured_from_thread_response` is now `async` | Needs
`await` on `history_metadata()` to populate real `history_log_id` /
`history_entry_count`. | Adds an async file-stat + full-file newline
scan to the session bootstrap path. The scan is bounded by
`history.max_bytes` and matches the legacy TUI's cost profile, but
startup latency still scales with file size. |

## Architecture

```
User presses Up                     User submits a prompt
       │                                    │
       ▼                                    ▼
ChatComposerHistory                 ChatWidget::do_submit_turn
  navigate_up()                       encode_history_mentions()
       │                                    │
       ▼                                    ▼
  AppEvent::CodexOp                  Op::AddToHistory { text }
  (GetHistoryEntryRequest)                  │
       │                                    ▼
       ▼                            App::try_handle_local_history_op
  App::try_handle_local_history_op    message_history::append_entry()
    spawn_blocking {                        │
      message_history::lookup()             ▼
    }                                $CODEX_HOME/history.jsonl
       │
       ▼
  AppEvent::ThreadEvent
  (GetHistoryEntryResponse)
       │
       ▼
  ChatComposerHistory::on_entry_response()
```

## Observability

- `tracing::warn` on `append_entry` failure (includes thread ID).
- `tracing::warn` on `spawn_blocking` lookup join error.
- `tracing::warn` from `message_history` internals on file-open, lock,
or parse failures.

## Tests

- `chat_composer_history::tests::navigation_with_async_fetch` — verifies
that Up emits `Op::GetHistoryEntryRequest` (was: checked for stub error
cell).
- `app::tests::history_lookup_response_is_routed_to_requesting_thread` —
verifies multi-thread composer recall routes the lookup result back to
the originating thread.
-
`app_server_session::tests::resume_response_relies_on_snapshot_replay_not_initial_messages`
— verifies app-server session restore still uses the upstream
thread-snapshot path.
-
`app_server_session::tests::session_configured_populates_history_metadata`
— verifies bootstrap sets nonzero `history_log_id` /
`history_entry_count` from the shared local history file.
2026-03-18 11:54:11 -06:00
30 changed files with 1161 additions and 132 deletions

View File

@@ -37,7 +37,7 @@ jobs:
run: |
set -euo pipefail
# Use a rust-release version that includes all native binaries.
CODEX_VERSION=0.74.0
CODEX_VERSION=0.115.0
OUTPUT_DIR="${RUNNER_TEMP}"
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \

14
codex-rs/Cargo.lock generated
View File

@@ -1557,6 +1557,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"codex-apply-patch",
"codex-fs-ops",
"codex-linux-sandbox",
"codex-shell-escalation",
"codex-utils-home-dir",
@@ -1846,6 +1847,7 @@ dependencies = [
"codex-environment",
"codex-execpolicy",
"codex-file-search",
"codex-fs-ops",
"codex-git",
"codex-hooks",
"codex-keyring-store",
@@ -2077,6 +2079,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-fs-ops"
version = "0.0.0"
dependencies = [
"anyhow",
"base64 0.22.1",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "codex-git"
version = "0.0.0"

View File

@@ -11,6 +11,7 @@ members = [
"apply-patch",
"arg0",
"feedback",
"fs-ops",
"codex-backend-openapi-models",
"cloud-requirements",
"cloud-tasks",
@@ -109,6 +110,7 @@ codex-exec = { path = "exec" }
codex-execpolicy = { path = "execpolicy" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-feedback = { path = "feedback" }
codex-fs-ops = { path = "fs-ops" }
codex-file-search = { path = "file-search" }
codex-git = { path = "utils/git" }
codex-hooks = { path = "hooks" }

View File

@@ -14,6 +14,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
codex-apply-patch = { workspace = true }
codex-fs-ops = { workspace = true }
codex-linux-sandbox = { workspace = true }
codex-shell-escalation = { workspace = true }
codex-utils-home-dir = { workspace = true }

View File

@@ -4,6 +4,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
use codex_utils_home_dir::find_codex_home;
#[cfg(unix)]
use std::os::unix::fs::symlink;
@@ -105,6 +106,17 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
};
std::process::exit(exit_code);
}
if argv1 == CODEX_CORE_FS_OPS_ARG1 {
let mut stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
let exit_code =
match codex_fs_ops::run_from_args(args, &mut stdin, &mut stdout, &mut stderr) {
Ok(()) => 0,
Err(_) => 1,
};
std::process::exit(exit_code);
}
// This modifies the environment, which is not thread-safe, so do this
// before creating any threads/the Tokio runtime.

View File

@@ -38,6 +38,7 @@ codex-environment = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills = { workspace = true }
codex-execpolicy = { workspace = true }
codex-fs-ops = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
codex-hooks = { workspace = true }

View File

@@ -332,6 +332,58 @@ pub(crate) async fn execute_exec_request(
finalize_exec_result(raw_output_result, sandbox, duration)
}
pub(crate) async fn execute_exec_request_bytes(
exec_request: ExecRequest,
sandbox_policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<ExecToolCallOutputBytes> {
let ExecRequest {
command,
cwd,
env,
network,
expiration,
sandbox,
windows_sandbox_level,
windows_sandbox_private_desktop,
sandbox_permissions,
sandbox_policy: _sandbox_policy_from_env,
file_system_sandbox_policy,
network_sandbox_policy,
justification,
arg0,
} = exec_request;
let _ = _sandbox_policy_from_env;
let params = ExecParams {
command,
cwd,
expiration,
env,
network: network.clone(),
sandbox_permissions,
windows_sandbox_level,
windows_sandbox_private_desktop,
justification,
arg0,
};
let start = Instant::now();
let raw_output_result = exec(
params,
sandbox,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
stdout_stream,
after_spawn,
)
.await;
let duration = start.elapsed();
finalize_exec_result_bytes(raw_output_result, sandbox, duration)
}
#[cfg(target_os = "windows")]
fn extract_create_process_as_user_error_code(err: &str) -> Option<String> {
let marker = "CreateProcessAsUserW failed: ";
@@ -574,6 +626,64 @@ fn finalize_exec_result(
}
}
fn finalize_exec_result_bytes(
raw_output_result: std::result::Result<RawExecToolCallOutput, CodexErr>,
sandbox_type: SandboxType,
duration: Duration,
) -> Result<ExecToolCallOutputBytes> {
match raw_output_result {
Ok(raw_output) => {
#[allow(unused_mut)]
let mut timed_out = raw_output.timed_out;
#[cfg(target_family = "unix")]
{
if let Some(signal) = raw_output.exit_status.signal() {
if signal == TIMEOUT_CODE {
timed_out = true;
} else {
return Err(CodexErr::Sandbox(SandboxErr::Signal(signal)));
}
}
}
let mut exit_code = raw_output.exit_status.code().unwrap_or(-1);
if timed_out {
exit_code = EXEC_TIMEOUT_EXIT_CODE;
}
let exec_output = ExecToolCallOutputBytes {
exit_code,
stdout: raw_output.stdout,
stderr: raw_output.stderr,
aggregated_output: raw_output.aggregated_output,
duration,
timed_out,
};
if timed_out {
return Err(CodexErr::Sandbox(SandboxErr::Timeout {
output: Box::new(exec_output.to_utf8_lossy_output()),
}));
}
let string_output = exec_output.to_utf8_lossy_output();
if is_likely_sandbox_denied(sandbox_type, &string_output) {
return Err(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(string_output),
network_policy_decision: None,
}));
}
Ok(exec_output)
}
Err(err) => {
tracing::error!("exec error: {err}");
Err(err)
}
}
}
pub(crate) mod errors {
use super::CodexErr;
use crate::sandboxing::SandboxTransformError;
@@ -741,6 +851,16 @@ pub struct ExecToolCallOutput {
pub timed_out: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct ExecToolCallOutputBytes {
pub exit_code: i32,
pub stdout: StreamOutput<Vec<u8>>,
pub stderr: StreamOutput<Vec<u8>>,
pub aggregated_output: StreamOutput<Vec<u8>>,
pub duration: Duration,
pub timed_out: bool,
}
impl Default for ExecToolCallOutput {
fn default() -> Self {
Self {
@@ -754,6 +874,19 @@ impl Default for ExecToolCallOutput {
}
}
impl ExecToolCallOutputBytes {
fn to_utf8_lossy_output(&self) -> ExecToolCallOutput {
ExecToolCallOutput {
exit_code: self.exit_code,
stdout: self.stdout.from_utf8_lossy(),
stderr: self.stderr.from_utf8_lossy(),
aggregated_output: self.aggregated_output.from_utf8_lossy(),
duration: self.duration,
timed_out: self.timed_out,
}
}
}
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
async fn exec(
params: ExecParams,

View File

@@ -63,7 +63,7 @@ mod mcp_tool_call;
mod memories;
pub mod mention_syntax;
mod mentions;
mod message_history;
pub mod message_history;
mod model_provider_info;
pub mod path_utils;
pub mod personality_migration;
@@ -114,6 +114,7 @@ pub mod default_client;
pub mod project_doc;
mod rollout;
pub(crate) mod safety;
mod sandboxed_fs;
pub mod seatbelt;
pub mod shell;
pub mod shell_snapshot;

View File

@@ -66,14 +66,22 @@ fn history_filepath(config: &Config) -> PathBuf {
path
}
/// Append a `text` entry associated with `conversation_id` to the history file. Uses
/// advisory file locking to ensure that concurrent writes do not interleave,
/// which entails a small amount of blocking I/O internally.
pub(crate) async fn append_entry(
text: &str,
conversation_id: &ThreadId,
config: &Config,
) -> Result<()> {
/// Append a `text` entry associated with `conversation_id` to the history file.
///
/// Uses advisory file locking (`File::try_lock`) with a retry loop to ensure
/// concurrent writes from multiple TUI processes do not interleave. The lock
/// acquisition and write are performed inside `spawn_blocking` so the caller's
/// async runtime is not blocked.
///
/// The entry is silently skipped when `config.history.persistence` is
/// [`HistoryPersistence::None`].
///
/// # Errors
///
/// Returns an I/O error if the history file cannot be opened/created, the
/// system clock is before the Unix epoch, or the exclusive lock cannot be
/// acquired after [`MAX_RETRIES`] attempts.
pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Config) -> Result<()> {
match config.history.persistence {
HistoryPersistence::SaveAll => {
// Save everything: proceed.
@@ -243,22 +251,29 @@ fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 {
soft_cap_bytes.max(newest_entry_len)
}
/// Asynchronously fetch the history file's *identifier* (inode on Unix) and
/// the current number of entries by counting newline characters.
pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
/// Asynchronously fetch the history file's *identifier* and current entry count.
///
/// The identifier is the file's inode on Unix or creation time on Windows.
/// The entry count is derived by counting newline bytes in the file. Returns
/// `(0, 0)` when the file does not exist or its metadata cannot be read. If
/// metadata succeeds but the file cannot be opened or scanned, returns
/// `(log_id, 0)` so callers can still detect that a history file exists.
pub async fn history_metadata(config: &Config) -> (u64, usize) {
let path = history_filepath(config);
history_metadata_for_file(&path).await
}
/// Given a `log_id` (on Unix this is the file's inode number,
/// on Windows this is the file's creation time) and a zero-based
/// `offset`, return the corresponding `HistoryEntry` if the identifier matches
/// the current history file **and** the requested offset exists. Any I/O or
/// parsing errors are logged and result in `None`.
/// Look up a single history entry by file identity and zero-based offset.
///
/// Note this function is not async because it uses a sync advisory file
/// locking API.
pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
/// Returns `Some(entry)` when the current history file's identifier (inode on
/// Unix, creation time on Windows) matches `log_id` **and** a valid JSON
/// record exists at `offset`. Returns `None` on any mismatch, I/O error, or
/// parse failure, all of which are logged at `warn` level.
///
/// This function is synchronous because it acquires a shared advisory file lock
/// via `File::try_lock_shared`. Callers on an async runtime should wrap it in
/// `spawn_blocking`.
pub fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<HistoryEntry> {
let path = history_filepath(config);
lookup_history_entry(&path, log_id, offset)
}

View File

@@ -0,0 +1,197 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::error::CodexErr;
use crate::error::SandboxErr;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutputBytes;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env_bytes;
use crate::sandboxing::merge_permission_profiles;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxablePreference;
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
use codex_fs_ops::FsError;
use codex_fs_ops::FsErrorKind;
use codex_protocol::models::PermissionProfile;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
const SANDBOXED_FS_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) async fn read_file(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
path: &Path,
) -> Result<Vec<u8>, SandboxedFsError> {
let output = run_request(session, turn, path).await?;
Ok(output.stdout.text)
}
async fn run_request(
session: &Arc<Session>,
turn: &Arc<TurnContext>,
path: &Path,
) -> Result<ExecToolCallOutputBytes, SandboxedFsError> {
let exe = std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe {
message: error.to_string(),
})?;
let additional_permissions = effective_granted_permissions(session).await;
let sandbox_manager = crate::sandboxing::SandboxManager::new();
let attempt = SandboxAttempt {
sandbox: sandbox_manager.select_initial(
&turn.file_system_sandbox_policy,
turn.network_sandbox_policy,
SandboxablePreference::Auto,
turn.windows_sandbox_level,
/*has_managed_network_requirements*/ false,
),
policy: &turn.sandbox_policy,
file_system_policy: &turn.file_system_sandbox_policy,
network_policy: turn.network_sandbox_policy,
enforce_managed_network: false,
manager: &sandbox_manager,
sandbox_cwd: &turn.cwd,
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
use_legacy_landlock: turn.features.use_legacy_landlock(),
windows_sandbox_level: turn.windows_sandbox_level,
windows_sandbox_private_desktop: turn.config.permissions.windows_sandbox_private_desktop,
};
let exec_request = attempt
.env_for(
CommandSpec {
program: exe.to_string_lossy().to_string(),
args: vec![
CODEX_CORE_FS_OPS_ARG1.to_string(),
"read".to_string(),
path.to_string_lossy().to_string(),
],
cwd: turn.cwd.clone(),
env: HashMap::new(),
expiration: ExecExpiration::Timeout(SANDBOXED_FS_TIMEOUT),
sandbox_permissions: SandboxPermissions::UseDefault,
additional_permissions,
justification: None,
},
/*network*/ None,
)
.map_err(|error| SandboxedFsError::ProcessFailed {
path: path.to_path_buf(),
exit_code: -1,
message: error.to_string(),
})?;
let output = execute_env_bytes(exec_request, /*stdout_stream*/ None)
.await
.map_err(|error| map_exec_error(path, error))?;
if output.exit_code != 0 {
return Err(parse_helper_failure(
path,
output.exit_code,
&output.stderr.text,
&output.stdout.text,
));
}
Ok(output)
}
async fn effective_granted_permissions(session: &Session) -> Option<PermissionProfile> {
let granted_session_permissions = session.granted_session_permissions().await;
let granted_turn_permissions = session.granted_turn_permissions().await;
merge_permission_profiles(
granted_session_permissions.as_ref(),
granted_turn_permissions.as_ref(),
)
}
fn map_exec_error(path: &Path, error: CodexErr) -> SandboxedFsError {
match error {
CodexErr::Sandbox(SandboxErr::Timeout { .. }) => SandboxedFsError::TimedOut {
path: path.to_path_buf(),
},
_ => SandboxedFsError::ProcessFailed {
path: path.to_path_buf(),
exit_code: -1,
message: error.to_string(),
},
}
}
fn parse_helper_failure(
path: &Path,
exit_code: i32,
stderr: &[u8],
stdout: &[u8],
) -> SandboxedFsError {
if let Ok(error) = serde_json::from_slice::<FsError>(stderr) {
return SandboxedFsError::Operation {
path: path.to_path_buf(),
error,
};
}
let stderr = String::from_utf8_lossy(stderr);
let stdout = String::from_utf8_lossy(stdout);
let message = if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else if !stdout.trim().is_empty() {
stdout.trim().to_string()
} else {
"no error details emitted".to_string()
};
SandboxedFsError::ProcessFailed {
path: path.to_path_buf(),
exit_code,
message,
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum SandboxedFsError {
#[error("failed to determine codex executable: {message}")]
ResolveExe { message: String },
#[error("sandboxed fs helper timed out while accessing `{path}`")]
TimedOut { path: PathBuf },
#[error("sandboxed fs helper exited with code {exit_code} while accessing `{path}`: {message}")]
ProcessFailed {
path: PathBuf,
exit_code: i32,
message: String,
},
#[error("sandboxed fs helper could not access `{path}`: {error}")]
Operation { path: PathBuf, error: FsError },
}
impl SandboxedFsError {
pub(crate) fn operation_error_kind(&self) -> Option<&FsErrorKind> {
match self {
Self::Operation { error, .. } => Some(&error.kind),
_ => None,
}
}
pub(crate) fn operation_error_message(&self) -> Option<&str> {
match self {
Self::Operation { error, .. } => Some(error.message.as_str()),
_ => None,
}
}
#[allow(dead_code)]
pub(crate) fn to_io_error(&self) -> std::io::Error {
match self {
Self::Operation { error, .. } => error.to_io_error(),
Self::TimedOut { .. } => {
std::io::Error::new(std::io::ErrorKind::TimedOut, self.to_string())
}
Self::ResolveExe { .. } | Self::ProcessFailed { .. } => {
std::io::Error::other(self.to_string())
}
}
}
}

View File

@@ -10,9 +10,11 @@ pub(crate) mod macos_permissions;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallOutput;
use crate::exec::ExecToolCallOutputBytes;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::execute_exec_request;
use crate::exec::execute_exec_request_bytes;
use crate::landlock::allow_network_for_proxy;
use crate::landlock::create_linux_sandbox_command_args_for_policies;
use crate::protocol::SandboxPolicy;
@@ -738,6 +740,20 @@ pub async fn execute_env(
.await
}
pub(crate) async fn execute_env_bytes(
exec_request: ExecRequest,
stdout_stream: Option<StdoutStream>,
) -> crate::error::Result<ExecToolCallOutputBytes> {
let effective_policy = exec_request.sandbox_policy.clone();
execute_exec_request_bytes(
exec_request,
&effective_policy,
stdout_stream,
/*after_spawn*/ None,
)
.await
}
pub async fn execute_exec_request_with_after_spawn(
exec_request: ExecRequest,
stdout_stream: Option<StdoutStream>,

View File

@@ -1,5 +1,4 @@
use async_trait::async_trait;
use codex_environment::ExecutorFileSystem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
@@ -13,6 +12,7 @@ use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
use crate::protocol::EventMsg;
use crate::protocol::ViewImageToolCallEvent;
use crate::sandboxed_fs;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -92,36 +92,6 @@ impl ToolHandler for ViewImageHandler {
AbsolutePathBuf::try_from(turn.resolve_path(Some(args.path))).map_err(|error| {
FunctionCallError::RespondToModel(format!("unable to resolve image path: {error}"))
})?;
let metadata = turn
.environment
.get_filesystem()
.get_metadata(&abs_path)
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
"unable to locate image at `{}`: {error}",
abs_path.display()
))
})?;
if !metadata.is_file {
return Err(FunctionCallError::RespondToModel(format!(
"image path `{}` is not a file",
abs_path.display()
)));
}
let file_bytes = turn
.environment
.get_filesystem()
.read_file(&abs_path)
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
"unable to read image at `{}`: {error}",
abs_path.display()
))
})?;
let event_path = abs_path.to_path_buf();
let can_request_original_detail =
@@ -134,10 +104,18 @@ impl ToolHandler for ViewImageHandler {
PromptImageMode::ResizeToFit
};
let image_detail = use_original_detail.then_some(ImageDetail::Original);
let image_bytes = sandboxed_fs::read_file(&session, &turn, abs_path.as_path())
.await
.map_err(|error| {
FunctionCallError::RespondToModel(render_view_image_read_error(
abs_path.as_path(),
&error,
))
})?;
let content = local_image_content_items_with_label_number(
abs_path.as_path(),
file_bytes,
image_bytes,
/*label_number*/ None,
image_mode,
)
@@ -165,3 +143,30 @@ impl ToolHandler for ViewImageHandler {
Ok(FunctionToolOutput::from_content(content, Some(true)))
}
}
fn render_view_image_read_error(
path: &std::path::Path,
error: &sandboxed_fs::SandboxedFsError,
) -> String {
let operation_message = error
.operation_error_message()
.map(str::to_owned)
.unwrap_or_else(|| error.to_string());
match error.operation_error_kind() {
Some(codex_fs_ops::FsErrorKind::IsADirectory) => {
format!("image path `{}` is not a file", path.display())
}
Some(codex_fs_ops::FsErrorKind::NotFound) => {
format!(
"unable to locate image at `{}`: {operation_message}",
path.display()
)
}
Some(_) | None => {
format!(
"unable to read image at `{}`: {operation_message}",
path.display()
)
}
}
}

View File

@@ -3,6 +3,7 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::CodexAuth;
use codex_core::config::Constrained;
use codex_core::features::Feature;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -13,9 +14,15 @@ use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
@@ -1244,6 +1251,109 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_respects_filesystem_sandbox() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let sandbox_policy_for_config = SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: Vec::new(),
},
network_access: false,
};
let mut builder = test_codex().with_config({
let sandbox_policy_for_config = sandbox_policy_for_config.clone();
move |config| {
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
config.permissions.file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Read,
},
]);
}
});
let TestCodex {
codex,
config,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let outside_dir = tempfile::tempdir()?;
let abs_path = outside_dir.path().join("blocked.png");
let image = ImageBuffer::from_pixel(256, 128, Rgba([10u8, 20, 30, 255]));
image.save(&abs_path)?;
let call_id = "view-image-sandbox-denied";
let arguments = serde_json::json!({ "path": abs_path }).to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "view_image", &arguments),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
let second_response = sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]);
let mock = responses::mount_sse_once(&server, second_response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "please attach the outside image".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let request = mock.single_request();
assert!(
request.inputs_of_type("input_image").is_empty(),
"sandbox-denied image should not produce an input_image message"
);
let output_text = request
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("output text present");
let expected_prefix = format!("unable to read image at `{}`:", abs_path.display());
assert!(
output_text.starts_with(&expected_prefix),
"expected sandbox denial prefix `{expected_prefix}` but got `{output_text}`"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "fs-ops",
crate_name = "codex_fs_ops",
)

View File

@@ -0,0 +1,22 @@
[package]
name = "codex-fs-ops"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_fs_ops"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
base64 = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -0,0 +1,38 @@
use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FsCommand {
ReadFile { path: PathBuf },
}
pub fn parse_command_from_args(
mut args: impl Iterator<Item = OsString>,
) -> Result<FsCommand, String> {
let Some(operation) = args.next() else {
return Err("missing operation".to_string());
};
let Some(operation) = operation.to_str() else {
return Err("operation must be valid UTF-8".to_string());
};
let Some(path) = args.next() else {
return Err(format!("missing path for operation `{operation}`"));
};
if args.next().is_some() {
return Err(format!(
"unexpected extra arguments for operation `{operation}`"
));
}
let path = PathBuf::from(path);
match operation {
"read" => Ok(FsCommand::ReadFile { path }),
_ => Err(format!(
"unsupported filesystem operation `{operation}`; expected `read`"
)),
}
}
#[cfg(test)]
#[path = "command_tests.rs"]
mod tests;

View File

@@ -0,0 +1,16 @@
use super::FsCommand;
use super::parse_command_from_args;
use pretty_assertions::assert_eq;
#[test]
fn parse_read_command() {
let command = parse_command_from_args(["read", "/tmp/example.png"].into_iter().map(Into::into))
.expect("command should parse");
assert_eq!(
command,
FsCommand::ReadFile {
path: "/tmp/example.png".into(),
}
);
}

View File

@@ -0,0 +1,3 @@
/// Special argv[1] flag used when the Codex executable self-invokes to run the
/// internal sandbox-backed filesystem helper path.
pub const CODEX_CORE_FS_OPS_ARG1: &str = "--codex-run-as-fs-ops";

View File

@@ -0,0 +1,70 @@
use serde::Deserialize;
use serde::Serialize;
use std::io::ErrorKind;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FsErrorKind {
NotFound,
PermissionDenied,
IsADirectory,
InvalidData,
Other,
}
impl From<ErrorKind> for FsErrorKind {
fn from(value: ErrorKind) -> Self {
match value {
ErrorKind::NotFound => Self::NotFound,
ErrorKind::PermissionDenied => Self::PermissionDenied,
ErrorKind::IsADirectory => Self::IsADirectory,
ErrorKind::InvalidData => Self::InvalidData,
_ => Self::Other,
}
}
}
impl FsErrorKind {
pub fn to_io_error_kind(&self) -> ErrorKind {
match self {
Self::NotFound => ErrorKind::NotFound,
Self::PermissionDenied => ErrorKind::PermissionDenied,
Self::IsADirectory => ErrorKind::IsADirectory,
Self::InvalidData => ErrorKind::InvalidData,
Self::Other => ErrorKind::Other,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsError {
pub kind: FsErrorKind,
pub message: String,
pub raw_os_error: Option<i32>,
}
impl std::fmt::Display for FsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl FsError {
pub fn to_io_error(&self) -> std::io::Error {
if let Some(raw_os_error) = self.raw_os_error {
std::io::Error::from_raw_os_error(raw_os_error)
} else {
std::io::Error::new(self.kind.to_io_error_kind(), self.message.clone())
}
}
}
impl From<std::io::Error> for FsError {
fn from(error: std::io::Error) -> Self {
Self {
kind: error.kind().into(),
message: error.to_string(),
raw_os_error: error.raw_os_error(),
}
}
}

View File

@@ -0,0 +1,13 @@
mod command;
mod constants;
mod error;
mod runner;
pub use command::FsCommand;
pub use command::parse_command_from_args;
pub use constants::CODEX_CORE_FS_OPS_ARG1;
pub use error::FsError;
pub use error::FsErrorKind;
pub use runner::execute;
pub use runner::run_from_args;
pub use runner::write_error;

View File

@@ -0,0 +1,55 @@
use crate::FsCommand;
use crate::FsError;
use crate::parse_command_from_args;
use anyhow::Context;
use anyhow::Result;
use std::ffi::OsString;
use std::io::Read;
use std::io::Write;
pub fn run_from_args(
args: impl Iterator<Item = OsString>,
stdin: &mut impl Read,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> Result<()> {
let command = match parse_command_from_args(args) {
Ok(command) => command,
Err(error) => {
writeln!(stderr, "{error}").context("failed to write fs helper usage error")?;
anyhow::bail!("{error}");
}
};
if let Err(error) = execute(command, stdin, stdout) {
write_error(stderr, &error)?;
anyhow::bail!("{error}");
}
Ok(())
}
pub fn execute(
command: FsCommand,
_stdin: &mut impl Read,
stdout: &mut impl Write,
) -> Result<(), FsError> {
match command {
FsCommand::ReadFile { path } => {
let mut file = std::fs::File::open(path).map_err(FsError::from)?;
std::io::copy(&mut file, stdout)
.map(|_| ())
.map_err(FsError::from)
}
}
}
pub fn write_error(stderr: &mut impl Write, error: &FsError) -> Result<()> {
serde_json::to_writer(&mut *stderr, error).context("failed to serialize fs error")?;
writeln!(stderr).context("failed to terminate fs error with newline")?;
Ok(())
}
#[cfg(test)]
#[path = "runner_tests.rs"]
mod tests;

View File

@@ -0,0 +1,76 @@
use super::execute;
use crate::FsCommand;
use crate::FsErrorKind;
use crate::run_from_args;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn run_from_args_streams_file_bytes_to_stdout() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join("image.bin");
let expected = b"hello\x00world".to_vec();
std::fs::write(&path, &expected).expect("write test file");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut stdin = std::io::empty();
run_from_args(
["read", path.to_str().expect("utf-8 test path")]
.into_iter()
.map(Into::into),
&mut stdin,
&mut stdout,
&mut stderr,
)
.expect("read should succeed");
assert_eq!(stdout, expected);
assert_eq!(stderr, Vec::<u8>::new());
}
#[test]
fn read_reports_directory_error() {
let tempdir = tempdir().expect("tempdir");
let mut stdout = Vec::new();
let mut stdin = std::io::empty();
let error = execute(
FsCommand::ReadFile {
path: tempdir.path().to_path_buf(),
},
&mut stdin,
&mut stdout,
)
.expect_err("reading a directory should fail");
#[cfg(target_os = "windows")]
assert_eq!(error.kind, FsErrorKind::PermissionDenied);
#[cfg(not(target_os = "windows"))]
assert_eq!(error.kind, FsErrorKind::IsADirectory);
}
#[test]
fn run_from_args_serializes_errors_to_stderr() {
let tempdir = tempdir().expect("tempdir");
let missing = tempdir.path().join("missing.txt");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut stdin = std::io::empty();
let result = run_from_args(
["read", missing.to_str().expect("utf-8 test path")]
.into_iter()
.map(Into::into),
&mut stdin,
&mut stdout,
&mut stderr,
);
assert!(result.is_err(), "missing file should fail");
assert_eq!(stdout, Vec::<u8>::new());
let error: crate::FsError = serde_json::from_slice(&stderr).expect("structured fs error");
assert_eq!(error.kind, FsErrorKind::NotFound);
assert!(error.raw_os_error.is_some());
}

View File

@@ -69,6 +69,7 @@ use codex_core::config::types::ApprovalsReviewer;
use codex_core::config::types::ModelAvailabilityNuxConfig;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::features::Feature;
use codex_core::message_history;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
@@ -86,10 +87,10 @@ use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FinalOutput;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::ListSkillsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpAuthStatus;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
@@ -457,6 +458,7 @@ struct ThreadEventSnapshot {
enum ThreadBufferedEvent {
Notification(ServerNotification),
Request(ServerRequest),
HistoryEntryResponse(GetHistoryEntryResponseEvent),
LegacyWarning(String),
LegacyRollback { num_turns: u32 },
}
@@ -616,6 +618,7 @@ impl ThreadEventStore {
.pending_interactive_replay
.should_replay_snapshot_request(request),
ThreadBufferedEvent::Notification(_)
| ThreadBufferedEvent::HistoryEntryResponse(_)
| ThreadBufferedEvent::LegacyWarning(_)
| ThreadBufferedEvent::LegacyRollback { .. } => true,
})
@@ -1763,8 +1766,21 @@ impl App {
return Ok(());
};
self.submit_thread_op(app_server, thread_id, op).await
}
async fn submit_thread_op(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
op: AppCommand,
) -> Result<()> {
crate::session_log::log_outbound_op(&op);
if self.try_handle_local_history_op(thread_id, &op).await? {
return Ok(());
}
if self
.try_resolve_app_server_request(app_server, thread_id, &op)
.await?
@@ -1777,7 +1793,7 @@ impl App {
.await?
{
if ThreadEventStore::op_can_change_pending_replay_state(&op) {
self.note_active_thread_outbound_op(&op).await;
self.note_thread_outbound_op(thread_id, &op).await;
self.refresh_pending_thread_approvals().await;
}
return Ok(());
@@ -1855,6 +1871,66 @@ impl App {
}
}
/// Intercept composer-history operations and handle them locally against
/// `$CODEX_HOME/history.jsonl`, bypassing the app-server RPC layer.
async fn try_handle_local_history_op(
&mut self,
thread_id: ThreadId,
op: &AppCommand,
) -> Result<bool> {
match op.view() {
AppCommandView::Other(Op::AddToHistory { text }) => {
let text = text.clone();
let config = self.chat_widget.config_ref().clone();
tokio::spawn(async move {
if let Err(err) =
message_history::append_entry(&text, &thread_id, &config).await
{
tracing::warn!(
thread_id = %thread_id,
error = %err,
"failed to append to message history"
);
}
});
Ok(true)
}
AppCommandView::Other(Op::GetHistoryEntryRequest { offset, log_id }) => {
let offset = *offset;
let log_id = *log_id;
let config = self.chat_widget.config_ref().clone();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let entry_opt = tokio::task::spawn_blocking(move || {
message_history::lookup(log_id, offset, &config)
})
.await
.unwrap_or_else(|err| {
tracing::warn!(error = %err, "history lookup task failed");
None
});
app_event_tx.send(AppEvent::ThreadHistoryEntryResponse {
thread_id,
event: GetHistoryEntryResponseEvent {
offset,
log_id,
entry: entry_opt.map(|entry| {
codex_protocol::message_history::HistoryEntry {
conversation_id: entry.session_id,
ts: entry.ts,
text: entry.text,
}
}),
},
});
});
Ok(true)
}
_ => Ok(false),
}
}
async fn try_submit_active_thread_op_via_app_server(
&mut self,
app_server: &mut AppServerSession,
@@ -2213,6 +2289,50 @@ impl App {
Ok(())
}
async fn enqueue_thread_history_entry_response(
&mut self,
thread_id: ThreadId,
event: GetHistoryEntryResponseEvent,
) -> Result<()> {
let (sender, store) = {
let channel = self.ensure_thread_channel(thread_id);
(channel.sender.clone(), Arc::clone(&channel.store))
};
let should_send = {
let mut guard = store.lock().await;
guard
.buffer
.push_back(ThreadBufferedEvent::HistoryEntryResponse(event.clone()));
if guard.buffer.len() > guard.capacity
&& let Some(removed) = guard.buffer.pop_front()
&& let ThreadBufferedEvent::Request(request) = &removed
{
guard
.pending_interactive_replay
.note_evicted_server_request(request);
}
guard.active
};
if should_send {
match sender.try_send(ThreadBufferedEvent::HistoryEntryResponse(event)) {
Ok(()) => {}
Err(TrySendError::Full(event)) => {
tokio::spawn(async move {
if let Err(err) = sender.send(event).await {
tracing::warn!("thread {thread_id} event channel closed: {err}");
}
});
}
Err(TrySendError::Closed(_)) => {
tracing::warn!("thread {thread_id} event channel closed");
}
}
}
Ok(())
}
async fn enqueue_thread_legacy_rollback(
&mut self,
thread_id: ThreadId,
@@ -2304,6 +2424,10 @@ impl App {
ThreadBufferedEvent::Request(request) => {
self.enqueue_thread_request(thread_id, request).await?;
}
ThreadBufferedEvent::HistoryEntryResponse(event) => {
self.enqueue_thread_history_entry_response(thread_id, event)
.await?;
}
ThreadBufferedEvent::LegacyWarning(message) => {
self.enqueue_thread_legacy_warning(thread_id, message)
.await?;
@@ -3465,22 +3589,12 @@ impl App {
self.submit_active_thread_op(app_server, op.into()).await?;
}
AppEvent::SubmitThreadOp { thread_id, op } => {
let app_command: AppCommand = op.into();
if self
.try_resolve_app_server_request(app_server, thread_id, &app_command)
.await?
{
return Ok(AppRunControl::Continue);
}
crate::session_log::log_outbound_op(&app_command);
tracing::error!(
thread_id = %thread_id,
op = ?app_command,
"unexpected unresolved thread-scoped app command"
);
self.chat_widget.add_error_message(format!(
"Thread-scoped request is no longer pending for thread {thread_id}."
));
self.submit_thread_op(app_server, thread_id, op.into())
.await?;
}
AppEvent::ThreadHistoryEntryResponse { thread_id, event } => {
self.enqueue_thread_history_entry_response(thread_id, event)
.await?;
}
AppEvent::DiffResult(text) => {
// Clear the in-progress state in the bottom pane
@@ -4639,6 +4753,9 @@ impl App {
self.chat_widget
.handle_server_request(request, /*replay_kind*/ None);
}
ThreadBufferedEvent::HistoryEntryResponse(event) => {
self.chat_widget.handle_history_entry_response(event);
}
ThreadBufferedEvent::LegacyWarning(message) => {
self.chat_widget.add_warning_message(message);
}
@@ -4660,6 +4777,9 @@ impl App {
ThreadBufferedEvent::Request(request) => self
.chat_widget
.handle_server_request(request, Some(ReplayKind::ThreadSnapshot)),
ThreadBufferedEvent::HistoryEntryResponse(event) => {
self.chat_widget.handle_history_entry_response(event)
}
ThreadBufferedEvent::LegacyWarning(message) => {
self.chat_widget.add_warning_message(message);
}
@@ -5520,6 +5640,44 @@ mod tests {
.expect("listener task drop notification should succeed");
}
#[tokio::test]
async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let handled = app
.try_handle_local_history_op(
thread_id,
&Op::GetHistoryEntryRequest {
offset: 0,
log_id: 1,
}
.into(),
)
.await?;
assert!(handled);
let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv())
.await
.expect("history lookup should emit an app event")
.expect("app event channel should stay open");
let AppEvent::ThreadHistoryEntryResponse {
thread_id: routed_thread_id,
event,
} = app_event
else {
panic!("expected thread-routed history response");
};
assert_eq!(routed_thread_id, thread_id);
assert_eq!(event.offset, 0);
assert_eq!(event.log_id, 1);
assert!(event.entry.is_none());
Ok(())
}
#[tokio::test]
async fn enqueue_thread_event_does_not_block_when_channel_full() -> Result<()> {
let mut app = make_test_app().await;

View File

@@ -15,6 +15,7 @@ use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_approval_presets::ApprovalPreset;
@@ -81,6 +82,12 @@ pub(crate) enum AppEvent {
op: Op,
},
/// Deliver a synthetic history lookup response to a specific thread channel.
ThreadHistoryEntryResponse {
thread_id: ThreadId,
event: GetHistoryEntryResponseEvent,
},
/// Start a new session.
NewSession,

View File

@@ -54,6 +54,7 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_core::config::Config;
use codex_core::message_history;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelAvailabilityNux;
@@ -277,7 +278,7 @@ impl AppServerSession {
})
.await
.wrap_err("thread/start failed during TUI bootstrap")?;
started_thread_from_start_response(response)
started_thread_from_start_response(response, config).await
}
pub(crate) async fn resume_thread(
@@ -291,14 +292,14 @@ impl AppServerSession {
.request_typed(ClientRequest::ThreadResume {
request_id,
params: thread_resume_params_from_config(
config,
config.clone(),
thread_id,
self.thread_params_mode(),
),
})
.await
.wrap_err("thread/resume failed during TUI bootstrap")?;
started_thread_from_resume_response(&response)
started_thread_from_resume_response(response, &config).await
}
pub(crate) async fn fork_thread(
@@ -312,14 +313,14 @@ impl AppServerSession {
.request_typed(ClientRequest::ThreadFork {
request_id,
params: thread_fork_params_from_config(
config,
config.clone(),
thread_id,
self.thread_params_mode(),
),
})
.await
.wrap_err("thread/fork failed during TUI bootstrap")?;
started_thread_from_fork_response(&response)
started_thread_from_fork_response(response, &config).await
}
fn thread_params_mode(&self) -> ThreadParamsMode {
@@ -843,10 +844,12 @@ fn thread_cwd_from_config(config: &Config, thread_params_mode: ThreadParamsMode)
}
}
fn started_thread_from_start_response(
async fn started_thread_from_start_response(
response: ThreadStartResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_start_response(&response)
let session = thread_session_state_from_thread_start_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
@@ -854,30 +857,35 @@ fn started_thread_from_start_response(
})
}
fn started_thread_from_resume_response(
response: &ThreadResumeResponse,
async fn started_thread_from_resume_response(
response: ThreadResumeResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_resume_response(response)
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.clone(),
turns: response.thread.turns,
})
}
fn started_thread_from_fork_response(
response: &ThreadForkResponse,
async fn started_thread_from_fork_response(
response: ThreadForkResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_fork_response(response)
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.clone(),
turns: response.thread.turns,
})
}
fn thread_session_state_from_thread_start_response(
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,
@@ -891,11 +899,14 @@ fn thread_session_state_from_thread_start_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
}
fn thread_session_state_from_thread_resume_response(
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,
@@ -909,11 +920,14 @@ fn thread_session_state_from_thread_resume_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
}
fn thread_session_state_from_thread_fork_response(
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,
@@ -927,7 +941,9 @@ fn thread_session_state_from_thread_fork_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
}
fn review_target_to_app_server(
@@ -953,7 +969,7 @@ fn review_target_to_app_server(
clippy::too_many_arguments,
reason = "session mapping keeps explicit fields"
)]
fn thread_session_state_from_thread_response(
async fn thread_session_state_from_thread_response(
thread_id: &str,
thread_name: Option<String>,
rollout_path: Option<PathBuf>,
@@ -965,9 +981,12 @@ fn thread_session_state_from_thread_response(
sandbox_policy: SandboxPolicy,
cwd: PathBuf,
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 (history_log_id, history_entry_count) = message_history::history_metadata(config).await;
let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX);
Ok(ThreadSessionState {
thread_id,
@@ -981,8 +1000,8 @@ fn thread_session_state_from_thread_response(
sandbox_policy,
cwd,
reasoning_effort,
history_log_id: 0,
history_entry_count: 0,
history_log_id,
history_entry_count,
network_proxy: None,
rollout_path,
})
@@ -1084,8 +1103,10 @@ mod tests {
assert_eq!(fork.model_provider, None);
}
#[test]
fn resume_response_restores_turns_from_thread_items() {
#[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 response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
@@ -1135,9 +1156,44 @@ mod tests {
reasoning_effort: None,
};
let started =
started_thread_from_resume_response(&response).expect("resume response should map");
let started = started_thread_from_resume_response(response.clone(), &config)
.await
.expect("resume response should map");
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();
message_history::append_entry("older", &thread_id, &config)
.await
.expect("history append should succeed");
message_history::append_entry("newer", &thread_id, &config)
.await
.expect("history append should succeed");
let session = thread_session_state_from_thread_response(
&thread_id.to_string(),
Some("restore".to_string()),
None,
"gpt-5.4".to_string(),
"openai".to_string(),
None,
AskForApproval::Never,
codex_protocol::config_types::ApprovalsReviewer::User,
SandboxPolicy::new_read_only_policy(),
PathBuf::from("/tmp/project"),
None,
&config,
)
.await
.expect("session should map");
assert_ne!(session.history_log_id, 0);
assert_eq!(session.history_entry_count, 2);
}
}

View File

@@ -740,7 +740,6 @@ impl ChatComposer {
/// composer rehydrates the entry immediately. This path intentionally routes through
/// [`Self::apply_history_entry`] so cursor placement remains aligned with keyboard history
/// recall semantics.
#[cfg(test)]
pub(crate) fn on_history_entry_response(
&mut self,
log_id: u64,

View File

@@ -4,10 +4,9 @@ use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::MentionBinding;
use crate::history_cell;
use crate::mention_codec::decode_history_mentions;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::TextElement;
use tracing::warn;
/// A composer history entry that can rehydrate draft state.
#[derive(Debug, Clone, PartialEq)]
@@ -237,7 +236,6 @@ impl ChatComposerHistory {
}
/// Integrate a GetHistoryEntryResponse event.
#[cfg(test)]
pub fn on_entry_response(
&mut self,
log_id: u64,
@@ -280,16 +278,10 @@ impl ChatComposerHistory {
self.last_history_text = Some(entry.text.clone());
return Some(entry);
} else if let Some(log_id) = self.history_log_id {
warn!(
app_event_tx.send(AppEvent::CodexOp(Op::GetHistoryEntryRequest {
offset: global_idx,
log_id,
offset = global_idx,
"composer history fetch is unavailable in app-server TUI"
);
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(
"Composer history fetch: Not available in app-server TUI yet.".to_string(),
),
)));
}));
}
None
}
@@ -344,17 +336,18 @@ mod tests {
assert!(history.should_handle_navigation("", 0));
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify that the app-server TUI emits an explicit user-facing stub error instead.
// Verify that a history lookup request was sent.
let event = rx.try_recv().expect("expected AppEvent to be sent");
let AppEvent::InsertHistoryCell(cell) = event else {
let AppEvent::CodexOp(op) = event else {
panic!("unexpected event variant");
};
let rendered = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<String>();
assert!(rendered.contains("Composer history fetch: Not available in app-server TUI yet."));
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 2,
},
op
);
// Inject the async response.
assert_eq!(
@@ -365,17 +358,18 @@ mod tests {
// Next Up should move to offset 1.
assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet
// Verify second stub error for offset 1.
// Verify second lookup request for offset 1.
let event2 = rx.try_recv().expect("expected second event");
let AppEvent::InsertHistoryCell(cell) = event2 else {
let AppEvent::CodexOp(op) = event2 else {
panic!("unexpected event variant");
};
let rendered = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<String>();
assert!(rendered.contains("Composer history fetch: Not available in app-server TUI yet."));
assert_eq!(
Op::GetHistoryEntryRequest {
log_id: 1,
offset: 1,
},
op
);
assert_eq!(
Some(HistoryEntry::new("older".to_string())),

View File

@@ -1073,7 +1073,6 @@ impl BottomPane {
|| self.composer.is_in_paste_burst()
}
#[cfg(test)]
pub(crate) fn on_history_entry_response(
&mut self,
log_id: u64,

View File

@@ -46,6 +46,8 @@ use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLinePreviewData;
use crate::bottom_pane::StatusLineSetupView;
use crate::mention_codec::LinkedMention;
use crate::mention_codec::encode_history_mentions;
use crate::model_catalog::ModelCatalog;
use crate::multi_agents;
use crate::status::RateLimitWindowDisplay;
@@ -3474,8 +3476,7 @@ impl ChatWidget {
}
}
#[cfg(test)]
fn on_get_history_entry_response(
pub(crate) fn handle_history_entry_response(
&mut self,
event: codex_protocol::protocol::GetHistoryEntryResponseEvent,
) {
@@ -5316,9 +5317,19 @@ impl ChatWidget {
return;
}
// Persist the text to cross-session message history.
// Persist the text to cross-session message history. Mentions are
// encoded into placeholder syntax so recall can reconstruct the
// mention bindings in a future session.
if !text.is_empty() {
warn!("skipping composer history persistence in app-server TUI");
let encoded_mentions = mention_bindings
.iter()
.map(|binding| LinkedMention {
mention: binding.mention.clone(),
path: binding.path.clone(),
})
.collect::<Vec<_>>();
let history_text = encode_history_mentions(&text, &encoded_mentions);
self.submit_op(Op::AddToHistory { text: history_text });
}
if let Some(pending_steer) = pending_steer {
@@ -6440,7 +6451,7 @@ impl ChatWidget {
EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev),
EventMsg::WebSearchEnd(ev) => self.on_web_search_end(ev),
EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev),
EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev),
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
EventMsg::ListCustomPromptsResponse(_) => {
tracing::warn!(

View File

@@ -59,7 +59,6 @@ pub fn load_for_prompt_bytes(
mode: PromptImageMode,
) -> Result<EncodedImage, ImageProcessingError> {
let path_buf = path.to_path_buf();
let key = ImageCacheKey {
digest: sha1_digest(&file_bytes),
mode,