Skip to content

Commit f72ab43

Browse files
authored
feat: memories in workspace write (openai#13467)
1 parent df61947 commit f72ab43

File tree

6 files changed

+165
-4
lines changed

6 files changed

+165
-4
lines changed

codex-rs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ codex --sandbox danger-full-access
8888
```
8989

9090
The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`.
91+
In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval.
9192

9293
## Code Organization
9394

codex-rs/core/src/codex.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4454,11 +4454,9 @@ mod handlers {
44544454
}
44554455

44564456
let memory_root = crate::memories::memory_root(&config.codex_home);
4457-
if let Err(err) = tokio::fs::remove_dir_all(&memory_root).await
4458-
&& err.kind() != std::io::ErrorKind::NotFound
4459-
{
4457+
if let Err(err) = crate::memories::clear_memory_root_contents(&memory_root).await {
44604458
errors.push(format!(
4461-
"failed removing memory directory {}: {err}",
4459+
"failed clearing memory directory {}: {err}",
44624460
memory_root.display()
44634461
));
44644462
}

codex-rs/core/src/config/mod.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use crate::features::FeatureOverrides;
4040
use crate::features::Features;
4141
use crate::features::FeaturesToml;
4242
use crate::git_info::resolve_root_git_project_for_trust;
43+
use crate::memories::memory_root;
4344
use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
4445
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
4546
use crate::model_provider_info::ModelProviderInfo;
@@ -1805,6 +1806,15 @@ impl Config {
18051806
Some(&constrained_sandbox_policy),
18061807
);
18071808
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
1809+
let memories_root = memory_root(&codex_home);
1810+
std::fs::create_dir_all(&memories_root)?;
1811+
let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?;
1812+
if !writable_roots
1813+
.iter()
1814+
.any(|existing| existing == &memories_root)
1815+
{
1816+
writable_roots.push(memories_root);
1817+
}
18081818
for path in additional_writable_roots {
18091819
if !writable_roots.iter().any(|existing| existing == &path) {
18101820
writable_roots.push(path);
@@ -3156,6 +3166,56 @@ trust_level = "trusted"
31563166
Ok(())
31573167
}
31583168

3169+
#[test]
3170+
fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> {
3171+
let codex_home = TempDir::new()?;
3172+
let memories_root = codex_home.path().join("memories");
3173+
let config = Config::load_from_base_config_with_overrides(
3174+
ConfigToml {
3175+
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
3176+
writable_roots: vec![AbsolutePathBuf::from_absolute_path(&memories_root)?],
3177+
..Default::default()
3178+
}),
3179+
..Default::default()
3180+
},
3181+
ConfigOverrides {
3182+
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
3183+
..Default::default()
3184+
},
3185+
codex_home.path().to_path_buf(),
3186+
)?;
3187+
3188+
if cfg!(target_os = "windows") {
3189+
match config.permissions.sandbox_policy.get() {
3190+
SandboxPolicy::ReadOnly { .. } => {}
3191+
other => panic!("expected read-only policy on Windows, got {other:?}"),
3192+
}
3193+
} else {
3194+
assert!(
3195+
memories_root.is_dir(),
3196+
"expected memories root directory to exist at {}",
3197+
memories_root.display()
3198+
);
3199+
let expected_memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?;
3200+
match config.permissions.sandbox_policy.get() {
3201+
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
3202+
assert_eq!(
3203+
writable_roots
3204+
.iter()
3205+
.filter(|root| **root == expected_memories_root)
3206+
.count(),
3207+
1,
3208+
"expected single writable root entry for {}",
3209+
expected_memories_root.display()
3210+
);
3211+
}
3212+
other => panic!("expected workspace-write policy, got {other:?}"),
3213+
}
3214+
}
3215+
3216+
Ok(())
3217+
}
3218+
31593219
#[test]
31603220
fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
31613221
let codex_home = TempDir::new()?;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
use std::path::Path;
2+
3+
pub(crate) async fn clear_memory_root_contents(memory_root: &Path) -> std::io::Result<()> {
4+
match tokio::fs::symlink_metadata(memory_root).await {
5+
Ok(metadata) if metadata.file_type().is_symlink() => {
6+
return Err(std::io::Error::new(
7+
std::io::ErrorKind::InvalidInput,
8+
format!(
9+
"refusing to clear symlinked memory root {}",
10+
memory_root.display()
11+
),
12+
));
13+
}
14+
Ok(_) => {}
15+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
16+
Err(err) => return Err(err),
17+
}
18+
19+
tokio::fs::create_dir_all(memory_root).await?;
20+
21+
let mut entries = tokio::fs::read_dir(memory_root).await?;
22+
while let Some(entry) = entries.next_entry().await? {
23+
let path = entry.path();
24+
let file_type = entry.file_type().await?;
25+
if file_type.is_dir() {
26+
tokio::fs::remove_dir_all(path).await?;
27+
} else {
28+
tokio::fs::remove_file(path).await?;
29+
}
30+
}
31+
32+
Ok(())
33+
}

codex-rs/core/src/memories/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
//! - Phase 2: claim a global consolidation lock, materialize consolidation inputs, and dispatch one consolidation agent.
66
77
pub(crate) mod citations;
8+
mod control;
89
mod phase1;
910
mod phase2;
1011
pub(crate) mod prompts;
@@ -16,6 +17,7 @@ pub(crate) mod usage;
1617

1718
use codex_protocol::openai_models::ReasoningEffort;
1819

20+
pub(crate) use control::clear_memory_root_contents;
1921
/// Starts the memory startup pipeline for eligible root sessions.
2022
/// This is the single entrypoint that `codex` uses to trigger memory startup.
2123
///

codex-rs/core/src/memories/tests.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use super::storage::rebuild_raw_memories_file_from_memories;
22
use super::storage::sync_rollout_summaries_from_memories;
33
use crate::config::types::DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION;
4+
use crate::memories::clear_memory_root_contents;
45
use crate::memories::ensure_layout;
56
use crate::memories::memory_root;
67
use crate::memories::raw_memories_file;
@@ -63,6 +64,72 @@ fn stage_one_output_schema_requires_rollout_slug_and_keeps_it_nullable() {
6364
assert_eq!(rollout_slug_types, vec!["null", "string"]);
6465
}
6566

67+
#[tokio::test]
68+
async fn clear_memory_root_contents_preserves_root_directory() {
69+
let dir = tempdir().expect("tempdir");
70+
let root = dir.path().join("memory");
71+
let nested_dir = root.join("rollout_summaries");
72+
tokio::fs::create_dir_all(&nested_dir)
73+
.await
74+
.expect("create rollout summaries dir");
75+
tokio::fs::write(root.join("MEMORY.md"), "stale memory index\n")
76+
.await
77+
.expect("write memory index");
78+
tokio::fs::write(nested_dir.join("rollout.md"), "stale rollout\n")
79+
.await
80+
.expect("write rollout summary");
81+
82+
clear_memory_root_contents(&root)
83+
.await
84+
.expect("clear memory root contents");
85+
86+
assert!(
87+
tokio::fs::try_exists(&root)
88+
.await
89+
.expect("check memory root existence"),
90+
"memory root should still exist after clearing contents"
91+
);
92+
let mut entries = tokio::fs::read_dir(&root)
93+
.await
94+
.expect("read memory root after clear");
95+
assert!(
96+
entries
97+
.next_entry()
98+
.await
99+
.expect("read next entry")
100+
.is_none(),
101+
"memory root should be empty after clearing contents"
102+
);
103+
}
104+
105+
#[cfg(unix)]
106+
#[tokio::test]
107+
async fn clear_memory_root_contents_rejects_symlinked_root() {
108+
let dir = tempdir().expect("tempdir");
109+
let target = dir.path().join("outside");
110+
tokio::fs::create_dir_all(&target)
111+
.await
112+
.expect("create symlink target dir");
113+
let target_file = target.join("keep.txt");
114+
tokio::fs::write(&target_file, "keep\n")
115+
.await
116+
.expect("write target file");
117+
118+
let root = dir.path().join("memory");
119+
std::os::unix::fs::symlink(&target, &root).expect("create memory root symlink");
120+
121+
let err = clear_memory_root_contents(&root)
122+
.await
123+
.expect_err("symlinked memory root should be rejected");
124+
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
125+
assert!(
126+
tokio::fs::try_exists(&target_file)
127+
.await
128+
.expect("check target file existence"),
129+
"rejecting a symlinked memory root should not delete the symlink target"
130+
);
131+
}
132+
66133
#[tokio::test]
67134
async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only() {
68135
let dir = tempdir().expect("tempdir");

0 commit comments

Comments
 (0)