Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
27ab54ec79 thread-store: store permission profiles 2026-05-17 08:55:35 -07:00
10 changed files with 249 additions and 32 deletions

View File

@@ -2075,6 +2075,7 @@ mod tests {
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -2090,7 +2091,6 @@ mod tests {
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
@@ -2166,7 +2166,7 @@ mod tests {
agent_path: None,
git_info: None,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some("before rollback".to_string()),
history: Some(StoredThreadHistory {

View File

@@ -65,13 +65,13 @@ mod thread_processor_behavior_tests {
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_state::ThreadMetadataBuilder;
@@ -407,7 +407,7 @@ mod thread_processor_behavior_tests {
agent_path: None,
git_info: None,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some("first user message".to_string()),
history: None,

View File

@@ -13,10 +13,10 @@ use chrono::Utc;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_thread_store::StoredThread;
use core_test_support::PathBufExt;
@@ -59,7 +59,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
repository_url: None,
}),
approval_mode: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: Some(first_user_message.to_string()),
history: None,

View File

@@ -76,7 +76,8 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem)
}
metadata.model = Some(turn_ctx.model.clone());
metadata.reasoning_effort = turn_ctx.effort;
metadata.sandbox_policy = enum_to_string(&turn_ctx.sandbox_policy);
metadata.sandbox_policy =
serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default();
metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy);
}
@@ -158,6 +159,7 @@ mod tests {
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
@@ -365,11 +367,50 @@ mod tests {
assert_eq!(metadata.cwd, PathBuf::from("/child/worktree"));
assert_eq!(
metadata.sandbox_policy,
super::enum_to_string(&SandboxPolicy::DangerFullAccess)
serde_json::to_string(&PermissionProfile::Disabled)
.expect("serialize permission profile")
);
assert_eq!(metadata.approval_mode, "never");
}
#[test]
fn turn_context_sets_permission_profile_metadata() {
let mut metadata = metadata_for_test();
let permission_profile = PermissionProfile::workspace_write();
apply_rollout_item(
&mut metadata,
&RolloutItem::TurnContext(TurnContextItem {
turn_id: Some("turn-1".to_string()),
trace_id: None,
cwd: PathBuf::from("/workspace"),
current_date: None,
timezone: None,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: Some(permission_profile.clone()),
network: None,
file_system_sandbox_policy: None,
model: "gpt-5".to_string(),
personality: None,
collaboration_mode: None,
realtime_active: None,
effort: None,
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
}),
"test-provider",
);
assert_eq!(
metadata.sandbox_policy,
serde_json::to_string(&permission_profile).expect("serialize permission profile")
);
}
#[test]
fn turn_context_sets_cwd_when_session_cwd_missing() {
let mut metadata = metadata_for_test();

View File

@@ -8,9 +8,9 @@ use std::sync::OnceLock;
use async_trait::async_trait;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
@@ -363,9 +363,9 @@ fn stored_thread_from_state(
approval_mode: metadata
.and_then(|metadata| metadata.approval_mode)
.unwrap_or(AskForApproval::Never),
sandbox_policy: metadata
.and_then(|metadata| metadata.sandbox_policy.clone())
.unwrap_or_else(SandboxPolicy::new_read_only_policy),
permission_profile: metadata
.and_then(|metadata| metadata.permission_profile.clone())
.unwrap_or_else(PermissionProfile::read_only),
token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()),
first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()),
history,

View File

@@ -9,8 +9,10 @@ use chrono::DateTime;
use chrono::Utc;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
@@ -140,13 +142,34 @@ pub(super) fn stored_thread_from_rollout_item(
agent_path: None,
git_info,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: item.first_user_message,
history: None,
})
}
pub(super) fn permission_profile_from_metadata_value(value: &str, cwd: &Path) -> PermissionProfile {
serde_json::from_str::<PermissionProfile>(value)
.or_else(|_| {
parse_legacy_sandbox_policy(value)
.map(|policy| PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd))
})
.unwrap_or_else(|_| PermissionProfile::read_only())
}
pub(super) fn permission_profile_to_metadata_value(
permission_profile: &PermissionProfile,
) -> String {
match serde_json::to_string(permission_profile) {
Ok(value) => value,
Err(err) => {
tracing::warn!("failed to serialize permission profile metadata: {err}");
String::new()
}
}
}
pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
let title = metadata.title.trim();
if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
@@ -169,6 +192,20 @@ fn parse_rfc3339(value: Option<&str>) -> Option<DateTime<Utc>> {
.map(|dt| dt.with_timezone(&Utc))
}
fn parse_legacy_sandbox_policy(value: &str) -> serde_json::Result<SandboxPolicy> {
serde_json::from_str(value)
.or_else(|_| serde_json::from_value(serde_json::Value::String(value.to_string())))
.or_else(|_| match value {
"danger-full-access" => Ok(SandboxPolicy::DangerFullAccess),
"read-only" => Ok(SandboxPolicy::new_read_only_policy()),
"workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()),
"external-sandbox" => Ok(SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
}),
_ => serde_json::from_value(serde_json::Value::String(value.to_string())),
})
}
pub(super) fn git_info_from_parts(
sha: Option<String>,
branch: Option<String>,

View File

@@ -1,7 +1,7 @@
use chrono::DateTime;
use chrono::Utc;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_rollout::RolloutRecorder;
@@ -15,6 +15,7 @@ use codex_state::ThreadMetadata;
use super::LocalThreadStore;
use super::helpers::distinct_thread_metadata_title;
use super::helpers::git_info_from_parts;
use super::helpers::permission_profile_from_metadata_value;
use super::helpers::rollout_path_is_archived;
use super::helpers::set_thread_name_from_title;
use super::helpers::stored_thread_from_rollout_item;
@@ -45,6 +46,7 @@ pub(super) async fn read_thread(
)
.await)
{
let metadata_sandbox_policy = metadata.sandbox_policy.clone();
let mut thread = stored_thread_from_sqlite_metadata(store, metadata).await;
if !params.include_history
&& let Some(rollout_path) = thread.rollout_path.clone()
@@ -57,6 +59,10 @@ pub(super) async fn read_thread(
rollout_thread.name = thread.name;
}
rollout_thread.git_info = thread.git_info;
rollout_thread.permission_profile = permission_profile_from_metadata_value(
&metadata_sandbox_policy,
rollout_thread.cwd.as_path(),
);
thread = rollout_thread;
}
attach_history_if_requested(&mut thread, params.include_history).await?;
@@ -286,6 +292,8 @@ async fn stored_thread_from_sqlite_metadata(
.clone()
.or_else(|| metadata.first_user_message.clone())
.unwrap_or_default();
let permission_profile =
permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path());
StoredThread {
thread_id: metadata.id,
rollout_path: Some(metadata.rollout_path),
@@ -315,10 +323,7 @@ async fn stored_thread_from_sqlite_metadata(
metadata.git_origin_url,
),
approval_mode: parse_or_default(&metadata.approval_mode, AskForApproval::OnRequest),
sandbox_policy: parse_or_default(
&metadata.sandbox_policy,
SandboxPolicy::new_read_only_policy(),
),
permission_profile,
token_usage: None,
first_user_message: metadata.first_user_message,
history: None,
@@ -377,7 +382,7 @@ fn stored_thread_from_meta_line(
agent_path: meta_line.meta.agent_path,
git_info: meta_line.git,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
permission_profile: PermissionProfile::read_only(),
token_usage: None,
first_user_message: None,
history: None,
@@ -412,6 +417,7 @@ mod tests {
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_state::ThreadMetadataBuilder;
use pretty_assertions::assert_eq;
@@ -671,6 +677,84 @@ mod tests {
assert_eq!(thread.name, Some("Saved title".to_string()));
}
#[tokio::test]
async fn read_thread_returns_permission_profile_from_sqlite_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(225);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.sandbox_policy =
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile");
runtime
.upsert_thread(&metadata)
.await
.expect("state db upsert should succeed");
let thread = store
.read_thread(ReadThreadParams {
thread_id,
include_archived: false,
include_history: false,
})
.await
.expect("read thread");
assert_eq!(thread.preview, "Hello from user");
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
}
#[tokio::test]
async fn read_thread_accepts_legacy_sandbox_policy_metadata() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let uuid = Uuid::from_u128(226);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let rollout_path =
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.sandbox_policy = "danger-full-access".to_string();
runtime
.upsert_thread(&metadata)
.await
.expect("state db upsert should succeed");
let thread = store
.read_thread(ReadThreadParams {
thread_id,
include_archived: false,
include_history: true,
})
.await
.expect("read thread");
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
}
#[tokio::test]
async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() {
let home = TempDir::new().expect("temp dir");
@@ -725,6 +809,7 @@ mod tests {
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.title = "Saved title".to_string();
metadata.first_user_message = Some("Hello from sqlite".to_string());
metadata.sandbox_policy = "workspace-write".to_string();
runtime
.upsert_thread(&metadata)
.await
@@ -745,6 +830,19 @@ mod tests {
assert_eq!(thread.name, Some("Saved title".to_string()));
assert_eq!(thread.model_provider, "rollout-provider");
assert_eq!(thread.cwd, rollout_cwd);
let legacy_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
assert_eq!(
thread.permission_profile,
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
&legacy_policy,
rollout_cwd.as_path()
)
);
}
#[tokio::test]

View File

@@ -18,6 +18,7 @@ use tracing::warn;
use super::LocalThreadStore;
use super::helpers::git_info_from_parts;
use super::helpers::permission_profile_to_metadata_value;
use super::live_writer;
use crate::GitInfoPatch;
use crate::ReadThreadParams;
@@ -275,8 +276,8 @@ async fn apply_metadata_update(
if let Some(approval_mode) = patch.approval_mode {
metadata.approval_mode = enum_to_string(&approval_mode);
}
if let Some(sandbox_policy) = patch.sandbox_policy {
metadata.sandbox_policy = enum_to_string(&sandbox_policy);
if let Some(permission_profile) = patch.permission_profile {
metadata.sandbox_policy = permission_profile_to_metadata_value(&permission_profile);
}
if let Some(token_usage) = patch.token_usage {
metadata.tokens_used = token_usage.total_tokens.max(0);
@@ -390,7 +391,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
|| patch.cwd.is_some()
|| patch.cli_version.is_some()
|| patch.approval_mode.is_some()
|| patch.sandbox_policy.is_some()
|| patch.permission_profile.is_some()
|| patch.token_usage.is_some()
|| patch.first_user_message.is_some()
|| patch.dynamic_tools.is_some()
@@ -613,6 +614,7 @@ fn rollout_path_is_archived(store: &LocalThreadStore, path: &Path) -> bool {
#[cfg(test)]
mod tests {
use codex_protocol::models::PermissionProfile;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
@@ -854,6 +856,45 @@ mod tests {
);
}
#[tokio::test]
async fn update_thread_metadata_sets_permission_profile() {
let home = TempDir::new().expect("temp dir");
let config = test_config(home.path());
let runtime = codex_state::StateRuntime::init(
config.sqlite_home.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime.clone()));
let uuid = Uuid::from_u128(317);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
write_session_file(home.path(), "2025-01-03T20-30-00", uuid).expect("session file");
let thread = store
.update_thread_metadata(UpdateThreadMetadataParams {
thread_id,
patch: ThreadMetadataPatch {
permission_profile: Some(PermissionProfile::Disabled),
..Default::default()
},
include_archived: false,
})
.await
.expect("set permission profile");
assert_eq!(thread.permission_profile, PermissionProfile::Disabled);
let metadata = runtime
.get_thread(thread_id)
.await
.expect("sqlite metadata read")
.expect("sqlite metadata");
assert_eq!(
metadata.sandbox_policy,
serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile")
);
}
#[tokio::test]
async fn update_thread_metadata_partially_updates_git_info() {
let home = TempDir::new().expect("temp dir");

View File

@@ -240,7 +240,7 @@ impl ThreadMetadataSync {
update.model = Some(turn_ctx.model.clone());
update.reasoning_effort = turn_ctx.effort;
update.approval_mode = Some(turn_ctx.approval_policy);
update.sandbox_policy = Some(turn_ctx.sandbox_policy.clone());
update.permission_profile = Some(turn_ctx.permission_profile());
}
RolloutItem::EventMsg(EventMsg::UserMessage(user)) => {
if let Some(preview) = user_message_preview(user) {
@@ -360,7 +360,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool {
|| update.cwd.is_some()
|| update.cli_version.is_some()
|| update.approval_mode.is_some()
|| update.sandbox_policy.is_some()
|| update.permission_profile.is_some()
|| update.token_usage.is_some()
|| update.first_user_message.is_some()
|| update.git_info.is_some()

View File

@@ -5,11 +5,11 @@ use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode as MemoryMode;
use codex_protocol::protocol::ThreadSource;
@@ -362,8 +362,8 @@ pub struct StoredThread {
pub git_info: Option<GitInfo>,
/// Approval mode captured for the thread.
pub approval_mode: AskForApproval,
/// Sandbox policy captured for the thread.
pub sandbox_policy: SandboxPolicy,
/// Canonical runtime permissions captured for the thread.
pub permission_profile: PermissionProfile,
/// Last observed token usage.
pub token_usage: Option<TokenUsage>,
/// First user message observed for this thread, if any.
@@ -485,8 +485,8 @@ pub struct ThreadMetadataPatch {
pub cli_version: Option<String>,
/// Approval mode.
pub approval_mode: Option<AskForApproval>,
/// Sandbox policy.
pub sandbox_policy: Option<SandboxPolicy>,
/// Canonical runtime permissions.
pub permission_profile: Option<PermissionProfile>,
/// Last observed token usage.
pub token_usage: Option<TokenUsage>,
/// First user message observed for this thread.
@@ -557,8 +557,8 @@ impl ThreadMetadataPatch {
if next.approval_mode.is_some() {
self.approval_mode = next.approval_mode;
}
if next.sandbox_policy.is_some() {
self.sandbox_policy = next.sandbox_policy;
if next.permission_profile.is_some() {
self.permission_profile = next.permission_profile;
}
if next.token_usage.is_some() {
self.token_usage = next.token_usage;
@@ -597,7 +597,7 @@ impl ThreadMetadataPatch {
&& self.cwd.is_none()
&& self.cli_version.is_none()
&& self.approval_mode.is_none()
&& self.sandbox_policy.is_none()
&& self.permission_profile.is_none()
&& self.token_usage.is_none()
&& self.first_user_message.is_none()
&& self.git_info.is_none()