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.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +110 -0
- data/README.md +43 -31
- data/docs/cli-installer.md +26 -4
- data/docs/client.md +40 -11
- data/docs/configuration.md +206 -1
- data/docs/errors.md +32 -2
- data/docs/hooks-and-permissions.md +30 -10
- data/docs/mcp-servers.md +30 -9
- data/docs/observability.md +61 -10
- data/docs/options.md +232 -0
- data/docs/rails.md +263 -18
- data/docs/sessions.md +40 -12
- data/docs/subagents.md +1 -1
- data/docs/types.md +100 -11
- data/lib/claude_agent_sdk/cli_installer.rb +140 -19
- data/lib/claude_agent_sdk/command_builder.rb +84 -27
- data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
- data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
- data/lib/claude_agent_sdk/query.rb +547 -132
- data/lib/claude_agent_sdk/railtie.rb +27 -2
- data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
- data/lib/claude_agent_sdk/session_mutations.rb +112 -92
- data/lib/claude_agent_sdk/session_resume.rb +356 -39
- data/lib/claude_agent_sdk/session_store.rb +31 -2
- data/lib/claude_agent_sdk/sessions.rb +720 -138
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +252 -29
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
- data/lib/claude_agent_sdk/transport.rb +28 -12
- data/lib/claude_agent_sdk/types/attributes.rb +9 -0
- data/lib/claude_agent_sdk/types/base.rb +85 -15
- data/lib/claude_agent_sdk/types/hooks.rb +73 -0
- data/lib/claude_agent_sdk/types/mcp.rb +37 -1
- data/lib/claude_agent_sdk/types/messages.rb +7 -1
- data/lib/claude_agent_sdk/types/option_values.rb +186 -4
- data/lib/claude_agent_sdk/types/options.rb +104 -17
- data/lib/claude_agent_sdk/types/permissions.rb +18 -9
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +111 -53
- data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
- data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
- data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
- data/sig/claude_agent_sdk/types/options.rbs +20 -7
- data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
- 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
|
-
|
|
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/),
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
warn
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
rows =
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
364
|
-
#
|
|
365
|
-
#
|
|
366
|
-
#
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
540
|
-
#
|
|
541
|
-
|
|
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',
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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')
|
|
462
|
+
home && Sessions.nfc_path(File.join(home, '.claude', 'projects'))
|
|
434
463
|
end
|
|
435
464
|
end
|
|
436
465
|
end
|