claude-agent-sdk 0.30.0 → 0.32.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/CHANGELOG.md +34 -0
- data/README.md +107 -223
- data/docs/cli-installer.md +57 -0
- data/docs/configuration.md +39 -0
- data/docs/errors.md +53 -0
- data/docs/sessions.md +42 -0
- data/docs/types.md +74 -4
- data/lib/claude_agent_sdk/command_builder.rb +42 -0
- data/lib/claude_agent_sdk/errors.rb +147 -0
- data/lib/claude_agent_sdk/message_parser.rb +38 -3
- data/lib/claude_agent_sdk/query.rb +73 -29
- data/lib/claude_agent_sdk/session_resume.rb +223 -33
- data/lib/claude_agent_sdk/sessions.rb +112 -19
- data/lib/claude_agent_sdk/types.rb +215 -2
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +57 -29
- metadata +3 -2
|
@@ -63,7 +63,8 @@ module ClaudeAgentSDK
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil,
|
|
66
|
-
exclude_dynamic_sections: nil,
|
|
66
|
+
exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
|
|
67
|
+
forward_subagent_text: false, callback_scheduling: :thread, callback_wrapper: nil)
|
|
67
68
|
@transport = transport
|
|
68
69
|
@is_streaming_mode = is_streaming_mode
|
|
69
70
|
@can_use_tool = can_use_tool
|
|
@@ -73,7 +74,9 @@ module ClaudeAgentSDK
|
|
|
73
74
|
@callback_wrapper = callback_wrapper
|
|
74
75
|
@agents = agents
|
|
75
76
|
@exclude_dynamic_sections = exclude_dynamic_sections
|
|
77
|
+
@system_prompt_snapshot = system_prompt_snapshot
|
|
76
78
|
@skills = skills
|
|
79
|
+
@forward_subagent_text = forward_subagent_text
|
|
77
80
|
|
|
78
81
|
# Control protocol state
|
|
79
82
|
@pending_control_responses = {}
|
|
@@ -97,7 +100,12 @@ module ClaudeAgentSDK
|
|
|
97
100
|
# #1088/#1103), so a result that arrives while this set is non-empty
|
|
98
101
|
# must not close stdin.
|
|
99
102
|
@inflight_tasks = Set.new
|
|
100
|
-
|
|
103
|
+
# Set to the result payload when the most recent message is a result
|
|
104
|
+
# with is_error=true. Used to replace the generic "exit code 1"
|
|
105
|
+
# ProcessError with a ResultError carrying what the CLI already
|
|
106
|
+
# reported. Mirrors the TypeScript SDK's `lastErrorResultText`
|
|
107
|
+
# (Query.ts), but keeps the whole payload rather than just the text.
|
|
108
|
+
@last_error_result = nil
|
|
101
109
|
@first_result_condition = Async::Condition.new
|
|
102
110
|
@task = nil
|
|
103
111
|
@child_tasks = []
|
|
@@ -182,9 +190,15 @@ module ClaudeAgentSDK
|
|
|
182
190
|
agents: agents_dict
|
|
183
191
|
}
|
|
184
192
|
request[:excludeDynamicSections] = @exclude_dynamic_sections unless @exclude_dynamic_sections.nil?
|
|
193
|
+
# false is meaningful (rebuild the prompt every request), so send it
|
|
194
|
+
# explicitly; only nil (unset) is omitted.
|
|
195
|
+
request[:systemPromptSnapshot] = @system_prompt_snapshot unless @system_prompt_snapshot.nil?
|
|
185
196
|
# 'all' and omitted are equivalent at the wire level (no filter), so
|
|
186
197
|
# only send the field when it's an explicit list (mirrors Python).
|
|
187
198
|
request[:skills] = @skills if @skills.is_a?(Array)
|
|
199
|
+
# Off is the CLI default, so only send the field when enabled — an
|
|
200
|
+
# older CLI then never sees an unknown key on the common path.
|
|
201
|
+
request[:forwardSubagentText] = true if @forward_subagent_text
|
|
188
202
|
|
|
189
203
|
response = send_control_request(request)
|
|
190
204
|
@initialized = true
|
|
@@ -355,45 +369,52 @@ module ClaudeAgentSDK
|
|
|
355
369
|
@first_result_condition.signal
|
|
356
370
|
end
|
|
357
371
|
if message[:is_error]
|
|
358
|
-
|
|
359
|
-
@last_error_result_text = errors.empty? ? (message[:subtype] || 'unknown error').to_s : errors
|
|
372
|
+
@last_error_result = message
|
|
360
373
|
else
|
|
361
|
-
@
|
|
374
|
+
@last_error_result = nil
|
|
362
375
|
end
|
|
363
376
|
elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
|
|
364
377
|
# Anything other than the post-turn session_state_changed marker
|
|
365
378
|
# means the conversation moved on; a ProcessError now is a fresh
|
|
366
379
|
# crash, not the expected exit from a prior error result. Mirrors
|
|
367
380
|
# the Python/TypeScript SDK reset logic.
|
|
368
|
-
@
|
|
381
|
+
@last_error_result = nil
|
|
369
382
|
end
|
|
370
383
|
# Regular SDK messages go to the queue
|
|
371
384
|
@message_queue.enqueue(message)
|
|
372
385
|
end
|
|
373
386
|
end
|
|
374
387
|
rescue StandardError => e
|
|
375
|
-
# Unblock pending control requests (e.g., initialize) so callers don't
|
|
376
|
-
# hang until timeout. INVARIANT: store the result before signaling —
|
|
377
|
-
# senders check the slot before waiting (level-trigger).
|
|
378
|
-
@pending_control_responses.dup.each do |request_id, condition|
|
|
379
|
-
@pending_control_results[request_id] ||= e
|
|
380
|
-
condition.signal
|
|
381
|
-
end
|
|
382
|
-
|
|
383
388
|
# When the CLI emits a result with is_error=true (e.g. error_max_turns,
|
|
384
|
-
# error_during_execution, a StructuredOutput error) it
|
|
385
|
-
# non-zero on purpose, for shell-script consumers. The
|
|
386
|
-
# ProcessError carries no information beyond "exit code 1" —
|
|
387
|
-
# with
|
|
388
|
-
# actionable. Mirrors the Python SDK
|
|
389
|
-
# SDK (Query.ts readMessages).
|
|
390
|
-
error = if e.is_a?(ProcessError) && @
|
|
391
|
-
|
|
392
|
-
|
|
389
|
+
# error_during_execution, an API failure, a StructuredOutput error) it
|
|
390
|
+
# then exits non-zero on purpose, for shell-script consumers. The
|
|
391
|
+
# trailing ProcessError carries no information beyond "exit code 1" —
|
|
392
|
+
# replace it with a ResultError carrying what the CLI already reported
|
|
393
|
+
# so the exception is actionable *and* typed. Mirrors the Python SDK
|
|
394
|
+
# (_read_messages) and the TypeScript SDK (Query.ts readMessages).
|
|
395
|
+
error = if e.is_a?(ProcessError) && @last_error_result
|
|
396
|
+
ResultError.new("Claude Code returned an error result: #{ResultError.error_text(@last_error_result)}",
|
|
397
|
+
data: @last_error_result, exit_code: e.exit_code, stderr: e.stderr,
|
|
398
|
+
original_error: e)
|
|
393
399
|
else
|
|
394
400
|
e
|
|
395
401
|
end
|
|
396
402
|
|
|
403
|
+
# Unblock pending control requests (e.g., initialize) so callers don't
|
|
404
|
+
# hang until timeout. Computed AFTER the replacement above so they get
|
|
405
|
+
# the same enriched error the message stream does: a refused resume (a
|
|
406
|
+
# nonexistent session, a failed --resume-drops-turn guard) is reported
|
|
407
|
+
# by the CLI as an error result followed by exit 1 *before* it answers
|
|
408
|
+
# the SDK's `initialize`, so signaling the raw `e` here handed that
|
|
409
|
+
# in-flight request "Command failed with exit code 1" and discarded the
|
|
410
|
+
# real reason (Python #1198).
|
|
411
|
+
# INVARIANT: store the result before signaling — senders check the slot
|
|
412
|
+
# before waiting (level-trigger).
|
|
413
|
+
@pending_control_responses.dup.each do |request_id, condition|
|
|
414
|
+
@pending_control_results[request_id] ||= error
|
|
415
|
+
condition.signal
|
|
416
|
+
end
|
|
417
|
+
|
|
397
418
|
# Put error in queue so iterators can handle it
|
|
398
419
|
@message_queue.enqueue({ type: 'error', error: error })
|
|
399
420
|
ensure
|
|
@@ -459,6 +480,24 @@ module ClaudeAgentSDK
|
|
|
459
480
|
end
|
|
460
481
|
end
|
|
461
482
|
|
|
483
|
+
# Whether the CLI may still send control requests that need a reply.
|
|
484
|
+
#
|
|
485
|
+
# SDK MCP servers, hooks and the can_use_tool permission callback are all
|
|
486
|
+
# served over the control protocol: the CLI writes a control_request to
|
|
487
|
+
# stdout and blocks until the SDK writes the matching control_response to
|
|
488
|
+
# stdin. Closing stdin while any of these is configured makes every later
|
|
489
|
+
# request fail CLI-side with "Stream closed". Mirrors the TypeScript
|
|
490
|
+
# SDK's hasBidirectionalNeeds, de-prefixed per Ruby naming (Python #1204).
|
|
491
|
+
#
|
|
492
|
+
# can_use_tool is tested for truthiness, not for nil: ClaudeAgentSDK
|
|
493
|
+
# .configure_can_use_tool treats a falsey callback as "no callback"
|
|
494
|
+
# (`can_use_tool: enabled ? cb : false` is a real config shape), and the
|
|
495
|
+
# two must agree — otherwise `false` would skip the stdio routing while
|
|
496
|
+
# still holding stdin open for a reply that can never be asked for.
|
|
497
|
+
def bidirectional_needs?
|
|
498
|
+
!@sdk_mcp_servers.empty? || !@hooks.empty? || !!@can_use_tool
|
|
499
|
+
end
|
|
500
|
+
|
|
462
501
|
# Flush the transcript-mirror batcher, swallowing errors — a mirror failure
|
|
463
502
|
# must never propagate into the read loop or its teardown.
|
|
464
503
|
def flush_transcript_mirror
|
|
@@ -1239,8 +1278,9 @@ module ClaudeAgentSDK
|
|
|
1239
1278
|
})
|
|
1240
1279
|
end
|
|
1241
1280
|
|
|
1242
|
-
# Wait for a run-ending result before closing stdin when hooks
|
|
1243
|
-
# servers may still need to exchange control
|
|
1281
|
+
# Wait for a run-ending result before closing stdin when hooks, SDK MCP
|
|
1282
|
+
# servers or a can_use_tool callback may still need to exchange control
|
|
1283
|
+
# messages with the CLI.
|
|
1244
1284
|
# The control protocol requires stdin to stay open for the entire turn
|
|
1245
1285
|
# (hook replies, can_use_tool replies and SDK MCP tool results are all
|
|
1246
1286
|
# written to stdin), so no timeout is applied — closing stdin mid-turn
|
|
@@ -1252,11 +1292,15 @@ module ClaudeAgentSDK
|
|
|
1252
1292
|
# another result. The condition is guaranteed to be signaled: by the
|
|
1253
1293
|
# result branch in read_messages once no tasks are in flight, or by its
|
|
1254
1294
|
# ensure block when the process exits early.
|
|
1295
|
+
#
|
|
1296
|
+
# Known limitation (same as Python's): the condition is one-shot and is
|
|
1297
|
+
# not aware of prompt messages still queued CLI-side, so an Enumerator
|
|
1298
|
+
# prompt yielding several user messages (several turns) releases the hold
|
|
1299
|
+
# at the first turn boundary with no tracked tasks; control requests from
|
|
1300
|
+
# later turns can then find stdin closed. Single-message and String
|
|
1301
|
+
# prompts — the common one-shot shapes — are fully covered.
|
|
1255
1302
|
def wait_for_result_and_end_input
|
|
1256
|
-
if !@first_result_received &&
|
|
1257
|
-
((@sdk_mcp_servers && !@sdk_mcp_servers.empty?) || (@hooks && !@hooks.empty?))
|
|
1258
|
-
@first_result_condition.wait
|
|
1259
|
-
end
|
|
1303
|
+
@first_result_condition.wait if !@first_result_received && bidirectional_needs?
|
|
1260
1304
|
ensure
|
|
1261
1305
|
@transport.end_input
|
|
1262
1306
|
end
|
|
@@ -5,6 +5,7 @@ require 'fileutils'
|
|
|
5
5
|
require 'tmpdir'
|
|
6
6
|
require 'open3'
|
|
7
7
|
require 'rbconfig'
|
|
8
|
+
require 'securerandom'
|
|
8
9
|
require_relative 'fiber_boundary'
|
|
9
10
|
require_relative 'sessions'
|
|
10
11
|
require_relative 'session_store'
|
|
@@ -36,7 +37,7 @@ module ClaudeAgentSDK
|
|
|
36
37
|
# copies, and tell the user where the data is so they can import it into
|
|
37
38
|
# the store manually. Never raises.
|
|
38
39
|
def preserve_transcripts
|
|
39
|
-
['.credentials.json', '.claude.json'].each do |name|
|
|
40
|
+
['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name|
|
|
40
41
|
FileUtils.rm_f(File.join(@config_dir, name))
|
|
41
42
|
end
|
|
42
43
|
warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
|
|
@@ -55,6 +56,18 @@ module ClaudeAgentSDK
|
|
|
55
56
|
# from the store, writes it to a temp dir laid out like ~/.claude/, and returns
|
|
56
57
|
# the path so the caller can point the subprocess at it via CLAUDE_CONFIG_DIR.
|
|
57
58
|
module SessionResume # rubocop:disable Metrics/ModuleLength
|
|
59
|
+
# User settings files seeded into the temp config dir. cowork_settings.json
|
|
60
|
+
# is the alternate filename the CLI reads in cowork-plugins mode.
|
|
61
|
+
SEEDED_SETTINGS_FILES = ['settings.json', 'cowork_settings.json'].freeze
|
|
62
|
+
|
|
63
|
+
# User-settings keys that only misbehave under the redirected
|
|
64
|
+
# CLAUDE_CONFIG_DIR: plugin declarations reconcile against the
|
|
65
|
+
# always-empty tmp_base/plugins cache and would network-install each
|
|
66
|
+
# declared marketplace on every resume.
|
|
67
|
+
RESUME_SETTINGS_STRIPPED_KEYS = %w[enabledPlugins extraKnownMarketplaces].freeze
|
|
68
|
+
|
|
69
|
+
UTF8_BOM = "\xEF\xBB\xBF".b.freeze
|
|
70
|
+
|
|
58
71
|
KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials'
|
|
59
72
|
KEYCHAIN_TIMEOUT_SECONDS = 5
|
|
60
73
|
|
|
@@ -269,13 +282,24 @@ module ClaudeAgentSDK
|
|
|
269
282
|
chmod_owner_only(path)
|
|
270
283
|
end
|
|
271
284
|
|
|
272
|
-
#
|
|
273
|
-
#
|
|
285
|
+
# Seed tmp_base with the caller's auth and user config so the resumed
|
|
286
|
+
# subprocess can authenticate: .credentials.json (refreshToken redacted),
|
|
287
|
+
# .claude.json, and user settings.json / cowork_settings.json (plugin
|
|
288
|
+
# declarations stripped).
|
|
289
|
+
#
|
|
290
|
+
# Source resolution mirrors the CLI: .credentials.json, settings.json and
|
|
291
|
+
# cowork_settings.json live under the config dir (default ~/.claude/), while
|
|
292
|
+
# .claude.json lives at $CLAUDE_CONFIG_DIR/.claude.json when set, else
|
|
293
|
+
# ~/.claude.json (NOT ~/.claude/.claude.json).
|
|
274
294
|
def copy_auth_files(tmp_base, opt_env)
|
|
275
295
|
caller_config_dir = env_value(opt_env, 'CLAUDE_CONFIG_DIR')
|
|
276
296
|
source_config_dir = caller_config_dir || File.join(Dir.home, '.claude')
|
|
277
297
|
|
|
278
|
-
|
|
298
|
+
# read_if_present returns raw bytes; the credentials path parses and
|
|
299
|
+
# re-serializes JSON, so hand it a UTF-8-tagged string (invalid bytes
|
|
300
|
+
# simply fail to parse and get written through, as before).
|
|
301
|
+
creds_bytes = read_if_present(File.join(source_config_dir, '.credentials.json'))
|
|
302
|
+
creds_json = creds_bytes&.dup&.force_encoding(Encoding::UTF_8)
|
|
279
303
|
|
|
280
304
|
# macOS default keeps OAuth tokens in the Keychain, not a file. Redirecting
|
|
281
305
|
# CLAUDE_CONFIG_DIR changes the Keychain service suffix so the subprocess's
|
|
@@ -291,6 +315,115 @@ module ClaudeAgentSDK
|
|
|
291
315
|
|
|
292
316
|
claude_json_src = caller_config_dir ? File.join(caller_config_dir, '.claude.json') : File.join(Dir.home, '.claude.json')
|
|
293
317
|
copy_if_present(claude_json_src, File.join(tmp_base, '.claude.json'))
|
|
318
|
+
|
|
319
|
+
# User settings carry apiKeyHelper (a fourth auth mechanism alongside
|
|
320
|
+
# .credentials.json / Keychain / env vars) plus the user's env, hooks and
|
|
321
|
+
# permissions. Without them the resumed subprocess sees no user settings
|
|
322
|
+
# at all, and an apiKeyHelper-only host fails with "Not logged in".
|
|
323
|
+
# cowork_settings.json is the alternate filename the CLI reads in
|
|
324
|
+
# cowork-plugins mode. Both pass through strip_settings_for_resume so
|
|
325
|
+
# plugin declarations don't reconcile against the empty tmp_base cache.
|
|
326
|
+
transform = ->(content) { strip_settings_for_resume(content) }
|
|
327
|
+
SEEDED_SETTINGS_FILES.each do |name|
|
|
328
|
+
copy_if_present(File.join(source_config_dir, name), File.join(tmp_base, name), transform)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Drop settings keys that only misbehave under the redirected config dir:
|
|
333
|
+
# RESUME_SETTINGS_STRIPPED_KEYS (plugin declarations, which reconcile
|
|
334
|
+
# against the always-empty tmp_base plugin cache and would network-install
|
|
335
|
+
# every declared marketplace on each resume) and env.CLAUDE_CONFIG_DIR
|
|
336
|
+
# (which would point the subprocess's config reads away from tmp_base).
|
|
337
|
+
# Content that doesn't parse as a JSON object is returned untouched so the
|
|
338
|
+
# subprocess sees exactly what the CLI would have read.
|
|
339
|
+
def strip_settings_for_resume(content)
|
|
340
|
+
parsed, restore = parse_settings_bytes(content)
|
|
341
|
+
return content unless parsed.is_a?(Hash)
|
|
342
|
+
|
|
343
|
+
stripped = false
|
|
344
|
+
RESUME_SETTINGS_STRIPPED_KEYS.each do |key|
|
|
345
|
+
next unless parsed.key?(key)
|
|
346
|
+
|
|
347
|
+
parsed.delete(key)
|
|
348
|
+
stripped = true
|
|
349
|
+
end
|
|
350
|
+
env_block = parsed['env']
|
|
351
|
+
if env_block.is_a?(Hash) && env_block.key?('CLAUDE_CONFIG_DIR')
|
|
352
|
+
env_block.delete('CLAUDE_CONFIG_DIR')
|
|
353
|
+
stripped = true
|
|
354
|
+
end
|
|
355
|
+
return content unless stripped
|
|
356
|
+
|
|
357
|
+
begin
|
|
358
|
+
out = JSON.generate(parsed)
|
|
359
|
+
rescue JSON::GeneratorError
|
|
360
|
+
# A spec-valid overflow like 1e999 parses to Infinity, and JSON.generate
|
|
361
|
+
# refuses to emit the bare token the CLI would reject anyway. Fall back
|
|
362
|
+
# to the original bytes rather than writing something unusable.
|
|
363
|
+
return content
|
|
364
|
+
end
|
|
365
|
+
restore ? restore.call(out) : out
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Parse settings bytes tolerating a UTF-8 BOM (PowerShell writes
|
|
369
|
+
# settings.json with one, and JSON.parse rejects it). Returns
|
|
370
|
+
# [parsed, restore] where +restore+ is nil, or a callable that must be
|
|
371
|
+
# applied to the re-serialized output (see mask_surrogate_escapes).
|
|
372
|
+
# Returns [nil, nil] when the bytes aren't valid UTF-8 or aren't valid JSON.
|
|
373
|
+
def parse_settings_bytes(content)
|
|
374
|
+
body = content.b
|
|
375
|
+
body = body.byteslice(3..) || +'' if body.start_with?(UTF8_BOM)
|
|
376
|
+
text = body.dup.force_encoding(Encoding::UTF_8)
|
|
377
|
+
return [nil, nil] unless text.valid_encoding?
|
|
378
|
+
|
|
379
|
+
parsed = begin
|
|
380
|
+
JSON.parse(text)
|
|
381
|
+
rescue JSON::ParserError
|
|
382
|
+
nil
|
|
383
|
+
end
|
|
384
|
+
return [parsed, nil] unless parsed.nil?
|
|
385
|
+
|
|
386
|
+
# Ruby's JSON parser rejects lone surrogate escapes ("\ud800") that
|
|
387
|
+
# Python's accepts, with no option to relax it. Passing the file through
|
|
388
|
+
# unstripped over one would leave enabledPlugins in place and let the
|
|
389
|
+
# resumed CLI keep network-installing every declared marketplace — the
|
|
390
|
+
# exact behavior this seeding exists to prevent. Retry with the surrogate
|
|
391
|
+
# escapes masked out, restoring them verbatim after re-serialization.
|
|
392
|
+
masked, restore = mask_surrogate_escapes(text)
|
|
393
|
+
return [nil, nil] if masked.nil?
|
|
394
|
+
|
|
395
|
+
begin
|
|
396
|
+
[JSON.parse(masked), restore]
|
|
397
|
+
rescue JSON::ParserError
|
|
398
|
+
[nil, nil]
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Replace every \uD800-\uDFFF escape with an opaque ASCII token so the
|
|
403
|
+
# document parses, and return [masked_text, restore] where +restore+ maps
|
|
404
|
+
# the tokens in generated output back to the original escape text
|
|
405
|
+
# byte-for-byte. Returns [nil, nil] when there is nothing to mask (the
|
|
406
|
+
# parse failure was something else) or the token could collide.
|
|
407
|
+
def mask_surrogate_escapes(text)
|
|
408
|
+
token_prefix = "CASDKSURROGATE#{SecureRandom.hex(8)}"
|
|
409
|
+
return [nil, nil] if text.include?(token_prefix)
|
|
410
|
+
|
|
411
|
+
escapes = []
|
|
412
|
+
masked = text.gsub(/\\+u[0-9a-fA-F]{4}/) do |match|
|
|
413
|
+
slashes = match[/\A\\+/]
|
|
414
|
+
# An even run is escaped backslashes followed by literal "uXXXX" text,
|
|
415
|
+
# not an escape sequence.
|
|
416
|
+
next match if slashes.length.even?
|
|
417
|
+
|
|
418
|
+
escape = "\\#{match[slashes.length..]}"
|
|
419
|
+
next match unless escape[2..].to_i(16).between?(0xD800, 0xDFFF)
|
|
420
|
+
|
|
421
|
+
escapes << escape
|
|
422
|
+
"#{slashes[0...-1]}#{token_prefix}#{escapes.length - 1}Z"
|
|
423
|
+
end
|
|
424
|
+
return [nil, nil] if escapes.empty?
|
|
425
|
+
|
|
426
|
+
[masked, ->(out) { out.gsub(/#{Regexp.escape(token_prefix)}(\d+)Z/) { escapes[::Regexp.last_match(1).to_i] } }]
|
|
294
427
|
end
|
|
295
428
|
|
|
296
429
|
# Write creds_json with claudeAiOauth.refreshToken removed. The resumed
|
|
@@ -298,24 +431,48 @@ module ClaudeAgentSDK
|
|
|
298
431
|
# single-use refresh token would be consumed and the new tokens written
|
|
299
432
|
# somewhere the parent never reads — revoking the parent's creds. Stripping
|
|
300
433
|
# refreshToken short-circuits the subprocess's refresh check.
|
|
434
|
+
#
|
|
435
|
+
# Content that isn't JSON at all is written through unchanged: the
|
|
436
|
+
# subprocess then reads exactly what the CLI would have read (and fails on
|
|
437
|
+
# it the same way). Note this branch is narrower than "unreadable content" —
|
|
438
|
+
# JSON.parse accepts illegal UTF-8 bytes inside an otherwise well-formed
|
|
439
|
+
# document, so redaction can instead fail at re-serialization time; see
|
|
440
|
+
# below for why that case writes nothing rather than writing through.
|
|
301
441
|
def write_redacted_credentials(creds_json, dst)
|
|
302
|
-
|
|
442
|
+
out = redacted_credentials(creds_json)
|
|
443
|
+
return if out.nil?
|
|
303
444
|
|
|
304
|
-
out = creds_json
|
|
305
|
-
begin
|
|
306
|
-
data = JSON.parse(creds_json)
|
|
307
|
-
oauth = data.is_a?(Hash) ? data['claudeAiOauth'] : nil
|
|
308
|
-
if oauth.is_a?(Hash) && oauth.key?('refreshToken')
|
|
309
|
-
oauth.delete('refreshToken')
|
|
310
|
-
out = JSON.generate(data)
|
|
311
|
-
end
|
|
312
|
-
rescue JSON::ParserError
|
|
313
|
-
# Unparseable — write through; the subprocess will fail to parse it too.
|
|
314
|
-
end
|
|
315
445
|
File.write(dst, out)
|
|
316
446
|
chmod_owner_only(dst)
|
|
317
447
|
end
|
|
318
448
|
|
|
449
|
+
# The redaction itself: returns the bytes to write, or nil to write no
|
|
450
|
+
# credentials file at all.
|
|
451
|
+
def redacted_credentials(creds_json)
|
|
452
|
+
return nil if creds_json.nil?
|
|
453
|
+
|
|
454
|
+
data = JSON.parse(creds_json)
|
|
455
|
+
oauth = data.is_a?(Hash) ? data['claudeAiOauth'] : nil
|
|
456
|
+
return creds_json unless oauth.is_a?(Hash) && oauth.key?('refreshToken')
|
|
457
|
+
|
|
458
|
+
oauth.delete('refreshToken')
|
|
459
|
+
begin
|
|
460
|
+
JSON.generate(data)
|
|
461
|
+
rescue JSON::GeneratorError => e
|
|
462
|
+
# The redaction succeeded but the result can't be re-emitted (illegal
|
|
463
|
+
# UTF-8 elsewhere in the file). Writing the original bytes through
|
|
464
|
+
# would put the un-redacted refreshToken back into the temp dir, so
|
|
465
|
+
# seed no credentials file instead — and don't abort the resume over
|
|
466
|
+
# it either, matching every other seed file's policy. A resume that
|
|
467
|
+
# falls back to another auth mechanism beats one that lets the
|
|
468
|
+
# subprocess burn the parent's single-use refresh token.
|
|
469
|
+
warn "Claude SDK: [SessionStore] resume: cannot redact credentials (#{e.message}); seeding none"
|
|
470
|
+
nil
|
|
471
|
+
end
|
|
472
|
+
rescue JSON::ParserError
|
|
473
|
+
creds_json
|
|
474
|
+
end
|
|
475
|
+
|
|
319
476
|
# Read OAuth credentials JSON from the macOS Keychain (default service name).
|
|
320
477
|
# Best-effort — returns nil on any error or non-macOS platforms.
|
|
321
478
|
def read_keychain_credentials
|
|
@@ -404,16 +561,17 @@ module ClaudeAgentSDK
|
|
|
404
561
|
# Partition entries into transcript vs agent_metadata and write the
|
|
405
562
|
# <subpath>.jsonl transcript and, if present, the <subpath>.meta.json sidecar.
|
|
406
563
|
def write_subagent_files(session_dir, subpath, entries)
|
|
407
|
-
|
|
564
|
+
# Last metadata entry wins (see Sessions.split_agent_metadata).
|
|
565
|
+
metadata, transcript = Sessions.split_agent_metadata(entries)
|
|
408
566
|
sub_file = File.join(session_dir, "#{subpath}.jsonl")
|
|
409
567
|
|
|
410
568
|
write_jsonl(sub_file, transcript) unless transcript.empty?
|
|
411
569
|
|
|
412
|
-
return if metadata.
|
|
570
|
+
return if metadata.nil?
|
|
413
571
|
|
|
414
|
-
#
|
|
415
|
-
meta_content = metadata.
|
|
416
|
-
meta_file =
|
|
572
|
+
# Strip the synthetic type field.
|
|
573
|
+
meta_content = metadata.except('type')
|
|
574
|
+
meta_file = Sessions.agent_metadata_sidecar_path(sub_file)
|
|
417
575
|
FileUtils.mkdir_p(File.dirname(meta_file))
|
|
418
576
|
File.write(meta_file, JSON.generate(meta_content))
|
|
419
577
|
chmod_owner_only(meta_file)
|
|
@@ -466,9 +624,26 @@ module ClaudeAgentSDK
|
|
|
466
624
|
FileUtils.rm_rf(path)
|
|
467
625
|
end
|
|
468
626
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
627
|
+
# Read a regular file's bytes, or return nil.
|
|
628
|
+
#
|
|
629
|
+
# A missing source is skipped silently. Any other reason it can't be read
|
|
630
|
+
# (EACCES, a directory or FIFO where a file was expected, ...) is logged and
|
|
631
|
+
# skipped: these files are best-effort enrichment of the temp config dir, so
|
|
632
|
+
# an unreadable one must not abort — or, for a FIFO, hang — the resume. The
|
|
633
|
+
# ftype check happens on the stat, before any open: opening a FIFO blocks
|
|
634
|
+
# forever, so it must never be opened in the first place.
|
|
635
|
+
def read_if_present(path)
|
|
636
|
+
ftype = File.stat(path).ftype
|
|
637
|
+
unless ftype == 'file'
|
|
638
|
+
warn "Claude SDK: [SessionStore] resume: skipping #{path} (not a regular file: #{ftype})"
|
|
639
|
+
return nil
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
File.binread(path)
|
|
643
|
+
rescue Errno::ENOENT
|
|
644
|
+
nil
|
|
645
|
+
rescue SystemCallError => e
|
|
646
|
+
warn "Claude SDK: [SessionStore] resume: skipping #{path} (#{e.message})"
|
|
472
647
|
nil
|
|
473
648
|
end
|
|
474
649
|
|
|
@@ -482,15 +657,28 @@ module ClaudeAgentSDK
|
|
|
482
657
|
nil
|
|
483
658
|
end
|
|
484
659
|
|
|
485
|
-
# Copy src to dst (locked to 0600) when src exists
|
|
486
|
-
#
|
|
487
|
-
#
|
|
660
|
+
# Copy src to dst (locked to 0600) when src exists, through an optional
|
|
661
|
+
# transform; no-op otherwise. Callers copy .claude.json and the user
|
|
662
|
+
# settings files, which can hold MCP-header secrets, customApiKeyResponses
|
|
663
|
+
# and apiKeyHelper env, so they get the same owner-only mode as the other
|
|
488
664
|
# materialized files rather than inheriting the source's (often 0644) mode.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
665
|
+
# See read_if_present for the skip policy.
|
|
666
|
+
def copy_if_present(src, dst, transform = nil)
|
|
667
|
+
content = read_if_present(src)
|
|
668
|
+
return if content.nil?
|
|
669
|
+
|
|
670
|
+
begin
|
|
671
|
+
File.binwrite(dst, transform ? transform.call(content) : content)
|
|
672
|
+
chmod_owner_only(dst)
|
|
673
|
+
rescue SystemCallError => e
|
|
674
|
+
# Don't leave a truncated dst behind for the subprocess to misparse.
|
|
675
|
+
begin
|
|
676
|
+
FileUtils.rm_f(dst)
|
|
677
|
+
rescue SystemCallError
|
|
678
|
+
nil
|
|
679
|
+
end
|
|
680
|
+
warn "Claude SDK: [SessionStore] resume: skipping #{src} (#{e.message})"
|
|
681
|
+
end
|
|
494
682
|
end
|
|
495
683
|
|
|
496
684
|
# Resolve the value the CHILD process will see for env var +name+. Presence
|
|
@@ -513,6 +701,8 @@ module ClaudeAgentSDK
|
|
|
513
701
|
private_class_method :load_candidate, :resolve_continue_candidate, :sortable_mtime, :with_timeout, :write_jsonl,
|
|
514
702
|
:copy_auth_files, :write_redacted_credentials, :read_keychain_credentials,
|
|
515
703
|
:capture_with_timeout, :materialize_subkeys, :write_subagent_files,
|
|
516
|
-
:resolve_dir, :
|
|
704
|
+
:resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value,
|
|
705
|
+
:strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes,
|
|
706
|
+
:redacted_credentials
|
|
517
707
|
end
|
|
518
708
|
end
|