Add a feature-gated account check for CLI thread resume (#58890)

## What changed

Add the disabled-by-default `cli_resume_account_check` feature. When enabled for `codex-tui` and `codex_exec`, reject resuming or attaching to a local thread if its recorded creator user or workspace differs from the current ChatGPT account. Return an error directing the user to sign in with the original account.

Carry creator IDs through stored thread metadata and check them before loading resume history. Preserve existing behavior for untagged threads, other clients, and authentication modes without a comparable ChatGPT account.

## Testing

Add integration coverage for account and workspace mismatches, matching accounts, legacy and paginated history, ID and path lookup, and the feature's default and bypass behavior. Verify that switching back to the original account permits attachment to a loaded thread and that creator metadata remains unchanged.

GitOrigin-RevId: a3ab089254703417dde0890a5f8d6326c4cbb75a
This commit is contained in:
jif
2026-11-02 13:51:49 +00:00
committed by copyberry
parent 020e0f7708
commit 1e8b536dc7
13 changed files with 394 additions and 27 deletions
@@ -6,6 +6,9 @@ mod daemon_snapshot;
mod workspace_switching;
#[path = "thread_resume_account.rs"]
mod thread_resume_account;
use super::persisted_resume_settings::PersistedResumeSettings;
use super::persisted_resume_settings::latest_owned_thread_settings;
use super::persisted_resume_settings::latest_persisted_model_settings;
@@ -4012,37 +4015,37 @@ impl ThreadRequestProcessor {
let prepared_model_context = prepared_config
.as_mut()
.and_then(|prepared| prepared.model_context.take());
let resume_result = if let Some(history) = history {
self.resume_thread_from_history(history.as_slice())
.await
.map(|thread_history| (thread_history, None))
} else if let Some(stored_thread) = stored_thread_from_running_probe {
self.load_resume_initial_history_from_stored_thread(
*stored_thread,
prepared_model_context,
let (thread_history, resume_source_thread) = if let Some(history) = history {
(
self.resume_thread_from_history(history.as_slice()).await?,
None,
)
.await
.map(|(thread_history, stored_thread)| (thread_history, Some(stored_thread)))
} else {
match self
.read_stored_thread_for_resume(
&thread_id,
path.as_ref(),
/*include_history*/ false,
)
.await
{
Ok(stored_thread) => self
.load_resume_initial_history_from_stored_thread(
stored_thread,
prepared_model_context,
let stored_thread = match stored_thread_from_running_probe {
Some(stored_thread) => *stored_thread,
None => {
self.read_stored_thread_for_resume(
&thread_id,
path.as_ref(),
/*include_history*/ false,
)
.await
.map(|(thread_history, stored_thread)| (thread_history, Some(stored_thread))),
Err(error) => Err(error),
}
.await?
}
};
thread_resume_account::check(
&self.config.features,
app_server_client_name.as_deref(),
&self.auth_manager,
&stored_thread,
)?;
let (thread_history, stored_thread) = self
.load_resume_initial_history_from_stored_thread(
stored_thread,
prepared_model_context,
)
.await?;
(thread_history, Some(stored_thread))
};
let (thread_history, resume_source_thread) = resume_result?;
// Path-based resume can use an empty request thread ID. Coordinate once its real
// identity is known; unrelated loaded threads never wait for this cold startup.
let _goal_resume_guard = if let InitialHistory::Resumed(resumed) = &thread_history {
@@ -4684,6 +4687,12 @@ impl ThreadRequestProcessor {
};
if let Some((existing_thread_id, existing_thread, mut source_thread)) = running_thread {
thread_resume_account::check(
&self.config.features,
app_server_client_name.as_deref(),
&self.auth_manager,
&source_thread,
)?;
let paginated_resume =
matches!(source_thread.history_mode, ThreadHistoryMode::Paginated);
let existing_thread_rollout_path = existing_thread.rollout_path();
@@ -502,6 +502,8 @@ mod thread_processor_behavior_tests {
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread");
let stored_thread = StoredThread {
creator_user_id: None,
creator_account_id: None,
originator: None,
thread_id,
extra_config: None,
@@ -0,0 +1,46 @@
//! Applies the rollout-gated CLI account check before resuming or attaching to a local thread.
//! Creator tags are immutable; untagged threads and other clients retain existing behavior.
use crate::error_code::invalid_request;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_features::Feature;
use codex_features::Features;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_thread_store::StoredThread;
pub(super) fn check(
features: &Features,
client_name: Option<&str>,
auth_manager: &AuthManager,
thread: &StoredThread,
) -> Result<(), JSONRPCErrorError> {
if !features.enabled(Feature::CliResumeAccountCheck)
|| !matches!(client_name, Some("codex-tui" | "codex_exec"))
|| thread.rollout_path.is_none()
|| (thread.creator_user_id.is_none() && thread.creator_account_id.is_none())
{
return Ok(());
}
let Some(auth) = auth_manager
.auth_cached()
.filter(CodexAuth::is_chatgpt_auth)
else {
// API keys and other auth modes do not identify a comparable ChatGPT account.
return Ok(());
};
if thread
.creator_user_id
.as_ref()
.is_some_and(|creator| auth.get_chatgpt_user_id().as_ref() != Some(creator))
|| thread
.creator_account_id
.as_ref()
.is_some_and(|creator| auth.get_account_id().as_ref() != Some(creator))
{
return Err(invalid_request(
"This thread was created with a different ChatGPT account or workspace. Sign in with the original account to resume it.",
));
}
Ok(())
}