Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions codex-rs/tui/src/app/history_pagination.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl App {
&cwd,
user_items.iter().map(|(_, item)| item.clone()),
visibility,
Some(self.config.codex_home.as_path()),
Some(&self.config),
);
let mut persisted_user_cells = user_items
.into_iter()
Expand Down Expand Up @@ -204,7 +204,7 @@ impl App {
&cwd,
items,
visibility,
Some(self.config.codex_home.as_path()),
Some(&self.config),
);
if self.backtrack.overlay_preview_active {
self.backtrack.nth_user_message = self.backtrack.nth_user_message.saturating_add(
Expand Down
14 changes: 7 additions & 7 deletions codex-rs/tui/src/app/tests/session_lifecycle_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() ->
&app.config.cwd,
started.turns.iter().flat_map(|turn| turn.items.clone()),
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
Some(&app.config),
);
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
Expand Down Expand Up @@ -575,7 +575,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> {
&app.config.cwd,
started.turns.iter().flat_map(|turn| turn.items.clone()),
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
Some(&app.config),
);
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
Expand Down Expand Up @@ -718,7 +718,7 @@ async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Re
&mut app_server,
paginated_thread_id,
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
Some(&app.config),
vec![Arc::new(PlainHistoryCell::new(vec!["visible".into()]))],
)
.await
Expand Down Expand Up @@ -940,7 +940,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri
&app.config.cwd,
started.turns.iter().flat_map(|turn| turn.items.clone()),
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
Some(&app.config),
);
initial_cells.insert(
/*index*/ 0,
Expand Down Expand Up @@ -1076,7 +1076,7 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> {
&mut app_server,
paginated_thread_id,
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
Some(&app.config),
)
.await?;
assert!(!cells.is_empty());
Expand Down Expand Up @@ -1105,7 +1105,7 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> {
&mut app_server,
legacy_thread_id,
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
Some(&app.config),
)
.await?;
let legacy_reads = recorded_params(&requests, "thread/read");
Expand Down Expand Up @@ -1287,7 +1287,7 @@ async fn cold_paginated_subagent_transcript_excludes_inherited_parent_history()
&mut app_server,
child_thread_id,
crate::thread_transcript::RawReasoningVisibility::Hidden,
Some(app.config.codex_home.as_path()),
Some(&app.config),
)
.await?;
let visible_history = cells
Expand Down
8 changes: 4 additions & 4 deletions codex-rs/tui/src/app/transcript_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use crate::history_cell::ReasoningSummaryCell;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::raw_lines_from_source;
use crate::legacy_core::config::Config;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::thread_items_to_transcript_cells;

Expand All @@ -42,12 +43,11 @@ impl App {
} else {
RawReasoningVisibility::Hidden
};
let codex_home = self.config.codex_home.to_path_buf();
let cells = load_export_transcript(
app_server,
thread_id,
visibility,
Some(codex_home.as_path()),
Some(&self.config),
self.transcript_cells.clone(),
)
.await?;
Expand Down Expand Up @@ -77,7 +77,7 @@ pub(super) async fn load_export_transcript(
app_server: &mut AppServerSession,
thread_id: ThreadId,
visibility: RawReasoningVisibility,
codex_home: Option<&Path>,
config: Option<&Config>,
visible_transcript: Vec<Arc<dyn HistoryCell>>,
) -> Result<Vec<Arc<dyn HistoryCell>>, String> {
let mut thread = app_server
Expand Down Expand Up @@ -123,7 +123,7 @@ pub(super) async fn load_export_transcript(
&thread.cwd,
[item],
visibility,
codex_home,
config,
));
}
}
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/app_server_session/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ fn rendered_history_rows(
&thread.cwd,
items,
visibility,
Some(config.codex_home.as_path()),
Some(config),
)
.into_iter()
.fold(rendered_rows, |rows, cell| {
Expand Down
48 changes: 40 additions & 8 deletions codex-rs/tui/src/inline_visualization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use std::ops::Range;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use url::Url;
use uuid::Uuid;

Expand All @@ -33,9 +35,12 @@ const MAX_FRAGMENT_BYTES: u64 = 2 * 1024 * 1024;
pub(crate) struct InlineVisualizationContext {
visualizations_dir: PathBuf,
thread_dir: PathBuf,
viewer_dir: PathBuf,
materialized_viewers: Arc<Mutex<HashMap<PathBuf, String>>>,
}

impl InlineVisualizationContext {
#[cfg(test)]
pub(crate) fn new(codex_home: &Path, thread_id: ThreadId) -> Option<Self> {
Self::new_with_writable_roots(codex_home, thread_id, std::iter::empty())
}
Expand All @@ -44,22 +49,41 @@ impl InlineVisualizationContext {
config: &crate::legacy_core::config::Config,
thread_id: ThreadId,
) -> Option<Self> {
let writable_roots = config
.permissions
.file_system_sandbox_policy()
.get_writable_roots_with_cwd(config.cwd.as_path());
Self::new_with_writable_roots(
let file_system_policy = config.permissions.file_system_sandbox_policy();
if file_system_policy.has_full_disk_write_access() {
return None;
}
let writable_roots = file_system_policy.get_writable_roots_with_cwd(config.cwd.as_path());
let context = Self::new_with_writable_roots(
config.codex_home.as_path(),
thread_id,
writable_roots.iter().map(|root| root.root.as_path()),
)
)?;
let viewer_caches = [
config.codex_home.as_path().join("visualization-viewers"),
context.viewer_dir.parent()?.parent()?.to_path_buf(),
];
for viewer_cache in viewer_caches {
if file_system_policy.can_write_path_with_cwd(&viewer_cache, config.cwd.as_path())
|| file_system_policy
.can_write_path_with_cwd(viewer_cache.parent()?, config.cwd.as_path())
|| writable_roots.iter().any(|root| {
root.is_path_writable(&viewer_cache)
|| root.root.as_path().starts_with(&viewer_cache)
})
{
return None;
}
}
Some(context)
}

fn new_with_writable_roots<'a>(
codex_home: &Path,
thread_id: ThreadId,
writable_roots: impl IntoIterator<Item = &'a Path>,
) -> Option<Self> {
let codex_home = fs::canonicalize(codex_home).ok()?;
let thread_id = thread_id.to_string();
let uuid = Uuid::parse_str(&thread_id).ok()?;
let timestamp = uuid.get_timestamp()?;
Expand All @@ -74,11 +98,17 @@ impl InlineVisualizationContext {
[thread_dir] => (*thread_dir).to_path_buf(),
_ => visualizations_dir
.join(created_at.format("%Y/%m/%d").to_string())
.join(thread_id),
.join(&thread_id),
};
let artifact_thread_id = thread_dir.file_name()?.to_owned();
Some(Self {
visualizations_dir,
thread_dir,
viewer_dir: codex_home
.join("visualization-viewers")
.join(thread_id)
.join(artifact_thread_id),
materialized_viewers: Arc::default(),
})
}

Expand Down Expand Up @@ -110,7 +140,9 @@ impl InlineVisualizationContext {
if !fragment_path.starts_with(&thread_dir) {
return None;
}
let viewer_path = materialize_document(&fragment_path, &thread_dir).ok()?;
let viewer_path =
materialize_document(&fragment_path, &self.viewer_dir, &self.materialized_viewers)
.ok()?;
Url::from_file_path(viewer_path).ok()
}
}
Expand Down
23 changes: 15 additions & 8 deletions codex-rs/tui/src/inline_visualization/viewer.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use super::MAX_FRAGMENT_BYTES;
use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Mutex;

const FRAGMENT_PLACEHOLDER: &str = "<!--__INLINE_VISUALIZATION_FRAGMENT__-->";
const VIEWER_DIRECTORY_NAME: &str = ".codex-viewers";
Expand All @@ -14,7 +16,11 @@ const VIEWER_RUNTIME: &str = include_str!("assets/visualize.html");
const FRAME_CSP: &str = "default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; style-src 'unsafe-inline' blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; img-src blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; font-src blob: data: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://esm.sh https://fonts.bunny.net https://fonts.googleapis.com https://fonts.gstatic.com https://unpkg.com; media-src blob: data:; worker-src blob:; connect-src blob: data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
const SHELL_STYLE: &str = ":root{color-scheme:light dark;background:light-dark(rgb(255 255 255), rgb(24 24 24))}html,body{margin:0}body{box-sizing:border-box;padding:1rem;background:inherit}iframe{display:block;width:100%;max-width:736px;height:calc(100vh - 2rem);margin:0 auto;border:0}";

pub(super) fn materialize_document(path: &Path, thread_dir: &Path) -> std::io::Result<PathBuf> {
pub(super) fn materialize_document(
path: &Path,
viewer_dir: &Path,
materialized_viewers: &Mutex<HashMap<PathBuf, String>>,
) -> std::io::Result<PathBuf> {
let metadata = path.metadata()?;
if !metadata.is_file() || metadata.len() > MAX_FRAGMENT_BYTES {
return Err(std::io::Error::other("invalid visualization fragment"));
Expand All @@ -28,29 +34,30 @@ pub(super) fn materialize_document(path: &Path, thread_dir: &Path) -> std::io::R
.replace('-', " ");
let document = render_fragment(&fragment, &title);

let thread_dir = fs::canonicalize(thread_dir)?;
let viewer_dir = thread_dir.join(VIEWER_DIRECTORY_NAME);
let viewer_dir = viewer_dir.join(VIEWER_DIRECTORY_NAME);
fs::create_dir_all(&viewer_dir)?;
let viewer_dir = fs::canonicalize(viewer_dir)?;
if !viewer_dir.starts_with(&thread_dir) {
if fs::canonicalize(&viewer_dir)? != viewer_dir {
return Err(std::io::Error::other(
"visualization viewer directory escapes thread directory",
"visualization viewer directory must not contain symbolic links",
));
}
let file_name = path
.file_name()
.ok_or_else(|| std::io::Error::other("visualization fragment has no file name"))?;
let viewer_path = viewer_dir.join(file_name);
if fs::read_to_string(&viewer_path).is_ok_and(|existing| existing == document) {
let mut materialized_viewers = materialized_viewers
.lock()
.map_err(|_| std::io::Error::other("visualization viewer cache is unavailable"))?;
if materialized_viewers.get(&viewer_path) == Some(&document) {
return Ok(viewer_path);
}

let mut temporary = tempfile::NamedTempFile::new_in(&viewer_dir)?;
temporary.write_all(document.as_bytes())?;
temporary.flush()?;
temporary
.persist(&viewer_path)
.map_err(|error| error.error)?;
materialized_viewers.insert(viewer_path.clone(), document);
Ok(viewer_path)
}

Expand Down
25 changes: 25 additions & 0 deletions codex-rs/tui/src/inline_visualization_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,37 @@ fn viewer_reuses_path_and_refreshes_static_document() {
let (_codex_home, context) = context_with_fragment("<div>first</div>");
let first_url = context.link_for("chart.html").expect("first viewer link");
let viewer_path = first_url.to_file_path().expect("viewer file path");
let original_viewer_metadata = fs::metadata(&viewer_path).expect("read viewer metadata");
assert!(
fs::read_to_string(&viewer_path)
.expect("read first viewer")
.contains("first")
);

let reused_url = context.link_for("chart.html").expect("reused viewer link");

assert_eq!(reused_url, first_url);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
assert_eq!(
fs::metadata(&viewer_path)
.expect("read reused viewer metadata")
.ino(),
original_viewer_metadata.ino()
);
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
assert_eq!(
fs::metadata(&viewer_path)
.expect("read reused viewer metadata")
.creation_time(),
original_viewer_metadata.creation_time()
);
}

fs::write(context.thread_dir.join("chart.html"), "<div>second</div>").expect("update fragment");
let second_url = context.link_for("chart.html").expect("second viewer link");

Expand Down
4 changes: 2 additions & 2 deletions codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3164,7 +3164,7 @@ mod tests {
let preview = crate::resume_picker::load_transcript_preview(
&mut app_server,
thread_id,
/*codex_home*/ None,
/*config*/ None,
)
.await?;
assert!(!preview.is_empty());
Expand All @@ -3173,7 +3173,7 @@ mod tests {
&mut app_server,
thread_id,
crate::thread_transcript::RawReasoningVisibility::Hidden,
/*codex_home*/ None,
/*config*/ None,
)
.await?;
assert!(cells.len() > 100);
Expand Down
Loading
Loading