claude_code_sdk_rust/internal/
session_resume.rs1use std::path::{Component, Path, PathBuf};
2use std::time::Duration;
3
4use crate::error::{ClaudeSDKError, Result};
5use crate::session_store::{
6 project_key_for_directory, SessionKey, SessionListSubkeysKey, SessionStoreEntry,
7 SessionStoreHandle,
8};
9use crate::types::ClaudeAgentOptions;
10
11#[derive(Debug, Clone)]
12pub struct MaterializedResume {
13 pub config_dir: PathBuf,
14 pub resume_session_id: String,
15}
16
17impl MaterializedResume {
18 pub async fn cleanup(&self) {
19 let _ = tokio::fs::remove_dir_all(&self.config_dir).await;
20 }
21}
22
23pub async fn materialize_resume_session(
24 options: &ClaudeAgentOptions,
25) -> Result<Option<MaterializedResume>> {
26 let Some(store) = options.session_store.clone() else {
27 return Ok(None);
28 };
29 if options.resume.is_none() && !options.continue_conversation {
30 return Ok(None);
31 }
32
33 let project_key = project_key_for_directory(options.cwd.as_deref().map(Path::new));
34 let timeout = Duration::from_millis(options.load_timeout_ms.max(0) as u64);
35 let resolved = if let Some(session_id) = options.resume.as_ref() {
36 if !is_uuid(session_id) {
37 return Ok(None);
38 }
39 load_candidate(&store, &project_key, session_id, timeout).await?
40 } else {
41 resolve_continue_candidate(&store, &project_key, timeout).await?
42 };
43
44 let Some((session_id, entries)) = resolved else {
45 return Ok(None);
46 };
47
48 let config_dir =
49 std::env::temp_dir().join(format!("claude-resume-rust-{}", uuid::Uuid::new_v4()));
50 let project_dir = config_dir.join("projects").join(&project_key);
51 tokio::fs::create_dir_all(&project_dir).await?;
52 if let Err(error) =
53 write_jsonl(&project_dir.join(format!("{session_id}.jsonl")), &entries).await
54 {
55 let _ = tokio::fs::remove_dir_all(&config_dir).await;
56 return Err(error);
57 }
58
59 if let Err(error) =
60 materialize_subkeys(&store, &project_dir, &project_key, &session_id, timeout).await
61 {
62 let _ = tokio::fs::remove_dir_all(&config_dir).await;
63 return Err(error);
64 }
65
66 copy_auth_files(&config_dir, &options.env).await;
67
68 Ok(Some(MaterializedResume {
69 config_dir,
70 resume_session_id: session_id,
71 }))
72}
73
74pub fn apply_materialized_options(
75 options: &ClaudeAgentOptions,
76 materialized: &MaterializedResume,
77) -> ClaudeAgentOptions {
78 let mut options = options.clone();
79 options.env.insert(
80 "CLAUDE_CONFIG_DIR".to_string(),
81 materialized.config_dir.to_string_lossy().to_string(),
82 );
83 options.resume = Some(materialized.resume_session_id.clone());
84 options.continue_conversation = false;
85 options
86}
87
88async fn load_candidate(
89 store: &SessionStoreHandle,
90 project_key: &str,
91 session_id: &str,
92 timeout: Duration,
93) -> Result<Option<(String, Vec<SessionStoreEntry>)>> {
94 let key = SessionKey {
95 project_key: project_key.to_string(),
96 session_id: session_id.to_string(),
97 subpath: None,
98 };
99 let entries = with_timeout(
100 store.load(key),
101 timeout,
102 format!("SessionStore.load() for session {session_id}"),
103 )
104 .await?;
105 Ok(entries
106 .filter(|entries| !entries.is_empty())
107 .map(|entries| (session_id.to_string(), entries)))
108}
109
110async fn resolve_continue_candidate(
111 store: &SessionStoreHandle,
112 project_key: &str,
113 timeout: Duration,
114) -> Result<Option<(String, Vec<SessionStoreEntry>)>> {
115 let mut sessions = with_timeout(
116 store.list_sessions(project_key),
117 timeout,
118 "SessionStore.list_sessions()".to_string(),
119 )
120 .await?;
121 sessions.sort_by_key(|session| std::cmp::Reverse(session.mtime));
122
123 for session in sessions {
124 if !is_uuid(&session.session_id) {
125 continue;
126 }
127 let Some((session_id, entries)) =
128 load_candidate(store, project_key, &session.session_id, timeout).await?
129 else {
130 continue;
131 };
132 if entries
133 .first()
134 .and_then(|entry| entry.get("isSidechain"))
135 .and_then(|value| value.as_bool())
136 == Some(true)
137 {
138 continue;
139 }
140 return Ok(Some((session_id, entries)));
141 }
142
143 Ok(None)
144}
145
146async fn materialize_subkeys(
147 store: &SessionStoreHandle,
148 project_dir: &Path,
149 project_key: &str,
150 session_id: &str,
151 timeout: Duration,
152) -> Result<()> {
153 let subkeys = store
154 .list_subkeys(SessionListSubkeysKey {
155 project_key: project_key.to_string(),
156 session_id: session_id.to_string(),
157 })
158 .await
159 .unwrap_or_default();
160 let session_dir = project_dir.join(session_id);
161
162 for subpath in subkeys {
163 if !is_safe_subpath(&subpath) {
164 continue;
165 }
166 let key = SessionKey {
167 project_key: project_key.to_string(),
168 session_id: session_id.to_string(),
169 subpath: Some(subpath.clone()),
170 };
171 let Some(entries) = with_timeout(
172 store.load(key),
173 timeout,
174 format!("SessionStore.load() for session {session_id} subpath {subpath}"),
175 )
176 .await?
177 else {
178 continue;
179 };
180 if entries.is_empty() {
181 continue;
182 }
183
184 write_subpath_entries(&session_dir, &subpath, &entries).await?;
185 }
186 Ok(())
187}
188
189async fn write_subpath_entries(
190 session_dir: &Path,
191 subpath: &str,
192 entries: &[SessionStoreEntry],
193) -> Result<()> {
194 let mut transcript = Vec::new();
195 let mut metadata = None;
196 for entry in entries {
197 if entry.get("type").and_then(|value| value.as_str()) == Some("agent_metadata") {
198 let mut metadata_entry = entry.clone();
199 metadata_entry.remove("type");
200 metadata = Some(metadata_entry);
201 } else {
202 transcript.push(entry.clone());
203 }
204 }
205
206 let base_path = session_dir.join(subpath);
207 let transcript_path = base_path.with_extension("jsonl");
208 if !transcript.is_empty() {
209 write_jsonl(&transcript_path, &transcript).await?;
210 }
211 if let Some(metadata) = metadata {
212 let metadata_path = base_path.with_extension("meta.json");
213 if let Some(parent) = metadata_path.parent() {
214 tokio::fs::create_dir_all(parent).await?;
215 }
216 tokio::fs::write(&metadata_path, serde_json::to_vec(&metadata)?).await?;
217 }
218 Ok(())
219}
220
221async fn write_jsonl(path: &Path, entries: &[SessionStoreEntry]) -> Result<()> {
222 if let Some(parent) = path.parent() {
223 tokio::fs::create_dir_all(parent).await?;
224 }
225 let mut out = Vec::new();
226 for entry in entries {
227 out.extend(serde_json::to_vec(entry)?);
228 out.push(b'\n');
229 }
230 tokio::fs::write(path, out).await?;
231 Ok(())
232}
233
234async fn with_timeout<F, T>(future: F, timeout: Duration, label: String) -> Result<T>
235where
236 F: std::future::Future<Output = Result<T>>,
237{
238 match tokio::time::timeout(timeout, future).await {
239 Ok(result) => result.map_err(|error| {
240 ClaudeSDKError::Session(format!(
241 "{label} failed during resume materialization: {error}"
242 ))
243 }),
244 Err(_) => Err(ClaudeSDKError::Session(format!(
245 "{label} timed out after {}ms during resume materialization",
246 timeout.as_millis()
247 ))),
248 }
249}
250
251async fn copy_auth_files(config_dir: &Path, env: &std::collections::HashMap<String, String>) {
252 let source_config_dir = env
253 .get("CLAUDE_CONFIG_DIR")
254 .map(PathBuf::from)
255 .or_else(|| std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from))
256 .or_else(|| dirs::home_dir().map(|home| home.join(".claude")));
257
258 if let Some(source_config_dir) = source_config_dir {
259 copy_redacted_credentials(
260 &source_config_dir.join(".credentials.json"),
261 &config_dir.join(".credentials.json"),
262 )
263 .await;
264 let claude_json = if env.contains_key("CLAUDE_CONFIG_DIR")
265 || std::env::var_os("CLAUDE_CONFIG_DIR").is_some()
266 {
267 source_config_dir.join(".claude.json")
268 } else {
269 dirs::home_dir()
270 .map(|home| home.join(".claude.json"))
271 .unwrap_or_else(|| PathBuf::from(".claude.json"))
272 };
273 copy_if_present(&claude_json, &config_dir.join(".claude.json")).await;
274 }
275}
276
277async fn copy_redacted_credentials(src: &Path, dst: &Path) {
278 let Ok(content) = tokio::fs::read_to_string(src).await else {
279 return;
280 };
281 let redacted = redact_refresh_token(&content).unwrap_or(content);
282 let _ = tokio::fs::write(dst, redacted).await;
283}
284
285fn redact_refresh_token(content: &str) -> Option<String> {
286 let mut value = serde_json::from_str::<serde_json::Value>(content).ok()?;
287 value
288 .get_mut("claudeAiOauth")?
289 .as_object_mut()?
290 .remove("refreshToken");
291 serde_json::to_string(&value).ok()
292}
293
294async fn copy_if_present(src: &Path, dst: &Path) {
295 if let Ok(content) = tokio::fs::read(src).await {
296 let _ = tokio::fs::write(dst, content).await;
297 }
298}
299
300fn is_uuid(value: &str) -> bool {
301 uuid::Uuid::parse_str(value).is_ok()
302}
303
304pub(crate) fn is_safe_subpath(subpath: &str) -> bool {
305 if subpath.is_empty()
306 || subpath.starts_with('/')
307 || subpath.starts_with('\\')
308 || subpath.contains('\0')
309 || subpath.contains(':')
310 {
311 return false;
312 }
313 let path = Path::new(subpath);
314 !path.is_absolute()
315 && path
316 .components()
317 .all(|component| matches!(component, Component::Normal(_)))
318}