diff --git a/codex-rs/core/src/session_share.rs b/codex-rs/core/src/session_share.rs index 9ffab84c77..4677130078 100644 --- a/codex-rs/core/src/session_share.rs +++ b/codex-rs/core/src/session_share.rs @@ -1,3 +1,6 @@ +use std::ffi::OsStr; +use std::ffi::OsString; +use std::io; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -201,6 +204,7 @@ pub async fn download_rollout_if_available( ) -> anyhow::Result> { let store = SessionObjectStore::new(base_url).await?; let key = object_key(session_id); + let meta_key = meta_key(session_id); let Some(data) = store.get_object_bytes(&key).await? else { return Ok(None); }; @@ -214,6 +218,21 @@ pub async fn download_rollout_if_available( tokio::fs::write(&path, data) .await .with_context(|| format!("failed to write rollout file {}", path.display()))?; + let meta_path = share_meta_path_for_rollout_path(&path); + match fetch_meta(&store, &meta_key).await? { + Some(meta) => { + let payload = + serde_json::to_vec(&meta).with_context(|| "failed to serialize metadata")?; + tokio::fs::write(&meta_path, payload) + .await + .with_context(|| { + format!("failed to write share metadata {}", meta_path.display()) + })?; + } + None => { + let _ = tokio::fs::remove_file(&meta_path).await; + } + } Ok(Some(path)) } @@ -225,6 +244,21 @@ fn meta_key(id: ThreadId) -> String { format!("{SHARE_OBJECT_PREFIX}/{id}{SHARE_META_SUFFIX}") } +pub fn local_share_owner(rollout_path: &Path) -> anyhow::Result> { + let meta_path = share_meta_path_for_rollout_path(rollout_path); + let bytes = match std::fs::read(&meta_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(err) + .with_context(|| format!("failed to read share metadata {}", meta_path.display())); + } + }; + let meta: SessionShareMeta = + serde_json::from_slice(&bytes).with_context(|| "failed to parse session share metadata")?; + Ok(Some(meta.owner)) +} + async fn fetch_meta( store: &SessionObjectStore, key: &str, @@ -264,6 +298,13 @@ fn build_rollout_download_path(codex_home: &Path, session_id: ThreadId) -> anyho Ok(dir.join(filename)) } +fn share_meta_path_for_rollout_path(path: &Path) -> PathBuf { + let file_name = path.file_name().unwrap_or_else(|| OsStr::new("session")); + let mut name = OsString::from(file_name); + name.push(".share-meta.json"); + path.with_file_name(name) +} + impl HttpObjectStore { fn object_url(&self, key: &str) -> anyhow::Result { let mut base = self.base_url.clone(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index d608c62b41..c0ec86d585 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -33,6 +33,7 @@ use codex_core::path_utils; use codex_core::protocol::AskForApproval; use codex_core::read_session_meta_line; use codex_core::session_share::download_rollout_if_available; +use codex_core::session_share::local_share_owner; use codex_core::terminal::Multiplexer; use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_protocol::ThreadId; @@ -697,6 +698,36 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + if let resume_picker::SessionSelection::Resume(path) = &session_selection { + match local_share_owner(path) { + Ok(Some(owner)) => { + let current_owner = auth_manager + .auth_cached() + .and_then(|auth| auth.get_account_email()); + if current_owner.as_deref() != Some(owner.as_str()) { + let session_id = read_session_meta_line(path) + .await + .ok() + .map(|meta| meta.meta.id.to_string()); + let (id_display, fork_hint) = match session_id { + Some(id) => (id.clone(), format!("Use `codex fork {id}` instead.")), + None => ( + "this session".to_string(), + "Use `codex fork` to select it instead.".to_string(), + ), + }; + return fatal_exit(format!( + "Cannot resume shared session {id_display} owned by {owner}. {fork_hint}" + )); + } + } + Ok(None) => {} + Err(err) => { + return fatal_exit(format!("Failed to read shared session metadata: {err}")); + } + } + } + let current_cwd = config.cwd.clone(); let allow_prompt = cli.cwd.is_none(); let action_and_path_if_resume_or_fork = match &session_selection { @@ -836,6 +867,9 @@ pub(crate) async fn resolve_cwd_for_resume_or_fork( let Some(history_cwd) = read_session_cwd(path).await else { return Ok(None); }; + if action == CwdPromptAction::Fork && !history_cwd.exists() { + return Ok(Some(current_cwd.to_path_buf())); + } if allow_prompt && cwds_differ(current_cwd, &history_cwd) { let selection = cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?;