Skip to main content

handshake_core/memories/
native_import.rs

1//! Folder-scoped access to Codex's native Claude memory importer.
2//! Handshake stores no memory bodies and never writes Codex's generated memory files.
3
4use 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);
23// This bounds the native protocol, not memory contents or the number of memories.
24const 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
74/// Inspects native memory or explicitly invokes Codex's own memory importer.
75///
76/// # Errors
77/// Returns an error for invalid folders, protocol failures, or unavailable local files.
78pub 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    // Build a memory-only item from detected metadata. Never forward other migration kinds.
262    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                // Codex scopes a native session to its first cwd. Later records can
422                // legitimately reflect commands run in other directories.
423                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            // Opt into Codex's native importer for this child only. Never edit user config.
504            .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        // The fake importer writes nothing: Handshake itself must not publish a substitute store.
727        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        // The fake native server performs cleanup, and the adapter verifies its result.
854        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}