claude-agent-sdk 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +110 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +40 -11
  7. data/docs/configuration.md +206 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +84 -27
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/query.rb +547 -132
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +252 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/messages.rb +7 -1
  37. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  38. data/lib/claude_agent_sdk/types/options.rb +104 -17
  39. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  40. data/lib/claude_agent_sdk/version.rb +1 -1
  41. data/lib/claude_agent_sdk.rb +111 -53
  42. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  43. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  44. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  45. data/sig/claude_agent_sdk/types/options.rbs +20 -7
  46. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  47. metadata +6 -4
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'digest'
4
5
  require 'fileutils'
5
6
  require 'tmpdir'
6
7
  require 'open3'
@@ -22,31 +23,167 @@ module ClaudeAgentSDK
22
23
  class MaterializedResume
23
24
  attr_reader :config_dir, :resume_session_id
24
25
 
25
- def initialize(config_dir:, resume_session_id:)
26
+ MIRROR_DROPPED = 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete.'
27
+ SKIPPED = 'Scrubbing was skipped and nothing was deleted:'
28
+ # Prefix of the private directory a preserved temp dir is moved into. Not
29
+ # "claude-resume-": that one is for directories the SDK may still delete.
30
+ STAGING_PREFIX = 'claude-preserved-resume-'
31
+ private_constant :MIRROR_DROPPED, :SKIPPED, :STAGING_PREFIX
32
+
33
+ # +root_identity+ is SessionResume.directory_identity of +config_dir+ as
34
+ # the SDK created it; materialize_resume_session takes it right after
35
+ # mkdtemp. Without it, it is taken here.
36
+ def initialize(config_dir:, resume_session_id:, root_identity: nil)
26
37
  @config_dir = config_dir
27
38
  @resume_session_id = resume_session_id
39
+ @root_identity = root_identity || SessionResume.directory_identity(config_dir)
40
+ @kept = false
28
41
  end
29
42
 
30
43
  # Best-effort removal of the temp config dir (never raises).
44
+ #
45
+ # Does nothing once preserve_transcripts was called, however that went. A
46
+ # teardown can run twice — a disconnect after one that was cut short —
47
+ # and the second time nothing remembers that the mirror dropped batches:
48
+ # it asks for a cleanup of the directory holding the only copy of them.
49
+ # Leaving copies that were not scrubbed yet is the lesser harm.
31
50
  def cleanup
51
+ return if @kept
52
+
32
53
  SessionResume.rmtree_with_retry(@config_dir)
33
54
  end
34
55
 
35
56
  # Teardown when the transcript mirror dropped batches: the CLI's
36
57
  # authoritative transcript lives in this temp dir, and the store copy is
37
58
  # missing the dropped turns — deleting the dir would permanently lose
38
- # them. Keep the transcripts (projects/), remove the redacted credential
39
- # copies, and tell the user where the data is so they can import it into
40
- # the store manually. Never raises.
59
+ # them. Keep the transcripts (projects/), delete every other entry, and
60
+ # tell the user where the data is so they can import it into the store
61
+ # manually. Never raises.
62
+ #
63
+ # An allow-list on purpose. Besides the files the SDK seeds
64
+ # (.credentials.json, .claude.json, settings.json, cowork_settings.json)
65
+ # the CLI writes its own: at startup it saves the seeded .claude.json —
66
+ # which can hold MCP header secrets — as backups/.claude.json.backup.<ts>.
67
+ # A list of names to delete missed that one and would miss the next.
68
+ #
69
+ # Deleting "everything else" must never reach outside the directory the
70
+ # SDK created, and the CLI's tools can write into that directory —
71
+ # sandboxed ones too — up to and during this call: they can turn the
72
+ # directory, or anything in it, into a symlink between two steps taken
73
+ # here. So a path that was checked is never resolved again:
74
+ #
75
+ # 1. The directory is moved, in one rename, into a fresh private directory
76
+ # next to it. A rename moves a symlink itself, never its target, and it
77
+ # takes the directory away from the path its writers know. (A read-only
78
+ # directory cannot be moved; it is made writable first, through a
79
+ # handle that has to be the directory the SDK created.)
80
+ # 2. What was moved is then examined: it has to be a real directory with
81
+ # the device and inode recorded when the SDK created it. If it is not,
82
+ # or it could not be moved, nothing is deleted and the warning says why.
83
+ # 3. Its entries are deleted by SessionResume::Scrubber, which takes each
84
+ # one out of the tree before it looks at it.
85
+ #
86
+ # config_dir is the new location afterwards, the one the warning names.
41
87
  def preserve_transcripts
42
- ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name|
43
- FileUtils.rm_f(File.join(@config_dir, name))
44
- end
45
- warn 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \
46
- "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
47
- 'deleting it — import it into your session store, then remove the directory.'
88
+ @kept = true # first: whatever happens below, #cleanup must not delete it
89
+ original = @config_dir
90
+ announced = false
91
+ warn "#{MIRROR_DROPPED} #{move_aside_and_scrub(original)}"
92
+ announced = true
48
93
  rescue StandardError => e
49
94
  warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}"
95
+ announced = true
96
+ ensure
97
+ # Cut short by something that is not a StandardError (a cancellation, a
98
+ # signal). #cleanup leaves the directory alone from now on, so say what
99
+ # is left to do with it.
100
+ warn "#{MIRROR_DROPPED} #{interrupted_notice(original)}" unless announced
101
+ end
102
+
103
+ private
104
+
105
+ # What is left to say when the scrub was cut short: where the transcript
106
+ # is, that copies which were not removed yet may be left, and which
107
+ # directory to remove once the transcript is imported. That is the private
108
+ # directory once the move happened (config_dir changed with it; the trash
109
+ # is in there too) and the temp dir itself before — never its parent,
110
+ # which would be the system's temp directory.
111
+ def interrupted_notice(original)
112
+ holding = @config_dir == original ? @config_dir : File.dirname(@config_dir)
113
+ "Scrubbing was interrupted; the session transcript is under #{File.join(@config_dir, 'projects')}. Copies of " \
114
+ "your credentials and settings that were not removed yet may be left under #{holding} — import the " \
115
+ "transcript into your session store, then remove #{holding}."
116
+ end
117
+
118
+ # Returns the rest of the preservation warning. +original+ is config_dir
119
+ # as it is when preserve_transcripts starts.
120
+ def move_aside_and_scrub(original)
121
+ staging, failure = move_aside(original)
122
+ if failure
123
+ return "#{SKIPPED} #{original} could not be moved aside (#{failure.message}). If it is still there, the " \
124
+ "session transcript is under #{File.join(original, 'projects')}, next to copies of your " \
125
+ 'credentials and settings that were not removed.'
126
+ end
127
+
128
+ @config_dir = File.join(staging, File.basename(original))
129
+ unless @root_identity && SessionResume.directory_identity(@config_dir) == @root_identity
130
+ return "#{SKIPPED} what was at #{original} is not the directory the SDK created (it had been replaced). " \
131
+ "It is now at #{@config_dir}; check what it holds before importing anything from it."
132
+ end
133
+
134
+ preserved = "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
135
+ "deleting it — import it into your session store, then remove #{staging}."
136
+ [preserved, scrub_all_but_projects(staging)].compact.join(' ')
137
+ end
138
+
139
+ # Step 1. Returns [private directory the root now lives in, nil], or
140
+ # [nil, error] when it stayed where it was.
141
+ def move_aside(original)
142
+ staging = nil
143
+ make_root_movable(original)
144
+ staging = Dir.mktmpdir(STAGING_PREFIX, File.dirname(original))
145
+ File.rename(original, File.join(staging, File.basename(original)))
146
+ [staging, nil]
147
+ rescue SystemCallError, SessionResume::Scrubber::Changed => e
148
+ Dir.rmdir(staging) if staging
149
+ [nil, e]
150
+ end
151
+
152
+ # Moving a directory to another parent takes write permission on the
153
+ # directory itself. Skipping the scrub for a read-only one would leave the
154
+ # credential copies behind, so it is made accessible first — through a
155
+ # handle that has to be the directory the SDK created.
156
+ def make_root_movable(root)
157
+ stat = File.lstat(root)
158
+ return if !stat.directory? || SessionResume::Scrubber.owner_rwx?(stat)
159
+
160
+ SessionResume::Scrubber.make_accessible(root, @root_identity)
161
+ end
162
+
163
+ # Step 3. Deletes every entry of the moved root but projects/, then checks
164
+ # that nothing else is left. Returns nil, or what the warning has to add.
165
+ def scrub_all_but_projects(staging)
166
+ trash = Dir.mktmpdir('scrub-', staging)
167
+ scrubber = SessionResume::Scrubber.new(trash)
168
+ errors = {}
169
+ Dir.children(@config_dir).each do |name|
170
+ next if name == 'projects'
171
+
172
+ scrubber.remove(@config_dir, name)
173
+ rescue SessionResume::Scrubber::Changed => e
174
+ errors[name] = e
175
+ break # something is rearranging the directory under the scrub
176
+ rescue SystemCallError => e
177
+ errors[name] = e
178
+ end
179
+ Dir.rmdir(trash) if errors.empty?
180
+
181
+ left = (Dir.children(@config_dir) - ['projects'] + errors.keys).uniq.sort
182
+ return if left.empty?
183
+
184
+ reason = errors.values_at(*left).compact.first&.message
185
+ "Scrubbing failed: could not remove #{left.join(', ')}#{" (#{reason})" if reason}. What is left is under " \
186
+ "#{staging} and can hold copies of your credentials and settings."
50
187
  end
51
188
  end
52
189
 
@@ -75,6 +212,10 @@ module ClaudeAgentSDK
75
212
  KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials'
76
213
  KEYCHAIN_TIMEOUT_SECONDS = 5
77
214
 
215
+ # The default ClaudeAgentOptions gives load_timeout_ms; used when a caller
216
+ # set the attribute back to nil.
217
+ DEFAULT_LOAD_TIMEOUT_MS = 60_000
218
+
78
219
  # SystemCallError classes that indicate a transiently-held handle (Windows
79
220
  # AV/indexer scanning a freshly-written file) or a recoverable resource
80
221
  # shortage (file-table exhaustion) rather than a permanent failure. EMFILE/
@@ -89,9 +230,11 @@ module ClaudeAgentSDK
89
230
  # Return a copy of +options+ repointed at a materialized temp config dir:
90
231
  # CLAUDE_CONFIG_DIR in env, resume set to the materialized session id, and
91
232
  # continue_conversation cleared (already resolved to a concrete session id).
233
+ # options.env reads nil once a caller set it back to nil (the constructor
234
+ # default is {}); that means no overrides.
92
235
  def apply_materialized_options(options, materialized)
93
236
  options.dup_with(
94
- env: options.env.merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s),
237
+ env: (options.env || {}).merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s),
95
238
  resume: materialized.resume_session_id,
96
239
  continue_conversation: false
97
240
  )
@@ -123,10 +266,11 @@ module ClaudeAgentSDK
123
266
  # exception or the timeout) if a store call fails or times out.
124
267
  def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order
125
268
  store = options.session_store
126
- return nil if store.nil?
127
- return nil if options.resume.nil? && !options.continue_conversation
269
+ return nil if store.nil? || (options.resume.nil? && !options.continue_conversation)
128
270
 
129
- timeout_s = options.load_timeout_ms / 1000.0
271
+ # load_timeout_ms reads nil once a caller set it back to nil; the
272
+ # constructor default applies then. 0 is a valid (immediate) timeout.
273
+ timeout_s = (options.load_timeout_ms || DEFAULT_LOAD_TIMEOUT_MS) / 1000.0
130
274
  # Probed ONCE at materialization entry (the resume path's construction
131
275
  # point) so an invalid callback_scheduling declaration fails fast here,
132
276
  # before any store IO or temp-dir work.
@@ -152,6 +296,9 @@ module ClaudeAgentSDK
152
296
  session_id, lines = resolved
153
297
  tmp_base = Dir.mktmpdir('claude-resume-')
154
298
  begin
299
+ # Taken now: a teardown that deletes inside the directory checks that
300
+ # the path still leads to this one (MaterializedResume#preserve_transcripts).
301
+ root_identity = directory_identity(tmp_base)
155
302
  project_dir = File.join(tmp_base, 'projects', project_key)
156
303
  FileUtils.mkdir_p(project_dir)
157
304
  write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), lines)
@@ -160,9 +307,7 @@ module ClaudeAgentSDK
160
307
  # so it can authenticate. Missing files are fine (API-key auth, etc.).
161
308
  copy_auth_files(tmp_base, options.env)
162
309
 
163
- if SessionStore.implements?(store, :list_subkeys)
164
- materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
165
- end
310
+ materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper)
166
311
  rescue Exception # rubocop:disable Lint/RescueException
167
312
  # Any failure after mkdtemp leaves tmp_base (which may already hold a
168
313
  # .credentials.json copy) on disk with no path for the caller to clean
@@ -172,7 +317,7 @@ module ClaudeAgentSDK
172
317
  raise
173
318
  end
174
319
 
175
- MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id)
320
+ MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id, root_identity: root_identity)
176
321
  end
177
322
 
178
323
  # -- Helpers --
@@ -276,17 +421,20 @@ module ClaudeAgentSDK
276
421
  # isSidechain check above stays even on the summary path: a missing or
277
422
  # stale sidecar row costs one extra load, never a wrong resume.
278
423
  def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper)
279
- return nil unless SessionStore.implements?(store, :list_session_summaries)
280
-
281
- rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do
282
- store.list_session_summaries(project_key)
424
+ # optional_call: NotImplementedError is a ScriptError that with_timeout
425
+ # does not wrap.
426
+ implemented, rows = SessionStores.optional_call(store, :list_session_summaries) do
427
+ with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do
428
+ store.list_session_summaries(project_key)
429
+ end
283
430
  end
431
+ return nil unless implemented
432
+
284
433
  Array(rows).each_with_object({}) do |row, acc|
285
434
  sid = row.is_a?(Hash) ? row['session_id'] : nil
286
435
  acc[sid] = row.dig('data', 'is_sidechain') == true if sid
287
436
  end
288
- # NotImplementedError is a ScriptError that with_timeout does not wrap.
289
- rescue StandardError, NotImplementedError
437
+ rescue StandardError
290
438
  nil
291
439
  end
292
440
 
@@ -357,16 +505,23 @@ module ClaudeAgentSDK
357
505
  # read_if_present returns raw bytes; the credentials path parses and
358
506
  # re-serializes JSON, so hand it a UTF-8-tagged string (invalid bytes
359
507
  # simply fail to parse and get written through, as before).
360
- creds_bytes = source_config_dir && read_if_present(File.join(source_config_dir, '.credentials.json'))
508
+ creds_path = source_config_dir && File.join(source_config_dir, '.credentials.json')
509
+ creds_bytes = creds_path && read_if_present(creds_path)
361
510
  creds_json = creds_bytes&.dup&.force_encoding(Encoding::UTF_8)
362
511
 
363
- # macOS default keeps OAuth tokens in the Keychain, not a file. Redirecting
364
- # CLAUDE_CONFIG_DIR changes the Keychain service suffix so the subprocess's
365
- # lookup misses; populate the plaintext file from the parent's Keychain.
366
- # Skipped when env-based auth or a custom config dir is already in play.
367
- if caller_config_dir.nil? && env_value(opt_env, 'ANTHROPIC_API_KEY').nil? &&
368
- env_value(opt_env, 'CLAUDE_CODE_OAUTH_TOKEN').nil?
369
- keychain = read_keychain_credentials
512
+ # macOS keeps OAuth tokens in the Keychain, not in a file, under a
513
+ # service name that depends on the config dir (see
514
+ # keychain_service_name). Redirecting CLAUDE_CONFIG_DIR changes that
515
+ # name, so the subprocess's own lookup misses; populate the plaintext
516
+ # file from the caller's entry instead. Skipped under env-based auth.
517
+ #
518
+ # Default config dir: a Keychain hit overrides the file. Custom config
519
+ # dir: the Keychain is consulted only when the directory has no
520
+ # .credentials.json at all — which of the two the CLI prefers when both
521
+ # exist is not established, so a file that is there keeps winning.
522
+ if env_value(opt_env, 'ANTHROPIC_API_KEY').nil? && env_value(opt_env, 'CLAUDE_CODE_OAUTH_TOKEN').nil? &&
523
+ (caller_config_dir.nil? || !File.exist?(creds_path))
524
+ keychain = read_keychain_credentials(caller_config_dir)
370
525
  creds_json = keychain unless keychain.nil?
371
526
  end
372
527
 
@@ -536,9 +691,11 @@ module ClaudeAgentSDK
536
691
  creds_json
537
692
  end
538
693
 
539
- # Read OAuth credentials JSON from the macOS Keychain (default service name).
540
- # Best-effort — returns nil on any error or non-macOS platforms.
541
- def read_keychain_credentials
694
+ # Read OAuth credentials JSON from the macOS Keychain entry the CLI keeps
695
+ # for +config_dir+ (the caller's CLAUDE_CONFIG_DIR; nil for the default
696
+ # config dir). Best-effort — returns nil on any error or non-macOS
697
+ # platforms.
698
+ def read_keychain_credentials(config_dir)
542
699
  return nil unless RbConfig::CONFIG['host_os'].match?(/darwin/)
543
700
 
544
701
  user = (ENV['USER'] && !ENV['USER'].empty? ? ENV['USER'] : nil) || begin
@@ -549,7 +706,7 @@ module ClaudeAgentSDK
549
706
  end
550
707
 
551
708
  stdout, status = capture_with_timeout(
552
- ['security', 'find-generic-password', '-a', user, '-w', '-s', KEYCHAIN_SERVICE_NAME],
709
+ ['security', 'find-generic-password', '-a', user, '-w', '-s', keychain_service_name(config_dir)],
553
710
  KEYCHAIN_TIMEOUT_SECONDS
554
711
  )
555
712
  return nil if status.nil? || !status.success?
@@ -560,6 +717,21 @@ module ClaudeAgentSDK
560
717
  nil
561
718
  end
562
719
 
720
+ # The Keychain service the CLI stores its credentials under. With the
721
+ # default config dir that is KEYCHAIN_SERVICE_NAME; with a custom
722
+ # CLAUDE_CONFIG_DIR the CLI appends the first 8 hex digits of the SHA-256
723
+ # of that directory — the string the environment carries (no realpath),
724
+ # NFC-normalized. The bytes are tagged UTF-8 first, which is how the CLI
725
+ # reads them: under a C locale Ruby tags a non-ASCII ENV value as binary,
726
+ # and unicode_normalize rejects that.
727
+ def keychain_service_name(config_dir)
728
+ return KEYCHAIN_SERVICE_NAME if config_dir.nil?
729
+
730
+ dir = config_dir.to_s.dup.force_encoding(Encoding::UTF_8)
731
+ dir = dir.unicode_normalize(:nfc) if dir.valid_encoding?
732
+ "#{KEYCHAIN_SERVICE_NAME}-#{Digest::SHA256.hexdigest(dir)[0, 8]}"
733
+ end
734
+
563
735
  # Run a command with a hard timeout, draining stdout on a side thread and
564
736
  # SIGKILL-ing on deadline (Timeout.timeout is unsafe under the fiber
565
737
  # scheduler). Returns [stdout, status] or [nil, nil] on timeout/error.
@@ -600,9 +772,17 @@ module ClaudeAgentSDK
600
772
  # Load and write all subagent transcripts/metadata under session_id.
601
773
  def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly
602
774
  session_dir = File.join(project_dir, session_id)
603
- subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do
604
- store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
775
+ # list_subkeys is optional: without it (or when it raises
776
+ # NotImplementedError, see optional_call) only the main transcript is
777
+ # materialized. Only this one call sits inside optional_call: the loop
778
+ # below calls the REQUIRED #load, and a NotImplementedError from that
779
+ # must surface as a failed resume, not read as "no list_subkeys".
780
+ listed, subkeys = SessionStores.optional_call(store, :list_subkeys) do
781
+ with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do
782
+ store.list_subkeys('project_key' => project_key, 'session_id' => session_id)
783
+ end
605
784
  end
785
+ return unless listed
606
786
 
607
787
  Array(subkeys).each do |subpath|
608
788
  # Subpaths come from an external store and become filesystem path
@@ -686,6 +866,143 @@ module ClaudeAgentSDK
686
866
  File.expand_path(dir)
687
867
  end
688
868
 
869
+ # [device, inode] of +path+ itself when it is a real directory — lstat, so
870
+ # a symlink is not followed and does not count — else nil. Taken when the
871
+ # temp config dir is created; MaterializedResume compares it before it
872
+ # deletes anything inside.
873
+ def directory_identity(path)
874
+ stat = File.lstat(path)
875
+ stat.directory? ? [stat.dev, stat.ino] : nil
876
+ rescue SystemCallError, TypeError
877
+ nil
878
+ end
879
+
880
+ # Deletes entries of a directory tree that something else may still be
881
+ # writing into, without ever following a symlink — not even one that takes
882
+ # an entry's place while this runs.
883
+ #
884
+ # Looking at an entry and then acting on it by path leaves a gap: what
885
+ # lstat called a directory can be a symlink by the time it is listed,
886
+ # chmodded or descended into, and a path through it then leads outside the
887
+ # tree. So an entry is taken out first — renamed into +trash+, a private
888
+ # directory nothing else knows; a rename moves a symlink itself, never its
889
+ # target. Only then is the entry examined, and unlinked or, for a
890
+ # directory, emptied the same way one level at a time. Every path used is
891
+ # therefore an entry of the directory handed to #remove (which the caller
892
+ # vouches for), trash/<taken> or trash/<taken>/<child>.
893
+ #
894
+ # A directory can refuse: moving one to another parent takes write
895
+ # permission on the directory itself, emptying it takes read, write and
896
+ # search. It is then made accessible (see .make_accessible); nothing else
897
+ # is ever chmodded.
898
+ class Scrubber
899
+ # An entry was no longer what an earlier look at it had found.
900
+ class Changed < StandardError; end
901
+
902
+ OWNER_RWX = 0o700
903
+
904
+ # How many times a directory that keeps receiving entries is emptied
905
+ # before rmdir's "not empty" is left to report it.
906
+ EMPTYING_PASSES = 3
907
+
908
+ def self.owner_rwx?(stat)
909
+ stat.mode.allbits?(OWNER_RWX)
910
+ end
911
+
912
+ # chmod 0700 the directory at +path+ without following a symlink: the
913
+ # mode is set through a handle (fchmod), and only once that handle
914
+ # proved to be a directory this user owns whose [device, inode] is
915
+ # +identity+. The path is looked at again afterwards. Raises Changed
916
+ # when either look finds something else, and SystemCallError when the
917
+ # directory cannot be opened (its owner cannot read it) or chmodded.
918
+ def self.make_accessible(path, identity)
919
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |handle|
920
+ stat = handle.stat
921
+ unless stat.directory? && [stat.dev, stat.ino] == identity
922
+ raise Changed, "#{path} is not the directory it was a moment ago"
923
+ end
924
+ raise Errno::EPERM, path unless stat.owned?
925
+
926
+ handle.chmod(OWNER_RWX)
927
+ end
928
+ return if SessionResume.directory_identity(path) == identity
929
+
930
+ raise Changed, "#{path} was replaced while it was being made writable"
931
+ rescue Errno::ELOOP, Errno::EMLINK
932
+ # What O_NOFOLLOW answers for a symlink (EMLINK on some BSDs).
933
+ raise Changed, "#{path} became a symlink"
934
+ end
935
+
936
+ def initialize(trash)
937
+ @trash = trash
938
+ @taken = 0
939
+ end
940
+
941
+ # Remove the entry +name+ of +dir+ and everything under it. Raises
942
+ # SystemCallError when something cannot be removed, and Changed when an
943
+ # entry was swapped while it was being made accessible.
944
+ #
945
+ # Nothing here recurses: this runs on the reactor, and a tree can be
946
+ # deeper than a fiber's stack. Emptying a directory moves its entries
947
+ # into the trash, which is flat, so what is still to be removed is a
948
+ # list of trash paths, whatever depth they came from.
949
+ def remove(dir, name)
950
+ pending = [take(dir, name)]
951
+ until pending.empty?
952
+ path = pending.pop
953
+ stat = File.lstat(path)
954
+ next File.unlink(path) unless stat.directory?
955
+
956
+ self.class.make_accessible(path, [stat.dev, stat.ino]) unless self.class.owner_rwx?(stat)
957
+ pending.concat(empty_and_remove(path))
958
+ end
959
+ end
960
+
961
+ private
962
+
963
+ # Empty the directory at +path+ into the trash and remove it; returns
964
+ # where its entries are now.
965
+ #
966
+ # Something that still holds the directory open can write into it
967
+ # meanwhile, and a listing cannot tell: the entry may arrive right after
968
+ # it, and the listing may well have been empty. rmdir is what notices
969
+ # (ENOTEMPTY; EEXIST on some systems). The directory then gets another
970
+ # pass, EMPTYING_PASSES in all; the last refusal is the caller's to
971
+ # report.
972
+ def empty_and_remove(path)
973
+ taken = []
974
+ passes = 0
975
+ begin
976
+ passes += 1
977
+ Dir.children(path).each { |child| taken << take(path, child) }
978
+ Dir.rmdir(path)
979
+ rescue Errno::ENOTEMPTY, Errno::EEXIST
980
+ retry if passes < EMPTYING_PASSES
981
+ raise
982
+ end
983
+ taken
984
+ end
985
+
986
+ # Move dir/name into the trash, whatever it is, and return its new path.
987
+ def take(dir, name)
988
+ source = File.join(dir, name)
989
+ target = File.join(@trash, (@taken += 1).to_s)
990
+ begin
991
+ File.rename(source, target)
992
+ rescue Errno::EACCES, Errno::EPERM
993
+ # A directory that is not writable cannot be moved to another
994
+ # parent. It is looked at immediately before it is repaired;
995
+ # whatever else refuses (the parent, a file) is left as it is.
996
+ stat = File.lstat(source)
997
+ raise if !stat.directory? || self.class.owner_rwx?(stat)
998
+
999
+ self.class.make_accessible(source, [stat.dev, stat.ino])
1000
+ File.rename(source, target)
1001
+ end
1002
+ target
1003
+ end
1004
+ end
1005
+
689
1006
  # Best-effort recursive removal with retries on transient lock errors
690
1007
  # (Windows AV/indexer). Never raises. The temp dir holds an access token, so
691
1008
  # the final sweep matters for not leaking secrets.
@@ -782,7 +1099,7 @@ module ClaudeAgentSDK
782
1099
 
783
1100
  private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl,
784
1101
  :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
785
- :capture_with_timeout, :materialize_subkeys, :write_subagent_files,
1102
+ :keychain_service_name, :capture_with_timeout, :materialize_subkeys, :write_subagent_files,
786
1103
  :resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value,
787
1104
  :strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes,
788
1105
  :redacted_credentials, :encode_candidate, :encode_jsonl_lines, :encode_entry,
@@ -318,6 +318,33 @@ module ClaudeAgentSDK
318
318
  mode
319
319
  end
320
320
 
321
+ # Run a block that calls the OPTIONAL adapter method +method+
322
+ # (list_sessions, list_session_summaries, delete, list_subkeys) and report
323
+ # whether the adapter implements it: [true, the block's value], or
324
+ # [false, nil] when the method is missing, is the stub inherited from
325
+ # SessionStore, or raises NotImplementedError when called.
326
+ #
327
+ # The last case is the one SessionStore.implements? cannot see. Behind a
328
+ # delegating wrapper (a SimpleDelegator around a SessionStore subclass:
329
+ # a metrics or tenancy decorator) every inherited stub answers
330
+ # respond_to? with the wrapper as its owner, so each optional method
331
+ # reads as implemented; and an adapter may decline one at run time by
332
+ # raising the marker itself. NotImplementedError is a ScriptError: it
333
+ # passed every `rescue StandardError`, the SessionStoreError wrapping
334
+ # included, and reached the caller raw where the documented behavior is
335
+ # the fallback of a store without the method.
336
+ #
337
+ # Only for an optional method, and only around the call itself: a
338
+ # NotImplementedError from #append or #load is an adapter bug and must
339
+ # surface, so nothing that calls those belongs in the block.
340
+ def optional_call(store, method)
341
+ return [false, nil] unless SessionStore.implements?(store, method)
342
+
343
+ [true, yield]
344
+ rescue NotImplementedError
345
+ [false, nil]
346
+ end
347
+
321
348
  # Derive a SessionKey from an absolute transcript file path.
322
349
  #
323
350
  # Main: <projects_dir>/<project_key>/<session_id>.jsonl
@@ -427,10 +454,12 @@ module ClaudeAgentSDK
427
454
  # NFC like Python's _get_projects_dir(env_override) — a decomposed
428
455
  # Unicode override would otherwise mismatch the NFC paths used for
429
456
  # the mirror's projects-dir prefix comparison and drop every frame.
430
- return File.join(override.unicode_normalize(:nfc), 'projects') if override
457
+ # Sessions.nfc_path: under LANG=C the ENV value arrives tagged BINARY,
458
+ # which unicode_normalize alone raises on.
459
+ return File.join(Sessions.nfc_path(override), 'projects') if override
431
460
 
432
461
  home = Sessions.home_dir(env_override)
433
- home && File.join(home, '.claude', 'projects').unicode_normalize(:nfc)
462
+ home && Sessions.nfc_path(File.join(home, '.claude', 'projects'))
434
463
  end
435
464
  end
436
465
  end