Module: ClaudeAgentSDK::SessionResume
- Defined in:
- lib/claude_agent_sdk/session_resume.rb
Overview
Materialize a SessionStore-backed resume into a temp CLAUDE_CONFIG_DIR.
When ‘resume` (or `continue_conversation`) is paired with a `session_store`, the session JSONL usually doesn’t exist on local disk — it lives in the store. The CLI only resumes from a local file. This module loads the session from the store, writes it to a temp dir laid out like ~/.claude/, and returns the path so the caller can point the subprocess at it via CLAUDE_CONFIG_DIR.
Constant Summary collapse
- KEYCHAIN_SERVICE_NAME =
rubocop:disable Metrics/ModuleLength
'Claude Code-credentials'- KEYCHAIN_TIMEOUT_SECONDS =
5- RETRYABLE_RMTREE_ERRORS =
SystemCallError classes that indicate a transiently-held handle (Windows AV/indexer scanning a freshly-written file) or a recoverable resource shortage (file-table exhaustion) rather than a permanent failure. EMFILE/ ENFILE are treated as transient so the backoff loop can succeed once descriptors free up, matching the Python SDK’s retryable errno set.
[ Errno::EBUSY, Errno::ENOTEMPTY, Errno::EPERM, Errno::EACCES, Errno::EMFILE, Errno::ENFILE ].freeze
Class Method Summary collapse
-
.apply_materialized_options(options, materialized) ⇒ Object
Return a copy of
optionsrepointed at a materialized temp config dir: CLAUDE_CONFIG_DIR in env, resume set to the materialized session id, and continue_conversation cleared (already resolved to a concrete session id). -
.build_mirror_batcher(store:, env:, on_error:, eager: false) ⇒ Object
Build a TranscriptMirrorBatcher for a configured session_store.
-
.materialize_resume_session(options) ⇒ Object
Load a session from options.session_store and write it to a temp dir.
-
.rmtree_with_retry(path, retries: 4, delay: 0.1) ⇒ Object
Best-effort recursive removal with retries on transient lock errors (Windows AV/indexer).
-
.safe_subpath?(subpath, session_dir) ⇒ Boolean
Reject subpaths that are empty, absolute, drive/UNC-prefixed, contain “.” or “..” components or a NUL byte, or escape session_dir after resolution.
Class Method Details
.apply_materialized_options(options, materialized) ⇒ Object
Return a copy of options repointed at a materialized temp config dir: CLAUDE_CONFIG_DIR in env, resume set to the materialized session id, and continue_conversation cleared (already resolved to a concrete session id).
58 59 60 61 62 63 64 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 58 def (, materialized) .dup_with( env: .env.merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s), resume: materialized.resume_session_id, continue_conversation: false ) end |
.build_mirror_batcher(store:, env:, on_error:, eager: false) ⇒ Object
Build a TranscriptMirrorBatcher for a configured session_store. Shared by both entry points (Client#install_transcript_mirror and the one-shot query()) so projects_dir resolution and the eager/batched threshold choice live in one place. env supplies the CLAUDE_CONFIG_DIR override used to locate the projects dir (already repointed at the temp dir when resuming from a store). Eager flush mode zeroes the buffer thresholds so every transcript_mirror frame triggers a background flush.
73 74 75 76 77 78 79 80 81 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 73 def build_mirror_batcher(store:, env:, on_error:, eager: false) TranscriptMirrorBatcher.new( store: store, projects_dir: SessionStores.projects_dir(env), on_error: on_error, max_pending_entries: eager ? 0 : TranscriptMirrorBatcher::MAX_PENDING_ENTRIES, max_pending_bytes: eager ? 0 : TranscriptMirrorBatcher::MAX_PENDING_BYTES ) end |
.materialize_resume_session(options) ⇒ Object
Load a session from options.session_store and write it to a temp dir. Returns a MaterializedResume, or nil when no materialization is needed (no store, no resume/continue, store has no entries, or the resolved session id is not a valid UUID) — the caller then falls through to the normal spawn path. Raises RuntimeError if a store call fails or times out.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 88 def materialize_resume_session() store = .session_store return nil if store.nil? return nil if .resume.nil? && !.continue_conversation timeout_s = .load_timeout_ms / 1000.0 project_key = Sessions.project_key_for_directory(.cwd) resolved = if .resume # session_id is used as a path component below; reject non-UUIDs to # prevent traversal and match every other resume path. return nil unless .resume.match?(Sessions::UUID_RE) load_candidate(store, project_key, .resume, timeout_s) else resolve_continue_candidate(store, project_key, timeout_s) end return nil if resolved.nil? session_id, entries = resolved tmp_base = Dir.mktmpdir('claude-resume-') begin project_dir = File.join(tmp_base, 'projects', project_key) FileUtils.mkdir_p(project_dir) write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), entries) # The subprocess runs with CLAUDE_CONFIG_DIR=tmp_base; copy auth config # so it can authenticate. Missing files are fine (API-key auth, etc.). copy_auth_files(tmp_base, .env) materialize_subkeys(store, project_dir, project_key, session_id, timeout_s) if SessionStore.implements?(store, :list_subkeys) rescue Exception # rubocop:disable Lint/RescueException # Any failure after mkdtemp leaves tmp_base (which may already hold a # .credentials.json copy) on disk with no path for the caller to clean # up. Remove it before re-raising. Rescue Exception (not StandardError) # so reactor stop/cancel also triggers cleanup. rmtree_with_retry(tmp_base) raise end MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id) end |
.rmtree_with_retry(path, retries: 4, delay: 0.1) ⇒ Object
Best-effort recursive removal with retries on transient lock errors (Windows AV/indexer). Never raises. The temp dir holds an access token, so the final sweep matters for not leaking secrets.
378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 378 def rmtree_with_retry(path, retries: 4, delay: 0.1) return unless path && File.exist?(path) retries.times do begin FileUtils.remove_entry(path) return rescue Errno::ENOENT return rescue SystemCallError => e break unless RETRYABLE_RMTREE_ERRORS.any? { |klass| e.is_a?(klass) } end sleep(delay) end FileUtils.rm_rf(path) end |
.safe_subpath?(subpath, session_dir) ⇒ Boolean
Reject subpaths that are empty, absolute, drive/UNC-prefixed, contain “.” or “..” components or a NUL byte, or escape session_dir after resolution.
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 |
# File 'lib/claude_agent_sdk/session_resume.rb', line 350 def safe_subpath?(subpath, session_dir) return false if subpath.nil? || subpath.empty? return false if subpath.start_with?('/', '\\') return false if subpath.match?(/\A[a-zA-Z]:/) # drive-prefixed (C:foo) / UNC return false if subpath.split(%r{[\\/]}).any? { |part| ['.', '..'].include?(part) } return false if subpath.include?("\u0000") base = resolve_dir(session_dir) # Join BEFORE expanding: expand_path on a relative first argument performs # tilde expansion, so a store-supplied "~nosuchuser/x" would raise # ArgumentError (and "~root/x" would resolve outside base even though the # literal path the writer uses is contained). The joined path is absolute, # so expand_path only normalizes it. target = File.(File.join(base, "#{subpath}.jsonl")) target == base || target.start_with?("#{base}#{File::SEPARATOR}") rescue ArgumentError false end |