1use std::fs;
5use std::io::{self, BufRead, BufReader, Read, Write};
6use std::os::fd::OwnedFd;
7use std::os::unix::net::UnixStream;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::time::{Duration, Instant};
11
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14use thiserror::Error;
15
16use super::MemoryRequest;
17use super::locations::{Locations, claude_key};
18use crate::harness::{HarnessId, LocatedHarness, LoginPath, locate_in};
19use crate::process::CommandChild;
20
21const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
22const IMPORT_TIMEOUT: Duration = Duration::from_secs(45);
23const MAX_PROTOCOL_BYTES: u64 = 8 * 1024 * 1024;
25
26#[derive(Clone, Debug, Deserialize)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct NativeImportRequest {
29 pub folder: PathBuf,
30 pub action: NativeImportAction,
31}
32
33#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "camelCase")]
35pub enum NativeImportAction {
36 Status,
37 Import,
38 RemoveImported,
39}
40
41#[derive(Clone, Debug, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct NativeImportSnapshot {
44 pub folder: PathBuf,
45 pub claude_path: PathBuf,
46 pub codex_path: PathBuf,
47 pub source_count: usize,
48 pub can_import: bool,
49 pub status: String,
50 pub message: String,
51 pub import_id: Option<String>,
52}
53
54#[derive(Debug, Error)]
55pub enum NativeImportError {
56 #[error("Enable cross-agent memory consolidation in Settings > Feature flags first.")]
57 Disabled,
58 #[error("Native memory import requires an existing absolute folder path.")]
59 InvalidFolder,
60 #[error("Codex returned an unsupported native memory response.")]
61 InvalidResponse,
62 #[error(
63 "Codex could not complete the native memory request. Its import API may be unavailable."
64 )]
65 NativeRequest,
66 #[error(
67 "Codex could not start the native memory importer. The installed CLI must support --enable external_agent_memory_import and app-server."
68 )]
69 ImporterUnavailable,
70 #[error(transparent)]
71 Io(#[from] io::Error),
72}
73
74pub fn execute(request: &NativeImportRequest) -> Result<NativeImportSnapshot, NativeImportError> {
79 if !crate::config::Config::load_user()
80 .feature_flags
81 .cross_agent_memory_consolidation
82 {
83 return Err(NativeImportError::Disabled);
84 }
85 if !request.folder.is_absolute() || !request.folder.is_dir() {
86 return Err(NativeImportError::InvalidFolder);
87 }
88 let folder = request.folder.canonicalize()?;
89 let locations = Locations::live(&MemoryRequest {
90 folder: Some(folder.clone()),
91 ..MemoryRequest::default()
92 });
93 let mut snapshot = snapshot(&folder, &locations)?;
94 if !supported_source(&locations, &mut snapshot)
95 || (snapshot.source_count == 0
96 && !imported_scope_matches(&snapshot.codex_path, &snapshot.folder)?)
97 {
98 return Ok(snapshot);
99 }
100 let shell = std::env::var_os("SHELL").map_or_else(|| PathBuf::from("/bin/zsh"), PathBuf::from);
101 let login = LoginPath::spawn(shell).get();
102 let located = match locate_in(
103 HarnessId::Codex.definition(),
104 login.as_deref(),
105 std::env::var_os("PATH").as_deref(),
106 ) {
107 Ok(located) => located,
108 Err(error) => {
109 snapshot.message = error.to_string();
110 return Ok(snapshot);
111 }
112 };
113 execute_located(request.action, snapshot, &located)
114}
115
116fn snapshot(
117 folder: &Path,
118 locations: &Locations,
119) -> Result<NativeImportSnapshot, NativeImportError> {
120 let key = claude_key(folder);
121 let claude_path = locations.claude.join("projects").join(&key).join("memory");
122 let source_count = markdown_count(&claude_path)?;
123 Ok(NativeImportSnapshot {
124 folder: folder.to_owned(),
125 claude_path,
126 codex_path: locations.codex.join("memories/extensions/external_agent_import/resources").join(key),
127 source_count,
128 can_import: false,
129 status: "unavailable".into(),
130 message: "No native Claude memories were found for this folder. Claude's memory system must create them first.".into(),
131 import_id: None,
132 })
133}
134
135fn markdown_count(root: &Path) -> io::Result<usize> {
136 match fs::symlink_metadata(root) {
137 Ok(metadata) if metadata.file_type().is_dir() => {}
138 Ok(_) => return Ok(0),
139 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
140 Err(error) => return Err(error),
141 }
142 let mut count = 0;
143 for entry in fs::read_dir(root)? {
144 let entry = entry?;
145 let kind = entry.file_type()?;
146 if kind.is_dir() {
147 count += markdown_count(&entry.path())?;
148 } else if kind.is_file()
149 && entry
150 .path()
151 .extension()
152 .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
153 {
154 count += 1;
155 }
156 }
157 Ok(count)
158}
159
160fn supported_source(locations: &Locations, snapshot: &mut NativeImportSnapshot) -> bool {
161 let default_claude = locations.home.join(".claude");
162 if locations
163 .claude
164 .canonicalize()
165 .unwrap_or_else(|_| locations.claude.clone())
166 != default_claude.canonicalize().unwrap_or(default_claude)
167 || std::env::var_os("CLAUDE_CODE_PROJECT_DIR_NAME").is_some()
168 {
169 snapshot.message = "This Codex importer supports only the default Claude project memory location; a Claude directory override is active.".into();
170 return false;
171 }
172 if locations
173 .claude_settings
174 .get("autoMemoryDirectory")
175 .is_some_and(|value| !value.is_null())
176 {
177 snapshot.message = "Claude has an autoMemoryDirectory override. Codex's native importer does not support that location.".into();
178 return false;
179 }
180 if locations
181 .claude_settings
182 .get("autoMemoryEnabled")
183 .and_then(Value::as_bool)
184 == Some(false)
185 {
186 snapshot.message =
187 "Claude auto memory is disabled. Enable its native memory system before importing."
188 .into();
189 return false;
190 }
191 true
192}
193
194fn execute_located(
195 action: NativeImportAction,
196 mut snapshot: NativeImportSnapshot,
197 located: &LocatedHarness,
198) -> Result<NativeImportSnapshot, NativeImportError> {
199 let mut client = NativeClient::start(located, &snapshot.folder)?;
200 let config = client.request(
201 "config/read",
202 &json!({"includeLayers": false, "cwd": snapshot.folder}),
203 )?;
204 let config = config
205 .get("config")
206 .filter(|value| value.is_object())
207 .ok_or(NativeImportError::InvalidResponse)?;
208 if !supported_codex(config, &mut snapshot) {
209 return Ok(snapshot);
210 }
211 let detection = client.request(
212 "externalAgentConfig/detect",
213 &json!({"includeHome": true, "cwds": [snapshot.folder]}),
214 )?;
215 let key = claude_key(&snapshot.folder);
216 let items = detection
217 .get("items")
218 .and_then(Value::as_array)
219 .ok_or(NativeImportError::InvalidResponse)?;
220 if !refresh_scope(&mut snapshot)? {
221 return Ok(snapshot);
222 }
223 let selected = items.iter().find(|item| {
224 item.get("itemType").and_then(Value::as_str) == Some("MEMORY")
225 && item
226 .pointer("/details/memory")
227 .and_then(Value::as_array)
228 .is_some_and(|keys| keys.iter().any(|value| value.as_str() == Some(&key)))
229 });
230 let Some(selected) = selected else {
231 if snapshot.source_count == 0 {
232 snapshot.message = "Claude has no memory files here, but Codex still has imported copies and did not offer native cleanup. Refresh native status or inspect Codex's import history.".into();
233 } else if imported_scope_matches(&snapshot.codex_path, &snapshot.folder)? {
234 snapshot.status = "current".into();
235 snapshot.message = "Imported files are up to date. Codex may still be consolidating them or waiting for account capacity. Start a fresh conversation after consolidation finishes.".into();
236 } else {
237 snapshot.message = "Codex could not identify this native Claude memory project. Open a normal Claude session in this exact folder so the importer can verify its scope.".into();
238 }
239 return Ok(snapshot);
240 };
241 snapshot.can_import = true;
242 snapshot.status = "ready".into();
243 snapshot.message = if snapshot.source_count == 0 {
244 "Claude has no memory files here. Codex can remove this folder's imported copies through its native importer; consolidated memory updates afterward."
245 } else {
246 "Import this folder's Claude memories into Codex. Codex consolidates them during conversations when account capacity allows."
247 }.into();
248 if action == NativeImportAction::Status {
249 return Ok(snapshot);
250 }
251 if !refresh_scope(&mut snapshot)? {
252 return Ok(snapshot);
253 }
254 if (action == NativeImportAction::RemoveImported) != (snapshot.source_count == 0) {
255 snapshot.status = "unavailable".into();
256 snapshot.can_import = false;
257 snapshot.message =
258 "Claude memories changed. Refresh before importing or removing copies.".into();
259 return Ok(snapshot);
260 }
261 let item = json!({
263 "itemType": "MEMORY", "description": selected.get("description").and_then(Value::as_str).unwrap_or("Import native folder memories"),
264 "cwd": null, "details": {"plugins": [], "skills": [], "sessions": [], "mcpServers": [], "hooks": [], "subagents": [], "commands": [], "memory": [key]}
265 });
266 let accepted = client.request(
267 "externalAgentConfig/import",
268 &json!({"migrationItems": [item], "source": "twine", "providerId": "claude-code"}),
269 )?;
270 let id = accepted
271 .get("importId")
272 .and_then(Value::as_str)
273 .filter(|id| !id.is_empty())
274 .ok_or(NativeImportError::InvalidResponse)?
275 .to_owned();
276 snapshot.import_id = Some(id.clone());
277 snapshot.can_import = false;
278 let completed = client.import_completion(&id);
279 finish_import(snapshot, &key, completed)
280}
281
282fn supported_codex(config: &Value, snapshot: &mut NativeImportSnapshot) -> bool {
283 if config
284 .pointer("/memories/version")
285 .and_then(Value::as_str)
286 .unwrap_or("v1")
287 != "v1"
288 {
289 snapshot.message = "Codex is using memories v2, but its Claude importer writes v1. Native import is unavailable for this configuration.".into();
290 return false;
291 }
292 if config
293 .pointer("/features/memories")
294 .and_then(Value::as_bool)
295 != Some(true)
296 || config
297 .pointer("/features/external_agent_memory_import")
298 .and_then(Value::as_bool)
299 != Some(true)
300 || config
301 .pointer("/memories/use_memories")
302 .and_then(Value::as_bool)
303 == Some(false)
304 {
305 snapshot.message = "Codex native memories or its experimental native importer are disabled for this folder. Enable native memories and use a Codex version supporting external_agent_memory_import.".into();
306 return false;
307 }
308 true
309}
310
311fn refresh_scope(snapshot: &mut NativeImportSnapshot) -> io::Result<bool> {
312 let before = markdown_count(&snapshot.claude_path)?;
313 let matches = if before == 0 {
314 imported_scope_matches(&snapshot.codex_path, &snapshot.folder)?
315 } else {
316 source_scope_matches(&snapshot.claude_path, &snapshot.folder)?
317 };
318 snapshot.source_count = markdown_count(&snapshot.claude_path)?;
319 if (before == 0) != (snapshot.source_count == 0) || !matches {
320 snapshot.can_import = false;
321 snapshot.status = "unavailable".into();
322 snapshot.message = "Native memory changed or its metadata does not establish this exact folder. Refresh before importing or removing copies.".into();
323 return Ok(false);
324 }
325 Ok(true)
326}
327
328fn finish_import(
329 mut snapshot: NativeImportSnapshot,
330 key: &str,
331 completed: Result<Value, NativeImportError>,
332) -> Result<NativeImportSnapshot, NativeImportError> {
333 if let Ok(completed) = completed {
334 let results = completed
335 .get("itemTypeResults")
336 .and_then(Value::as_array)
337 .ok_or(NativeImportError::InvalidResponse)?;
338 let success = results.iter().any(|result| {
339 result.get("itemType").and_then(Value::as_str) == Some("MEMORY")
340 && result
341 .get("failures")
342 .and_then(Value::as_array)
343 .is_some_and(Vec::is_empty)
344 && result
345 .get("successes")
346 .and_then(Value::as_array)
347 .is_some_and(|values| {
348 values
349 .iter()
350 .any(|value| value.get("source").and_then(Value::as_str) == Some(key))
351 })
352 });
353 if success {
354 snapshot.status = "imported".into();
355 snapshot.message = if snapshot.source_count == 0 {
356 "Codex removed this folder's imported memory copies. Native consolidation is pending and may wait for account capacity; older information can remain until it finishes."
357 } else {
358 "Imported into Codex. Native consolidation is pending. It runs during Codex conversations and may pause when account capacity is low. After it finishes, start a fresh conversation."
359 }.into();
360 if snapshot.source_count == 0
361 && !fs::symlink_metadata(&snapshot.codex_path)
362 .is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
363 {
364 snapshot.status = "unconfirmed".into();
365 snapshot.message = "Codex completed a native operation, but removal of the imported copies was not verified. Claude memories may have changed; refresh native status before retrying.".into();
366 }
367 } else {
368 snapshot.status = "unconfirmed".into();
369 snapshot.message = "Codex did not confirm successful import of this folder. Inspect its native import history before retrying.".into();
370 }
371 } else {
372 snapshot.status = "unconfirmed".into();
373 snapshot.message = "Codex accepted the native import but completion was not confirmed before the connection closed. Refresh native status and inspect Codex's import history before retrying.".into();
374 }
375 Ok(snapshot)
376}
377
378fn source_scope_matches(memory: &Path, folder: &Path) -> io::Result<bool> {
379 let Some(project) = memory.parent() else {
380 return Ok(false);
381 };
382 let mut found = false;
383 let deadline = Instant::now() + REQUEST_TIMEOUT;
384 for entry in fs::read_dir(project)? {
385 let entry = entry?;
386 if !entry.file_type()?.is_file()
387 || entry
388 .path()
389 .extension()
390 .is_none_or(|extension| extension != "jsonl")
391 {
392 continue;
393 }
394 let mut reader = BufReader::new(fs::File::open(entry.path())?);
395 loop {
396 if Instant::now() >= deadline {
397 return Err(io::Error::new(
398 io::ErrorKind::TimedOut,
399 "Native Claude scope verification timed out.",
400 ));
401 }
402 let mut line = Vec::new();
403 let count = reader
404 .by_ref()
405 .take(MAX_PROTOCOL_BYTES + 1)
406 .read_until(b'\n', &mut line)?;
407 if count == 0 {
408 break;
409 }
410 if count as u64 > MAX_PROTOCOL_BYTES {
411 return Ok(false);
412 }
413 let Ok(value) = serde_json::from_slice::<Value>(&line) else {
414 continue;
415 };
416 if let Some(cwd) = value.get("cwd").and_then(Value::as_str) {
417 if Path::new(cwd).canonicalize().ok().as_deref() != Some(folder) {
418 return Ok(false);
419 }
420 found = true;
421 break;
424 }
425 }
426 }
427 Ok(found)
428}
429
430fn imported_scope_matches(memory: &Path, folder: &Path) -> io::Result<bool> {
431 let scope = memory.join("scope.json");
432 match fs::symlink_metadata(&scope) {
433 Ok(metadata) if metadata.file_type().is_file() => {}
434 Ok(_) => return Ok(false),
435 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false),
436 Err(error) => return Err(error),
437 }
438 let mut bytes = Vec::new();
439 fs::File::open(scope)?
440 .take(MAX_PROTOCOL_BYTES + 1)
441 .read_to_end(&mut bytes)?;
442 if bytes.len() as u64 > MAX_PROTOCOL_BYTES {
443 return Ok(false);
444 }
445 let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
446 return Ok(false);
447 };
448 Ok(value
449 .get("cwd")
450 .and_then(Value::as_str)
451 .is_some_and(|cwd| Path::new(cwd).canonicalize().ok().as_deref() == Some(folder)))
452}
453
454fn read_message(
455 reader: &mut BufReader<UnixStream>,
456 remaining: &mut u64,
457 deadline: Instant,
458) -> Result<Value, NativeImportError> {
459 let mut bytes = Vec::new();
460 loop {
461 let timeout = deadline
462 .checked_duration_since(Instant::now())
463 .filter(|timeout| !timeout.is_zero())
464 .ok_or_else(|| {
465 io::Error::new(
466 io::ErrorKind::TimedOut,
467 "Codex native memory request timed out.",
468 )
469 })?;
470 reader.get_ref().set_read_timeout(Some(timeout))?;
471 let buffer = reader.fill_buf()?;
472 let newline = buffer.iter().position(|byte| *byte == b'\n');
473 let count = newline.map_or(buffer.len(), |index| index + 1);
474 if count == 0 || count as u64 > *remaining {
475 return Err(NativeImportError::InvalidResponse);
476 }
477 bytes.extend_from_slice(&buffer[..count]);
478 *remaining -= count as u64;
479 reader.consume(count);
480 if newline.is_some() {
481 return serde_json::from_slice(&bytes).map_err(|_| NativeImportError::InvalidResponse);
482 }
483 }
484}
485
486struct NativeClient {
487 _child: CommandChild,
488 socket: UnixStream,
489 reader: BufReader<UnixStream>,
490 next_id: u64,
491 remaining: u64,
492 completions: Vec<Value>,
493}
494
495impl NativeClient {
496 fn start(located: &LocatedHarness, folder: &Path) -> Result<Self, NativeImportError> {
497 let (socket, remote) = UnixStream::pair()?;
498 socket.set_write_timeout(Some(REQUEST_TIMEOUT))?;
499 let input: OwnedFd = remote.try_clone()?.into();
500 let output: OwnedFd = remote.into();
501 let mut command = Command::new(&located.program);
502 command
503 .args(["--enable", "external_agent_memory_import"])
505 .arg("app-server")
506 .current_dir(folder)
507 .env("PATH", &located.path)
508 .stdin(Stdio::from(input))
509 .stdout(Stdio::from(output))
510 .stderr(Stdio::null());
511 let child = CommandChild::spawn(&mut command)?;
512 let mut client = Self {
513 _child: child,
514 reader: BufReader::new(socket.try_clone()?),
515 socket,
516 next_id: 1,
517 remaining: MAX_PROTOCOL_BYTES,
518 completions: Vec::new(),
519 };
520 client
521 .request(
522 "initialize",
523 &json!({"clientInfo": {"name": "twine", "version": env!("CARGO_PKG_VERSION")}}),
524 )
525 .map_err(|_| NativeImportError::ImporterUnavailable)?;
526 client.send(&json!({"method": "initialized"}))?;
527 Ok(client)
528 }
529
530 fn send(&mut self, value: &Value) -> io::Result<()> {
531 serde_json::to_writer(&mut self.socket, value)?;
532 self.socket.write_all(b"\n")
533 }
534
535 fn read(&mut self, deadline: Instant) -> Result<Value, NativeImportError> {
536 read_message(&mut self.reader, &mut self.remaining, deadline)
537 }
538
539 fn request(&mut self, method: &str, params: &Value) -> Result<Value, NativeImportError> {
540 let id = self.next_id;
541 self.next_id += 1;
542 self.send(&json!({"id": id, "method": method, "params": params}))?;
543 let deadline = Instant::now() + REQUEST_TIMEOUT;
544 loop {
545 let value = self.read(deadline)?;
546 if value.get("id").and_then(Value::as_u64) == Some(id) {
547 return value
548 .get("result")
549 .cloned()
550 .ok_or(NativeImportError::NativeRequest);
551 }
552 if value.get("method").and_then(Value::as_str)
553 == Some("externalAgentConfig/import/completed")
554 {
555 self.completions.push(
556 value
557 .get("params")
558 .cloned()
559 .ok_or(NativeImportError::InvalidResponse)?,
560 );
561 }
562 }
563 }
564
565 fn import_completion(&mut self, id: &str) -> Result<Value, NativeImportError> {
566 let deadline = Instant::now() + IMPORT_TIMEOUT;
567 loop {
568 if let Some(index) = self
569 .completions
570 .iter()
571 .position(|value| value.get("importId").and_then(Value::as_str) == Some(id))
572 {
573 return Ok(self.completions.swap_remove(index));
574 }
575 let value = self.read(deadline)?;
576 if value.get("method").and_then(Value::as_str)
577 == Some("externalAgentConfig/import/completed")
578 {
579 self.completions.push(
580 value
581 .get("params")
582 .cloned()
583 .ok_or(NativeImportError::InvalidResponse)?,
584 );
585 }
586 }
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use std::ffi::OsString;
594 use std::os::unix::fs::PermissionsExt;
595
596 struct Fixture {
597 directory: tempfile::TempDir,
598 locations: Locations,
599 located: LocatedHarness,
600 folder: PathBuf,
601 requests: PathBuf,
602 }
603
604 impl Fixture {
605 fn new(version: &str, detected: bool, completed: bool) -> Self {
606 let directory = tempfile::tempdir().unwrap();
607 let mut locations = Locations::examples(directory.path());
608 fs::create_dir_all(directory.path().join("folder")).unwrap();
609 let folder = directory.path().join("folder").canonicalize().unwrap();
610 locations.folder = Some(folder.clone());
611 let key = claude_key(&folder);
612 let project = locations.claude.join("projects").join(&key);
613 fs::create_dir_all(project.join("memory/topics")).unwrap();
614 fs::write(project.join("memory/MEMORY.md"), "# Project memory\n").unwrap();
615 fs::write(project.join("memory/topics/fact.md"), "A folder fact.\n").unwrap();
616 fs::write(project.join("session.jsonl"), format!("{}\n", json!({"cwd": folder, "type": "user", "timestamp": "2026-10-24T12:00:00Z", "message": {"role": "user", "content": "A real task"}}))).unwrap();
617 let requests = directory.path().join("requests.jsonl");
618 let responses = json!({
619 "version": version, "key": key, "detected": detected, "completed": completed,
620 "requests": requests,
621 "memory": project.join("memory"),
622 "destination": locations.codex.join("memories/extensions/external_agent_import/resources").join(&key),
623 });
624 let program = directory.path().join("codex");
625 fs::write(&program, format!(r"#!/usr/bin/python3
626import json, sys, shutil
627from pathlib import Path
628assert sys.argv[1:] == ['--enable', 'external_agent_memory_import', 'app-server']
629settings = json.loads({settings})
630def send(value):
631 print(json.dumps(value), flush=True)
632for line in sys.stdin:
633 request = json.loads(line)
634 with open(settings['requests'], 'a') as log:
635 log.write(json.dumps(request) + '\n')
636 method = request['method']
637 if method == 'initialized':
638 continue
639 result = {{}}
640 if method == 'config/read':
641 result = {{'config': {{'features': {{'memories': True, 'external_agent_memory_import': True}}, 'memories': {{'version': settings['version']}}}}}}
642 elif method == 'externalAgentConfig/detect':
643 result = {{'items': [{{'itemType': 'CONFIG', 'description': 'Do not import settings', 'cwd': None, 'details': None}}]}}
644 if settings['detected']:
645 result['items'].append({{'itemType': 'MEMORY', 'description': 'Native memory', 'cwd': None, 'details': {{'memory': ['another-project', settings['key']]}}}})
646 elif method == 'externalAgentConfig/import':
647 if settings['completed']:
648 if not any(Path(settings['memory']).rglob('*.md')):
649 shutil.rmtree(settings['destination'], ignore_errors=True)
650 send({{'method': 'externalAgentConfig/import/completed', 'params': {{'importId': 'fixture-import', 'itemTypeResults': [{{'itemType': 'MEMORY', 'successes': [{{'source': settings['key']}}], 'failures': []}}]}}}})
651 send({{'id': request['id'], 'result': {{'importId': 'fixture-import'}}}})
652 if not settings['completed']:
653 sys.exit(0)
654 continue
655 send({{'id': request['id'], 'result': result}})
656", settings=serde_json::to_string(&responses.to_string()).unwrap())).unwrap();
657 fs::set_permissions(&program, fs::Permissions::from_mode(0o700)).unwrap();
658 let located = LocatedHarness {
659 program,
660 path: OsString::from("/usr/bin:/bin"),
661 };
662 Self {
663 directory,
664 locations,
665 located,
666 folder,
667 requests,
668 }
669 }
670
671 fn run(&self, action: NativeImportAction) -> NativeImportSnapshot {
672 execute_located(
673 action,
674 snapshot(&self.folder, &self.locations).unwrap(),
675 &self.located,
676 )
677 .unwrap()
678 }
679
680 fn commands(&self) -> Vec<Value> {
681 fs::read_to_string(&self.requests)
682 .unwrap()
683 .lines()
684 .map(|line| serde_json::from_str(line).unwrap())
685 .collect()
686 }
687 }
688
689 #[test]
690 fn status_detects_native_project_without_importing_or_writing_memories() {
691 let fixture = Fixture::new("v1", true, true);
692 let result = fixture.run(NativeImportAction::Status);
693 assert_eq!(result.source_count, 2);
694 assert_eq!(result.status, "ready");
695 assert!(result.can_import);
696 assert!(!result.codex_path.exists());
697 assert!(
698 fixture
699 .commands()
700 .iter()
701 .all(|command| command["method"] != "externalAgentConfig/import")
702 );
703 }
704
705 #[test]
706 fn explicit_import_selects_only_this_folder_and_waits_for_native_completion() {
707 let fixture = Fixture::new("v1", true, true);
708 let result = fixture.run(NativeImportAction::Import);
709 assert_eq!(result.status, "imported");
710 assert!(!result.can_import);
711 assert_eq!(result.import_id.as_deref(), Some("fixture-import"));
712 assert!(result.message.contains("consolidation is pending"));
713 let commands = fixture.commands();
714 let import = commands
715 .iter()
716 .find(|command| command["method"] == "externalAgentConfig/import")
717 .unwrap();
718 let items = import["params"]["migrationItems"].as_array().unwrap();
719 assert_eq!(items.len(), 1);
720 assert_eq!(items[0]["itemType"], "MEMORY");
721 assert_eq!(
722 items[0]["details"]["memory"],
723 json!([claude_key(&fixture.folder)])
724 );
725 assert_eq!(items[0]["details"]["sessions"], json!([]));
726 assert!(!result.codex_path.exists());
728 }
729
730 #[test]
731 fn accepting_an_import_id_does_not_claim_completion() {
732 let fixture = Fixture::new("v1", true, false);
733 let result = fixture.run(NativeImportAction::Import);
734 assert_eq!(result.status, "unconfirmed");
735 assert_eq!(result.import_id.as_deref(), Some("fixture-import"));
736 assert!(!result.can_import);
737 }
738
739 #[test]
740 fn unsupported_versions_and_missing_native_detection_never_import() {
741 for (version, detected) in [("v2", true), ("v1", false)] {
742 let fixture = Fixture::new(version, detected, true);
743 let result = fixture.run(NativeImportAction::Import);
744 assert_eq!(result.status, "unavailable");
745 assert!(!result.can_import);
746 assert!(
747 fixture
748 .commands()
749 .iter()
750 .all(|command| command["method"] != "externalAgentConfig/import")
751 );
752 }
753 }
754
755 #[test]
756 fn another_folders_native_session_blocks_import_even_with_matching_project_key() {
757 let fixture = Fixture::new("v1", true, true);
758 let source = snapshot(&fixture.folder, &fixture.locations).unwrap();
759 fs::write(
760 source.claude_path.parent().unwrap().join("session.jsonl"),
761 format!("{}\n", json!({"cwd": fixture.directory.path()})),
762 )
763 .unwrap();
764 let result = fixture.run(NativeImportAction::Import);
765 assert!(!result.can_import);
766 assert_eq!(result.status, "unavailable");
767 assert!(
768 fixture
769 .commands()
770 .iter()
771 .all(|command| command["method"] != "externalAgentConfig/import")
772 );
773 }
774
775 #[test]
776 fn later_command_directories_do_not_change_native_session_scope() {
777 let fixture = Fixture::new("v1", true, true);
778 let source = snapshot(&fixture.folder, &fixture.locations).unwrap();
779 let session = source.claude_path.parent().unwrap().join("session.jsonl");
780 let mut contents = fs::read_to_string(&session).unwrap();
781 contents
782 .push_str(&json!({"cwd": fixture.directory.path(), "type": "assistant"}).to_string());
783 fs::write(session, contents).unwrap();
784 assert_eq!(fixture.run(NativeImportAction::Status).status, "ready");
785 }
786
787 #[test]
788 fn no_pending_import_requires_matching_source_and_persisted_scopes() {
789 let fixture = Fixture::new("v1", false, true);
790 let source = snapshot(&fixture.folder, &fixture.locations).unwrap();
791 fs::create_dir_all(&source.codex_path).unwrap();
792 let scope = source.codex_path.join("scope.json");
793 fs::write(&scope, json!({"cwd": fixture.directory.path()}).to_string()).unwrap();
794 assert_eq!(
795 fixture.run(NativeImportAction::Status).status,
796 "unavailable"
797 );
798
799 fs::write(&scope, json!({"cwd": fixture.folder}).to_string()).unwrap();
800 assert_eq!(fixture.run(NativeImportAction::Status).status, "current");
801
802 let session = source.claude_path.parent().unwrap().join("session.jsonl");
803 fs::write(
804 &session,
805 json!({"cwd": fixture.directory.path()}).to_string(),
806 )
807 .unwrap();
808 assert_eq!(
809 fixture.run(NativeImportAction::Status).status,
810 "unavailable"
811 );
812 fs::remove_file(session).unwrap();
813 assert_eq!(
814 fixture.run(NativeImportAction::Status).status,
815 "unavailable"
816 );
817 assert!(
818 fixture
819 .commands()
820 .iter()
821 .all(|command| command["method"] != "externalAgentConfig/import")
822 );
823 }
824
825 #[test]
826 fn deleted_source_project_can_use_verified_native_cleanup() {
827 let fixture = Fixture::new("v1", true, true);
828 let source = snapshot(&fixture.folder, &fixture.locations).unwrap();
829 fs::create_dir_all(&source.codex_path).unwrap();
830 fs::write(
831 source.codex_path.join("scope.json"),
832 json!({"cwd": fixture.folder}).to_string(),
833 )
834 .unwrap();
835 fs::remove_dir_all(source.claude_path.parent().unwrap()).unwrap();
836 let ready = fixture.run(NativeImportAction::Status);
837 assert_eq!(ready.source_count, 0);
838 assert_eq!(ready.status, "ready");
839 assert!(ready.can_import);
840 assert!(
841 ready
842 .message
843 .contains("remove this folder's imported copies")
844 );
845 let imported = fixture.run(NativeImportAction::RemoveImported);
846 assert_eq!(imported.status, "imported");
847 assert!(
848 imported
849 .message
850 .contains("removed this folder's imported memory copies")
851 );
852 assert!(imported.message.contains("consolidation is pending"));
853 assert!(!source.codex_path.exists());
855 }
856
857 #[test]
858 fn native_cleanup_requires_matching_scope_and_detected_project() {
859 for (detected, matching_scope) in [(true, false), (false, true)] {
860 let fixture = Fixture::new("v1", detected, true);
861 let source = snapshot(&fixture.folder, &fixture.locations).unwrap();
862 fs::create_dir_all(&source.codex_path).unwrap();
863 let cwd = if matching_scope {
864 &fixture.folder
865 } else {
866 fixture.directory.path()
867 };
868 fs::write(
869 source.codex_path.join("scope.json"),
870 json!({"cwd": cwd}).to_string(),
871 )
872 .unwrap();
873 fs::remove_dir_all(source.claude_path.parent().unwrap()).unwrap();
874 let result = fixture.run(NativeImportAction::RemoveImported);
875 assert_eq!(result.status, "unavailable");
876 assert!(!result.can_import);
877 assert!(
878 fixture
879 .commands()
880 .iter()
881 .all(|command| command["method"] != "externalAgentConfig/import")
882 );
883 if matching_scope {
884 assert!(result.message.contains("still has imported copies"));
885 }
886 }
887 }
888
889 #[test]
890 fn explicit_cleanup_does_not_import_repopulated_source() {
891 let fixture = Fixture::new("v1", true, true);
892 let result = fixture.run(NativeImportAction::RemoveImported);
893 assert_eq!(result.status, "unavailable");
894 assert!(!result.can_import);
895 assert!(result.message.contains("Claude memories changed"));
896 assert!(
897 fixture
898 .commands()
899 .iter()
900 .all(|command| command["method"] != "externalAgentConfig/import")
901 );
902 }
903
904 #[test]
905 fn foreign_source_created_during_detection_never_imports() {
906 let fixture = Fixture::new("v1", true, true);
907 let source = snapshot(&fixture.folder, &fixture.locations).unwrap();
908 fs::create_dir_all(&source.codex_path).unwrap();
909 fs::write(
910 source.codex_path.join("scope.json"),
911 json!({"cwd": fixture.folder}).to_string(),
912 )
913 .unwrap();
914 fs::remove_dir_all(source.claude_path.parent().unwrap()).unwrap();
915 let memory = serde_json::to_string(&source.claude_path).unwrap();
916 let session =
917 serde_json::to_string(&json!({"cwd": fixture.directory.path()}).to_string()).unwrap();
918 let injection = format!(
919 " elif method == 'externalAgentConfig/detect':\n memory = Path({memory})\n memory.mkdir(parents=True, exist_ok=True)\n (memory / 'new.md').write_text('New fixture memory')\n (memory.parent / 'session.jsonl').write_text({session})"
920 );
921 let program = fs::read_to_string(&fixture.located.program).unwrap();
922 fs::write(
923 &fixture.located.program,
924 program.replace(
925 " elif method == 'externalAgentConfig/detect':",
926 &injection,
927 ),
928 )
929 .unwrap();
930 let result = fixture.run(NativeImportAction::RemoveImported);
931 assert_eq!(result.source_count, 1);
932 assert_eq!(result.status, "unavailable");
933 assert!(!result.can_import);
934 assert!(
935 fixture
936 .commands()
937 .iter()
938 .all(|command| command["method"] != "externalAgentConfig/import")
939 );
940 }
941
942 #[test]
943 fn fragmented_native_output_cannot_extend_the_absolute_deadline() {
944 let (socket, mut remote) = UnixStream::pair().unwrap();
945 let writer = std::thread::spawn(move || {
946 for _ in 0..48 {
947 if remote.write_all(b" ").is_err() {
948 break;
949 }
950 std::thread::sleep(Duration::from_millis(15));
951 }
952 });
953 let mut reader = BufReader::new(socket);
954 let mut remaining = MAX_PROTOCOL_BYTES;
955 let started = Instant::now();
956 let result = read_message(
957 &mut reader,
958 &mut remaining,
959 started + Duration::from_millis(75),
960 );
961 assert!(
962 matches!(result, Err(NativeImportError::Io(error)) if matches!(error.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock))
963 );
964 assert!(started.elapsed() < Duration::from_millis(400));
965 drop(reader);
966 writer.join().unwrap();
967 }
968
969 #[test]
970 fn source_count_skips_symlinks_and_memory_directory_overrides_are_explicit() {
971 let mut fixture = Fixture::new("v1", true, true);
972 let mut result = snapshot(&fixture.folder, &fixture.locations).unwrap();
973 std::os::unix::fs::symlink("topics/fact.md", result.claude_path.join("alias.md")).unwrap();
974 assert_eq!(markdown_count(&result.claude_path).unwrap(), 2);
975 fixture.locations.claude_settings = json!({"autoMemoryDirectory": "/unsupported"});
976 assert!(!supported_source(&fixture.locations, &mut result));
977 assert!(result.message.contains("autoMemoryDirectory"));
978 }
979}