From 9eaa2efb9f4fdc3dd472ba5c4d588ccc827b242e Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:37:04 +0800 Subject: [PATCH 001/194] End the run before stopping its ceiling sleeper With a streamed prompt and hooks, a can_use_tool callback or SDK MCP servers configured, query() could close stdin right after writing a message, before that message's turn had produced a single frame. Control requests from that turn then failed CLI-side with "Stream closed". Query#end_run cleared the between-turns ceiling first and marked the run ended second. Clearing the ceiling stops the sleeper task, and stopping a task yields to whatever else is ready. A stream_input task that was ready at that moment wrote its next message, found the run still open (so reopen_run did nothing), and started waiting on the run that was about to end; end! then released it and stdin closed. It needed the CLI's "idle" and the next message to become ready in the same reactor pass, with the ceiling armed. The two statements are swapped: the run is marked ended before the first suspension point, so a message written in that gap gets a run of its own. Nothing else relied on the old order. end_run_at_ceiling detaches the sleeper before it calls end_run, wait_for_result_and_end_input clears the ceiling itself, and arm_run_end_ceiling and reopen_run both read the ended flag, which is now already set when another task can run. The new spec forces the interleaving with two enqueues and no clock. Exploration finding: F-P3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk/query.rb | 9 +- spec/unit/query_end_run_order_spec.rb | 117 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 spec/unit/query_end_run_order_spec.rb diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 5be6be42..41035eee 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -669,9 +669,16 @@ def maybe_end_run end # The run is over: wake the stdin-closing waiter. Idempotent. + # + # The run is marked ended BEFORE the ceiling is cleared: stopping the + # sleeper task yields to whatever else is ready, and a #stream_input task + # that writes its next message in that gap must find the run already + # ended, so that #reopen_run gives the message a run of its own. Cleared + # first, the message joined the run that was about to end, and stdin + # closed before the message's own run had produced a frame. def end_run - clear_run_end_ceiling @run_end.end! + clear_run_end_ceiling end # Reopen an ended run for work that started after it ended. A waiter the diff --git a/spec/unit/query_end_run_order_spec.rb b/spec/unit/query_end_run_order_spec.rb new file mode 100644 index 00000000..4ba86227 --- /dev/null +++ b/spec/unit/query_end_run_order_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'async/queue' + +# Query#end_run has one suspension point: stopping the ceiling sleeper hands +# the reactor to whatever else is ready. A stream_input task that is ready at +# that moment writes its next message, and the run that message belongs to is +# decided by what it sees: an ended run is reopened as a fresh one, a run +# that is still open is joined. So the run must already count as ended when +# end_run reaches that suspension point, or the message joins the run that is +# about to end and stdin closes before its own run produced a frame. +# +# No clock takes part in the ordering below: two enqueues make the read loop +# and the stream task ready, in that order, and the example then only yields +# to the reactor until the state it waits for is there. +RSpec.describe ClaudeAgentSDK::Query, 'ending a run while the next streamed message is ready' do + # Queue-backed stand-in for the CLI: frames are fed to the read loop one by + # one, and every stdin close records how many messages had been written. + let(:transport_class) do + Class.new do + attr_reader :frames, :writes, :stdin_closed_after, :frames_handled + + def initialize + @frames = Async::Queue.new + @writes = [] + @stdin_closed_after = [] + @frames_handled = 0 + end + + def connect; end + + def ready? + true + end + + def read_messages + while (frame = @frames.dequeue) != :eof + yield frame + @frames_handled += 1 # the read loop is done with this frame + end + end + + def write(line) + @writes << line + end + + def end_input + @stdin_closed_after << @writes.length + end + + def close + @frames.enqueue(:eof) + end + end + end + + # The frames CLI 2.1.286 sends around a turn when the transport asked for + # session state: "running", the turn, its result, then "idle". + def session_state(value) + { type: 'system', subtype: 'session_state_changed', state: value, sdk_host_only: true, + uuid: SecureRandom.uuid, session_id: 'b1c2d3e4-0000-4000-8000-000000000001' } + end + + def turn_result + sample_result_message.merge(result: 'done', stop_reason: 'end_turn', terminal_reason: 'completed') + end + + def user_message(content) + { type: 'user', message: { role: 'user', content: content }, parent_tool_use_id: nil, session_id: '' } + end + + def reactor_settles(task, limit: 10) + task.with_timeout(limit) { task.yield until yield } + end + + it 'keeps stdin open for a message written while the previous run was ending' do + transport = transport_class.new + gate = Async::Queue.new + prompts = Enumerator.new do |yielder| + yielder << user_message('first') + gate.dequeue + yielder << user_message('second') + end + # A hook keeps the control channel in use, so stdin waits for the run's end. + query = described_class.new(transport: transport, is_streaming_mode: true, + hooks: { 'PreToolUse' => [{ matcher: 'Bash', hooks: [proc {}] }] }) + + Async do |task| + query.start + streamer = task.async { query.stream_input(prompts) } # writes "first", parks on the gate + transport.frames.enqueue(session_state('running')) + transport.frames.enqueue(turn_result) + reactor_settles(task) { transport.frames_handled == 2 } + # The precondition of the whole example: the result armed the ceiling + # sleeper, so ending the run has a task to stop — the suspension point. + expect(query.instance_variable_get(:@run_end_ceiling_task)).not_to be_nil + + transport.frames.enqueue(session_state('idle')) # the read loop becomes ready first ... + gate.enqueue(:go) # ... and the stream task second + reactor_settles(task) { transport.writes.length == 2 && transport.frames_handled == 3 } + 3.times { task.yield } # whatever the ended run woke gets its turn + + # "second" owes a run of its own, and none of that run's frames exists yet. + expect(transport.stdin_closed_after).to be_empty + + transport.frames.enqueue(session_state('running')) + transport.frames.enqueue(turn_result) + transport.frames.enqueue(session_state('idle')) + task.with_timeout(10) { streamer.wait } + expect(transport.stdin_closed_after).to eq([2]) + ensure + query.close + end.wait + end +end From 944c5dd537e866437a8f6dd63fc113569733d093 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:39:51 +0800 Subject: [PATCH 002/194] Contain callback failures that are not StandardErrors A hook, a can_use_tool callback or an SDK MCP tool / resource / prompt handler that raised NotImplementedError, LoadError (both ScriptError), SystemStackError or SecurityError was not rescued anywhere: every clause on the way out rescued StandardError only. No control response was written, so the CLI kept waiting for that tool call, and the handler task ended with an exception Async treats as fatal, which stopped the reactor and every other task and session on it. query() raised the raw exception. The same happened when the callback_wrapper raised one. FiberBoundary::CALLBACK_FAILURES now lists what a failing callback raises (StandardError, ScriptError, SystemStackError, SecurityError) and is rescued where a callback's failure becomes the CLI's answer: - SdkMcpServer's two tool rescues, so the model gets the same in-band isError result an ordinary exception produces. - The last rescue of Query#handle_control_request, after the Async::Stop and SystemExit / SignalException clauses, which are unchanged. Hooks and can_use_tool get the error control response; a resource or prompt handler gets the JSON-RPC -32603 error it gets for a RuntimeError. That shape is built by the helper respond_to_process_exit already used, now shared, because such an exception has passed the rescue in handle_sdk_mcp_request that shapes ordinary failures. An explicit list rather than `rescue Exception`: cancellation and process exits keep propagating, and NoMemoryError and bare Exception subclasses stay fatal. Users can notice: the session and the reactor survive, and the model sees the failure text. SdkMcpServer#call_tool called directly returns an isError result for these exceptions instead of raising them. Exploration finding: D-P1 (= A-P2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda --- docs/hooks-and-permissions.md | 2 + docs/mcp-servers.md | 6 +- lib/claude_agent_sdk/fiber_boundary.rb | 17 ++ lib/claude_agent_sdk/query.rb | 28 ++- lib/claude_agent_sdk/sdk_mcp_server.rb | 11 +- spec/support/callback_exit_harness.rb | 31 ++- .../unit/callback_failure_containment_spec.rb | 192 ++++++++++++++++++ 7 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 spec/unit/callback_failure_containment_spec.rb diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index 20e4034b..be5952ba 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -202,6 +202,8 @@ An exception raised inside a hook or a `can_use_tool` callback fails that control request: the CLI receives an error response carrying the exception message, and the session carries on with later requests. The request's cancellation signal is invalidated, as for any other callback failure. +`NotImplementedError`, `LoadError`, `SystemStackError` and `SecurityError` are +answered the same way, although they are not `StandardError`s. `exit`, `Interrupt` and other signal exceptions are never swallowed. If one is raised while a callback runs (by the callback itself, or a real Ctrl-C / diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 296cbb6b..c2cb8d86 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -125,6 +125,8 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( An exception raised inside a handler is returned to the model as an `isError: true` result carrying the exception message, so it can self-correct. +That holds for `NotImplementedError`, `LoadError`, `SystemStackError` and +`SecurityError` as well, although they are not `StandardError`s. `exit`, `Interrupt` and other signal exceptions are never swallowed: the CLI first gets an `isError` result naming the exception class (`"SystemExit: exit"`), so it is not left waiting on the tool call, and then @@ -196,7 +198,9 @@ server = ClaudeAgentSDK.create_sdk_mcp_server( ``` An exception raised inside a resource reader or prompt generator is answered -with a JSON-RPC internal error (`-32603`) carrying the exception message. For +with a JSON-RPC internal error (`-32603`) carrying the exception message; +`NotImplementedError`, `LoadError`, `SystemStackError` and `SecurityError` +are covered here too. For `exit`, `Interrupt` and other signal exceptions the CLI gets that error first, naming the exception class, and then the exception propagates as Ruby normally would. diff --git a/lib/claude_agent_sdk/fiber_boundary.rb b/lib/claude_agent_sdk/fiber_boundary.rb index 878db026..a4b75b38 100644 --- a/lib/claude_agent_sdk/fiber_boundary.rb +++ b/lib/claude_agent_sdk/fiber_boundary.rb @@ -135,6 +135,23 @@ def close end end + # What a failing user callback raises — a hook, can_use_tool, an SDK MCP + # tool / resource / prompt handler, or the callback_wrapper around one. + # The Ruby spelling of Python's `except Exception`: NotImplementedError + # and LoadError (both ScriptError), SystemStackError and SecurityError + # are not StandardErrors, so `rescue StandardError` lets them through. + # The control request then goes unanswered and its handler task ends + # with an exception Async treats as fatal for the whole reactor. Rescue + # `*FiberBoundary::CALLBACK_FAILURES` wherever a callback's failure is + # turned into the answer the CLI is waiting for. + # + # An explicit list, not `rescue Exception`: cancellation (Async::Stop, + # InlineCancellation) and process exits (SystemExit, SignalException — + # see .invoke_callback) must keep propagating. NoMemoryError stays out + # as well: building the answer would most likely fail again. + # @api private + CALLBACK_FAILURES = [StandardError, ScriptError, SystemStackError, SecurityError].freeze + # Carries a SystemExit / SignalException (Interrupt included) raised by # a user callback out of the FiberBoundary hop — see .invoke_callback. # A StandardError so the hop ends normally: a :thread worker that died diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 5be6be42..b1a55418 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -842,15 +842,31 @@ def handle_control_request(request) # rubocop:disable Metrics/MethodLength -- su raise rescue StandardError => e send_control_error(request_id, e.message) + rescue *FiberBoundary::CALLBACK_FAILURES => e + # What is left of the list: a callback (or its callback_wrapper) + # failing outside StandardError — NotImplementedError, LoadError, + # SystemStackError, SecurityError. No `rescue StandardError` on the + # way here caught it, not even the one in #handle_sdk_mcp_request that + # turns a resource or prompt handler's failure into its JSON-RPC + # error, so the answer an ordinary failure gets is built here. + # Unanswered, it would also end this task with an exception Async + # treats as fatal for the whole reactor. + respond_to_callback_failure(request_id, request_data, e.message) + end + + # Answers the request, then leaves the process-exit exception to its + # caller to re-raise: the response an ordinary exception from the + # callback would have produced, naming the exception by class. + def respond_to_process_exit(request_id, request_data, error) + respond_to_callback_failure(request_id, request_data, FiberBoundary.process_exit_message(error)) end # The response an ordinary exception from the callback would have - # produced, with the process-exit exception named by class: an error - # control response for hooks / can_use_tool; for SDK MCP requests an - # in-band isError result (tools/call) or a JSON-RPC internal error - # (resources/read, prompts/get), inside a successful control response. - def respond_to_process_exit(request_id, request_data, error) - message = FiberBoundary.process_exit_message(error) + # produced, with +message+ as its text: an error control response for + # hooks / can_use_tool; for SDK MCP requests an in-band isError result + # (tools/call) or a JSON-RPC internal error (resources/read, + # prompts/get), inside a successful control response. + def respond_to_callback_failure(request_id, request_data, message) mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message' return send_control_error(request_id, message) unless mcp_message.is_a?(Hash) diff --git a/lib/claude_agent_sdk/sdk_mcp_server.rb b/lib/claude_agent_sdk/sdk_mcp_server.rb index cdb099e3..fb357162 100644 --- a/lib/claude_agent_sdk/sdk_mcp_server.rb +++ b/lib/claude_agent_sdk/sdk_mcp_server.rb @@ -306,8 +306,10 @@ def call_tool(name, arguments) return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content result - rescue StandardError => e - # Bare e.message like Python's str(e) — no prefix. + rescue *FiberBoundary::CALLBACK_FAILURES => e + # Bare e.message like Python's str(e) — no prefix. The list, not just + # StandardError: a handler's NotImplementedError, LoadError or + # SystemStackError is a tool failure the model should read as well. error_tool_result(e.message) end @@ -507,12 +509,15 @@ def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgumen error: !!is_error, structured_content: structured_content ) - rescue StandardError => e + rescue *FiberBoundary::CALLBACK_FAILURES => e # Report handler failures in-band HERE rather than letting them # reach the gem: mcp >= 1.2 deliberately drops e.message from # its "Internal error calling tool X" wrapper (CWE-209), which # would hide the text the model needs to self-correct. Bare # e.message like Python's str(e) and #call_tool — no prefix. + # The list, not just StandardError: the gem rescues only + # StandardError, so a handler's NotImplementedError / LoadError / + # SystemStackError would pass it unanswered. # Nothing gem-internal can be swallowed here today: handlers get # no server_context, so MCP::CancelledError never originates # inside this method. Revisit if cancellation is ever plumbed in. diff --git a/spec/support/callback_exit_harness.rb b/spec/support/callback_exit_harness.rb index 9687e029..095c96b3 100644 --- a/spec/support/callback_exit_harness.rb +++ b/spec/support/callback_exit_harness.rb @@ -11,6 +11,10 @@ # so the parent can check it went out BEFORE the process died. Plain Ruby # with no RSpec dependency; it is loaded by spec_helper too, which only # defines the module. +# +# spec/unit/callback_failure_containment_spec.rb reuses the callbacks and +# requests IN-PROCESS for the failures the SDK contains instead +# (FAILURE_KINDS): nothing there ends the process. module CallbackExitHarness # rubocop:disable Metrics/ModuleLength -- one self-contained child-process fixture # Every control-request path whose user callback answers the CLI. # :hook_timeout takes the HookMatcher#timeout variants (Async with_timeout @@ -33,6 +37,18 @@ module CallbackExitHarness # rubocop:disable Metrics/ModuleLength -- one self-co sigterm: { message: 'SignalException: SIGTERM', termsig: 'TERM' } }.freeze + # What a failing callback can raise that is neither a StandardError nor a + # process exit: the exception class, and the message the CLI is told. The + # SDK answers the request as for any other callback failure and carries on. + FAILURE_KINDS = { + not_implemented: [NotImplementedError, 'refunds are not supported yet'], + load_error: [LoadError, 'cannot load such file -- callback_exit_harness/missing_gem'], + stack_overflow: [SystemStackError, 'stack level too deep'], + security_error: [SecurityError, 'unsigned payload refused'] + }.freeze + # The failure the SDK has always answered, to compare the above against. + ORDINARY_FAILURE = 'ordinary failure' + RESPONSE = 'RESPONSE ' SURVIVED = 'CALLBACK_EXIT_HARNESS_SURVIVED' @@ -61,10 +77,21 @@ def trigger(kind) when :interrupt then raise Interrupt when :signal then raise SignalException, 'TERM' when :sigint, :sigterm then busy_until_signalled(kind == :sigint ? 'INT' : 'TERM') + when :none then nil # the callback succeeds + when :standard_error then raise ORDINARY_FAILURE + when :not_implemented, :security_error then raise(*FAILURE_KINDS.fetch(kind)) + when :load_error then require 'callback_exit_harness/missing_gem' # a lazy require of a gem that is not installed + when :stack_overflow then overflow_stack else raise ArgumentError, "unknown kind #{kind.inspect}" end end + # Runaway recursion: a real SystemStackError, on whichever stack runs the + # callback (the worker thread's, or the handler task's fiber when inline). + def overflow_stack(depth = 0) + overflow_stack(depth + 1) + 1 + end + # CPU-bound (scheduler-opaque) work on the callback's own thread — for an # :inline callback the reactor's, i.e. the main thread — while another # thread sends the process a real signal, which MRI delivers to the main @@ -78,7 +105,7 @@ def busy_until_signalled(signal) nil while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline end - def build_query(path, mode, kind, transport) + def build_query(path, mode, kind, transport, wrapper: nil) can_use_tool = lambda do |_tool_name, _input, _context| trigger(kind) ClaudeAgentSDK::PermissionResultAllow.new @@ -93,7 +120,7 @@ def build_query(path, mode, kind, transport) end ClaudeAgentSDK::Query.new( transport: transport, is_streaming_mode: true, can_use_tool: can_use_tool, - sdk_mcp_servers: { 'srv' => mcp_server(kind) }, callback_scheduling: mode + sdk_mcp_servers: { 'srv' => mcp_server(kind) }, callback_scheduling: mode, callback_wrapper: wrapper ).tap do |query| query.instance_variable_set(:@hook_callbacks, { 'hook' => hook }) timeout = { hook_timeout: 5, hook_timeout_abandoned: 0.05 }[path] diff --git a/spec/unit/callback_failure_containment_spec.rb b/spec/unit/callback_failure_containment_spec.rb new file mode 100644 index 00000000..97a9f66c --- /dev/null +++ b/spec/unit/callback_failure_containment_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# A hook, a can_use_tool callback or an SDK MCP tool / resource / prompt +# handler can fail with an exception that is neither a StandardError nor a +# process exit: NotImplementedError and LoadError (both ScriptError), +# SystemStackError, SecurityError. No `rescue StandardError` on the way out +# matched those, so the control request was never answered and its handler +# task ended with an exception Async treats as fatal — which stopped the +# reactor and every other task (and session) on it. They are answered like +# any other callback failure now, and the session carries on. +# +# Deliberately not contained, so not in the matrix: NoMemoryError, a bare +# Exception subclass, and what spec/unit/callback_process_exit_spec.rb covers +# (process exits are answered and then re-raised; cancellation propagates). +RSpec.describe 'user callbacks failing outside StandardError' do + harness = CallbackExitHarness + + # The CLI side, scripted: hands the read loop one control request at a + # time and lets the handler task it spawned run to its end before the + # next, so the follow-up request reaches a read loop that has already + # lived through the failure. Records every frame the SDK writes. + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + attr_reader :frames + + def initialize(requests) + super() + @requests = requests + @frames = [] + end + + def write(data) + @frames << JSON.parse(data) + end + + def read_messages + @requests.each do |request| + yield request + # Handler tasks are children of the task running the read loop. + Async::Task.current.children&.each(&:wait) + end + end + end + end + + # A request no user callback takes part in. + let(:follow_up_request) do + { type: 'control_request', request_id: 'req_next', request: CallbackExitHarness.mcp_request('tools/list', {}) } + end + + # One failing control request through Query#read_messages, as in a session, + # then the follow-up; next to them an unrelated task on the same reactor, + # parked until the session is done with both. + def run_session(path, mode, kind, wrapper: nil) + transport = transport_class.new([CallbackExitHarness.control_request(path), follow_up_request]) + query = CallbackExitHarness.build_query(path, mode, kind, transport, wrapper: wrapper) + outcome = { frames: transport.frames, escaped: nil, bystander_finished: false } + begin + Sync do |task| + gate = Thread::Queue.new + bystander = task.async do + gate.pop + outcome[:bystander_finished] = true + end + task.async { query.send(:read_messages) }.wait + gate << :session_done + bystander.wait + end + rescue ScriptError, SystemStackError, SecurityError => e + outcome[:escaped] = e # what stopped the reactor + end + outcome + end + + def control_response(request_id, fields) + { 'type' => 'control_response', + 'response' => { 'request_id' => request_id, 'requestId' => request_id }.merge(fields) } + end + + # The response an ordinary exception from the callback produces on each + # path (the shapes spec/unit/callback_process_exit_spec.rb pins too): an + # error control response for hooks and can_use_tool; for SDK MCP requests, + # inside a successful control response, an isError result (tools/call) or + # a JSON-RPC internal error (resources/read, prompts/get). + def failure_response(path, message) + mcp_response = + case path + when :call_tool then { 'result' => { 'content' => [{ 'type' => 'text', 'text' => message }], 'isError' => true } } + when :read_resource, :get_prompt then { 'error' => { 'code' => -32_603, 'message' => message } } + else return control_response('req_fail', 'subtype' => 'error', 'error' => message) + end + control_response('req_fail', 'subtype' => 'success', + 'response' => { 'mcp_response' => { 'jsonrpc' => '2.0', 'id' => 7 }.merge(mcp_response) }) + end + + def responses_to(outcome, request_id) + outcome[:frames].select { |frame| frame.dig('response', 'request_id') == request_id } + end + + # The tool names in each answer to the follow-up request: one answer, and + # a successful one, when the read loop outlived the failure. + def tools_listed_afterwards(outcome) + responses_to(outcome, 'req_next').map do |frame| + frame.dig('response', 'response', 'mcp_response', 'result', 'tools')&.map { |tool| tool['name'] } + end + end + + def expect_answered_and_running(outcome, path, message) + escaped = outcome[:escaped] + aggregate_failures do + expect(escaped).to be_nil, "the reactor was stopped by #{escaped.class}: #{escaped&.message}" + expect(responses_to(outcome, 'req_fail')).to eq([failure_response(path, message)]) + expect(tools_listed_afterwards(outcome)).to eq([['boom']]), 'the session did not answer the next request' + expect(outcome[:frames].length).to eq(2) + expect(outcome[:bystander_finished]).to be(true), 'an unrelated task on the same reactor did not finish' + end + end + + harness::PATHS.each do |path| + harness::MODES.each do |mode| + context "#{path} with #{mode} callback scheduling" do + harness::FAILURE_KINDS.each do |kind, (error_class, message)| + it "answers a #{error_class} like any other callback failure and keeps the session running" do + expect_answered_and_running(run_session(path, mode, kind), path, message) + end + end + + # The callback_wrapper runs around the callback, outside its body. + it 'does the same when the callback_wrapper raises instead of the callback' do + wrapper = ->(_invocation) { CallbackExitHarness.trigger(:not_implemented) } + + outcome = run_session(path, mode, :none, wrapper: wrapper) + + expect_answered_and_running(outcome, path, CallbackExitHarness::FAILURE_KINDS.fetch(:not_implemented).last) + end + + it 'answers an ordinary StandardError the same way (the response the others must match)' do + outcome = run_session(path, mode, :standard_error) + + expect_answered_and_running(outcome, path, CallbackExitHarness::ORDINARY_FAILURE) + end + end + end + end + + # Without a session there is no control request to answer; a tool + # handler's failure is still the in-band result the model can read. + describe 'SdkMcpServer tools called directly' do + harness::MODES.each do |mode| + harness::FAILURE_KINDS.each do |kind, (error_class, message)| + it "reports a #{error_class} from a #{mode} handler as an isError result of #call_tool" do + server = CallbackExitHarness.mcp_server(kind) + server.callback_scheduling = mode + + result = Sync { server.call_tool('boom', {}) } + + expect(result).to eq(content: [{ type: 'text', text: message }], isError: true) + end + + it "reports a #{error_class} from a #{mode} handler as an isError result of #handle_message" do + server = CallbackExitHarness.mcp_server(kind) + server.callback_scheduling = mode + request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'boom', arguments: {} } } + + response = Sync { server.handle_message(request) } + + expect(response).to include(id: 1) + expect(response[:result]).to include(content: [{ type: 'text', text: message }], isError: true) + end + end + end + end + + describe 'FiberBoundary::CALLBACK_FAILURES' do + it 'covers what a failing callback raises' do + contained = [RuntimeError, NotImplementedError, LoadError, SyntaxError, SystemStackError, SecurityError] + + expect(contained).to all(satisfy { |raised| ClaudeAgentSDK::FiberBoundary::CALLBACK_FAILURES.any? { |listed| raised <= listed } }) + end + + # `rescue Exception` in its place would swallow cancellation and process + # exits; an answer built after NoMemoryError would likely fail again. + it 'leaves out cancellation, process exits and NoMemoryError' do + passed_on = [Async::Stop, ClaudeAgentSDK::FiberBoundary::InlineCancellation, SystemExit, Interrupt, + SignalException, NoMemoryError, Exception] + + expect(passed_on).to all(satisfy { |raised| ClaudeAgentSDK::FiberBoundary::CALLBACK_FAILURES.none? { |listed| raised <= listed } }) + end + end +end From 743cac0fa97694084623454c2744841c324443b3 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:41:29 +0800 Subject: [PATCH 003/194] Return every result of parallel tool calls from the session readers get_session_messages and get_subagent_messages returned tool_use blocks without their tool_result whenever one assistant turn made several tool calls. The CLI writes one assistant entry per tool_use and parents each tool_result on the entry holding its own tool_use, so only the result the conversation continued from is an ancestor of the leaf; the others are siblings of the next tool_use entry, and the readers follow a single parentUuid path. A caller rendering the history saw calls with no result; a caller replaying it to the API sent tool_use without tool_result. After the leaf-to-root walk, the readers now put those results back: for each assistant entry on the chain, its user children that are off the chain and carry a tool_result for a tool_use on the chain, in file order, before the next user entry of the chain. The tool_use_id match keeps other user siblings out (the prompt of a branch abandoned by a rewind is one). Subagent transcripts get the same pass without the sidechain/meta/team rejection, since every entry there is a sidechain entry. Disk and SessionStore readers share the code. Callers see more messages for affected sessions, so offset/limit pages over them shift. Exploration finding: U-P1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 2 +- lib/claude_agent_sdk/sessions.rb | 107 +++++++- spec/fixtures/claude_config_dir.rb | 38 +++ spec/fixtures/cli_transcript.rb | 210 ++++++++++++++++ .../session_parallel_tool_results_spec.rb | 237 ++++++++++++++++++ 5 files changed, 586 insertions(+), 8 deletions(-) create mode 100644 spec/fixtures/claude_config_dir.rb create mode 100644 spec/fixtures/cli_transcript.rb create mode 100644 spec/unit/session_parallel_tool_results_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index 7d7ddb75..a0ca9b0e 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -40,7 +40,7 @@ messages.each { |msg| puts "[#{msg.type}] #{msg.message}" } ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...', offset: 10, limit: 20) ``` -Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)). +Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)). Messages come back in conversation order. The CLI writes one entry per content block, so an assistant turn with several tool calls comes back as several `assistant` messages and one `user` message per `tool_result`; every result follows the `tool_use` it answers and precedes the next assistant turn. ## Reading Subagent Transcripts diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 61df305a..d3e1e2b0 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1159,14 +1159,19 @@ def entries_to_subagent_messages(entries, limit, offset, parent_tool_use_id = ni end # Find the last user/assistant entry and walk parentUuid links back to the - # root (subagent transcripts are linear). Mirrors Python's - # _build_subagent_chain. + # root, as Python's _build_subagent_chain does. Subagent transcripts are + # not linear either (Python's comment says they are): parallel tool calls + # fan out exactly as in a main transcript, so the results the walk passes + # by are put back. No flag rejection there — every subagent entry is a + # sidechain entry. def build_subagent_chain(entries) return [] if entries.empty? by_uuid = entries.to_h { |e| [e['uuid'], e] } leaf = entries.reverse_each.find { |e| %w[user assistant].include?(e['type']) } - leaf ? walk_to_root(by_uuid, leaf) : [] + return [] unless leaf + + reattach_parallel_tool_results(walk_to_root(by_uuid, leaf), entries, skip_flagged: false) end # Find the subpath for a subagent, scanning subkeys (subagents may be nested @@ -1571,14 +1576,100 @@ def build_conversation_chain(entries) end # Keep only main-chain candidates (not sidechain, team, or meta) - main_leaves = leaf_candidates.reject do |e| - e['isSidechain'] || e['teamName'] || e['isMeta'] - end + main_leaves = leaf_candidates.reject { |e| off_main_conversation?(e) } return [] if main_leaves.empty? # Pick the leaf with highest file position, walk to root best_leaf = main_leaves.max_by { |e| by_position[e['uuid']] || 0 } - walk_to_root(by_uuid, best_leaf) + reattach_parallel_tool_results(walk_to_root(by_uuid, best_leaf), entries, skip_flagged: true) + end + + # An entry that is not part of the user's own conversation: written by a + # subagent (sidechain) or a teammate, or a meta injection. + def off_main_conversation?(entry) + entry['isSidechain'] || entry['teamName'] || entry['isMeta'] + end + + # Put the results of parallel tool calls back on a leaf-to-root chain. + # + # The CLI writes one assistant entry per tool_use block (chained through + # parentUuid) and parents every tool_result on the entry that holds ITS + # tool_use. With two or more calls in one API message, only the result + # the conversation continued from is an ancestor of the leaf; the others + # are siblings of the next tool_use entry, and a single-path walk returns + # their tool_use without them. + # + # For each assistant entry on the chain, take its user children that are + # not on the chain and carry a tool_result for a tool_use on the chain, + # and insert them — in file order — before the next user entry of the + # chain (the batch's own on-chain result), or at the end when the chain + # has none. The anchor, not the raw file position, decides the place: a + # result that arrived after the conversation had already moved on to a + # further tool_use of the same message still lands with its batch, so + # every result follows the assistant turn that asked for it. + # + # The tool_use_id match is what keeps other user siblings out: a prompt + # abandoned by a rewind is a second child of a chain entry too, and + # starts a branch that was dropped. +skip_flagged+ additionally rejects + # sidechain / meta / team entries (main transcripts; a subagent + # transcript is sidechain throughout). + def reattach_parallel_tool_results(chain, entries, skip_flagged:) + off_chain = off_chain_tool_results(chain, entries, skip_flagged) + return chain if off_chain.empty? + + placed = [] + pending = [] + chain.each do |entry| + if entry['type'] == 'user' && !pending.empty? + placed.concat(pending.sort_by(&:first).map(&:last)) + pending = [] + end + placed << entry + pending.concat(off_chain.fetch(entry['uuid'], [])) + end + placed.concat(pending.sort_by(&:first).map(&:last)) + end + + # { uuid of an assistant entry on the chain => [[file position, entry], ...] } + # for the off-chain user children reattach_parallel_tool_results places. + def off_chain_tool_results(chain, entries, skip_flagged) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one filter per condition of the re-attachment rule + on_chain = Set.new + assistants = Set.new + tool_use_ids = Set.new + chain.each do |entry| + on_chain << entry['uuid'] + next unless entry['type'] == 'assistant' + + assistants << entry['uuid'] + tool_use_ids.merge(content_block_values(entry, 'tool_use', 'id')) + end + return {} if tool_use_ids.empty? + + # A store may hold an entry twice (a retried mirror batch overlaps the + # write it retries); the first copy counts, as each uuid counts once on + # the chain. + seen = Set.new + found = {} + entries.each_with_index do |entry, position| + next unless entry['type'] == 'user' && assistants.include?(entry['parentUuid']) + next if on_chain.include?(entry['uuid']) + next if skip_flagged && off_main_conversation?(entry) + next unless content_block_values(entry, 'tool_result', 'tool_use_id').any? { |id| tool_use_ids.include?(id) } + next unless seen.add?(entry['uuid']) + + (found[entry['parentUuid']] ||= []) << [position, entry] + end + found + end + + # Values of +key+ over the +type+ content blocks of an entry's message + # ([] for a message without array content — entries are opaque blobs). + def content_block_values(entry, type, key) + message = entry['message'] + content = message.is_a?(Hash) ? message['content'] : nil + return [] unless content.is_a?(Array) + + content.filter_map { |block| block[key] if block.is_a?(Hash) && block['type'] == type } end def walk_to_leaf(by_uuid, uuid) @@ -1634,6 +1725,8 @@ def filter_visible_messages(chain) :find_session_file, :stat_candidate, :resolve_subagents_dir, :collect_agent_files, :parse_jsonl_entries, :build_conversation_chain, :walk_to_leaf, :walk_to_root, + :off_main_conversation?, :reattach_parallel_tool_results, :off_chain_tool_results, + :content_block_values, :filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts, :valid_agent_id?, :sidechain_head?, :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot, diff --git a/spec/fixtures/claude_config_dir.rb b/spec/fixtures/claude_config_dir.rb new file mode 100644 index 00000000..126ca8fe --- /dev/null +++ b/spec/fixtures/claude_config_dir.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +# A private Claude config dir and a project directory for one example, both +# real and canonical (macOS tmpdirs sit behind the /var symlink), reached +# through CLAUDE_CONFIG_DIR exactly as an application would set it. +RSpec.shared_context 'with a Claude config dir' do + attr_reader :config_dir, :cwd + + around do |example| + saved = ENV.fetch('CLAUDE_CONFIG_DIR', nil) + Dir.mktmpdir('claude-config') do |config| + Dir.mktmpdir('claude-project') do |project| + @config_dir = File.realpath(config) + @cwd = File.realpath(project) + ENV['CLAUDE_CONFIG_DIR'] = @config_dir + example.run + end + end + ensure + saved.nil? ? ENV.delete('CLAUDE_CONFIG_DIR') : ENV['CLAUDE_CONFIG_DIR'] = saved + end + + # The directory the CLI keeps a project's transcripts in. + def project_dir_for(path) + File.join(config_dir, 'projects', ClaudeAgentSDK::Sessions.sanitize_path(path)) + end + + def transcript_path(session_id, path = cwd) + File.join(project_dir_for(path), "#{session_id}.jsonl") + end + + def subagent_transcript_path(session_id, agent_id, path = cwd) + File.join(project_dir_for(path), session_id, 'subagents', "agent-#{agent_id}.jsonl") + end +end diff --git a/spec/fixtures/cli_transcript.rb b/spec/fixtures/cli_transcript.rb new file mode 100644 index 00000000..3fb93a96 --- /dev/null +++ b/spec/fixtures/cli_transcript.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require 'digest' +require 'fileutils' +require 'json' + +# Hand-built Claude Code transcripts for the session specs. +# +# The shape is the one CLI 2.1.285-2.1.287 writes: key names, key order and +# nesting were read off real transcripts, every value here is invented. +# What the fixtures must not lose (each has hidden a bug behind a +# hand-simplified fixture before): +# +# * An assistant API message is written as ONE ENTRY PER CONTENT BLOCK. The +# entries are chained through parentUuid and share message.id. +# * A tool_result is a user entry whose parentUuid (and +# sourceToolAssistantUUID) names the assistant entry that holds ITS +# tool_use. With parallel tool calls the results are therefore siblings of +# the next tool_use entry, not links of one chain. +# * Hook attachments hang off the tool_use entry (PreToolUse) and off the +# tool_result entry (PostToolUse); the next API message is parented on the +# last entry written under the result it follows. +# * Metadata lines (queue-operation, permission-mode, ai-title, custom-title, +# last-prompt, tag, file-history-snapshot, ...) have no uuid. +# * Every entry of a subagent transcript carries isSidechain: true and agentId. +# +# Entries carry a subset of the real keys, never a different structure. +# +# Labels stand in for uuids: #id maps a label to a stable UUID (the CLI's +# uuid / parentUuid values are UUIDs) and #labels maps SessionMessages back, +# so an expectation reads %w[prompt use_a use_b result_a result_b]. +class CLITranscript + CLI_VERSION = '2.1.286' + MODEL = 'claude-haiku-4-5-20251001' + + attr_reader :session_id, :cwd, :entries + + # @param agent_id [String, nil] build a SUBAGENT transcript (agent-.jsonl) + def initialize(session_id:, cwd: '/work/app', agent_id: nil, git_branch: 'main', + start: Time.utc(2026, 9, 8, 5, 19, 30)) + @session_id = session_id + @cwd = cwd + @agent_id = agent_id + @git_branch = git_branch + @clock = start + @entries = [] + @labels = {} + end + + # Stable UUID for a label (nil stays nil: a root entry's parentUuid). + def id(label) + return nil if label.nil? + + hex = Digest::MD5.hexdigest("#{@session_id}/#{@agent_id}/#{label}") + uuid = "#{hex[0, 8]}-#{hex[8, 4]}-4#{hex[13, 3]}-8#{hex[17, 3]}-#{hex[20, 12]}" + @labels[uuid] = label.to_s + uuid + end + + # Labels of the given SessionMessages, in order. + def labels(messages) + messages.map { |message| @labels.fetch(message.uuid, message.uuid) } + end + + # -- conversation entries (each is appended to the file and returned) ------ + + # A prompt the user typed: message.content is a String. + def prompt(label, text, parent: nil, **extra) + add(label, parent, + { 'promptId' => id("prompt-id-#{label}"), 'type' => 'user', + 'message' => { 'role' => 'user', 'content' => text } }, + { 'permissionMode' => 'default' }.merge(extra.transform_keys(&:to_s))) + end + + # One content block of an assistant API message (see the header). + def assistant(label, block, parent:, message: 'msg_01', **extra) + add(label, parent, + { 'message' => { + 'model' => MODEL, 'id' => message, 'type' => 'message', 'role' => 'assistant', + 'content' => [block], + 'stop_reason' => block['type'] == 'tool_use' ? 'tool_use' : 'end_turn', 'stop_sequence' => nil, + 'usage' => { 'input_tokens' => 3, 'cache_creation_input_tokens' => 0, + 'cache_read_input_tokens' => 0, 'output_tokens' => 12 } + }, + 'requestId' => "req_#{message}", 'type' => 'assistant' }, + extra.transform_keys(&:to_s)) + end + + # The result of one tool call: a user entry parented on the assistant entry + # that holds the tool_use. + def tool_result(label, tool_use_id, content, parent:, **extra) + add(label, parent, + { 'promptId' => id('prompt-id-turn'), 'type' => 'user', + 'message' => { 'role' => 'user', + 'content' => [{ 'tool_use_id' => tool_use_id, 'type' => 'tool_result', + 'content' => content }] } }, + { 'toolUseResult' => { 'type' => 'text', 'file' => { 'filePath' => '/work/app/a.rb', 'numLines' => 1 } }, + 'sourceToolAssistantUUID' => id(parent) }.merge(extra.transform_keys(&:to_s))) + end + + # A hook attachment (PreToolUse under a tool_use entry, PostToolUse under a + # tool_result entry), or any other attachment type. + def attachment(label, parent:, hook: 'PostToolUse', type: 'hook_success', **extra) + body = if type == 'hook_success' + { 'type' => type, 'hookName' => "#{hook}:Read", 'toolUseID' => id("hook-#{label}"), + 'hookEvent' => hook, 'content' => '', 'stdout' => '', 'stderr' => '', 'exitCode' => 0, + 'command' => 'true', 'durationMs' => 3 } + else + { 'type' => type } + end + add(label, parent, { 'attachment' => body.merge(extra.transform_keys(&:to_s)), 'type' => 'attachment' }) + end + + # -- content blocks -------------------------------------------------------- + + def text(text) = { 'type' => 'text', 'text' => text } + + def thinking(text = 'Let me look.') = { 'type' => 'thinking', 'thinking' => text, 'signature' => 'sig' } + + def tool_use(tool_use_id, name = 'Read', input = { 'file_path' => '/work/app/a.rb' }) + { 'type' => 'tool_use', 'id' => tool_use_id, 'name' => name, 'input' => input } + end + + # -- metadata lines (no uuid) ---------------------------------------------- + + # The two lines an SDK-driven session starts with. + def queue_operations(text) + raw({ 'type' => 'queue-operation', 'operation' => 'enqueue', 'timestamp' => tick, + 'sessionId' => @session_id, 'content' => text }) + raw({ 'type' => 'queue-operation', 'operation' => 'dequeue', 'timestamp' => tick, 'sessionId' => @session_id }) + end + + def permission_mode(mode = 'default') + raw({ 'type' => 'permission-mode', 'permissionMode' => mode, 'sessionId' => @session_id }) + end + + # Its timestamp is NESTED: the entry itself has no top-level timestamp. + def file_history_snapshot(timestamp:) + message_id = id("snapshot-#{@entries.length}") + raw({ 'type' => 'file-history-snapshot', 'messageId' => message_id, + 'snapshot' => { 'messageId' => message_id, 'trackedFileBackups' => {}, 'timestamp' => timestamp }, + 'isSnapshotUpdate' => false }) + end + + def custom_title(title) + raw({ 'type' => 'custom-title', 'customTitle' => title, 'sessionId' => @session_id }) + end + + def ai_title(title) + raw({ 'type' => 'ai-title', 'aiTitle' => title, 'sessionId' => @session_id }) + end + + def last_prompt(text, leaf: nil) + raw({ 'type' => 'last-prompt', 'lastPrompt' => text, 'leafUuid' => id(leaf), 'sessionId' => @session_id }) + end + + def tag(tag) + raw({ 'type' => 'tag', 'tag' => tag, 'sessionId' => @session_id }) + end + + # Append any other line verbatim (a Hash is one entry; a String one raw line). + def raw(entry) + @entries << entry + entry + end + + # -- output ---------------------------------------------------------------- + + # The file as the CLI leaves it: one compact JSON object per line, each + # line newline-terminated. + def to_jsonl + @entries.map { |entry| entry.is_a?(String) ? "#{entry}\n" : "#{JSON.generate(entry)}\n" }.join + end + + def write(path) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, to_jsonl) + path + end + + # The parsed objects, as a SessionStore receives them from the mirror. + def store_entries + @entries.grep(Hash).map { |entry| JSON.parse(JSON.generate(entry)) } + end + + private + + def add(label, parent, head, tail = {}) + entry = { 'parentUuid' => id(parent), 'isSidechain' => !@agent_id.nil? } + if @agent_id + # Real subagent entries: promptId (user entries) comes before agentId. + prompt_id = head.key?('promptId') ? { 'promptId' => head['promptId'] } : {} + entry.merge!(prompt_id, 'agentId' => @agent_id) + end + entry.merge!(head) + entry['uuid'] = id(label) + entry['timestamp'] = tick + entry.merge!(tail) + entry.merge!('userType' => 'external', 'entrypoint' => 'cli', 'cwd' => @cwd, + 'sessionId' => @session_id, 'version' => CLI_VERSION, 'gitBranch' => @git_branch) + @entries << entry + entry + end + + # Millisecond-precision ISO timestamps, one per entry, as the CLI writes them. + def tick + @clock += 0.25 + @clock.strftime('%Y-%m-%dT%H:%M:%S.%3NZ') + end +end diff --git a/spec/unit/session_parallel_tool_results_spec.rb b/spec/unit/session_parallel_tool_results_spec.rb new file mode 100644 index 00000000..e94978a6 --- /dev/null +++ b/spec/unit/session_parallel_tool_results_spec.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The CLI writes one assistant entry per tool_use and parents each tool_result +# on the entry holding ITS tool_use. With parallel tool calls only the result +# written last is an ancestor of the leaf; the others hang off the chain as +# siblings of the next tool_use entry. A reader that follows one parentUuid +# path returns tool_use blocks whose results are on disk but not in its answer. +RSpec.describe 'session readers and parallel tool calls' do + include_context 'with a Claude config dir' + + let(:session_id) { '0d9f6a3c-2b1e-4c7d-8a5f-3e2d1c0b9a88' } + let(:agent_id) { 'a1b2c3d4e5f60718' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + # Two tool calls of one API message, in the order the CLI writes them: + # + # prompt + # └─ think assistant[thinking] msg_01 + # └─ use_a assistant[tool_use a] msg_01 + # ├─ use_b assistant[tool_use b] msg_01 + # │ ├─ pre attachment PreToolUse + # │ └─ result_b user[tool_result b] + # │ └─ post_b attachment PostToolUse + # │ └─ answer assistant[text] msg_02 <- leaf + # └─ result_a user[tool_result a] <- off the leaf's path + # └─ post_a attachment PostToolUse + # └─ pre_b attachment PreToolUse + def two_tool_batch(transcript) + transcript.prompt(:prompt, 'Read a.rb and b.rb') + transcript.assistant(:think, transcript.thinking, parent: :prompt) + transcript.assistant(:use_a, transcript.tool_use('toolu_a'), parent: :think) + transcript.assistant(:use_b, transcript.tool_use('toolu_b'), parent: :use_a) + transcript.attachment(:pre, parent: :use_b, hook: 'PreToolUse') + transcript.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + transcript.attachment(:post_a, parent: :result_a) + transcript.attachment(:pre_b, parent: :post_a, hook: 'PreToolUse') + transcript.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) + transcript.attachment(:post_b, parent: :result_b) + transcript.assistant(:answer, transcript.text('Both files read.'), parent: :post_b, message: 'msg_02') + transcript + end + + def main_transcript + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.queue_operations('Read a.rb and b.rb') + yield transcript + transcript.last_prompt('Read a.rb and b.rb', leaf: :answer) + transcript + end + + def tool_result_ids(messages) + messages.flat_map(&:content_blocks).grep(ClaudeAgentSDK::ToolResultBlock).map(&:tool_use_id) + end + + let(:batch_order) { %w[prompt think use_a use_b result_a result_b answer] } + + describe 'get_session_messages' do + it 'returns every result of a parallel batch from a transcript on disk' do + transcript = main_transcript { |t| two_tool_batch(t) } + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(batch_order) + expect(tool_result_ids(messages)).to eq(%w[toolu_a toolu_b]) + end + + it 'returns every result of a parallel batch from a session store' do + transcript = main_transcript { |t| two_tool_batch(t) } + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, transcript.store_entries) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + + expect(transcript.labels(messages)).to eq(batch_order) + end + + it 'returns each result once when a retried mirror batch repeated it in the store' do + transcript = main_transcript { |t| two_tool_batch(t) } + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, transcript.store_entries) + store.append(key, transcript.store_entries.last(8)) # the retry overlaps the first write + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + + expect(transcript.labels(messages)).to eq(batch_order) + end + + # One API message, three tool calls, streamed: the second call finished + # before the third tool_use was written (so that entry is parented under + # result_b), and the first call finished last. + # + # use_a ─ use_b ─ result_b ─ post_b ─ use_c ─ result_c ─ post_c ─ answer + # └─ result_a (written after use_c) + it 'keeps a late result with its batch: before the first result on the chain' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb, b.rb and c.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.assistant(:use_b, t.tool_use('toolu_b'), parent: :use_a) + t.attachment(:pre, parent: :use_b, hook: 'PreToolUse') + t.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) + t.attachment(:post_b, parent: :result_b) + t.assistant(:use_c, t.tool_use('toolu_c'), parent: :post_b) + t.attachment(:pre_c, parent: :use_c, hook: 'PreToolUse') + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.attachment(:post_a, parent: :result_a) + t.attachment(:pre_x, parent: :post_a, hook: 'PreToolUse') + t.tool_result(:result_c, 'toolu_c', 'contents of c.rb', parent: :use_c) + t.attachment(:post_c, parent: :result_c) + t.assistant(:answer, t.text('All three read.'), parent: :post_c, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a use_b result_a result_b use_c result_c answer]) + end + + it 'puts the results of a three-call batch back in the order they were written' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb, b.rb and c.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.assistant(:use_b, t.tool_use('toolu_b'), parent: :use_a) + t.assistant(:use_c, t.tool_use('toolu_c'), parent: :use_b) + t.attachment(:pre, parent: :use_c, hook: 'PreToolUse') + t.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) # b finished first + t.attachment(:post_b, parent: :result_b) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.attachment(:post_a, parent: :result_a) + t.tool_result(:result_c, 'toolu_c', 'contents of c.rb', parent: :use_c) + t.attachment(:post_c, parent: :result_c) + t.assistant(:answer, t.text('All three read.'), parent: :post_c, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a use_b use_c result_b result_a result_c answer]) + end + + # A rewind leaves the abandoned prompt in the file as a second child of + # the entry the conversation was rewound to. It is a user sibling too, + # but it answers no tool call: the branch it starts was dropped. + it 'does not pull in the prompt of a branch abandoned by a rewind' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.assistant(:summary, t.text('a.rb defines Foo.'), parent: :result_a, message: 'msg_02') + t.prompt(:abandoned, 'Now delete it', parent: :summary) + t.assistant(:abandoned_reply, t.text('Deleted.'), parent: :abandoned, message: 'msg_03') + t.prompt(:kept, 'Now rename it', parent: :summary) + t.assistant(:answer, t.text('Renamed.'), parent: :kept, message: 'msg_04') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a summary kept answer]) + end + + # Not a shape the CLI writes (a result is parented on its own tool_use + # entry); it pins the check itself. A user child that carries a + # tool_result for a call the conversation does not contain stays out. + it 'does not pull in a tool_result that answers no tool_use of the conversation' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.tool_result(:stray, 'toolu_from_another_branch', 'unrelated', parent: :use_a) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.assistant(:answer, t.text('a.rb defines Foo.'), parent: :result_a, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a answer]) + end + + it 'leaves a conversation without parallel calls as it was' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.attachment(:pre, parent: :use_a, hook: 'PreToolUse') + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.attachment(:post_a, parent: :result_a) + t.assistant(:answer, t.text('a.rb defines Foo.'), parent: :post_a, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a answer]) + end + end + + # Every entry of a real subagent transcript carries isSidechain: true, and + # its parallel batches have the same shape as the main transcript's. + describe 'get_subagent_messages' do + def subagent_transcript + two_tool_batch(CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id)) + end + + def parent_session + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.prompt(:prompt, 'Use a subagent to read a.rb and b.rb') + transcript + end + + it 'returns every result of a parallel batch from a subagent transcript on disk' do + parent_session.write(transcript_path(session_id)) + transcript = subagent_transcript + transcript.write(subagent_transcript_path(session_id, agent_id)) + + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd) + + expect(transcript.entries.grep(Hash)).to all(include('isSidechain' => true)) + expect(transcript.labels(messages)).to eq(batch_order) + end + + it 'returns every result of a parallel batch from a subagent transcript in a session store' do + transcript = subagent_transcript + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, parent_session.store_entries) + store.append(key.merge('subpath' => "subagents/agent-#{agent_id}"), transcript.store_entries) + + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd, + session_store: store) + + expect(transcript.labels(messages)).to eq(batch_order) + end + end +end From 078f02a611793802f308cf6324393c5e8909b927 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:46:11 +0800 Subject: [PATCH 004/194] List a subdirectory's own sessions when the repo has several worktrees list_sessions(directory:) left out the sessions recorded for the directory itself when the directory was a subdirectory of a repository with more than one git worktree (a monorepo package, with agent worktrees around). `git worktree list` reports worktree roots; with several of them the listing read only the project dirs of those roots, so the caller's directory was never read. The result held the sessions of every worktree root and none of the ones asked for, without an error. get_session_info and get_session_messages found the same sessions, and so did include_worktrees: false. The multi-worktree branch now reads the caller's directory first, then each worktree, reading a project dir once (as the Python SDK does). Noticeable beyond the missing sessions: when the same session sits in several of those project dirs with equal mtime and size, the copy kept is now the one in the directory that was asked about, then the worktrees in `git worktree list` order. It used to be the main worktree's copy whichever worktree was queried. docs/sessions.md says so. Adds the first examples that run the porcelain parser against a real `git init` + `git worktree add`. Exploration finding: B-P1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 2 +- lib/claude_agent_sdk/sessions.rb | 26 +++-- spec/unit/session_worktree_listing_spec.rb | 111 +++++++++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 spec/unit/session_worktree_listing_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index a0ca9b0e..609b8308 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -27,7 +27,7 @@ Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_ Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths. -When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (worktree listings: the worktree `git worktree list` reports first, i.e. the main worktree). +When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (`directory:` listings: the directory's own copy, then the worktrees in the order `git worktree list` reports them, main worktree first). ## Reading Session Messages diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index d3e1e2b0..06fc83fa 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1349,13 +1349,20 @@ def list_sessions_for_directory(directory, include_worktrees) return project_dir ? read_sessions_from_dir(project_dir, path) : [] end - # Multiple worktrees: scan all project dirs for matches + # Several worktrees: the caller's own directory first, unconditionally. + # `git worktree list` reports worktree ROOTS, so a subdirectory (a + # monorepo package) is none of them, and reading only the listed paths + # left out exactly the sessions that were asked for (Python: + # "Always include the user's actual directory"). Then every worktree; + # a project dir is read once. all_sessions = [] - worktree_paths.each do |wt_path| - project_dir = find_project_dir(wt_path) - next unless project_dir + seen = {} + [path, *worktree_paths].each do |dir| + project_dir = find_project_dir(dir) + next if project_dir.nil? || seen[project_dir] - all_sessions.concat(read_sessions_from_dir(project_dir, wt_path)) + seen[project_dir] = true + all_sessions.concat(read_sessions_from_dir(project_dir, dir)) end deduplicate_sessions(all_sessions) @@ -1381,10 +1388,11 @@ def list_all_sessions # One entry per session_id when the same session sits in several project # dirs (copied config dirs, worktrees). The newest last_modified wins; on # equal mtimes the larger file (the more complete copy), and then the - # copy scanned first — project dirs in name order for the global listing, - # worktrees in `git worktree list` order (main worktree first) for a - # directory listing. Python keeps the first copy seen in iterdir() order - # (sessions.py _deduplicate_by_session_id), which is arbitrary on a tie. + # copy scanned first — project dirs in name order for the global listing; + # for a directory listing the directory itself, then its worktrees in + # `git worktree list` order (main worktree first). Python keeps the first + # copy seen in iterdir() order (sessions.py _deduplicate_by_session_id), + # which is arbitrary on a tie. def deduplicate_sessions(sessions) by_id = {} sessions.each do |s| diff --git a/spec/unit/session_worktree_listing_spec.rb b/spec/unit/session_worktree_listing_spec.rb new file mode 100644 index 00000000..c9e19f2b --- /dev/null +++ b/spec/unit/session_worktree_listing_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'securerandom' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# list_sessions(directory:) covers the directory and every worktree of its +# repository. `git worktree list` reports worktree ROOTS, so a subdirectory +# (a monorepo package) is none of the paths it prints. +RSpec.describe 'list_sessions over git worktrees' do + include_context 'with a Claude config dir' + + # One listed session recorded for +directory+: the CLI keeps it in the + # project dir named after the canonical, NFC-normalized directory. + def record_session(directory, prompt, session_id: SecureRandom.uuid) + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.queue_operations(prompt) + transcript.prompt(:prompt, prompt) + transcript.assistant(:answer, transcript.text('Done.'), parent: :prompt) + transcript.last_prompt(prompt, leaf: :answer) + transcript.write(transcript_path(session_id, directory)) + end + + def summaries(directory, **) + ClaudeAgentSDK.list_sessions(directory: directory, **).map(&:summary) + end + + def git(repo, *) + output, status = Open3.capture2e('git', '-C', repo, '-c', 'user.name=spec', '-c', 'user.email=spec@example.invalid', + '-c', 'commit.gpgsign=false', *) + raise "git #{[*].join(' ')} failed: #{output}" unless status.success? + end + + let(:root) { File.join(cwd, 'repo').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:second) { File.join(cwd, 'repo-wt2').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:package) { File.join(root, 'packages', 'app').tap { |dir| FileUtils.mkdir_p(dir) } } + + context 'when the repository has two worktrees' do + before do + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([root, second]) + record_session(root, 'asked in the repository root') + record_session(second, 'asked in the second worktree') + record_session(package, 'asked in packages/app') + end + + it "lists a subdirectory's own sessions along with those of every worktree" do + expect(summaries(package)).to contain_exactly( + 'asked in packages/app', 'asked in the repository root', 'asked in the second worktree' + ) + end + + it 'lists a worktree root as before' do + expect(summaries(root)).to contain_exactly('asked in the repository root', 'asked in the second worktree') + end + + it "lists only the directory's own sessions with include_worktrees: false" do + expect(summaries(package, include_worktrees: false)).to eq(['asked in packages/app']) + end + end + + # The same session in two project dirs with equal mtime and size: the copy + # recorded for the directory that was asked about is kept. + it "keeps the directory's own copy of a session on an equal-rank duplicate" do + main = File.join(cwd, 'repo-a').tap { |dir| FileUtils.mkdir_p(dir) } + linked = File.join(cwd, 'repo-b').tap { |dir| FileUtils.mkdir_p(dir) } + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([main, linked]) + session_id = '7c1e9d52-3a4b-4f60-8e21-0b9a8c7d6e5f' + written = Time.utc(2026, 9, 8, 6, 0, 0) + copies = { main => 'copy kept for repo-a', linked => 'copy kept for repo-b' }.map do |directory, prompt| + record_session(directory, prompt, session_id: session_id).tap { |path| File.utime(written, written, path) } + end + + expect(copies.map { |path| File.size(path) }.uniq.length).to eq(1) + expect(summaries(linked)).to eq(['copy kept for repo-b']) + expect(summaries(main)).to eq(['copy kept for repo-a']) + end + + # No stub: a real `git worktree list --porcelain` over a real repository. + context 'with a real repository' do + # git takes GIT_DIR and friends from the environment (they are set while + # a git hook runs the suite) and would then describe another repository. + around do |example| + saved = ENV.select { |name, _| name.start_with?('GIT_') } + saved.each_key { |name| ENV.delete(name) } + example.run + ensure + saved&.each { |name, value| ENV[name] = value } + end + + before do + git(root, 'init', '-q') + git(root, 'commit', '-q', '--no-verify', '--allow-empty', '-m', 'init') + git(root, 'worktree', 'add', '-q', '--detach', '--no-checkout', second) + record_session(root, 'asked in the repository root') + record_session(second, 'asked in the second worktree') + record_session(package, 'asked in packages/app') + end + + it 'lists a subdirectory with the sessions of both worktrees' do + expect(summaries(package)).to contain_exactly( + 'asked in packages/app', 'asked in the repository root', 'asked in the second worktree' + ) + end + + it 'lists the linked worktree with the sessions of the main one' do + expect(summaries(second)).to contain_exactly('asked in the repository root', 'asked in the second worktree') + end + end +end From d70f5c0178e0ef155a21babd01efe46fd8f6cfb2 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:47:41 +0800 Subject: [PATCH 005/194] Answer a callback whose error message is not valid UTF-8 When a hook or can_use_tool callback raised an exception whose message held bytes that are not valid UTF-8 (a multibyte character cut by byteslice, binary output of a subprocess or an HTTP body, a BINARY-tagged string), the error response was never sent: send_control_error ran JSON.generate inside the rescue clause that was answering the request, and the JSON::GeneratorError it raised had no rescue left. The CLI kept waiting for that request. On the process-exit path (#119) the same GeneratorError replaced the SystemExit that has to end the process: with :thread scheduling the process died of the wrong exception, with :inline it kept running after `exit`. The error text now goes through a private Query#wire_text before it is serialized: valid UTF-8 is untouched; UTF-8, BINARY and US-ASCII strings are read as UTF-8 with U+FFFD in place of the bad bytes; any other encoding is transcoded, so valid ISO-8859-1 or Shift_JIS text arrives intact. Both writers use it: send_control_error and the shared answer behind respond_to_process_exit. As a second line of defense, a response JSON.generate still rejects is replaced by a fixed ASCII text (or, for an SDK MCP request, by the error response), so an answer is written whatever the message holds and the original process-exit exception is the one re-raised. Users can notice: such a request now gets its error response, with U+FFFD where the message had invalid bytes. Exploration finding: A-P5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda --- docs/hooks-and-permissions.md | 3 +- lib/claude_agent_sdk/query.rb | 41 +++++- spec/support/callback_exit_harness.rb | 9 ++ spec/unit/control_error_encoding_spec.rb | 180 +++++++++++++++++++++++ 4 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 spec/unit/control_error_encoding_spec.rb diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index be5952ba..5c2751aa 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -203,7 +203,8 @@ control request: the CLI receives an error response carrying the exception message, and the session carries on with later requests. The request's cancellation signal is invalidated, as for any other callback failure. `NotImplementedError`, `LoadError`, `SystemStackError` and `SecurityError` are -answered the same way, although they are not `StandardError`s. +answered the same way, although they are not `StandardError`s. Bytes of the +message that are not valid UTF-8 are replaced with U+FFFD. `exit`, `Interrupt` and other signal exceptions are never swallowed. If one is raised while a callback runs (by the callback itself, or a real Ctrl-C / diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index b1a55418..ebf10d29 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -867,6 +867,7 @@ def respond_to_process_exit(request_id, request_data, error) # (tools/call) or a JSON-RPC internal error (resources/read, # prompts/get), inside a successful control response. def respond_to_callback_failure(request_id, request_data, message) + message = wire_text(message) mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message' return send_control_error(request_id, message) unless mcp_message.is_a?(Hash) @@ -883,10 +884,19 @@ def respond_to_callback_failure(request_id, request_data, message) response: { mcp_response: mcp_response } } })) + rescue JSON::GeneratorError + # Not reachable through the text, which #wire_text made encodable. + # Kept because an exception leaving this method would take the place + # of the process exit the caller is about to re-raise. + send_control_error(request_id, message) rescue CLIConnectionError nil # the CLI is already gone; nothing is waiting for the answer end + # Called from the rescue clauses of #handle_control_request, where an + # exception raised while building the answer has no rescue left: the + # request would stay unanswered. So the text goes through #wire_text, and + # whatever JSON.generate still rejects is replaced by a fixed one. def send_control_error(request_id, message) error_response = { type: 'control_response', @@ -894,10 +904,16 @@ def send_control_error(request_id, message) subtype: 'error', request_id: request_id, requestId: request_id, - error: message + error: wire_text(message) } } - writeln(JSON.generate(error_response)) + line = begin + JSON.generate(error_response) + rescue JSON::GeneratorError + error_response[:response][:error] = 'Control request failed; its error message could not be encoded as JSON' + JSON.generate(error_response) + end + writeln(line) rescue CLIConnectionError # EOF/close can invalidate a callback after the peer has gone away. # Only this best-effort reply is discarded; read errors still reach @@ -905,6 +921,27 @@ def send_control_error(request_id, message) nil end + # Error text as JSON.generate accepts it. An exception message can hold + # anything: a multibyte character cut by byteslice, the raw bytes of a + # subprocess or an HTTP body, a driver's own encoding. Valid UTF-8 + # passes through. A UTF-8, BINARY or US-ASCII string is read as UTF-8, + # with U+FFFD in place of each byte that is not valid there. Any other + # encoding is transcoded, so valid text in it survives, with U+FFFD for + # what cannot be converted. + def wire_text(text) + text = text.to_s + return text if text.encoding == Encoding::UTF_8 && text.valid_encoding? + + if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding) + text.dup.force_encoding(Encoding::UTF_8).scrub + else + text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + end + rescue EncodingError + # No converter for the declared encoding (a dummy one such as UTF-7). + text.dup.force_encoding(Encoding::UTF_8).scrub + end + def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion raise 'canUseTool callback is not provided' unless @can_use_tool diff --git a/spec/support/callback_exit_harness.rb b/spec/support/callback_exit_harness.rb index 095c96b3..d9506aaa 100644 --- a/spec/support/callback_exit_harness.rb +++ b/spec/support/callback_exit_harness.rb @@ -49,6 +49,14 @@ module CallbackExitHarness # rubocop:disable Metrics/ModuleLength -- one self-co # The failure the SDK has always answered, to compare the above against. ORDINARY_FAILURE = 'ordinary failure' + # A process exit whose message is not valid UTF-8: text cut inside a + # multibyte character, as `byteslice` leaves it. The CLI is told the + # message with the stray byte replaced, and the exit still ends the + # process (spec/unit/control_error_encoding_spec.rb). + INVALID_UTF8_EXIT = { + text: 'no such order: 注文'.byteslice(0, 16), message: "SystemExit: no such order: \uFFFD", exitstatus: 3 + }.freeze + RESPONSE = 'RESPONSE ' SURVIVED = 'CALLBACK_EXIT_HARNESS_SURVIVED' @@ -77,6 +85,7 @@ def trigger(kind) when :interrupt then raise Interrupt when :signal then raise SignalException, 'TERM' when :sigint, :sigterm then busy_until_signalled(kind == :sigint ? 'INT' : 'TERM') + when :exit_invalid_utf8 then raise SystemExit.new(INVALID_UTF8_EXIT[:exitstatus], INVALID_UTF8_EXIT[:text]) when :none then nil # the callback succeeds when :standard_error then raise ORDINARY_FAILURE when :not_implemented, :security_error then raise(*FAILURE_KINDS.fetch(kind)) diff --git a/spec/unit/control_error_encoding_spec.rb b/spec/unit/control_error_encoding_spec.rb new file mode 100644 index 00000000..e70f3f6c --- /dev/null +++ b/spec/unit/control_error_encoding_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'rbconfig' + +# The process-exit cells of this file end their process, so each one runs in +# a child (spec/support/callback_exit_harness.rb): all of them in parallel, +# once, the first time an example asks for a result. +module UnencodableExitChildren + CELLS = %i[hook read_resource call_tool].product(CallbackExitHarness::MODES).freeze + + def self.result(cell) + results.fetch(cell) + end + + def self.results + @results ||= CELLS.zip(CELLS.map { |cell| Thread.new { run(cell) } }.map(&:value)).to_h + end + + # Read as bytes: the answer carries U+FFFD, and how a text-mode read would + # tag it depends on the locale. + def self.run(cell) + lib_dir = File.expand_path('../../lib', __dir__) + harness_file = File.expand_path('../support/callback_exit_harness.rb', __dir__) + out, err, status = Open3.capture3(RbConfig.ruby, '-I', lib_dir, '-r', 'claude_agent_sdk', '-r', harness_file, + '-e', 'CallbackExitHarness.main(ARGV)', *cell.map(&:to_s), 'exit_invalid_utf8', + binmode: true) + [out.force_encoding(Encoding::UTF_8), err, status] + end +end + +# A callback's exception message need not be valid UTF-8: `byteslice` cuts a +# multibyte character, an error text embeds the raw bytes of a subprocess or +# an HTTP body, a driver reports in its own encoding. Building the error +# response from such a message raised JSON::GeneratorError — inside the +# rescue clause that was answering the request, where nothing catches it. +# The request was never answered, and on the process-exit path the +# GeneratorError also replaced the SystemExit that has to end the process. +RSpec.describe 'control error responses for messages that are not valid UTF-8' do + # Records every frame the SDK writes. + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + attr_reader :frames + + def initialize + super + @frames = [] + end + + def write(data) + @frames << JSON.parse(data) + end + end + end + + # The frames written for one hook / can_use_tool request whose callback + # raises +error+. + def frames_for(path, mode, error) + callback = ->(*) { raise error } + transport = transport_class.new + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true, + can_use_tool: callback, callback_scheduling: mode) + query.instance_variable_set(:@hook_callbacks, { 'hook' => callback }) + Sync { query.send(:handle_control_request, CallbackExitHarness.control_request(path)) } + transport.frames + end + + def control_response(fields) + { 'type' => 'control_response', + 'response' => { 'request_id' => 'req_fail', 'requestId' => 'req_fail' }.merge(fields) } + end + + def error_response(text) + control_response('subtype' => 'error', 'error' => text) + end + + # label => [the message as raised, the text the CLI is told] + unencodable = { + 'cut inside a multibyte character' => ['tool output: 日本語'.byteslice(0, 15), "tool output: \uFFFD"], + 'BINARY-tagged with bytes that are not UTF-8' => ["upstream replied: caf\xE9".b, "upstream replied: caf\uFFFD"], + 'US-ASCII-tagged with a high byte' => [(+"caf\xE9").force_encoding(Encoding::US_ASCII), "caf\uFFFD"], + 'invalid in its own non-UTF-8 encoding' => [(+"bad \x82").force_encoding(Encoding::Shift_JIS), "bad \uFFFD"], + 'in an encoding Ruby has no converter for' => [(+'no converter').force_encoding(Encoding::UTF_7), 'no converter'] + } + # Text the SDK delivered before as well: it must arrive undamaged, i.e. + # transcoded from its declared encoding rather than scrubbed as if UTF-8. + encodable = { + 'BINARY-tagged holding valid UTF-8' => ['upstream replied: café'.b, 'upstream replied: café'], + 'valid ISO-8859-1' => [(+"caf\xE9").force_encoding(Encoding::ISO_8859_1), 'café'], + 'valid Shift_JIS' => ['テスト'.encode(Encoding::Shift_JIS), 'テスト'] + } + + %i[hook can_use_tool].each do |path| + CallbackExitHarness::MODES.each do |mode| + context "#{path} with #{mode} callback scheduling" do + unencodable.each do |label, (raised, told)| + it "answers once, replacing the bad bytes, when the message is #{label}" do + expect(frames_for(path, mode, RuntimeError.new(raised))).to eq([error_response(told)]) + end + end + + encodable.each do |label, (raised, told)| + it "sends the text itself when the message is #{label}" do + expect(frames_for(path, mode, RuntimeError.new(raised))).to eq([error_response(told)]) + end + end + end + end + end + + # The second line of defense: whatever makes JSON.generate reject the + # response, the request is still answered. + context 'when the response cannot be generated all the same' do + # The first JSON.generate fails as it does for an unencodable String; + # later calls run for real. + def fail_first_generate + attempts = 0 + allow(JSON).to receive(:generate).and_wrap_original do |original, *args, **options| + attempts += 1 + raise JSON::GeneratorError, 'source sequence is illegal/malformed utf-8' if attempts == 1 + + original.call(*args, **options) + end + end + + it 'answers a hook with a fixed ASCII text' do + fail_first_generate + + frames = frames_for(:hook, :inline, RuntimeError.new('ordinary failure')) + + expect(frames).to eq([error_response('Control request failed; its error message could not be encoded as JSON')]) + expect(frames.first.dig('response', 'error')).to be_ascii_only + end + + it 'answers an SDK MCP request with an error response when its in-band answer cannot be generated' do + fail_first_generate + transport = transport_class.new + query = CallbackExitHarness.build_query(:read_resource, :inline, :not_implemented, transport) + + Sync { query.send(:handle_control_request, CallbackExitHarness.control_request(:read_resource)) } + + expect(transport.frames).to eq([error_response(CallbackExitHarness::FAILURE_KINDS.fetch(:not_implemented).last)]) + end + end + + # Issue #119's promise, kept for a message that cannot be encoded: the CLI + # is answered, then the ORIGINAL exception ends the process. The answer is + # the one an ordinary failure gets on each path — an error control response + # (hook), a JSON-RPC internal error (resources/read) or an isError result + # (tools/call) inside a successful control response. + describe 'a process exit raised with such a message' do + def responses(out) + prefix = CallbackExitHarness::RESPONSE + out.lines.map(&:chomp).select { |line| line.start_with?(prefix) }.map { |line| JSON.parse(line.delete_prefix(prefix)) } + end + + def failure_response(path, message) + mcp_response = + case path + when :call_tool then { 'result' => { 'content' => [{ 'type' => 'text', 'text' => message }], 'isError' => true } } + when :read_resource then { 'error' => { 'code' => -32_603, 'message' => message } } + else return error_response(message) + end + control_response('subtype' => 'success', + 'response' => { 'mcp_response' => { 'jsonrpc' => '2.0', 'id' => 7 }.merge(mcp_response) }) + end + + UnencodableExitChildren::CELLS.each do |path, mode| + it "answers #{path} once (#{mode} scheduling), then exits with the original SystemExit" do + out, err, status = UnencodableExitChildren.result([path, mode]) + exit_kind = CallbackExitHarness::INVALID_UTF8_EXIT + + expect(out).not_to include(CallbackExitHarness::SURVIVED) + expect(status.exitstatus).to eq(exit_kind[:exitstatus]), "child: #{status.inspect}\n#{err}" + expect(responses(out)).to eq([failure_response(path, exit_kind[:message])]) + end + end + end +end From bf9f293c750378b1a57b79cafd1ce718b096329c Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:47:43 +0800 Subject: [PATCH 006/194] NFC-normalize the worktree paths git reports git prints a worktree path as the filesystem stores it. On a filesystem that decomposes names (HFS+) an accented character arrives as base letter + combining mark, and that form sanitizes to a different project directory name than the NFC form the CLI (and canonicalize_path) uses. The sessions of such a worktree were missing from list_sessions and not found by the other directory-scoped readers and mutations when they were looked up through another worktree of the repository. detect_worktrees now normalizes each path it parses, as the Python SDK does. The regression example runs the reader against a `git` on PATH that prints a decomposed path. Exploration finding: B-P1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 6 +++- spec/unit/session_worktree_listing_spec.rb | 32 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 06fc83fa..911596d5 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1446,8 +1446,12 @@ def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/Cyclomatic return [path] unless wait_thr.value.success? + # NFC, like every path the SDK derives a project dir name from + # (canonicalize_path; Python normalizes here too): git prints a path as + # the filesystem stores it, and a decomposed name sanitizes to a + # different project dir than the one the CLI created. paths = stdout_buf.lines.filter_map do |line| - line.strip.delete_prefix('worktree ') if line.start_with?('worktree ') + line.strip.delete_prefix('worktree ').unicode_normalize(:nfc) if line.start_with?('worktree ') end paths.empty? ? [path] : paths rescue StandardError diff --git a/spec/unit/session_worktree_listing_spec.rb b/spec/unit/session_worktree_listing_spec.rb index c9e19f2b..0d75c9e4 100644 --- a/spec/unit/session_worktree_listing_spec.rb +++ b/spec/unit/session_worktree_listing_spec.rb @@ -33,6 +33,23 @@ def git(repo, *) raise "git #{[*].join(' ')} failed: #{output}" unless status.success? end + # A `git` that prints +listing+ for any arguments, first on PATH: the + # reader runs it as a real subprocess and reads a real pipe. + def with_git_printing(listing) + Dir.mktmpdir('fake-git') do |bin| + script = File.join(bin, 'git') + File.binwrite(script, "#!/bin/sh\nprintf '%s\\n' #{listing.lines(chomp: true).map { |l| "'#{l}'" }.join(' ')}\n") + File.chmod(0o755, script) + saved = ENV.fetch('PATH', nil) + ENV['PATH'] = "#{bin}#{File::PATH_SEPARATOR}#{saved}" + begin + yield + ensure + ENV['PATH'] = saved + end + end + end + let(:root) { File.join(cwd, 'repo').tap { |dir| FileUtils.mkdir_p(dir) } } let(:second) { File.join(cwd, 'repo-wt2').tap { |dir| FileUtils.mkdir_p(dir) } } let(:package) { File.join(root, 'packages', 'app').tap { |dir| FileUtils.mkdir_p(dir) } } @@ -108,4 +125,19 @@ def git(repo, *) expect(summaries(second)).to contain_exactly('asked in the repository root', 'asked in the second worktree') end end + + # git prints a path as the filesystem stores it; one that decomposes names + # (HFS+) reports an accented e as "e" + U+0301. The CLI names the project + # dir after the NFC form, and the two forms sanitize to different names. + it 'finds the sessions of a worktree whose path git reports decomposed' do + composed = File.join(cwd, "caf#{[0xE9].pack('U')}-wt") # U+00E9 + decomposed = File.join(cwd, "cafe#{[0x301].pack('U')}-wt") # e, then combining U+0301 + record_session(root, 'asked in the repository root') + record_session(composed, 'asked in the accented worktree') + + listing = "worktree #{root}\nHEAD 1111111\nbranch refs/heads/main\n\nworktree #{decomposed}\nHEAD 2222222\ndetached\n" + with_git_printing(listing) do + expect(summaries(root)).to contain_exactly('asked in the repository root', 'asked in the accented worktree') + end + end end From f80384f8d856adf8aab73feacd8216d4a8104e3b Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:48:01 +0800 Subject: [PATCH 007/194] Poll the install lock so fibers of one reactor cannot deadlock Two fibers of one Async reactor calling CLIInstaller.install for the same directory never returned. The first took the lock and parked in a scheduler-aware wait inside the critical section (resolving the dist-tag, fetching the manifest, downloading); the second then called flock(LOCK_EX), which has no Fiber-scheduler hook and blocked the reactor thread, so the lock holder was never resumed. With the default version: 'stable' this happened even when nothing needed installing, because the tag is resolved inside the lock. Threads and processes were not affected. with_install_lock now polls: sleep(LOCK_POLL_SECONDS) until flock(LOCK_EX | LOCK_NB). Kernel#sleep yields to a Fiber scheduler and is an ordinary sleep on a plain thread. Mutual exclusion is unchanged, and so are the publish order, the in-lock dist-tag resolve and the in-lock sweep that formal/tla/CLIInstaller.tla checks (the model never assumed a fair or FIFO lock). The regression spec runs the two fibers in a child process, because a regression blocks the calling thread instead of failing. The existing thread-contention example recognized a waiter by its innermost frame being File#flock; a waiter is now asleep between attempts, so the helper looks for the poll loop instead. User-visible: a waiting installer wakes up to 50 ms after the lock is released instead of immediately. Exploration finding: D-P5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/cli-installer.md | 2 +- lib/claude_agent_sdk/cli_installer.rb | 21 +++- spec/unit/cli_installer_fiber_lock_spec.rb | 130 +++++++++++++++++++++ spec/unit/cli_installer_spec.rb | 14 ++- 4 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 spec/unit/cli_installer_fiber_lock_spec.rb diff --git a/docs/cli-installer.md b/docs/cli-installer.md index e4ec42b4..9d812f90 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -27,7 +27,7 @@ ClaudeAgentSDK::CLIInstaller.installed_path `install` is idempotent and safe to run concurrently, so it fits `bin/setup`, a cached Docker layer, and every process of a multi-process boot: - The install directory's `VERSION` file records the installed version, verified SHA-256, and target platform (OS, architecture, libc). The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when all three match — a truncated binary or a cache copied from another platform is reinstalled instead of trusted. It makes **no network request**, so same-platform repeat boots work offline — with a pinned concrete version; `'stable'`/`'latest'` must always re-resolve through the endpoint, which is one more reason to pin in production. Older one- or two-line metadata lacks a platform and requires one online reinstall to migrate; subsequent pinned installs work offline again. -- An exclusive `flock` on `/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race; the loser simply observes the finished install. +- An exclusive `flock` on `/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race — whether they come from separate processes, threads, or fibers of one `Async` reactor (a waiting installer polls the lock rather than blocking its thread on it); the loser simply observes the finished install. Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`. diff --git a/lib/claude_agent_sdk/cli_installer.rb b/lib/claude_agent_sdk/cli_installer.rb index ab9a9423..123355e4 100644 --- a/lib/claude_agent_sdk/cli_installer.rb +++ b/lib/claude_agent_sdk/cli_installer.rb @@ -60,6 +60,11 @@ module CLIInstaller # rubocop:disable Metrics/ModuleLength -- Http/Platform/Rele VERSION_FILE = 'VERSION' # @api private LOCK_FILE = '.install.lock' + # How often a waiting installer retries the install lock. See + # .with_install_lock for why it polls instead of blocking in flock. + # + # @api private + LOCK_POLL_SECONDS = 0.05 # Relative to .root (Dir.pwd when unset), resolved at CALL time by # .default_dir — an absolute constant would freeze the working directory # as of require time, which is wrong for anything that chdirs (Rake @@ -441,14 +446,24 @@ def installed_path(dir: nil) private # Cross-process mutual exclusion for the whole install. flock is - # advisory and per open file description, so concurrent threads in one - # process contend here exactly like separate processes do. + # advisory and per open file description, so concurrent threads — and + # concurrent fibers — in one process contend here exactly like separate + # processes do. + # + # The lock is polled (LOCK_NB + sleep), never taken with a blocking + # LOCK_EX. File#flock has no Fiber-scheduler hook, so a blocking call + # parks the whole reactor THREAD; when the holder is another fiber of + # that reactor, waiting inside the critical section for the network, it + # is never resumed and neither install returns. Kernel#sleep yields to + # a scheduler and is an ordinary sleep on a plain thread. Mutual + # exclusion is the same either way; like flock itself, the order in + # which waiters get the lock is unspecified. def with_install_lock(dir) flags = File::RDWR | File::CREAT # Never follow a symlink planted at the lock path. flags |= File::NOFOLLOW if defined?(File::NOFOLLOW) File.open(File.join(dir, LOCK_FILE), flags, 0o644) do |lock| - lock.flock(File::LOCK_EX) + sleep(LOCK_POLL_SECONDS) until lock.flock(File::LOCK_EX | File::LOCK_NB) begin yield ensure diff --git a/spec/unit/cli_installer_fiber_lock_spec.rb b/spec/unit/cli_installer_fiber_lock_spec.rb new file mode 100644 index 00000000..f880ca14 --- /dev/null +++ b/spec/unit/cli_installer_fiber_lock_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'rbconfig' +require 'tmpdir' + +# CLIInstaller.install called from two fibers of ONE reactor, into one +# directory. The install lock used to be a blocking flock(LOCK_EX), and +# File#flock has no Fiber-scheduler hook: the second fiber blocked the reactor +# THREAD while the first one — the lock holder — was parked in a +# scheduler-aware wait inside the critical section, so the holder was never +# resumed and neither install returned. +# +# Runs in a child process: when this regresses, the thread that calls install +# blocks forever, which in-process would hang the suite instead of failing it. +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'called from two fibers of one reactor' do + # The child's own watchdog fires first and says where the reactor thread is + # stuck; the parent's bound only covers a child that cannot even do that. + # Both are far above the fraction of a second the installs take. + child_watchdog_seconds = 30 + parent_bound_seconds = 120 + + # ARGV: the install directory and the point inside the critical section + # where the lock holder waits for the second fiber ('download' or 'resolve'). + # HTTP is replaced at the Http module level, as in cli_installer_spec.rb. + child_script = <<~RUBY + require 'async' + require 'claude_agent_sdk/cli_installer' + require 'digest' + require 'json' + + dir, parking_point = ARGV + installer = ClaudeAgentSDK::CLIInstaller + body = 'not-really-280MB-of-claude' + manifest = JSON.generate( + 'platforms' => { 'linux-x64' => { 'checksum' => Digest::SHA256.hexdigest(body), 'size' => body.bytesize } } + ) + # Closed by the second fiber on its way to the lock. Until then the lock + # holder is parked on it — a scheduler-aware wait inside the critical + # section, which is what the real socket read is. Once closed, pop returns + # at once, so the second fiber never parks here itself. + gate = Thread::Queue.new + downloads = 0 + + installer::Platform.define_singleton_method(:detect) { 'linux-x64' } + installer::Http.define_singleton_method(:fetch_text) do |url, limit:| + if url.end_with?('/stable') + gate.pop if parking_point == 'resolve' + "2.1.220\\n" + elsif url.end_with?('/2.1.220/manifest.json') + manifest + else + raise "unexpected fetch_text(\#{url.inspect})" + end + end + installer::Http.define_singleton_method(:download_to) do |_url, path, max_bytes: nil| + downloads += 1 + gate.pop if parking_point == 'download' + File.binwrite(path, body) + path + end + + # A regression blocks the reactor thread inside File#flock, which releases + # the GVL — this thread still runs and can report where the main one is. + Thread.new do + sleep #{child_watchdog_seconds} + warn "DEADLOCK: neither install returned; main thread at \#{Thread.main.backtrace.first(2).join(' <- ')}" + exit!(3) + end + + # 'resolve': the pinned version is already installed, and both fibers ask + # for the dist-tag — nothing needs installing, yet the tag is resolved + # inside the lock, so the holder still waits on the network there. + installer.install(version: '2.1.220', dir: dir) if parking_point == 'resolve' + version = parking_point == 'resolve' ? 'stable' : '2.1.220' + + paths = Async do |task| + first = task.async { installer.install(version: version, dir: dir) } + second = task.async do + gate.close + installer.install(version: version, dir: dir) + end + [first.wait, second.wait] + end.wait + + puts "paths=\#{paths.inspect} downloads=\#{downloads}" + RUBY + + around do |example| + # Created (and removed) here rather than in the child, so a child that is + # killed mid-install leaves nothing behind. + Dir.mktmpdir('cli-installer-fibers') do |dir| + @dir = dir + example.run + end + end + + let(:dir) { @dir } + let(:binary_path) { File.join(dir, 'claude') } + + define_method(:run_two_fibers) do |parking_point| + lib_dir = File.expand_path('../../lib', __dir__) + Open3.popen3(RbConfig.ruby, '-I', lib_dir, '-e', child_script, dir, parking_point) do |stdin, stdout, stderr, waiter| + stdin.close + unless waiter.join(parent_bound_seconds) + Process.kill('KILL', waiter.pid) + waiter.join + end + [stdout.read, stderr.read, waiter.value] + end + end + + it 'finishes both installs when the lock holder is parked in its download' do + out, err, status = run_two_fibers('download') + + expect(status.exitstatus).to eq(0), "child: #{status.inspect}\n#{err}" + expect(out).to include("paths=#{[binary_path, binary_path].inspect} downloads=1") + expect(File.binread(binary_path)).to eq('not-really-280MB-of-claude') + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION', 'claude') + end + + it 'finishes both installs of an installed dist-tag while the holder resolves it' do + out, err, status = run_two_fibers('resolve') + + expect(status.exitstatus).to eq(0), "child: #{status.inspect}\n#{err}" + # The one download is the setup install; neither fiber needed another. + expect(out).to include("paths=#{[binary_path, binary_path].inspect} downloads=1") + end +end diff --git a/spec/unit/cli_installer_spec.rb b/spec/unit/cli_installer_spec.rb index 343b4dc2..1f94172f 100644 --- a/spec/unit/cli_installer_spec.rb +++ b/spec/unit/cli_installer_spec.rb @@ -77,9 +77,11 @@ def eventually?(timeout: 5) true end - # True while +thread+ is parked inside File#flock (its innermost frame). - def blocked_in_flock?(thread) - thread.backtrace_locations&.first&.base_label == 'flock' + # True while +thread+ is waiting for the install lock: in with_install_lock's + # poll loop, asleep between attempts or inside the non-blocking flock call. + def waiting_for_install_lock?(thread) + innermost, caller = thread.backtrace_locations&.first(2)&.map(&:base_label) + %w[sleep flock].include?(innermost) && caller == 'with_install_lock' end describe '.default_dir' do @@ -680,11 +682,11 @@ def stub_response(*chunks) contended = nil allow(http).to receive(:download_to) do |_url, path, **| counter_mutex.synchronize { downloads += 1 } - # Hold the lock until the other installer is observably blocked in - # flock (bounded poll) rather than sleeping and hoping it got there. + # Hold the lock until the other installer is observably waiting for + # it (bounded poll) rather than sleeping and hoping it got there. # Without the lock the other thread would reach its own download # instead: the poll times out and downloads ends up 2. - contended = eventually? { threads.any? { |t| !t.equal?(Thread.current) && blocked_in_flock?(t) } } + contended = eventually? { threads.any? { |t| !t.equal?(Thread.current) && waiting_for_install_lock?(t) } } File.binwrite(path, binary_body) path end From b442bc2cdf52bb788ecc4c90a4323db45045d349 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:48:53 +0800 Subject: [PATCH 008/194] Report each API response's token usage on one generation span The CLI sends one assistant frame per content block, and every frame of an API response repeats that response's message id and usage snapshot. OTelObserver put the snapshot on every claude_agent.generation span, so a response with a thinking block and a tool call counted its input, cache-read and cache-creation tokens twice (N times for N blocks). On a recorded real turn the ResultMessage reported 18 / 45,028 / 4,928 input / cache-read / cache-creation tokens and the generation spans summed to 36 / 90,056 / 9,856. The gen_ai.usage.* token attributes now go on the first generation span of each message id; the other spans of that response carry none. The ids are remembered per trace and cleared when it ends (result, superseding init, close). A message without an id keeps its usage, and a frame without usage does not use up its id. Span count, names and parentage are unchanged, and the session span was already correct. Backends that sum gen_ai.usage.* over generation spans see the totals drop by the duplication factor. gen_ai.usage.output_tokens on a generation span is still the snapshot taken when the response started, not its final output count; docs/observability.md now says so and points at the session span for totals. The spec replays a sanitized recording of a real CLI 2.1.286 turn (spec/fixtures/cli_frames/), because the hand-built messages in the existing examples hold a whole response in one frame with no message id, a shape the CLI does not send. Exploration finding: H-P3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS --- docs/observability.md | 4 +- lib/claude_agent_sdk/instrumentation/otel.rb | 21 ++- spec/fixtures/cli_frames/README.md | 18 +++ spec/fixtures/cli_frames/tool_turn.jsonl | 10 ++ spec/unit/instrumentation/otel_spec.rb | 151 +++++++++++++++++++ 5 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 spec/fixtures/cli_frames/README.md create mode 100644 spec/fixtures/cli_frames/tool_turn.jsonl diff --git a/docs/observability.md b/docs/observability.md index d0320424..253b5701 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -18,7 +18,7 @@ In `Client` mode, call `disconnect` (ideally in an `ensure` block) so `on_close` ``` claude_agent.session (root span — one per query/session) -├── claude_agent.generation (per AssistantMessage, with model + token usage) +├── claude_agent.generation (per AssistantMessage, with model; token usage once per API response) ├── claude_agent.tool.Bash (per tool call, open on ToolUseBlock, close on ToolResultBlock) ├── claude_agent.tool.Read ├── claude_agent.generation @@ -118,6 +118,8 @@ The OTel observer sets attributes using both `gen_ai.*` (OTel GenAI) and OpenInf | `claude_agent.generation` | `generation` | `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens`, `output.value` | | `claude_agent.tool.*` | `tool` | `tool.name`, `input.value`, `output.value` | +**Token usage on generation spans.** The CLI sends one `AssistantMessage` per content block, so an API response with a thinking block and a tool call produces two `claude_agent.generation` spans, and both messages repeat the response's `message_id` and `usage`. The observer sets the four `gen_ai.usage.*` token attributes on the first generation span of each `message_id` and on none of that response's other spans, so a sum over generation spans counts every response once. A message without a `message_id` keeps its usage. That usage is the snapshot taken when the response started: the input, cache-read and cache-creation counts are final, but `gen_ai.usage.output_tokens` holds only the few tokens generated by then, and the response's final output count never reaches the message stream. For authoritative totals, output tokens included, read the session span, which takes them from `ResultMessage#usage`. + `gen_ai.usage.cost` and `llm.cost.total` record the **increase** since the last observed `ResultMessage.total_cost_usd` in the connected session, rather than repeating that cumulative total on every turn's span. The baseline survives per-turn span resets and resets on close, a changed session ID (such as `/clear`), or a decreased counter. The original `ResultMessage` is unchanged. Missing costs omit both attributes without discarding the last known total; the next reported increment can therefore include an unreported or interrupted turn. The first observation uses the CLI's reported total. If a newer CLI restores historical spend on resume, that first span also includes it: a fresh observer cannot separate spend it never observed. These are CLI cost estimates, not billing records. diff --git a/lib/claude_agent_sdk/instrumentation/otel.rb b/lib/claude_agent_sdk/instrumentation/otel.rb index 4a0d8630..06836b6f 100644 --- a/lib/claude_agent_sdk/instrumentation/otel.rb +++ b/lib/claude_agent_sdk/instrumentation/otel.rb @@ -53,6 +53,7 @@ def initialize(tracer_name: TRACER_NAME, **default_attributes) @first_user_input = nil # first user prompt of the current trace @pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt) @last_assistant_text = nil # capture last assistant text for trace output + @usage_reported_message_ids = Set.new # API responses whose usage a generation span of this trace carries @cost_session_id = nil @last_total_cost_usd = nil end @@ -203,7 +204,7 @@ def handle_assistant(message) 'gen_ai.completion' => truncate(combined_text), # OpenInference: Langfuse maps output.value to the Preview Output field 'output.value' => truncate(combined_text) - }.merge(usage_token_attrs(message.usage || {})) + }.merge(generation_usage_attrs(message)) OpenTelemetry::Context.with_current(@root_context) do span = @tracer.start_span('claude_agent.generation', attributes: compact_attrs(attrs)) @@ -343,10 +344,12 @@ def finish_open_spans # Clear per-trace buffers so a reused observer instance (sequential # query() calls or multi-turn Client sessions) does not stamp stale - # input/output onto later traces. + # input/output onto later traces, and so the message ids remembered by + # generation_usage_attrs never outlive their trace. def reset_session_buffers @first_user_input = nil @last_assistant_text = nil + @usage_reported_message_ids.clear end def record_retry_event(message) @@ -397,6 +400,20 @@ def usage_token_attrs(usage) } end + # gen_ai.usage.* attributes for one generation span. The CLI sends one + # assistant frame per content block, and every frame of an API response + # repeats that response's message id and usage snapshot, so putting the + # snapshot on every span counted the response's input, cache-read and + # cache-creation tokens once per block. Only the first span of a message + # id carries it. A message without an id cannot be grouped and keeps its + # usage; a frame without usage does not use up its id. + def generation_usage_attrs(message) + attrs = compact_attrs(usage_token_attrs(message.usage || {})) + return attrs if attrs.empty? || message.message_id.nil? + + @usage_reported_message_ids.add?(message.message_id) ? attrs : {} + end + # Usage hashes arrive symbol-keyed from the live CLI (symbolize_names: # true) and string-keyed from session transcripts. def usage_value(usage, key) diff --git a/spec/fixtures/cli_frames/README.md b/spec/fixtures/cli_frames/README.md new file mode 100644 index 00000000..033eb9b7 --- /dev/null +++ b/spec/fixtures/cli_frames/README.md @@ -0,0 +1,18 @@ +# Recorded CLI frames + +Each `.jsonl` file is the stdout of one real `claude` run in stream-JSON mode: one frame per line, in the order the CLI wrote them, as `SubprocessCLITransport#read_messages` yields them. Specs replay these instead of hand-building frames because the shape is the point. The CLI sends one `assistant` frame per content block, and every frame of one API response repeats that response's `message.id` and `usage`; a hand-built message that holds a whole response in one frame hides bugs that only show with the real shape. + +| File | CLI | What it holds | +| --- | --- | --- | +| `tool_turn.jsonl` | 2.1.286 | One prompt. Response 1 is a thinking frame and a `tool_use` (Bash) frame, then the tool result; response 2 is a thinking frame and a text frame; then the `result`. | + +## What a recording keeps and what it does not + +A recording is sanitized before it is committed. + +- **Left out:** the `control_response` that answers `initialize` (the first line the CLI writes). It lists the installed commands, agents and models and names the account. `Query` consumes it, so no message consumer or observer ever sees it. +- **Replaced with placeholders:** every text, thinking signature, tool input and tool output, and the result text; the session id and the per-frame `uuid`s; the local paths in `init` (`cwd`, `memory_paths`, `scratchpad_path`, `messaging_socket_path`); the rate-limit utilization figures. +- **Trimmed:** the `tools`, `slash_commands`, `skills`, `agents` and `plugins` lists in `init`. +- **Kept as recorded:** the order, type and keys of every frame; `message.id`, tool-use ids and request ids; the model; usage, cost and duration numbers; stop reasons; timestamps. + +When you add a recording, read the sanitized file line by line before committing it. diff --git a/spec/fixtures/cli_frames/tool_turn.jsonl b/spec/fixtures/cli_frames/tool_turn.jsonl new file mode 100644 index 00000000..e609167c --- /dev/null +++ b/spec/fixtures/cli_frames/tool_turn.jsonl @@ -0,0 +1,10 @@ +{"type":"system","subtype":"session_state_changed","state":"running","sdk_host_only":true,"uuid":"00000000-0000-4000-8000-000000000001","session_id":"00000000-0000-4000-8000-000000000000"} +{"type":"system","subtype":"init","cwd":"/workspace/project","session_id":"00000000-0000-4000-8000-000000000000","tools":["Task","Bash","Read"],"mcp_servers":[],"model":"claude-haiku-4-5-20251001","permissionMode":"bypassPermissions","slash_commands":["compact","context","clear"],"terminal_slash_commands":["doctor","color","focus","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.286","output_style":"default","agents":["general-purpose","Explore"],"skills":["code-review","simplify"],"plugins":[{"name":"cc-plugin-agents-md","path":"builtin","source":"cc-plugin-agents-md@builtin"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1","sdk_mcp_tools_list_changed","sdk_mcp_manifests","mcp_read_resource_v1","mcp_tool_ui_meta_v1","ui_surface_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"00000000-0000-4000-8000-000000000002","memory_paths":{"auto":"/home/user/.claude/projects/-workspace-project/memory/"},"scratchpad_path":"/tmp/claude/-workspace-project/00000000-0000-4000-8000-000000000000/scratchpad","messaging_socket_path":"/tmp/cc-socks/1.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":false,"view_mode":"default"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1790897400,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.1,"resetsAt":1790897400},"seven_day":{"utilization":0.2,"resetsAt":1790917200}}},"uuid":"00000000-0000-4000-8000-000000000003","session_id":"00000000-0000-4000-8000-000000000000"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2qvqiaFFYGxduAzxDr","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000004","timestamp":"2026-10-01T19:00:24.619Z","request_id":"req_011Cfc2quEieCQViU34wPvb9"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2qvqiaFFYGxduAzxDr","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CHuGKVaNkChVspFMWK3zcj","name":"Bash","input":{"command":"echo placeholder","description":"placeholder"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000005","timestamp":"2026-10-01T19:00:25.035Z","request_id":"req_011Cfc2quEieCQViU34wPvb9","wire_tool_inputs":{"toolu_01CHuGKVaNkChVspFMWK3zcj":{"command":"echo placeholder","description":"placeholder"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CHuGKVaNkChVspFMWK3zcj","type":"tool_result","content":"placeholder","is_error":false}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000006","timestamp":"2026-10-01T19:00:27.782Z","tool_use_result":{"stdout":"placeholder","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2rFTtZTztTpTJF3xPG","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000007","timestamp":"2026-10-01T19:00:28.733Z","request_id":"req_011Cfc2rF4ayP3qLD5K7Bu4f"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2rFTtZTztTpTJF3xPG","type":"message","role":"assistant","content":[{"type":"text","text":"Placeholder answer."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000008","timestamp":"2026-10-01T19:00:28.741Z","request_id":"req_011Cfc2rF4ayP3qLD5K7Bu4f"} +{"duration_api_ms":5094,"stop_reason":"end_turn","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0.0161638,"usage":{"input_tokens":18,"cache_creation_input_tokens":4928,"cache_read_input_tokens":45028,"output_tokens":161,"output_tokens_details":{"thinking_tokens":74},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":4928,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":8,"output_tokens":39,"cache_read_input_tokens":24871,"cache_creation_input_tokens":214,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":935,"outputTokens":174,"cacheReadInputTokens":45028,"cacheCreationInputTokens":4928,"webSearchRequests":0,"costUSD":0.0161638,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":74,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"Placeholder answer.","ttft_ms":2446,"type":"result","duration_ms":6591,"uuid":"00000000-0000-4000-8000-000000000009","ttft_stream_ms":1907,"time_to_request_ms":196,"first_content_frame_ms":1908,"queued_turn_count":0,"result_index":0} +{"type":"system","subtype":"session_state_changed","state":"idle","sdk_host_only":true,"uuid":"00000000-0000-4000-8000-000000000010","session_id":"00000000-0000-4000-8000-000000000000"} diff --git a/spec/unit/instrumentation/otel_spec.rb b/spec/unit/instrumentation/otel_spec.rb index 56d5a83f..26516cb7 100644 --- a/spec/unit/instrumentation/otel_spec.rb +++ b/spec/unit/instrumentation/otel_spec.rb @@ -1096,4 +1096,155 @@ def deliver_result(tool_use_id, content) expect(span.finished).to be(true) end end + + # The CLI sends one `assistant` frame PER CONTENT BLOCK, and every frame of + # one API response repeats that response's message id and usage snapshot. + # The hand-built messages above hold a whole response in one message with + # no id, a shape the CLI does not send, so these examples replay a recorded + # turn instead (spec/fixtures/cli_frames/README.md). + describe 'token usage on generation spans, from recorded CLI frames' do + let(:frames) do + path = File.expand_path('../../fixtures/cli_frames/tool_turn.jsonl', __dir__) + File.readlines(path).map { |line| JSON.parse(line, symbolize_names: true) } + end + let(:assistant_frames) { frames.select { |frame| frame[:type] == 'assistant' } } + let(:token_keys) { %i[input_tokens output_tokens cache_creation_input_tokens cache_read_input_tokens] } + + # Hands frames to the observer the way a session does: Query drops the + # `sdk_host_only` state frames, everything else is parsed and observed. + def replay(frames) + frames.each do |frame| + next if frame[:subtype] == 'session_state_changed' && frame[:sdk_host_only] == true + + observer.on_message(ClaudeAgentSDK::MessageParser.parse(frame)) + end + end + + def recorded_init + ClaudeAgentSDK::MessageParser.parse(frames.find { |frame| frame[:subtype] == 'init' }) + end + + def recorded_result + ClaudeAgentSDK::MessageParser.parse(frames.find { |frame| frame[:type] == 'result' }) + end + + # A recorded assistant frame with another message id / usage, for frame + # orders the recording does not contain. + def assistant_frame(id:, usage: assistant_frames.last.dig(:message, :usage)) + frame = assistant_frames.last + ClaudeAgentSDK::MessageParser.parse(frame.merge(message: frame[:message].merge(id: id, usage: usage))) + end + + def generation_spans + created_spans.select { |span| span.name == 'claude_agent.generation' } + end + + def token_usage(span) + token_keys.select { |key| span.attributes.key?("gen_ai.usage.#{key}") } + .to_h { |key| [key, span.attributes["gen_ai.usage.#{key}"]] } + end + + it 'replays a turn in which each API response spans two frames that repeat one usage snapshot' do + by_message_id = assistant_frames.group_by { |frame| frame.dig(:message, :id) } + + expect(by_message_id.keys).to all(start_with('msg_')) + expect(by_message_id.values.map(&:length)).to eq([2, 2]) + expect(assistant_frames.map { |frame| frame.dig(:message, :content).map { |block| block[:type] } }) + .to eq([%w[thinking], %w[tool_use], %w[thinking], %w[text]]) + by_message_id.each_value do |group| + expect(group.map { |frame| frame.dig(:message, :usage) }.uniq.length).to eq(1) + end + end + + it 'still emits one generation span per assistant frame' do + replay(frames) + + expect(generation_spans.length).to eq(4) + expect(generation_spans).to all(have_attributes(finished: true)) + end + + it 'attaches the usage of an API response to the first of its generation spans only' do + replay(frames) + + first_frames = assistant_frames.uniq { |frame| frame.dig(:message, :id) } + expected = assistant_frames.map do |frame| + first_frames.any? { |first| first.equal?(frame) } ? frame.dig(:message, :usage).slice(*token_keys) : {} + end + expect(expected.map(&:empty?)).to eq([false, true, false, true]) + expect(generation_spans.map { |span| token_usage(span) }).to eq(expected) + end + + it 'reports the same input-side token usage on generation spans as the ResultMessage' do + replay(frames) + + input_side = %i[input_tokens cache_read_input_tokens cache_creation_input_tokens] + summed = input_side.to_h { |key| [key, generation_spans.sum { |span| token_usage(span).fetch(key, 0) }] } + + expect(summed).to eq(input_tokens: 18, cache_read_input_tokens: 45_028, cache_creation_input_tokens: 4928) + expect(summed).to eq(recorded_result.usage.slice(*input_side)) + end + + # Every frame carries the snapshot taken when the response started, so a + # generation span cannot know the response's final output count; the + # ResultMessage, and with it the session span, can. + it 'leaves the authoritative totals, output tokens included, to the session span' do + replay(frames) + + session_span = created_spans.find { |span| span.name == 'claude_agent.session' } + expect(token_usage(session_span)).to eq(recorded_result.usage.slice(*token_keys)) + expect(token_usage(session_span)).to include(output_tokens: 161) + expect(generation_spans.sum { |span| token_usage(span).fetch(:output_tokens, 0) }).to eq(4 + 2) + end + + it 'opens the tool span on the tool_use frame and closes it on the tool result frame' do + replay(frames) + + tool_span = created_spans.find { |span| span.name == 'claude_agent.tool.Bash' } + expect(tool_span).to have_attributes(finished: true) + expect(tool_span.attributes).to include( + 'input.value' => '{"command":"echo placeholder","description":"placeholder"}', + 'output.value' => 'placeholder' + ) + end + + # Constructed order: two subagents answering at once interleave the + # frames of their responses. + it 'tells the frames of interleaved API responses apart by message id' do + observer.on_message(recorded_init) + %w[msg_a msg_b msg_a msg_b].each { |id| observer.on_message(assistant_frame(id: id)) } + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([false, false, true, true]) + end + + it 'keeps the usage for a later frame when the first frame of a response carries none' do + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a', usage: nil)) + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_message(assistant_frame(id: 'msg_a')) + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([true, false, true]) + end + + it 'keeps usage on every span when messages carry no id to group them by' do + observer.on_message(recorded_init) + 2.times { observer.on_message(assistant_frame(id: nil)) } + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([false, false]) + end + + it 'remembers message ids for one trace only' do + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_message(recorded_result) # the trace ends + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_message(recorded_init) # superseded without a result + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_close + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a')) + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([false, false, false, false]) + end + end end From b05facd8fffaf428fed08039f8cbb5f7f8a11c78 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:48:55 +0800 Subject: [PATCH 009/194] Keep the resume specs off the developer's macOS Keychain Store-backed resume runs `security find-generic-password` whenever the caller has no CLAUDE_CONFIG_DIR and no env auth. session_resume_spec.rb stubbed that bridge in five places only, so on a default macOS setup eight ordinary examples reached it through the public resume flow: 11 real `security` invocations per run, a copy of the developer's credentials (minus the refresh token) written to a temp dir, and results that depended on the local Keychain. With CLAUDE_CONFIG_DIR set, the same run made none and the bridge had no coverage at all. session_resume_spec.rb now stubs read_keychain_credentials for every example that does not opt in with `keychain: true`. session_resume_keychain_spec.rb tests the bridge itself, reached through materialize_resume_session with capture_with_timeout replaced by a recorder: the argv and service name, a hit (redacted, 0600), the precedence over ~/.claude/.credentials.json, a missing or empty entry, a timeout, env auth, and a non-macOS host. The host OS and the environment variables the bridge reads are pinned, so the examples also run on Linux CI and in a shell that exports ANTHROPIC_API_KEY. capture_with_timeout gets examples against a real `ruby -e` child: output, output larger than a pipe buffer, a non-zero exit, a child killed and reaped at the deadline, and a command that cannot be spawned. Specs only; no library change. Exploration finding: F-C1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- spec/unit/session_resume_keychain_spec.rb | 243 ++++++++++++++++++++++ spec/unit/session_resume_spec.rb | 12 ++ 2 files changed, 255 insertions(+) create mode 100644 spec/unit/session_resume_keychain_spec.rb diff --git a/spec/unit/session_resume_keychain_spec.rb b/spec/unit/session_resume_keychain_spec.rb new file mode 100644 index 00000000..97c19997 --- /dev/null +++ b/spec/unit/session_resume_keychain_spec.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'tmpdir' +require 'json' +require 'fileutils' +require 'open3' +require 'rbconfig' + +# The macOS Keychain bridge of store-backed resume. On macOS the CLI keeps its +# OAuth credentials in the Keychain rather than in .credentials.json, so +# materialization copies them (minus the refresh token) into the temp config +# dir the resumed subprocess runs against. +# +# No example here runs the real `security` binary or reads a real Keychain. +# The bridge is reached through materialize_resume_session — a stub of +# read_keychain_credentials would hide a wiring regression — and the layer +# below it, capture_with_timeout, is replaced by a recorder that answers like +# `security find-generic-password -w` does. capture_with_timeout itself is +# exercised against a real child process that is not `security`. +# +# `keychain: true` opts out of a default stub of read_keychain_credentials +# (session_resume_spec.rb installs one). +RSpec.describe ClaudeAgentSDK::SessionResume, keychain: true do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:cwd) { Dir.mktmpdir } + let(:home) { Dir.mktmpdir } + let(:sid) { SecureRandom.uuid } + let(:security_calls) { [] } + let(:materializations) { [] } + + # What the CLI stores in the Keychain item (values are fake). + let(:oauth) do + { 'accessToken' => 'fake-access-token', 'refreshToken' => 'fake-refresh-token', + 'expiresAt' => 1_790_000_000_000, 'scopes' => %w[user:inference user:profile], + 'subscriptionType' => 'max', 'rateLimitTier' => 'default_claude_max_20x' } + end + let(:keychain_payload) { JSON.generate('claudeAiOauth' => oauth) } + + # The bridge keys off the environment the CHILD will see, which falls back to + # the parent's for every key options.env does not set. Pin the ones it reads, + # so the examples behave the same on any machine: a shell or CI job exporting + # ANTHROPIC_API_KEY would otherwise skip the bridge altogether. + around do |example| + names = %w[CLAUDE_CONFIG_DIR ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN USER] + saved = names.to_h { |name| [name, ENV.fetch(name, nil)] } + names.each { |name| ENV.delete(name) } + ENV['USER'] = 'spec-user' + example.run + ensure + saved.each { |name, value| value.nil? ? ENV.delete(name) : (ENV[name] = value) } + end + + before do + stub_host_os('darwin24') # the bridge is a no-op elsewhere, and CI runs on Linux + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => sid }, + [{ 'parentUuid' => nil, 'isSidechain' => false, 'userType' => 'external', 'cwd' => cwd, + 'sessionId' => sid, 'version' => '2.1.286', 'gitBranch' => 'main', 'type' => 'user', + 'message' => { 'role' => 'user', 'content' => 'hello' }, 'uuid' => SecureRandom.uuid, + 'timestamp' => '2026-09-01T12:00:00.000Z' }]) + end + + after do + materializations.each(&:cleanup) + [cwd, home].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + def stub_host_os(host_os) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return(host_os) + end + + # Stand-in for `security find-generic-password -w`, which prints the secret + # and a newline and exits 0 on a hit, and prints nothing and exits non-zero + # when the item does not exist. +exit_ok+ nil is capture_with_timeout's own + # answer for a command that timed out or could not be run: [nil, nil]. + def stub_security(stdout, exit_ok) + status = exit_ok.nil? ? nil : instance_double(Process::Status, success?: exit_ok) + allow(described_class).to receive(:capture_with_timeout) do |argv, timeout_s| + security_calls << [argv, timeout_s] + [stdout, status] + end + end + + # Store-backed resume as query() and Client#connect run it. HOME is a + # scratch directory, so nothing is seeded from the developer's own ~/.claude. + def materialize(env = {}) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: sid, cwd: cwd, + env: { 'HOME' => home }.merge(env)) + described_class.materialize_resume_session(options).tap { |mat| materializations << mat } + end + + def seeded_credentials(materialized) + File.join(materialized.config_dir, '.credentials.json') + end + + def write_credentials_file(config_dir, access_token) + FileUtils.mkdir_p(config_dir) + File.write(File.join(config_dir, '.credentials.json'), + JSON.generate('claudeAiOauth' => oauth.merge('accessToken' => access_token))) + end + + def security_argv(service) + ['security', 'find-generic-password', '-a', 'spec-user', '-w', '-s', service] + end + + describe 'with the default config dir' do + it "reads the CLI's default Keychain entry and seeds it without the refresh token, owner-only" do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(security_calls).to eq([[security_argv('Claude Code-credentials'), described_class::KEYCHAIN_TIMEOUT_SECONDS]]) + seeded = seeded_credentials(materialized) + expect(JSON.parse(File.read(seeded))).to eq('claudeAiOauth' => oauth.except('refreshToken')) + expect(File.read(seeded)).not_to include('fake-refresh-token') + expect(format('%o', File.stat(seeded).mode & 0o777)).to eq('600') + end + + it 'lets a Keychain hit override ~/.claude/.credentials.json' do + write_credentials_file(File.join(home, '.claude'), 'from-the-file') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(JSON.parse(File.read(seeded_credentials(materialized))).dig('claudeAiOauth', 'accessToken')) + .to eq('fake-access-token') + end + + it 'falls back to ~/.claude/.credentials.json when the Keychain has no entry' do + write_credentials_file(File.join(home, '.claude'), 'from-the-file') + stub_security('', false) + + materialized = materialize + + expect(security_calls.length).to eq(1) + expect(JSON.parse(File.read(seeded_credentials(materialized)))) + .to eq('claudeAiOauth' => oauth.except('refreshToken').merge('accessToken' => 'from-the-file')) + end + + { + 'the lookup exits non-zero (no such entry)' => ['', false], + 'the entry is empty' => ["\n", true], + 'the lookup times out or cannot be run' => [nil, nil] + }.each do |outcome, (stdout, exit_ok)| + it "seeds no credentials file when #{outcome}, and still materializes the transcript" do + stub_security(stdout, exit_ok) + + materialized = materialize + + expect(security_calls.length).to eq(1) + expect(File).not_to exist(seeded_credentials(materialized)) + expect(Dir.glob(File.join(materialized.config_dir, 'projects', '*', "#{sid}.jsonl")).length).to eq(1) + end + end + + %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN].each do |name| + it "leaves the Keychain alone when options.env carries #{name}" do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize(name => 'from-the-env') + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + + it "leaves the Keychain alone when the child inherits #{name} from the parent" do + stub_security("#{keychain_payload}\n", true) + ENV[name] = 'from-the-env' + + materialize + + expect(security_calls).to be_empty + end + end + + it 'leaves the Keychain alone off macOS' do + stub_host_os('linux-gnu') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end + + describe '.capture_with_timeout' do + # A real child process (never `security`). A child that is expected to exit + # gets a deadline it cannot plausibly miss — the call returns the moment it + # exits — so machine load never decides the outcome; only the child that + # never exits meets a short one. + let(:ample) { 120 } + + def ruby_child(script) + [RbConfig.ruby, '--disable-gems', '-e', script] + end + + def capture(argv, timeout_s) + described_class.send(:capture_with_timeout, argv, timeout_s) + end + + it "returns the child's stdout and a successful status" do + stdout, status = capture(ruby_child('print "from the child"'), ample) + + expect(stdout).to eq('from the child') + expect(status).to be_success + end + + it 'returns everything a child wrote, past the size of a pipe buffer' do + stdout, status = capture(ruby_child('print "x" * 300_000'), ample) + + expect(stdout.bytesize).to eq(300_000) + expect(status).to be_success + end + + it 'returns the output and the failed status of a child that exits non-zero' do + stdout, status = capture(ruby_child('print "partial"; exit 3'), ample) + + expect(stdout).to eq('partial') + expect(status.exitstatus).to eq(3) + expect(status).not_to be_success + end + + it 'kills and reaps a child that outlives the deadline, returning [nil, nil]' do + waiter = nil + allow(Open3).to receive(:popen3).and_wrap_original do |original, *argv| + streams_and_waiter = original.call(*argv) + waiter = streams_and_waiter.last + streams_and_waiter + end + + expect(capture(ruby_child('sleep'), 0.05)).to eq([nil, nil]) + expect(waiter).not_to be_alive # already reaped: no zombie, no stray `security` + expect(waiter.value.termsig).to eq(Signal.list['KILL']) + end + + it 'returns [nil, nil] for a command that cannot be spawned' do + expect(capture([File.join(home, 'no-such-command')], ample)).to eq([nil, nil]) + end + end +end diff --git a/spec/unit/session_resume_spec.rb b/spec/unit/session_resume_spec.rb index f30c1228..834e86d6 100644 --- a/spec/unit/session_resume_spec.rb +++ b/spec/unit/session_resume_spec.rb @@ -13,6 +13,18 @@ let(:project_key) { ClaudeAgentSDK.project_key_for_directory(cwd) } let(:sid) { SecureRandom.uuid } + # Hermetic by default. Materialization runs the macOS Keychain bridge + # (`security find-generic-password`) whenever the caller has no + # CLAUDE_CONFIG_DIR and no env auth — a contributor's default macOS setup — + # so these examples used to read the developer's real credentials and to + # depend on the local Keychain. The bridge has its own examples in + # session_resume_keychain_spec.rb, which stub one level lower + # (capture_with_timeout); an example in this file that needs the real + # method opts in with `keychain: true` and does the same. + before do |example| + allow(described_class).to receive(:read_keychain_credentials).and_return(nil) unless example.metadata[:keychain] + end + after { FileUtils.remove_entry(cwd) if File.directory?(cwd) } def entry(text, **extra) From 98561f994ae56d0a135940b2ec27649e8eb25d64 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:50:05 +0800 Subject: [PATCH 010/194] Pick the control-request waiter by reactor ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control method (Client#interrupt, #set_model, ...) called from a fiber on a reactor other than the session's own — `Sync { client.interrupt }` on a request thread while the session lives on a background reactor thread, or a Sync block inside a :thread-mode callback — waited on an Async::Condition, because send_control_request chose the waiter by "the caller has an Async task". The session's read loop signals that waiter from its own thread: - async 2.10-2.28: Condition#signal transfers to the waiting fiber, which raises "FiberError: fiber called across threads" inside the read loop. The read loop treats it as a fatal stream error, so the whole session ends and the caller waits out the control-request timeout (1200 s by default). Reproduced against the real CLI. - async >= 2.29: the signal is thread-safe but edge-triggered. A response routed between the caller's slot check and its park is lost, and the caller waits out the same timeout with its answer already stored. The waiter is now chosen by ownership, with the predicate Query#close already uses: an Async::Condition only for a fiber of the owning reactor (Fiber.scheduler.equal?(@owning_scheduler)), the level-triggered ThreadWaiter for every other caller. The deadline mechanism is still chosen by "has a task", so a foreign fiber parks in Thread::Queue#pop under the cooperative timeout. A reactor caller on a Query that was never started has no owning reactor and gets the ThreadWaiter as well. formal/tla/ControlProtocol.tla and its README now describe sender 2 as any caller not on the owning reactor. Comments only: the model is unchanged and run.sh still meets all 16 expectations. Exploration finding: A-P3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- formal/tla/ControlProtocol.tla | 23 +- formal/tla/README.md | 4 +- lib/claude_agent_sdk/query.rb | 48 ++-- .../query_control_waiter_ownership_spec.rb | 207 ++++++++++++++++++ 4 files changed, 260 insertions(+), 22 deletions(-) create mode 100644 spec/unit/query_control_waiter_ownership_spec.rb diff --git a/formal/tla/ControlProtocol.tla b/formal/tla/ControlProtocol.tla index 9b06706f..1c4ef8fb 100644 --- a/formal/tla/ControlProtocol.tla +++ b/formal/tla/ControlProtocol.tla @@ -6,10 +6,15 @@ (* ensure. *) (* *) (* Senders call a control method (interrupt, set_model, ...). Sender 1 is *) -(* a reactor fiber (Async::Condition: edge-triggered, but checking the *) -(* result slot and parking happen without a suspension point in between). *) -(* Sender 2 runs on a FiberBoundary worker thread (ThreadWaiter): between *) -(* its slot check and its park the read loop can run. *) +(* a fiber of the reactor that owns the Query, the one its read loop runs *) +(* on (Async::Condition: edge-triggered, but checking the result slot and *) +(* parking happen without a suspension point in between). Sender 2 is any *) +(* caller NOT on the owning reactor (ThreadWaiter): a FiberBoundary worker *) +(* thread, a plain thread, or a fiber on another thread's reactor. Between *) +(* its slot check and its park the read loop can run. The code picks the *) +(* waiter by that ownership (Fiber.scheduler.equal?(@owning_scheduler)), *) +(* not by whether the caller has an Async task. Kind's "fiber" / "thread" *) +(* below are this model's names for sender 1 / sender 2. *) (* *) (* Timeouts are left out ON PURPOSE: with the 1200s deadline everything *) (* terminates trivially. The question is whether a sender ever NEEDS it. *) @@ -23,7 +28,10 @@ (* mutex, shared with the EOF snapshot *) (* CHECK_SLOT_FIRST `waiter.wait until slot.key?` (vs. a bare wait) *) (* LEVEL_TRIGGERED ThreadWaiter pushes a token (vs. a plain *) -(* edge-triggered condition on the thread path) *) +(* edge-triggered condition for sender 2; FALSE *) +(* is also what a fiber on another thread's *) +(* reactor got while the waiter was picked by *) +(* "has an Async task" instead of by ownership) *) (***************************************************************************) EXTENDS Naturals, FiniteSets @@ -121,8 +129,9 @@ Detect(s) == /\ UNCHANGED <> -\* `until @pending_control_results.key?(id)` -- the check. A fiber parks in -\* the same step (no suspension point); a thread parks in a separate one. +\* `until @pending_control_results.key?(id)` -- the check. A fiber of the +\* owning reactor parks in the same step (no suspension point); any other +\* caller parks in a separate one. Wait(s) == /\ spc[s] = "wait" /\ IF CHECK_SLOT_FIRST /\ slot[s] # NONE diff --git a/formal/tla/README.md b/formal/tla/README.md index 0b2ad1d5..b7ec5c64 100644 --- a/formal/tla/README.md +++ b/formal/tla/README.md @@ -29,7 +29,7 @@ Properties: `ReadersSeeVerifiedBinary` (a lock-free reader never sees a partial ## `ControlProtocol.tla`: outbound control requests -Models the outbound half of the control protocol in `lib/claude_agent_sdk/query.rb`: `send_control_request` / `await_control_response`, the read loop's `handle_control_response`, and the EOF broadcast in `read_messages`' `ensure`. There are two senders: a reactor fiber (`Async::Condition`, which checks the result slot and parks with no suspension point between them) and a FiberBoundary worker thread (`ThreadWaiter`, where the read loop can run between the check and the park). The CLI may answer each request or never answer, and it eventually exits. The model checks liveness under weak fairness. Control-request timeouts are left out on purpose, because the 1200 s deadline would make every liveness property hold trivially. What the model asks is whether a caller ever *needs* the timeout. +Models the outbound half of the control protocol in `lib/claude_agent_sdk/query.rb`: `send_control_request` / `await_control_response`, the read loop's `handle_control_response`, and the EOF broadcast in `read_messages`' `ensure`. There are two senders. One is a fiber of the reactor that owns the `Query`, the reactor its read loop runs on (`Async::Condition`, which checks the result slot and parks with no suspension point between them). The other is any caller that is not on the owning reactor: a FiberBoundary worker thread, a plain thread, or a fiber on another thread's reactor (`ThreadWaiter`, where the read loop can run between the check and the park). `send_control_request` picks the waiter by that ownership, not by whether the caller has an Async task. The CLI may answer each request or never answer, and it eventually exits. The model checks liveness under weak fairness. Control-request timeouts are left out on purpose, because the 1200 s deadline would make every liveness property hold trivially. What the model asks is whether a caller ever *needs* the timeout. There are three properties. `EverySenderFinishes` says every control method returns or raises. `NoHalfExecutedRequest` says a method that fails locally never reached the CLI. `DeliveredMeansAnswered` says a response the read loop consumed is never replaced by the stream-end error; this holds because the broadcast uses `||=`. None of the variants below targets `DeliveredMeansAnswered`, but every config still checks it. @@ -39,7 +39,7 @@ There are three properties. `EverySenderFinishes` says every control method retu | `bug_detect_after_write` | `DETECT_MODE_BEFORE_WRITE = FALSE` (the code before `420ee09`) | `NoHalfExecutedRequest` violated: the worker thread registers and writes the request, then `Async::Task.current` raises, and its `ensure` evicts the waiter. The CLI has the request, and its answer will be dropped by the `key?` guard | `send_control_request`: "Detecting after the write left a half-executed request (written to the CLI, then RuntimeError; the eventual response dropped by the key? guard)" | | `bug_nonatomic` | `ATOMIC_REGISTRATION = FALSE` | `EverySenderFinishes` violated: a sender passed the stream-error check, EOF snapshotted the pending map before it registered, and it waits forever | `send_control_request`: "Register atomically with the terminal-state check so EOF cannot strand a sender that missed the final broadcast" | | `bug_nocheck` | `CHECK_SLOT_FIRST = FALSE` | `EverySenderFinishes` violated: the fiber's write suspended after delivery, and the broadcast signal fired before it waited, so the signal was lost | `await_control_response`: "a signal arriving before the sender reaches wait would otherwise be dropped" | -| `bug_edge_thread` | `LEVEL_TRIGGERED = FALSE` | `EverySenderFinishes` violated: the thread checked the slot, the signal fired before it parked, and the signal was lost | `ThreadWaiter`: "closing the check-then-wait gap that an edge-triggered Condition would lose across threads" | +| `bug_edge_thread` | `LEVEL_TRIGGERED = FALSE` | `EverySenderFinishes` violated: the sender off the owning reactor checked the slot, the signal fired before it parked, and the signal was lost. A fiber on another thread's reactor was in exactly this position while the waiter was picked by "has an Async task" | `ThreadWaiter`: "closing the check-then-wait gap that an edge-triggered Condition would lose across threads" | ## What the models assume and leave out diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 41035eee..3931e64d 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -136,14 +136,16 @@ def self.parse_streamed_message(string) end private_class_method :parse_streamed_message - # Waiter for control responses awaited OFF the reactor — i.e. a control - # method called from inside a hook/can_use_tool/SDK-MCP callback, which - # runs on a FiberBoundary worker thread (Python supports this reentrancy - # natively: callbacks are event-loop tasks and anyio.Event is - # level-triggered). Duck-types Async::Condition#signal for the read - # loop's signal sites; the unconditional token push makes it - # level-triggered, closing the check-then-wait gap that an - # edge-triggered Condition would lose across threads. + # Waiter for control responses awaited OFF the reactor that owns the + # Query — a control method called from inside a hook/can_use_tool/SDK-MCP + # callback, which runs on a FiberBoundary worker thread (Python supports + # this reentrancy natively: callbacks are event-loop tasks and + # anyio.Event is level-triggered), from any other plain thread, or from a + # fiber on another thread's reactor (see #send_control_request for the + # choice). Duck-types Async::Condition#signal for the read loop's signal + # sites; the unconditional token push makes it level-triggered, closing + # the check-then-wait gap that an edge-triggered Condition would lose + # across threads. class ThreadWaiter def initialize @queue = ::Queue.new @@ -1323,10 +1325,25 @@ def send_control_request(request) # RuntimeError; the eventual response dropped by the key? guard). task = Async::Task.current? - # Reactor callers wait on an Async::Condition; worker-thread callers - # on a ThreadWaiter. Register atomically with the terminal-state check - # so EOF cannot strand a sender that missed the final broadcast. - waiter = task ? Async::Condition.new : ThreadWaiter.new + # The waiter is chosen by reactor OWNERSHIP, not by "has a task". Only + # a fiber of the reactor that owns this Query (the one #start ran on, + # whose read loop does the signaling) checks its result slot and parks + # with no chance for the read loop to run in between, so only there is + # the edge-triggered Async::Condition safe. Every other caller races + # the read loop between its check and its park and gets the + # level-triggered ThreadWaiter: a FiberBoundary worker thread, a plain + # thread, and also a fiber on ANOTHER thread's reactor (`Sync { + # client.interrupt }` on a request thread while the session lives on a + # background reactor; a Sync block inside a :thread-mode callback). + # Given a Condition, such a fiber could lose its wakeup on async >= + # 2.29, and on async 2.10-2.28 the cross-thread signal raised + # FiberError in the read loop, ending the whole session. The deadline + # mechanism (#await_control_response) is still chosen by "has a task". + # + # Register atomically with the terminal-state check so EOF cannot + # strand a sender that missed the final broadcast. + on_owning_reactor = task && Fiber.scheduler.equal?(@owning_scheduler) + waiter = on_owning_reactor ? Async::Condition.new : ThreadWaiter.new request_id = @request_counter_mutex.synchronize do raise @control_stream_error if @control_stream_error @@ -1376,7 +1393,12 @@ def await_control_response(request_id, waiter, task, timeout_seconds, subtype) # only this deadline is translated, not an outer task's cancellation. FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do yield - waiter.wait until @pending_control_results.key?(request_id) + # A ThreadWaiter here belongs to a fiber on a reactor that does not + # own this Query: Thread::Queue#pop parks that fiber through its + # own scheduler, and the deadline cancels it like any suspension. + until @pending_control_results.key?(request_id) + waiter.is_a?(ThreadWaiter) ? waiter.wait(nil) : waiter.wait + end end else # Only schedulerless callers use stdlib Timeout. A fresh, private diff --git a/spec/unit/query_control_waiter_ownership_spec.rb b/spec/unit/query_control_waiter_ownership_spec.rb new file mode 100644 index 00000000..84a2003a --- /dev/null +++ b/spec/unit/query_control_waiter_ownership_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +# Outbound control requests (interrupt, set_model, ...) wait for the CLI's +# response on a waiter that the session's read loop signals. Which waiter is +# safe depends on where the caller runs, not on whether it has an Async task: +# only a fiber of the reactor that owns the Query checks the result slot and +# parks without the read loop being able to run in between. A fiber on +# ANOTHER thread's reactor (`Sync { client.interrupt }` on a request thread +# while the session lives on a background reactor, or a `Sync { }` inside a +# :thread-mode callback) races the read loop exactly like a plain thread, so +# it needs the level-triggered ThreadWaiter too. +# +# No clock takes part in the ordering below. The control-request timeout is +# only the cap that turns a lost wakeup into a failing example. +RSpec.describe ClaudeAgentSDK::Query, 'control requests from a fiber on another reactor' do + # In-memory stand-in for the CLI. Thread::Queue everywhere: the caller + # writes from one thread while the owning reactor reads on another. + let(:transport_class) do + Class.new do + attr_reader :held + + def initialize + @incoming = Thread::Queue.new + @handled = Thread::Queue.new + @held = Thread::Queue.new + @hold_interrupt_response = false + end + + def hold_interrupt_response! + @hold_interrupt_response = true + end + + def connect; end + + def ready? + true + end + + def end_input; end + + def close + @incoming << nil + end + + def deliver(frame) + @incoming << frame + end + + # What CLI 2.1.286 answers to `initialize` (payload trimmed) and to + # `interrupt`. + def write(line) + request = JSON.parse(line, symbolize_names: true) + return unless request[:type] == 'control_request' + + case request.dig(:request, :subtype) + when 'initialize' + deliver(type: 'control_response', + response: { subtype: 'success', request_id: request[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + when 'interrupt' + response = { type: 'control_response', + response: { subtype: 'success', request_id: request[:request_id], + response: { still_queued: [] } } } + @hold_interrupt_response ? @held << response : deliver(response) + end + end + + def read_messages + while (frame = @incoming.pop) + yield frame + @handled << frame # the read loop is done with this frame + end + ensure + @handled << :read_loop_ended + end + + # Blocks until the read loop has finished handling +frame+. + def wait_until_handled(frame) + loop do + seen = @handled.pop + return if seen.equal?(frame) + raise 'the read loop ended before it handled the frame' if seen == :read_loop_ended + end + end + end + end + + # A result-slot Hash that can hold its next #key? caller right after the + # check, as a thread preempted between "is my response there?" and "park". + let(:holding_slots_class) do + Class.new(Hash) do + attr_reader :checked + + def hold_next_check! + @checked = Thread::Queue.new + @release = Thread::Queue.new + @armed = true + end + + def release! + @release << true + end + + def key?(request_id) + present = super + if @armed + @armed = false + @checked << present + @release.pop + end + present + end + end + end + + let(:transport) { transport_class.new } + let(:query) { described_class.new(transport: transport, is_streaming_mode: true) } + + before do + # The cap for a response that never wakes its sender (1200 s by default). + allow(query).to receive(:control_request_timeout_seconds).and_return(10) + end + + # Runs the session — read loop included — on a reactor of its own, on a + # background thread, for the duration of the block. + def with_session_on_its_own_reactor + ready = Thread::Queue.new + finish = Thread::Queue.new + owner = Thread.new do + Thread.current.report_on_exception = false + Sync do + query.start + query.initialize_protocol + ready << :ready + finish.pop + ensure + query.close + end + ensure + ready << :owner_exited # never leaves the example waiting for a session that did not start + end + raise 'the session did not start' unless ready.pop == :ready + + yield + ensure + finish << :done + owner.join # re-raises whatever ended the owning thread + end + + # Calls the control method from a reactor of its own on a new thread. The + # thread's value is the response, or the error the call raised. + def interrupt_from_another_reactor(announce_exit_on: nil) + Thread.new do + Sync { query.interrupt } + rescue StandardError => e + e + ensure + announce_exit_on&.push(:sender_exited) + end + end + + def read_task + query.instance_variable_get(:@task) + end + + it 'answers a control method called inside Sync on another thread and keeps the session running' do + with_session_on_its_own_reactor do + # The caller has an Async task, but on a reactor that is not the Query's. + answer = interrupt_from_another_reactor.value + aggregate_failures do + expect(answer).to eq({ still_queued: [] }) + expect(read_task).to be_alive # signalling the caller did not take the read loop down + end + end + end + + it 'does not lose the response routed between the sender checking its slot and parking' do + slots = holding_slots_class.new + query.instance_variable_set(:@pending_control_results, slots) + transport.hold_interrupt_response! + + with_session_on_its_own_reactor do + slots.hold_next_check! + sender = interrupt_from_another_reactor(announce_exit_on: slots.checked) + + expect(slots.checked.pop).to be(false) # the sender looked: no response yet + response = transport.held.pop + transport.deliver(response) # the CLI's response arrives ... + transport.wait_until_handled(response) # ... and the read loop has stored and signalled it + slots.release! # only now does the sender go on to park + + answer = sender.value + aggregate_failures do + expect(answer).to eq({ still_queued: [] }) + expect(read_task).to be_alive + end + ensure + slots.release! + sender&.join + end + end +end From 4815b48b352e81065b7c92464d739b5a8fb1cb90 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:52:13 +0800 Subject: [PATCH 011/194] Read transcripts as UTF-8 bytes in the message readers get_session_messages and get_subagent_messages raised Encoding::CompatibilityError (not a ClaudeSDKError) in two situations: - under a non-UTF-8 locale (LANG=C / LC_ALL=C: minimal Docker images, cron, systemd), for any transcript with one non-ASCII character, because the file was read in the locale's encoding; - in any locale, for a transcript whose last line stops inside a multibyte character (the CLI was killed mid-write) or that holds a raw non-UTF-8 byte in a tool result. This one was permanent: a later rename_session appends after the broken line and the read kept raising. list_sessions, get_session_info, fork_session and import_session_to_store already read the same files without failing. The reader now opens the transcript in binary mode, tags each line as UTF-8 and scrubs it when it is not valid, the strategy SessionMutations.parse_fork_transcript already uses. A torn line still fails to parse and is skipped; a complete line with a stray byte keeps its entry, with the byte replaced by U+FFFD (noticeable: that message used to make the whole read fail). Exploration finding: B-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 2 +- lib/claude_agent_sdk/sessions.rb | 20 ++- spec/unit/session_transcript_encoding_spec.rb | 121 ++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 spec/unit/session_transcript_encoding_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index 609b8308..114506b8 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -40,7 +40,7 @@ messages.each { |msg| puts "[#{msg.type}] #{msg.message}" } ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...', offset: 10, limit: 20) ``` -Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)). Messages come back in conversation order. The CLI writes one entry per content block, so an assistant turn with several tool calls comes back as several `assistant` messages and one `user` message per `tool_result`; every result follows the `tool_use` it answers and precedes the next assistant turn. +Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)). Messages come back in conversation order. The CLI writes one entry per content block, so an assistant turn with several tool calls comes back as several `assistant` messages and one `user` message per `tool_result`; every result follows the `tool_use` it answers and precedes the next assistant turn. Transcripts are read as UTF-8 whatever the process locale is; a line that does not parse (the last line of a session whose CLI was killed mid-write) is skipped, and bytes that are not valid UTF-8 inside a line that does parse come back as U+FFFD. ## Reading Subagent Transcripts diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 911596d5..10f375db 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1546,8 +1546,8 @@ def collect_agent_files(base_dir, results = []) def parse_jsonl_entries(file_path) entries = [] - File.foreach(file_path) do |line| - entry = JSON.parse(line.strip, symbolize_names: false) + File.foreach(file_path, mode: 'rb') do |line| + entry = JSON.parse(utf8_transcript_line(line).strip, symbolize_names: false) next unless entry.is_a?(Hash) next unless TRANSCRIPT_ENTRY_TYPES.include?(entry['type']) next unless entry['uuid'].is_a?(String) @@ -1559,6 +1559,20 @@ def parse_jsonl_entries(file_path) entries end + # One transcript line, read in binary mode, as UTF-8 text. Transcripts are + # UTF-8 whatever the process locale says: a line tagged with the locale's + # encoding (File.foreach's default) raised from String#strip on the first + # non-ASCII character under LANG=C. Bytes that are not valid UTF-8 — a + # final line the CLI was killed in the middle of, raw binary in a tool + # result — become U+FFFD, the policy + # SessionMutations.parse_fork_transcript already has: a torn line then + # fails JSON.parse and is skipped like any other bad line instead of + # raising, and a complete line keeps its entry. + def utf8_transcript_line(line) + line.force_encoding(Encoding::UTF_8) + line.valid_encoding? ? line : line.scrub + end + # Build the conversation chain by finding the leaf and walking parentUuid. # Returns messages in chronological order (root -> leaf). # @@ -1735,7 +1749,7 @@ def filter_visible_messages(chain) :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, :find_session_file, :stat_candidate, :resolve_subagents_dir, - :collect_agent_files, :parse_jsonl_entries, + :collect_agent_files, :parse_jsonl_entries, :utf8_transcript_line, :build_conversation_chain, :walk_to_leaf, :walk_to_root, :off_main_conversation?, :reattach_parallel_tool_results, :off_chain_tool_results, :content_block_values, diff --git a/spec/unit/session_transcript_encoding_spec.rb b/spec/unit/session_transcript_encoding_spec.rb new file mode 100644 index 00000000..b2adab2f --- /dev/null +++ b/spec/unit/session_transcript_encoding_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Transcripts are UTF-8 whatever the process locale says, and a CLI killed in +# the middle of a write leaves a final line that stops inside a character. +RSpec.describe 'reading transcripts that are not clean UTF-8 text' do + include_context 'with a Claude config dir' + + let(:session_id) { '3f8a1c2d-5e6b-4a79-9c80-d1e2f3a4b5c6' } + let(:agent_id) { 'b7c8d9e0f1a2b3c4' } + let(:question) { '你好,请读一下 a.rb — 然后总结' } + let(:answer) { '好的。a.rb 定义了 Foo。' } + let(:replacement) { [0xFFFD].pack('U') } + + def conversation(transcript) + transcript.prompt(:prompt, question) + transcript.assistant(:use_a, transcript.tool_use('toolu_a'), parent: :prompt) + transcript.tool_result(:result_a, 'toolu_a', 'class Foo; end', parent: :use_a) + transcript.assistant(:answer, transcript.text(answer), parent: :result_a, message: 'msg_02') + transcript + end + + def main_transcript + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.queue_operations(question) + conversation(transcript) + transcript.last_prompt(question, leaf: :answer) + transcript + end + + # What LANG=C / LC_ALL=C does to a Ruby process: files and pipes opened + # without an explicit encoding yield US-ASCII-tagged Strings. + def with_default_external(encoding) + saved = Encoding.default_external + switch_default_external(encoding) + yield + ensure + switch_default_external(saved) + end + + def switch_default_external(encoding) + verbose = $VERBOSE + $VERBOSE = nil # the assignment warns under -w + Encoding.default_external = encoding + ensure + $VERBOSE = verbose + end + + # +line+ cut one byte into the 3-byte character +char+: no closing quote, + # no newline, and a lead byte with nothing after it. + def cut_inside(line, char) + bytes = line.b + bytes[0, bytes.index(char.b) + 1] + end + + describe 'under a non-UTF-8 locale' do + it 'get_session_messages reads a transcript with non-ASCII text' do + main_transcript.write(transcript_path(session_id)) + + messages = with_default_external(Encoding::US_ASCII) do + ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + end + + expect(messages.map(&:text)).to eq([question, '', '', answer]) + expect(messages.last.text.encoding).to eq(Encoding::UTF_8) + end + + it 'get_subagent_messages reads a subagent transcript with non-ASCII text' do + main_transcript.write(transcript_path(session_id)) + conversation(CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id)) + .write(subagent_transcript_path(session_id, agent_id)) + + messages = with_default_external(Encoding::US_ASCII) do + ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd) + end + + expect(messages.map(&:text)).to eq([question, '', '', answer]) + end + end + + describe 'a final line cut inside a multibyte character' do + before do + transcript = main_transcript + interrupted = CLITranscript.new(session_id: session_id, cwd: cwd) + .assistant(:interrupted, transcript.text('最后一条回复被截断'), parent: :answer) + File.binwrite(transcript.write(transcript_path(session_id)), + cut_inside(JSON.generate(interrupted), '最'), mode: 'ab') + end + + it 'returns the complete messages before it' do + expect(File.binread(transcript_path(session_id))[-1].unpack1('C')).to eq(0xE6) # a lead byte, then EOF + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(messages.map(&:text)).to eq([question, '', '', answer]) + end + + it 'stays readable after a later rename appended to the file' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Renamed', directory: cwd) + + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd).length).to eq(4) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd).custom_title).to eq('Renamed') + end + end + + it 'returns a message that holds a raw invalid byte, with the byte replaced by U+FFFD' do + transcript = main_transcript + path = transcript.write(transcript_path(session_id)) + File.binwrite(path, File.binread(path).sub('class Foo; end'.b, "class \xFF; end".b)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a answer]) + result = messages[2].content_blocks.first + expect(result.content).to eq("class #{replacement}; end") + expect(result.content).to be_valid_encoding + end +end From b4a0622aab98b28ce2df09d1379bae4e6e110271 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:52:35 +0800 Subject: [PATCH 012/194] Filter settings, extra_args and MCP urls/args in #inspect Type#inspect filtered only `env` and MCP `headers`. Logging an options object or a server config still printed credentials held elsewhere: `settings` (a JSON String, or a Hash with `env` / `apiKeyHelper`), the values of `extra_args`, McpStdioServerConfig#args (`--api-key ...`), and the `url` of an HTTP/SSE server (a token in the userinfo, path or query). Raw Hash server configs inside `mcp_servers` showed their url in full, and their `env` / `headers` stayed out of the output only because they sat below the nesting limit. Now: - ClaudeAgentOptions filters `settings` and `extra_args` like `env`: a Hash keeps its keys, any other value (a JSON String, a settings path) prints as "[FILTERED]". - McpStdioServerConfig filters `args`. - An MCP url prints as scheme, host and port only, in the typed configs and in raw Hash configs: "https://mcp.example.com/[FILTERED]". A value that is not an http(s)/ws(s) URL with a plain host is replaced outright. - A raw Hash config in `mcp_servers` keeps its keys and shows `type`, `command`, `name`, `instance` and the redacted `url`; every other value is filtered, whatever its depth. Display only: the objects are not modified, and #to_h and the command line are byte-identical to before (pinned by the new spec). Exploration finding: C-P10 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP --- lib/claude_agent_sdk/types/base.rb | 28 +- lib/claude_agent_sdk/types/mcp.rb | 19 +- lib/claude_agent_sdk/types/options.rb | 37 ++- spec/unit/type_inspect_filtering_spec.rb | 320 +++++++++++++++++++++++ spec/unit/types_spec.rb | 2 +- 5 files changed, 399 insertions(+), 7 deletions(-) create mode 100644 spec/unit/type_inspect_filtering_spec.rb diff --git a/lib/claude_agent_sdk/types/base.rb b/lib/claude_agent_sdk/types/base.rb index 0d7cac15..5c77c502 100644 --- a/lib/claude_agent_sdk/types/base.rb +++ b/lib/claude_agent_sdk/types/base.rb @@ -134,9 +134,10 @@ def to_s inspect end - # Declares attributes that carry credentials (env vars, auth headers). - # Objects get logged, so #inspect shows them filtered; #to_h and - # everything sent to the CLI are unaffected. Inherited by subclasses. + # Declares attributes that carry credentials (env vars, auth headers, + # settings, extra CLI arguments, an MCP server's args). Objects get + # logged, so #inspect shows them filtered; #to_h and everything sent to + # the CLI are unaffected. Inherited by subclasses. # # @api private def self.inspect_filtered(*names) @@ -196,6 +197,27 @@ def inspect_filter(value) value.respond_to?(:each_key) ? value.each_key.to_h { |key| [key, '[FILTERED]'] } : '[FILTERED]' end + INSPECT_URL_ORIGIN = %r{ + \A(https?|wss?):// # scheme + (?:[^/?\#\\]*@)? # userinfo, up to the last @ before the path + (\[[0-9a-f:.]+\]|[[:alnum:]._-]+) # host: an IPv6 literal or a name + (:\d{1,5})? # port + (?=[/?\#]|\z) # then the path, the query, the fragment or the end + }ix + private_constant :INSPECT_URL_ORIGIN + + # A URL can carry a credential in its userinfo, path, query or fragment, + # so only where it points is shown: `https://mcp.example.com/[FILTERED]` + # (scheme, host and port). Anything else is replaced outright: a value + # that is not a String, another scheme, a host or port with a character + # that does not belong there. + def inspect_filter_url(value) + origin = value.is_a?(String) && INSPECT_URL_ORIGIN.match(value) + origin ? "#{origin[1]}://#{origin[2]}#{origin[3]}/[FILTERED]" : '[FILTERED]' + rescue StandardError + '[FILTERED]' + end + def inspect_class_name self.class.name || self.class.inspect end diff --git a/lib/claude_agent_sdk/types/mcp.rb b/lib/claude_agent_sdk/types/mcp.rb index f9fa1a8c..bffeb7a4 100644 --- a/lib/claude_agent_sdk/types/mcp.rb +++ b/lib/claude_agent_sdk/types/mcp.rb @@ -138,7 +138,8 @@ class McpStdioServerConfig < Type attr_accessor :command, :args, :env attr_reader :type - inspect_filtered :env + # `args` carries flags such as `--api-key `. + inspect_filtered :env, :args def initialize(attributes = {}) super @@ -173,6 +174,14 @@ def to_h result[:headers] = @headers if @headers result end + + private + + # The url can carry a token (userinfo, path or query): #inspect shows + # its scheme and host. + def inspect_attributes + super.map { |name, value| [name, name == 'url' ? inspect_filter_url(value) : value] } + end end class McpHttpServerConfig < Type @@ -195,6 +204,14 @@ def to_h result[:headers] = @headers if @headers result end + + private + + # The url can carry a token (userinfo, path or query): #inspect shows + # its scheme and host. + def inspect_attributes + super.map { |name, value| [name, name == 'url' ? inspect_filter_url(value) : value] } + end end class McpSdkServerConfig < Type diff --git a/lib/claude_agent_sdk/types/options.rb b/lib/claude_agent_sdk/types/options.rb index 0d0eefc3..08f91db2 100644 --- a/lib/claude_agent_sdk/types/options.rb +++ b/lib/claude_agent_sdk/types/options.rb @@ -18,8 +18,10 @@ module ClaudeAgentSDK # Claude Agent Options for configuring queries class ClaudeAgentOptions < Type - # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...). - inspect_filtered :env + # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...). So does + # `settings` (its own `env`, `apiKeyHelper`), as a Hash or a JSON String, + # and an `extra_args` value can be one. + inspect_filtered :env, :settings, :extra_args attr_accessor :allowed_tools, :system_prompt, :mcp_servers, :permission_mode, :resume, :resume_session_at, :session_id, :max_turns, :disallowed_tools, @@ -345,6 +347,37 @@ def fill_nil_defaults self.callback_scheduling = :thread if callback_scheduling.nil? end + # Keys of a raw Hash server config whose values #inspect shows as they are. + INSPECT_MCP_CONFIG_KEYS = %w[type command name instance].freeze + private_constant :INSPECT_MCP_CONFIG_KEYS + + # `mcp_servers` holds typed configs, which filter themselves, next to raw + # Hash configs carrying the same credentials (env, headers, args, a token + # in the url). A raw config keeps its keys and shows what identifies the + # server: its type, its command (or the SDK server's name and instance) + # and the scheme and host of its url. Every other value is filtered + # here rather than left to the nesting limit of #inspect. + def inspect_attributes + super.map { |name, value| [name, name == 'mcp_servers' ? inspect_mcp_servers(value) : value] } + end + + def inspect_mcp_servers(servers) + return servers unless servers.is_a?(Hash) + + servers.to_h { |name, config| [name, config.is_a?(Hash) ? inspect_mcp_server_config(config) : config] } + rescue StandardError + '[FILTERED]' + end + + def inspect_mcp_server_config(config) + config.to_h do |key, value| + name = key.to_s + next [key, value] if INSPECT_MCP_CONFIG_KEYS.include?(name) + + [key, name == 'url' ? inspect_filter_url(value) : '[FILTERED]'] + end + end + # Strict key validation: unlike other Type subclasses (which silently drop # unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions # is a developer-facing config object — typos should fail loudly. diff --git a/spec/unit/type_inspect_filtering_spec.rb b/spec/unit/type_inspect_filtering_spec.rb new file mode 100644 index 00000000..cb15de47 --- /dev/null +++ b/spec/unit/type_inspect_filtering_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'pp' +require 'uri' + +# Options objects and MCP server configs end up in logs, so Type#inspect +# filters every attribute that can carry a credential: env and headers, and +# also settings, extra_args, a stdio server's args, the URL of an HTTP / SSE +# server, and the raw Hash server configs inside mcp_servers. Filtering is +# for display only: nothing sent to the CLI changes. +RSpec.describe 'Type#inspect credential filtering' do + let(:secret) { 'sk-ant-api03-SECRET0123' } + + # url template (%s is the secret) => what #inspect shows for it + url_rows = { + 'a token in the query string' => ['https://mcp.example.com/v1/mcp?api_key=%s', 'https://mcp.example.com/[FILTERED]'], + 'credentials in the userinfo' => ['https://deploy:%s@mcp.example.com/v1/mcp', 'https://mcp.example.com/[FILTERED]'], + 'an "@" inside the password' => ['https://deploy:p@ss%s@mcp.example.com/', 'https://mcp.example.com/[FILTERED]'], + 'a token in the path' => ['https://mcp.example.com/s/%s/mcp', 'https://mcp.example.com/[FILTERED]'], + 'a token in the fragment' => ['https://mcp.example.com/mcp#%s', 'https://mcp.example.com/[FILTERED]'], + 'a port' => ['http://localhost:8080/sse?token=%s', 'http://localhost:8080/[FILTERED]'], + 'an IPv6 host' => ['https://[::1]:8443/mcp?token=%s', 'https://[::1]:8443/[FILTERED]'], + 'a WebSocket scheme' => ['wss://mcp.example.com/ws?token=%s', 'wss://mcp.example.com/[FILTERED]'], + 'nothing after the host' => ['https://mcp.example.com', 'https://mcp.example.com/[FILTERED]'], + 'no scheme' => ['mcp.example.com/mcp?token=%s', '[FILTERED]'], + 'a scheme other than http(s) or ws(s)' => ['%s://mcp.example.com/mcp', '[FILTERED]'], + 'no host' => ['https://deploy:%s@/mcp', '[FILTERED]'], + 'a port that is not a number' => ['https://mcp.example.com:%s/mcp', '[FILTERED]'], + 'a backslash after the host' => ['https://mcp.example.com\\%s/mcp', '[FILTERED]'], + 'a backslash before an "@"' => ['https://mcp.example.com\\@%s/mcp', '[FILTERED]'], + 'a line break after the host' => ["https://mcp.example.com\n%s/mcp", '[FILTERED]'], + 'a leading space' => [' https://mcp.example.com/mcp?token=%s', '[FILTERED]'] + }.freeze + + describe ClaudeAgentSDK::ClaudeAgentOptions do + it 'still filters env values and keeps the variable names' do + rendered = described_class.new(env: { 'ANTHROPIC_API_KEY' => secret }).inspect + + expect(rendered).to include('env={"ANTHROPIC_API_KEY" => "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + + it 'filters settings given as a JSON String' do + [{ env: { ANTHROPIC_API_KEY: secret } }, { apiKeyHelper: "echo #{secret}" }].each do |settings| + rendered = described_class.new(settings: JSON.generate(settings)).inspect + + expect(rendered).to include('settings="[FILTERED]"') + expect(rendered).not_to include('SECRET') + end + end + + it 'filters the values of settings given as a Hash and keeps its keys' do + settings = { env: { ANTHROPIC_API_KEY: secret }, apiKeyHelper: "echo #{secret}" } + rendered = described_class.new(settings: settings).inspect + + expect(rendered).to include('settings={env: "[FILTERED]", apiKeyHelper: "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + + it 'filters a settings file path as well: any String is replaced' do + rendered = described_class.new(settings: '/etc/claude/settings.json').inspect + + expect(rendered).to include('settings="[FILTERED]"') + expect(rendered).not_to include('/etc/claude') + end + + it 'filters extra_args values and keeps the flag names' do + rendered = described_class.new(extra_args: { 'api-key' => secret, 'debug-to-stderr' => nil }).inspect + + expect(rendered).to include('extra_args={"api-key" => "[FILTERED]", "debug-to-stderr" => "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + + it 'leaves the empty defaults as they are' do + rendered = described_class.new.inspect + + expect(rendered).to include(' env={}').and include(' extra_args={}').and include(' mcp_servers={}') + expect(rendered).not_to include('settings=') + end + + describe 'raw Hash server configs in mcp_servers' do + it 'shows type and command of a stdio config and filters args and env' do + options = described_class.new( + mcp_servers: { + github: { + type: 'stdio', command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github', '--token', secret], + env: { 'GITHUB_PERSONAL_ACCESS_TOKEN' => secret } + } + } + ) + + expect(options.inspect) + .to include('mcp_servers={github: {type: "stdio", command: "npx", args: "[FILTERED]", env: "[FILTERED]"}}') + expect(options.inspect).not_to include('SECRET') + end + + it 'shows type and host of an http config and filters headers' do + options = described_class.new( + mcp_servers: { + api: { + type: 'http', url: "https://mcp.example.com/v1/mcp?api_key=#{secret}", + headers: { 'Authorization' => "Bearer #{secret}" } + } + } + ) + + expect(options.inspect).to include( + 'mcp_servers={api: {type: "http", url: "https://mcp.example.com/[FILTERED]", headers: "[FILTERED]"}}' + ) + expect(options.inspect).not_to include('SECRET') + end + + # A String is printed at any nesting depth, so this one was never + # covered by the nesting limit that happened to hide env and headers. + it 'filters a key it does not know, whatever the value' do + options = described_class.new( + mcp_servers: { + api: { + type: 'sse', url: 'https://mcp.example.com/sse', + headersHelper: "/usr/local/bin/mcp-headers --token #{secret}", oauth: { clientSecret: secret } + } + } + ) + + expect(options.inspect).to include( + 'mcp_servers={api: {type: "sse", url: "https://mcp.example.com/[FILTERED]", ' \ + 'headersHelper: "[FILTERED]", oauth: "[FILTERED]"}}' + ) + expect(options.inspect).not_to include('SECRET') + end + + it 'reads a String-keyed config the same way' do + options = described_class.new( + mcp_servers: { + 'api' => { + 'type' => 'http', 'url' => "https://mcp.example.com/mcp?key=#{secret}", + 'headers' => { 'Authorization' => "Bearer #{secret}" } + } + } + ) + + expect(options.inspect).to include( + 'mcp_servers={"api" => {"type" => "http", "url" => "https://mcp.example.com/[FILTERED]", ' \ + '"headers" => "[FILTERED]"}}' + ) + expect(options.inspect).not_to include('SECRET') + end + + it 'keeps showing an SDK server config: type, name and instance' do + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: []) + rendered = described_class.new(mcp_servers: { calc: server }).inspect + + expect(rendered) + .to include('mcp_servers={calc: {type: "sdk", name: "calc", instance: #}}') + end + + it 'leaves a typed config to its own filtering' do + config = ClaudeAgentSDK::McpStdioServerConfig.new( + command: 'mcp-files', args: ["--token=#{secret}"], env: { 'TOKEN' => secret } + ) + rendered = described_class.new(mcp_servers: { files: config }).inspect + + expect(rendered).to include('mcp_servers={files: #}') + expect(rendered).not_to include('SECRET') + end + + url_rows.each do |label, (template, shown)| + it "shows scheme and host of a url with #{label}" do + url = format(template, secret) + rendered = described_class.new(mcp_servers: { api: { type: 'http', url: url } }).inspect + + expect(rendered).to include(%(mcp_servers={api: {type: "http", url: "#{shown}"}})) + expect(rendered).not_to include('SECRET') + end + end + end + + it 'filters the same way through #to_s, interpolation and pp' do + options = described_class.new( + settings: { apiKeyHelper: "echo #{secret}" }, extra_args: { 'api-key' => secret }, + mcp_servers: { api: { type: 'http', url: "https://mcp.example.com/mcp?key=#{secret}" } } + ) + + [options.to_s, "#{options}", PP.pp(options, +'')].each do |rendered| # rubocop:disable Style/RedundantInterpolation + expect(rendered).to include('settings={apiKeyHelper: "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + end + end + + describe ClaudeAgentSDK::McpStdioServerConfig do + it 'filters args, which carry flags such as --api-key' do + config = described_class.new(command: 'npx', args: ['-y', 'mcp-remote', "--api-key=#{secret}"]) + + expect(config.inspect).to eq('#') + end + + it 'still filters env values and keeps the variable names' do + config = described_class.new(command: 'npx', env: { 'GITHUB_PERSONAL_ACCESS_TOKEN' => secret }) + + expect(config.inspect).to eq('# "[FILTERED]"} type="stdio">') + end + end + + [ClaudeAgentSDK::McpHttpServerConfig, ClaudeAgentSDK::McpSSEServerConfig].each do |klass| + describe klass do + let(:type) { klass.new.type } + + it 'still filters header values and keeps the header names' do + config = klass.new(url: 'https://mcp.example.com/mcp', headers: { 'Authorization' => "Bearer #{secret}" }) + + expect(config.inspect).to eq(%(#<#{klass.name} url="https://mcp.example.com/[FILTERED]" ) + + %(headers={"Authorization" => "[FILTERED]"} type="#{type}">)) + end + + url_rows.each do |label, (template, shown)| + it "shows scheme and host of a url with #{label}" do + config = klass.new(url: format(template, secret)) + + expect(config.inspect).to eq(%(#<#{klass.name} url="#{shown}" type="#{type}">)) + end + end + + it 'filters a url that is not a String', rbs_incompatible: 'plants a URI where the signature says String' do + config = klass.new(url: URI("https://mcp.example.com/mcp?key=#{secret}")) + + expect(config.inspect).to eq(%(#<#{klass.name} url="[FILTERED]" type="#{type}">)) + end + end + end + + # What the CLI receives is built from the attributes and #to_h, never from + # #inspect: the filters above change no byte of it. + describe 'the wire form' do + let(:stdio) do + ClaudeAgentSDK::McpStdioServerConfig.new(command: 'mcp-files', args: ["--token=#{secret}"], + env: { 'TOKEN' => secret }) + end + let(:sse) do + ClaudeAgentSDK::McpSSEServerConfig.new(url: "https://deploy:#{secret}@sse.example.com/events", + headers: { 'X-Api-Key' => secret }) + end + let(:http) do + ClaudeAgentSDK::McpHttpServerConfig.new(url: "https://mcp.example.com/s/#{secret}/mcp", + headers: { 'Authorization' => "Bearer #{secret}" }) + end + let(:options) do + ClaudeAgentSDK::ClaudeAgentOptions.new( + env: { 'ANTHROPIC_API_KEY' => secret }, + settings: { env: { ANTHROPIC_API_KEY: secret }, apiKeyHelper: "echo #{secret}" }, + extra_args: { 'api-key' => secret, 'debug-to-stderr' => nil }, + mcp_servers: { + github: { + type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github', '--token', secret], + env: { 'GITHUB_PERSONAL_ACCESS_TOKEN' => secret } + }, + api: { + type: 'http', url: "https://mcp.example.com/v1/mcp?api_key=#{secret}", + headers: { 'Authorization' => "Bearer #{secret}" } + }, + files: stdio, events: sse, docs: http + } + ) + end + + def command_line(options) + ClaudeAgentSDK::CommandBuilder.new('claude', options).build + end + + it 'builds the same command line before and after #inspect, credentials included' do + before = command_line(options) + options.inspect + options.to_s + argv = command_line(options) + + expect(argv).to eq(before) + expect(argv[argv.index('--settings') + 1]) + .to eq(%({"env":{"ANTHROPIC_API_KEY":"#{secret}"},"apiKeyHelper":"echo #{secret}"})) + expect(argv[argv.index('--mcp-config') + 1]).to eq( + [ + '{"mcpServers":{', + '"github":{"type":"stdio","command":"npx",', + %("args":["-y","@modelcontextprotocol/server-github","--token","#{secret}"],), + %("env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"#{secret}"}},), + %("api":{"type":"http","url":"https://mcp.example.com/v1/mcp?api_key=#{secret}",), + %("headers":{"Authorization":"Bearer #{secret}"}},), + %("files":{"type":"stdio","command":"mcp-files","args":["--token=#{secret}"],"env":{"TOKEN":"#{secret}"}},), + %("events":{"type":"sse","url":"https://deploy:#{secret}@sse.example.com/events",), + %("headers":{"X-Api-Key":"#{secret}"}},), + %("docs":{"type":"http","url":"https://mcp.example.com/s/#{secret}/mcp",), + %("headers":{"Authorization":"Bearer #{secret}"}}}}) + ].join + ) + expect(argv.each_cons(2)).to include(['--api-key', secret]) + expect(argv).to include('--debug-to-stderr') + end + + it 'leaves every #to_h and every attribute as it was' do + [options, stdio, sse, http].each(&:inspect) + + expect(stdio.to_h).to eq(type: 'stdio', command: 'mcp-files', args: ["--token=#{secret}"], + env: { 'TOKEN' => secret }) + expect(sse.to_h).to eq(type: 'sse', url: "https://deploy:#{secret}@sse.example.com/events", + headers: { 'X-Api-Key' => secret }) + expect(http.to_h).to eq(type: 'http', url: "https://mcp.example.com/s/#{secret}/mcp", + headers: { 'Authorization' => "Bearer #{secret}" }) + expect(options.env).to eq('ANTHROPIC_API_KEY' => secret) + expect(options.settings).to eq(env: { ANTHROPIC_API_KEY: secret }, apiKeyHelper: "echo #{secret}") + expect(options.extra_args).to eq('api-key' => secret, 'debug-to-stderr' => nil) + expect(options.mcp_servers[:api]).to eq(type: 'http', url: "https://mcp.example.com/v1/mcp?api_key=#{secret}", + headers: { 'Authorization' => "Bearer #{secret}" }) + expect(options.mcp_servers[:github][:env]).to eq('GITHUB_PERSONAL_ACCESS_TOKEN' => secret) + end + end +end diff --git a/spec/unit/types_spec.rb b/spec/unit/types_spec.rb index 5150ef45..2a73b86c 100644 --- a/spec/unit/types_spec.rb +++ b/spec/unit/types_spec.rb @@ -3027,7 +3027,7 @@ def []=(key, value) end it 'keeps filtered attributes per class (subclasses inherit, siblings do not)' do - expect(ClaudeAgentSDK::ClaudeAgentOptions.inspect_filtered_attributes).to eq(['env']) + expect(ClaudeAgentSDK::ClaudeAgentOptions.inspect_filtered_attributes).to eq(%w[env settings extra_args]) expect(ClaudeAgentSDK::McpHttpServerConfig.inspect_filtered_attributes).to eq(['headers']) expect(ClaudeAgentSDK::ResultMessage.inspect_filtered_attributes).to eq([]) end From 80b1261dbec9514e4ef2be57c5b398de600e9256 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:52:45 +0800 Subject: [PATCH 013/194] Let install's offline shortcut work in a read-only directory A boot-time CLIInstaller.install_pinned raised CLIInstallError (EACCES) in a directory the process cannot write, although the pinned binary was installed and intact: an image built as root and run as another user, a read-only root filesystem. The idempotent shortcut (re-hash the binary, compare with VERSION, return) sits inside the install lock, and the lock file is opened read-write, and created, before it is ever consulted. Lock-free discovery (installed_path, find_cli) kept working, so only callers that run install at boot were hit, which is what the docs recommend. install now rescues EACCES, EROFS and EPERM around opening the lock file and nothing else. When the request is a concrete version that is installed and re-hashes to its recorded checksum for this platform, it returns the binary; otherwise it raises exactly as before. A dist-tag always raises there: it has to be resolved and may have to be published, which needs the lock. A permission error raised inside the critical section still fails the install. Nothing is written and no lock is taken on that path, so it is one more lock-free reader of the kind ReadersSeeVerifiedBinary covers; the publish order, the in-lock resolve and the in-lock sweep are untouched, and the TLA model's expectations do not change (formal/tla/README.md says so). with_install_lock now takes the open lock file instead of the directory; both are private. Exploration finding: D-P7 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/cli-installer.md | 1 + formal/tla/README.md | 1 + lib/claude_agent_sdk/cli_installer.rb | 58 +++++-- spec/unit/cli_installer_read_only_spec.rb | 183 ++++++++++++++++++++++ 4 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 spec/unit/cli_installer_read_only_spec.rb diff --git a/docs/cli-installer.md b/docs/cli-installer.md index 9d812f90..dfbebdee 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -28,6 +28,7 @@ ClaudeAgentSDK::CLIInstaller.installed_path - The install directory's `VERSION` file records the installed version, verified SHA-256, and target platform (OS, architecture, libc). The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when all three match — a truncated binary or a cache copied from another platform is reinstalled instead of trusted. It makes **no network request**, so same-platform repeat boots work offline — with a pinned concrete version; `'stable'`/`'latest'` must always re-resolve through the endpoint, which is one more reason to pin in production. Older one- or two-line metadata lacks a platform and requires one online reinstall to migrate; subsequent pinned installs work offline again. - An exclusive `flock` on `/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race — whether they come from separate processes, threads, or fibers of one `Async` reactor (a waiting installer polls the lock rather than blocking its thread on it); the loser simply observes the finished install. +- The shortcut also works where the running process cannot write the install directory: an image built as root and run as another user, or a read-only root filesystem. `install` cannot open its lock file there (`EACCES`, `EROFS` or `EPERM`), and still returns the binary when the request is a concrete version that is already installed and intact — the same `VERSION` and SHA-256 check, without the lock. Anything that would need a write raises `CLIInstallError` as before: a dist-tag (`'stable'` / `'latest'`, which has to be resolved and may have to be installed), a different version, a damaged binary, an empty directory. Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`. diff --git a/formal/tla/README.md b/formal/tla/README.md index 0b2ad1d5..306cb209 100644 --- a/formal/tla/README.md +++ b/formal/tla/README.md @@ -45,5 +45,6 @@ There are three properties. `EverySenderFinishes` says every control method retu - File contents are abstracted to "the version they are the bytes of", so a checksum match is equality. Platform detection is not modelled. - The models assume that POSIX `rename` is atomic and that the kernel releases a dead process's flock. `ReadersSeeVerifiedBinary` holds by construction given those assumptions. +- Not modelled: `install`'s read-only shortcut. When the lock file cannot be opened (`EACCES`, `EROFS`, `EPERM`) and the request is a concrete version that is already installed and re-hashes to its recorded checksum, `install` returns the binary without the lock. That path takes no lock and writes nothing, so it is one more lock-free reader, which `ReadersSeeVerifiedBinary` already covers; no modelled ordering and no `run.sh` expectation changes. - Not modelled: control-request timeouts (see above); the inbound half (`handle_control_request`, the #119 process-exit path, cancellation during a response write, which the transport answers by poisoning itself so no partial frame is ever followed by another); `Metadata.write`'s own temp file; `TranscriptMirrorBatcher`. - **The Ruby is not verified.** A model can drift from the code it describes. When you change one of the orderings the tables cite, update the model and `run.sh`'s expectations in the same change. Each counterexample trace can also be turned into an RSpec regression by stubbing `CLIInstaller::Http` / `Release` / `Metadata`, or with a scripted transport for `Query`, so that the step TLC found is exercised against the real code. diff --git a/lib/claude_agent_sdk/cli_installer.rb b/lib/claude_agent_sdk/cli_installer.rb index 123355e4..5748401c 100644 --- a/lib/claude_agent_sdk/cli_installer.rb +++ b/lib/claude_agent_sdk/cli_installer.rb @@ -391,6 +391,12 @@ def default_dir # touches the network: repeat boots must work offline (with a pinned # version — a dist-tag has to be re-resolved to be resolved at all). # + # That shortcut also holds in a directory this process cannot write (an + # image built as root and run as another user, a read-only root + # filesystem): a concrete version that is already installed and intact + # is returned. Anything that would need a write — a dist-tag, another + # version, a damaged binary — raises there. + # # An upgrade never destroys a working install: see #publish. def install(version: 'stable', dir: nil) dir = File.expand_path(dir || default_dir) @@ -401,7 +407,19 @@ def install(version: 'stable', dir: nil) requested = Release.validate_version(version) binary = File.join(dir, BINARY_NAME) FileUtils.mkdir_p(dir) - with_install_lock(dir) do + begin + lock = open_lock_file(dir) + rescue Errno::EACCES, Errno::EROFS, Errno::EPERM + # The lock file cannot be opened for writing, so nothing can be + # installed here — and nothing has to be when the requested version + # already is (see .installed_without_lock?). This rescue covers + # OPENING the lock file and nothing else: a permission error raised + # inside the critical section must keep failing the install. + raise unless installed_without_lock?(dir, requested) + + return binary + end + with_install_lock(lock) do sweep_stale_temp_files(dir) resolved = Release.resolve_version(requested) platform = Platform.detect @@ -458,18 +476,38 @@ def installed_path(dir: nil) # a scheduler and is an ordinary sleep on a plain thread. Mutual # exclusion is the same either way; like flock itself, the order in # which waiters get the lock is unspecified. - def with_install_lock(dir) + # + # +lock+ is the open lock file (see .open_lock_file); it is closed here. + def with_install_lock(lock) + sleep(LOCK_POLL_SECONDS) until lock.flock(File::LOCK_EX | File::LOCK_NB) + begin + yield + ensure + lock.flock(File::LOCK_UN) + end + ensure + lock.close + end + + # Opened read-write and created when missing. Kept apart from + # .with_install_lock so that .install can tell a lock file that cannot + # be opened from a failure inside the critical section. + def open_lock_file(dir) flags = File::RDWR | File::CREAT # Never follow a symlink planted at the lock path. flags |= File::NOFOLLOW if defined?(File::NOFOLLOW) - File.open(File.join(dir, LOCK_FILE), flags, 0o644) do |lock| - sleep(LOCK_POLL_SECONDS) until lock.flock(File::LOCK_EX | File::LOCK_NB) - begin - yield - ensure - lock.flock(File::LOCK_UN) - end - end + File.open(File.join(dir, LOCK_FILE), flags, 0o644) + end + + # Whether .install can answer +requested+ without the install lock: it + # has to be a concrete version — a dist-tag must be resolved and may + # have to be published, which needs the lock — whose binary is in place + # and re-hashes to the checksum recorded for it. This is the lock-free + # read .installed_path already documents (#publish only ever renames a + # complete, verified binary into place) plus the re-hash, and it writes + # nothing. + def installed_without_lock?(dir, requested) + !DIST_TAGS.include?(requested) && installed?(dir, requested, Platform.detect) end # Remove temp files abandoned by an earlier install that died before its diff --git a/spec/unit/cli_installer_read_only_spec.rb b/spec/unit/cli_installer_read_only_spec.rb new file mode 100644 index 00000000..cfaba5fe --- /dev/null +++ b/spec/unit/cli_installer_read_only_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'digest' +require 'fileutils' +require 'json' +require 'tmpdir' + +# The offline, idempotent shortcut of CLIInstaller.install in a directory the +# process cannot write: an image built as root and run as another user, or a +# read-only root filesystem. The shortcut sits inside the install lock, and the +# lock file is opened read-write first — so a boot-time install_pinned raised +# EACCES although the pinned binary was installed and intact. +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'in a read-only install directory' do + let(:base) { 'https://downloads.claude.ai/claude-code-releases' } + let(:binary_body) { 'not-really-280MB-of-claude' } + let(:http) { ClaudeAgentSDK::CLIInstaller::Http } + let(:dir) { @dir } + let(:binary_path) { File.join(dir, 'claude') } + let(:lock_path) { File.join(dir, '.install.lock') } + + around do |example| + Dir.mktmpdir('cli-installer-read-only') do |tmp| + @dir = File.join(tmp, 'vendor', 'claude') + example.run + ensure + # mktmpdir cannot remove a directory it may not write. + FileUtils.chmod(0o755, @dir) if File.directory?(@dir) + end + end + + # The two GET endpoints, as cli_installer_spec.rb stubs them: no HTTP + # library, the real Digest / Metadata / rename path. + def stub_release(version: '2.1.220', platform: 'linux-x64') + allow(ClaudeAgentSDK::CLIInstaller::Platform).to receive(:detect).and_return(platform) + allow(http).to receive(:fetch_text) do |url, **| + case url + when "#{base}/stable" then "#{version}\n" + when "#{base}/#{version}/manifest.json" + entry = { 'checksum' => Digest::SHA256.hexdigest(binary_body), 'size' => binary_body.bytesize } + JSON.generate('platforms' => { platform => entry }) + else raise "unexpected fetch_text(#{url.inspect})" + end + end + allow(http).to receive(:download_to) do |_url, path, **| + File.binwrite(path, binary_body) + path + end + end + + def expect_no_network + expect(http).not_to receive(:fetch_text) + expect(http).not_to receive(:download_to) + end + + def expect_permission_failure(error) + expect(error.message).to include("Failed to install the Claude Code CLI into #{dir}", 'Errno::EACCES') + expect(error.cause).to be_a(Errno::EACCES) + end + + before do + stub_release + described_class.install(version: '2.1.220', dir: dir) + end + + # chmod stands in for the foreign owner, so the process must be one that + # file modes apply to. + context 'when file modes deny writing' do + before { skip 'file modes do not restrict root' if Process.uid.zero? } + + # What the runtime user of an image built by another user sees: everything + # readable and executable, nothing writable. + def make_read_only(lock_file: :present) + lock_file == :present ? File.chmod(0o444, lock_path) : File.delete(lock_path) + File.chmod(0o555, dir) + end + + it 'returns the installed binary for its concrete version, offline and without the lock' do + make_read_only + expect_no_network + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + end + + it 'returns the installed binary when the directory has no lock file to open' do + # The install directory was copied into the image without its dotfile. + make_read_only(lock_file: :absent) + expect_no_network + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + expect(Dir.children(dir)).to contain_exactly('VERSION', 'claude') + end + + it 'serves install_pinned the same way' do + stub_const('ClaudeAgentSDK::CLIInstaller::PINNED_CLI_VERSION', '2.1.220') + make_read_only + expect_no_network + + expect(described_class.install_pinned(dir: dir)).to eq(binary_path) + end + + it 'raises for a dist-tag, which has to be resolved and may have to be published' do + make_read_only + expect_no_network + + expect { described_class.install(version: 'stable', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when the binary no longer matches its recorded checksum' do + File.binwrite(binary_path, 'truncated-or-swapped') + make_read_only + expect_no_network + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when another version is requested than the one installed' do + make_read_only + expect_no_network + + expect { described_class.install(version: '2.1.226', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when the binary was installed for another platform' do + make_read_only + allow(ClaudeAgentSDK::CLIInstaller::Platform).to receive(:detect).and_return('linux-arm64-musl') + expect_no_network + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when nothing is installed' do + FileUtils.rm_f([binary_path, File.join(dir, 'VERSION')]) + make_read_only + expect_no_network + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + end + + # Errors chmod cannot produce: a read-only filesystem (EROFS) and a denied + # operation (EPERM: an immutable file, a sandbox profile). Injected where + # the kernel would raise them — at the open of the lock file. + context 'when opening the lock file fails' do + def fail_lock_open(error) + allow(File).to receive(:open).and_call_original + allow(File).to receive(:open).with(lock_path, any_args).and_raise(error) + end + + [Errno::EROFS, Errno::EPERM, Errno::EACCES].each do |errno| + it "returns the installed binary for its concrete version on #{errno}" do + fail_lock_open(errno.new(lock_path)) + expect_no_network + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + end + + it "still raises for a dist-tag on #{errno}" do + fail_lock_open(errno.new(lock_path)) + expect_no_network + + expect { described_class.install(version: 'stable', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect(error.cause).to be_a(errno) } + end + end + + it 'raises on any other failure to open the lock file, even when the binary is intact' do + # A symlink planted at the lock path (opened NOFOLLOW) is not a + # read-only directory, and neither is running out of descriptors. + [Errno::ELOOP, Errno::EMFILE].each do |errno| + fail_lock_open(errno.new(lock_path)) + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect(error.cause).to be_a(errno) } + end + end + end +end From b906729762ed56869378612f0c5e5c89439ee236 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:53:13 +0800 Subject: [PATCH 014/194] Scrub invalid UTF-8 when importing a transcript into a store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import_session_to_store skipped a truncated line with a warning, but a complete JSON line holding a raw non-UTF-8 byte (binary tool output) parsed — Ruby's JSON parser accepts such a string — and the resulting entry made any adapter that serializes its entries raise JSON::GeneratorError from #append. The import stopped there, leaving the batches before it in the store. The import now reads the transcript as bytes, tags each line as UTF-8 and scrubs it when it is not valid, like the message readers and fork_session. The entry is stored with U+FFFD in place of the stray byte. Exploration finding: B-P16 (item 3) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 3 +- lib/claude_agent_sdk/sessions.rb | 14 +++++--- spec/unit/session_transcript_encoding_spec.rb | 34 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index 114506b8..aeccfa4c 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -489,7 +489,8 @@ be resumed with `session_store:` + `resume:` from the original directory. Re-importing appends the entries again, so adapters should dedupe by `entry['uuid']`. It raises `ArgumentError` for an invalid `session_id` and `Errno::ENOENT` when the transcript cannot be found; an unparseable line is -skipped with a warning. +skipped with a warning, and bytes that are not valid UTF-8 inside a line that +does parse are stored as U+FFFD (an adapter could not serialize them). > **Deprecated:** the separate store functions (`list_sessions_from_store`, > `get_session_info_from_store`, `get_session_messages_from_store`, diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 10f375db..9d1ca814 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1257,11 +1257,15 @@ def parent_ids_from_agent_metadata(meta) def append_jsonl_file_in_batches(file_path, key, store, batch_size) batch = [] nbytes = 0 - # encoding: transcripts are UTF-8 regardless of locale; without it a - # LANG=C process raises Encoding::InvalidByteSequenceError on the first - # multibyte line, aborting the import mid-way (Python pins utf-8 here). - File.foreach(file_path, encoding: 'UTF-8').with_index(1) do |line, lineno| - line = line.chomp + # Read as UTF-8 bytes regardless of locale (a LANG=C process raised + # Encoding::InvalidByteSequenceError on the first multibyte line, + # aborting the import mid-way; Python pins utf-8 here), and scrub a + # line that is not valid UTF-8: Ruby's JSON parser accepts a raw + # invalid byte inside a string, and the entry it yields makes every + # adapter that serializes what it is given raise JSON::GeneratorError + # from #append — after the batches before it were already stored. + File.foreach(file_path, mode: 'rb').with_index(1) do |line, lineno| + line = utf8_transcript_line(line).chomp next if line.empty? begin diff --git a/spec/unit/session_transcript_encoding_spec.rb b/spec/unit/session_transcript_encoding_spec.rb index b2adab2f..42e8a39c 100644 --- a/spec/unit/session_transcript_encoding_spec.rb +++ b/spec/unit/session_transcript_encoding_spec.rb @@ -118,4 +118,38 @@ def cut_inside(line, char) expect(result.content).to eq("class #{replacement}; end") expect(result.content).to be_valid_encoding end + + describe 'import_session_to_store' do + # A networked adapter serializes what it is given, and JSON.generate + # rejects a String that is not valid UTF-8. + let(:store) do + Class.new(ClaudeAgentSDK::SessionStore) do + def initialize + super + @rows = Hash.new { |rows, key| rows[key] = [] } + end + + def append(key, entries) + @rows[key].concat(entries.map { |entry| JSON.generate(entry) }) + end + + def load(key) + @rows.key?(key) ? @rows[key].map { |row| JSON.parse(row) } : nil + end + end.new + end + + it 'imports every entry of a transcript that holds a raw invalid byte' do + transcript = main_transcript + path = transcript.write(transcript_path(session_id)) + File.binwrite(path, File.binread(path).sub('class Foo; end'.b, "class \xFF; end".b)) + + ClaudeAgentSDK.import_session_to_store(session_id: session_id, session_store: store, directory: cwd, + batch_size: 2) + + stored = store.load('project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id) + expect(stored.map { |entry| entry['type'] }).to eq(transcript.entries.map { |entry| entry['type'] }) + expect(stored[4].dig('message', 'content', 0, 'content')).to eq("class #{replacement}; end") + end + end end From 8852a5a6522be1b70f22f4d51abee2f3b909bc16 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:54:54 +0800 Subject: [PATCH 015/194] Stop specs leaking doubles into the at-exit process registry The suite failed for some seeds (`rspec --seed 81`, `--seed 124`): six examples in query_run_end_spec.rb call SubprocessCLITransport#connect with Open3.popen3 stubbed and never close, so each left an instance_double(Process::Waiter) in the process-wide at-exit registry. When that group ran before the transport spec, the registry examples there counted seven entries instead of one, or sent `alive?` to an expired double. It looked like a transport regression and was neither. The group now clears the registry after each example. spec_helper.rb adds the same check for the whole suite: an example that leaves an entry behind fails, naming what it left, and the registry is emptied for the next example. Both go through the registry's own mutex, because a fallback-termination worker from an earlier example may still be deregistering on its thread. Specs only; no library change. Exploration finding: F-P2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/spec_helper.rb | 21 +++++++++++++++++++++ spec/unit/query_run_end_spec.rb | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 41f827c1..5f05c045 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -61,4 +61,25 @@ # Show the slowest examples config.profile_examples = 10 if ENV['PROFILE'] + + # SubprocessCLITransport's at-exit registry is process-wide, so an entry one + # example leaves in it is seen by whichever example runs next: the suite + # used to fail for the seeds (81, 124) that ran a registry example right + # after a leaking one. Fail the example that leaked, and hand the next one + # an empty registry either way. This runs after a group's own `after` + # hooks. Snapshot and clear in one step under the registry's own mutex: a + # fallback-termination worker started by an earlier example may still be + # deregistering on its own thread. + config.after do + transport = ClaudeAgentSDK::SubprocessCLITransport + leaked = transport.active_processes_mutex.synchronize do + entries = transport.active_processes.to_a + transport.active_processes.clear + entries + end + + leak = "this example left #{leaked.size} process(es) in SubprocessCLITransport's at-exit registry " \ + "(#{leaked.inspect}): close the transport it connected, or deregister what it registered" + expect(leaked).to be_empty, leak + end end diff --git a/spec/unit/query_run_end_spec.rb b/spec/unit/query_run_end_spec.rb index b3ebf880..e658453f 100644 --- a/spec/unit/query_run_end_spec.rb +++ b/spec/unit/query_run_end_spec.rb @@ -475,6 +475,12 @@ def with_query(**kwargs) end RSpec.describe ClaudeAgentSDK::SubprocessCLITransport do + # #connect registers the stubbed Process::Waiter in the process-wide + # at-exit registry and these examples never #close: drop it here, so the + # double does not outlive its example (see the suite-wide check in + # spec_helper.rb). + after { described_class.active_processes_mutex.synchronize { described_class.active_processes.clear } } + def connect_and_capture_env(options) transport = described_class.new('hi', options) allow(transport).to receive(:check_claude_version) From f7273296600a1af5582c65c12f09b292c0487fe1 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:55:51 +0800 Subject: [PATCH 016/194] Return nil from #[] for a setter-shaped name; reject '[]' as an option `msg[:session_id=]`, `msg['sessionId=']` and `options[:env=]` raised `ArgumentError: wrong number of arguments (given 0, expected 1)`: the attribute check strips a trailing `=`, so #[] took the writer for an attribute and called it without an argument, after storing the name in the per-class reader cache. docs/types.md promises nil for a name that is not an attribute. #[] now returns nil for any name ending in `=`, before the cache is consulted (the camelCase writers resolved by method_missing live in the same cache) and without caching it. `ClaudeAgentOptions.new('[]' => 1)` and `options['[]'] = 1` raised `wrong number of arguments (given 1, expected 2)`: the option check was a bare `respond_to?("#{name}=")`, which #[]= satisfies. An option name is now a declared attribute or a setter user code defined on a subclass, a mixin or the object (those still work), so '[]' gets the usual "unknown ClaudeAgentOptions option" error, spelled as written with or without configured defaults. Noticeable: the keys '=', '!' and '==' used to call #==, #!= and #=== with the value and were dropped silently; they now raise the same unknown-option ArgumentError. Exploration finding: C-P12 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP --- docs/types.md | 2 +- lib/claude_agent_sdk/types/attributes.rb | 9 + lib/claude_agent_sdk/types/options.rb | 17 +- spec/unit/type_setter_shaped_names_spec.rb | 182 +++++++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 spec/unit/type_setter_shaped_names_spec.rb diff --git a/docs/types.md b/docs/types.md index e667a668..523aabdf 100644 --- a/docs/types.md +++ b/docs/types.md @@ -403,7 +403,7 @@ Accepted: Symbol or String keys, snake_case or camelCase spellings, and the fixe `#[]`, `#[]=` and the camelCase readers (`msg[:session_id]`, `msg['sessionId']`, `msg.sessionId`) are public API for a type's **attributes**: the fields it declares, plus predicates such as `options.forkSession?`. Methods your own code adds to a subclass (an `attr_accessor`, a hand-written reader or setter, a mixin's accessors, a singleton method) count as attributes too. -A name that is not an attribute behaves like an undefined one: `#[]` returns `nil`, `#[]=` ignores it (on the strict types above it raises `ArgumentError`), a camelCase call raises `NoMethodError`, and `respond_to?` answers `false`. So `msg[:to_h]` is `nil`, `msg['freeze']` does not freeze the message, and `msg.toH` raises; call the method directly instead (`msg.to_h`). `UserMessage#text` and `AssistantMessage#text` are convenience methods, not attributes. +A name that is not an attribute behaves like an undefined one: `#[]` returns `nil`, `#[]=` ignores it (on the strict types above it raises `ArgumentError`), a camelCase call raises `NoMethodError`, and `respond_to?` answers `false`. So `msg[:to_h]` is `nil`, `msg['freeze']` does not freeze the message, and `msg.toH` raises; call the method directly instead (`msg.to_h`). `#[]` only reads: a writer's name, as in `msg['session_id=']`, is `nil` too. `UserMessage#text` and `AssistantMessage#text` are convenience methods, not attributes. (Through 0.37 these accessors reached any public method, with a one-time warning in 0.37.) diff --git a/lib/claude_agent_sdk/types/attributes.rb b/lib/claude_agent_sdk/types/attributes.rb index be1744c3..f9d256ea 100644 --- a/lib/claude_agent_sdk/types/attributes.rb +++ b/lib/claude_agent_sdk/types/attributes.rb @@ -184,6 +184,10 @@ def assign_attribute(name, value) end def read_attribute(name) + # A writer's name (`session_id=`) is not readable. Checked before the + # cache, which also holds the camelCase writers method_missing resolved. + return if writer_name?(name) + reader = self.class.cached_attribute_reader(name) return public_send(reader) if reader @@ -198,6 +202,11 @@ def read_attribute(name) end end + def writer_name?(name) + name = name.to_s unless name.is_a?(Symbol) || name.is_a?(String) + name.end_with?('=') + end + # Whether a normalized reader, writer or predicate name belongs to an # attribute: a declared one (`session_id`, `session_id=`, # `fork_session?`), or a method user code defined on its own subclass, a diff --git a/lib/claude_agent_sdk/types/options.rb b/lib/claude_agent_sdk/types/options.rb index 08f91db2..c6822287 100644 --- a/lib/claude_agent_sdk/types/options.rb +++ b/lib/claude_agent_sdk/types/options.rb @@ -382,12 +382,23 @@ def inspect_mcp_server_config(config) # unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions # is a developer-facing config object — typos should fail loudly. def assign_attribute(name, value) - setter = :"#{normalize_name(name)}=" - raise ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}" unless respond_to?(setter) + setter = option_setter(normalize_name(name)) + raise ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}" unless setter public_send(setter, value) end + # The writer of the option with this normalized name, nil when the name + # is not an option. An option is a declared attribute, or a setter user + # code defined (on a subclass, a module it includes, or the object); + # respond_to? alone would also answer for '[]' (#[]=) and '=' (#==). + def option_setter(normalized) + setter = :"#{normalized}=" + return unless respond_to?(setter) + + setter if self.class.attribute?(normalized) || user_defined_method?(setter) + end + # Merge caller-provided attributes with configured defaults. # Only keys the caller explicitly passed are treated as overrides; # method-signature defaults ([], {}, false) are NOT present unless the caller wrote them. @@ -436,7 +447,7 @@ def merge_with_defaults(attributes) # reports the typo exactly as the developer wrote it. def option_key(name) normalized = normalize_name(name) - respond_to?(:"#{normalized}=") ? normalized.to_sym : name + option_setter(normalized) ? normalized.to_sym : name end end end diff --git a/spec/unit/type_setter_shaped_names_spec.rb b/spec/unit/type_setter_shaped_names_spec.rb new file mode 100644 index 00000000..ffa6f1f9 --- /dev/null +++ b/spec/unit/type_setter_shaped_names_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# #[] reads attributes, and a name that is not one reads as nil +# (docs/types.md, "Attributes Only"). A setter-shaped name (`session_id=`) +# names a writer: #[] must not call it. On the write side, +# ClaudeAgentOptions accepts an option name only when it is an attribute or a +# setter user code defined, never a method such as #[]= or #==. +RSpec.describe 'setter-shaped and operator-shaped attribute names' do + describe 'Type#[] with a setter-shaped name' do + # A subclass of its own, so the per-class reader cache starts empty. + let(:message_class) { Class.new(ClaudeAgentSDK::ResultMessage) } + let(:message) { message_class.new(subtype: 'success', session_id: 's1') } + + it 'returns nil in every spelling and leaves the attribute alone' do + [:session_id=, 'session_id=', :sessionId=, 'sessionId='].each do |name| + expect(message[name]).to be_nil, "#[#{name.inspect}]" + end + + expect(message.session_id).to eq('s1') + end + + it 'never stores the name in the reader cache' do + message[:session_id=] + message['sessionId='] + + expect(message_class.cached_attribute_reader(:session_id=)).to be_nil + expect(message_class.cached_attribute_reader('sessionId=')).to be_nil + end + + # The camelCase writer (message.sessionId = ...) is resolved by + # method_missing, which keeps what it resolved in the same cache. + it 'returns nil after the camelCase writer has been called' do + message.sessionId = 's2' + + expect(message[:sessionId=]).to be_nil + expect(message.session_id).to eq('s2') + end + + it 'returns nil for a hand-written setter and for a setter user code defined' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(fork_session: true, env: { 'A' => 'b' }) + custom = Class.new(ClaudeAgentSDK::ResultMessage) do + def note=(value) # rubocop:disable Style/TrivialAccessors + @note = value + end + end.new + + expect(options[:env=]).to be_nil + expect(options['forkSession=']).to be_nil + expect(ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash')['hooks=']).to be_nil + expect(custom[:note=]).to be_nil + expect(options.fork_session).to be(true) + expect(options.env).to eq('A' => 'b') + end + + it 'returns nil for a name that only ends in "=" once converted to a String', + rbs_incompatible: 'passes a name that is neither a Symbol nor a String' do + name = Class.new { def to_s = 'session_id=' }.new + + expect(message[name]).to be_nil + expect(message.session_id).to eq('s1') + end + + it 'still returns nil for the operators that end in "="' do + %i[== != === []=].each { |name| expect(message[name]).to be_nil, "#[#{name.inspect}]" } + end + + it 'still reads attributes and predicates' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(fork_session: true) + + expect([message[:session_id], message['sessionId']]).to eq(%w[s1 s1]) + expect([options[:fork_session], options[:fork_session?], options['forkSession?']]).to eq([true, true, true]) + end + end + + describe 'ClaudeAgentOptions with an option name that resolves to a method of the SDK or of Ruby' do + after { ClaudeAgentSDK.reset_configuration } + + it 'reports "[]" as an unknown option in the constructor' do + expect { ClaudeAgentSDK::ClaudeAgentOptions.new('[]' => 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + end + + it 'reports it through #[]= and #dup_with too' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + + expect { options['[]'] = 1 }.to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + expect { options.dup_with('[]': 1) }.to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :[]') + end + + it 'reports the name as written when defaults are configured' do + ClaudeAgentSDK.configure { |config| config.default_options = { model: 'sonnet' } } + + expect { ClaudeAgentSDK::ClaudeAgentOptions.new('[]' => 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + end + + # '=' + '=' is #==, '!' + '=' is #!=, '==' + '=' is #===: each used to be + # called with the value, and the key was dropped without a word. + it 'reports the names that resolve to a comparison operator' do + ['=', '!', '=='].each do |name| + expect { ClaudeAgentSDK::ClaudeAgentOptions.new(name => 1) } + .to raise_error(ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}") + end + end + + it 'still reports a misspelled option and a method that is not an option' do + expect { ClaudeAgentSDK::ClaudeAgentOptions.new(modle: 'sonnet') } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :modle') + expect { ClaudeAgentSDK::ClaudeAgentOptions.new(dup_with: 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :dup_with') + end + + it 'still accepts every declared option in any spelling' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new('permissionMode' => 'plan', forkSession: true, 'max-turns' => 3) + options[:callbackScheduling] = 'inline' + + expect([options.permission_mode, options.fork_session, options.max_turns, options.callback_scheduling]) + .to eq(['plan', true, 3, :inline]) + end + end + + # docs/types.md, "Attributes Only": methods user code adds to a subclass + # (an attr_accessor, a hand-written setter, a mixin's accessors, a singleton + # method) count as attributes. + describe 'a ClaudeAgentOptions subclass with its own setters' do + after { ClaudeAgentSDK.reset_configuration } + + let(:mixin) { Module.new { attr_accessor :team } } + let(:labelled_options) do + mod = mixin + Class.new(ClaudeAgentSDK::ClaudeAgentOptions) do + include mod + + attr_accessor :tenant + + def label=(value) + @label = value.to_s + end + end + end + + it 'accepts them in the constructor, in any spelling' do + options = labelled_options.new(label: :nightly, 'tenant' => 'acme', team: 'infra', model: 'sonnet') + + expect(options.instance_variable_get(:@label)).to eq('nightly') + expect([options.tenant, options.team, options.model]).to eq(%w[acme infra sonnet]) + end + + it 'accepts them through #[]= and #dup_with' do + options = labelled_options.new + options[:label] = 'weekly' + copy = options.dup_with(tenant: 'acme', label: 'daily') + + expect(options.instance_variable_get(:@label)).to eq('weekly') + expect([copy.tenant, copy.instance_variable_get(:@label)]).to eq(%w[acme daily]) + end + + it 'accepts them when defaults are configured' do + ClaudeAgentSDK.configure { |config| config.default_options = { model: 'sonnet' } } + options = labelled_options.new('label' => 'nightly', tenant: 'acme') + + expect([options.instance_variable_get(:@label), options.tenant, options.model]).to eq(%w[nightly acme sonnet]) + end + + it 'accepts a setter defined on the object itself' do + options = labelled_options.new + options.define_singleton_method(:trace_id=) { |value| @trace_id = value } + options[:trace_id] = 'abc' + + expect(options.instance_variable_get(:@trace_id)).to eq('abc') + end + + it 'still reports an unknown option and "[]"' do + expect { labelled_options.new(lable: 'x') } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :lable') + expect { labelled_options.new('[]' => 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + end + end +end From 1d3c31bb4eb3883b0410cab9771fa7bdce31d4e1 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:57:22 +0800 Subject: [PATCH 017/194] Leave a span for a session that fails before its first InitMessage OTelObserver#on_error returned when no root span existed, and the root span is only created by the InitMessage. A CLI that could not be found or started, or an initialize that failed or timed out, therefore raised to the caller and exported no span at all. on_error now gives an error that arrives before the session's first InitMessage a claude_agent.session span of its own: the observer's default attributes, the prompt if one had been sent, the exception event and error status. No model or session.id, since the CLI never reported them. The span is finished in on_error and never becomes the root span. query() does call on_close afterwards, but a Client#connect that fails before the handshake never does, and an unfinished span is never exported. With no root span set, a later on_close has nothing to finish a second time, and the buffers are reset so the failed session's prompt cannot label the next trace of a reused observer. Only errors before the first InitMessage get this span. The CLI exits non-zero after an error result, so query() raises a ResultError once the trace has already ended and reported the failure; a second session span for it would be a duplicate. Such an error stays unrecorded, as before, until on_close starts the next session. Users who count claude_agent.session spans will now see failed starts among them. Exploration finding: H-P5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS --- docs/observability.md | 2 + lib/claude_agent_sdk/instrumentation/otel.rb | 91 +++++--- spec/unit/instrumentation/otel_spec.rb | 215 ++++++++++++++++++- 3 files changed, 282 insertions(+), 26 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 253b5701..83da8b90 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -25,6 +25,8 @@ claude_agent.session (root span — one per query/session) └── ... ``` +A session that fails before its first `InitMessage` (the CLI cannot be found or started, or the `initialize` handshake fails or times out) still leaves a span. `OTelObserver` emits a `claude_agent.session` span that records the exception and has error status, and ends it at once, so it is exported even when no `on_close` follows: a `Client#connect` that fails before the handshake never calls it. The span carries the observer's default attributes and, if a prompt had already been sent, `input.value`. It has no model, `session.id` or `claude_code.*` attributes, because the CLI never reported them. Once a trace has started, an error is recorded on its session span instead, and an error that arrives after the trace ended adds no span: the usual one is the `ResultError` raised when the CLI exits non-zero after an error result, which that trace already reports. + ## Setup with Langfuse **1. Install the OTel gems** (not bundled with the SDK — you choose your exporter): diff --git a/lib/claude_agent_sdk/instrumentation/otel.rb b/lib/claude_agent_sdk/instrumentation/otel.rb index 06836b6f..88622027 100644 --- a/lib/claude_agent_sdk/instrumentation/otel.rb +++ b/lib/claude_agent_sdk/instrumentation/otel.rb @@ -49,6 +49,7 @@ def initialize(tracer_name: TRACER_NAME, **default_attributes) @default_attributes = default_attributes @root_span = nil @root_context = nil + @trace_started = false # an InitMessage opened a trace since this observer was created or last closed @tool_spans = {} # tool_use_id => span @first_user_input = nil # first user prompt of the current trace @pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt) @@ -100,16 +101,25 @@ def on_message(message) end end - # Recording-only by design: a Client session can survive an error (the - # user may rescue one bad message and keep receiving), so finishing here - # would orphan later spans and break the next turn. Finish ownership - # stays with end_trace/on_close; start_trace also finishes any dangling - # span from a previous trace as the never-disconnected backstop. + # With a trace open this is recording-only by design: a Client session + # can survive an error (the user may rescue one bad message and keep + # receiving), so finishing here would orphan later spans and break the + # next turn. Finish ownership stays with end_trace/on_close; start_trace + # also finishes any dangling span from a previous trace as the + # never-disconnected backstop. + # + # With no trace open, an error that arrives before the session's first + # InitMessage gets a session span of its own (record_failed_start). Once + # a trace has run, an error between traces stays unrecorded: the usual + # one is the ResultError query() raises when the CLI exits non-zero + # after an error result, and the trace that just ended already reports + # that failure. def on_error(error) - return unless @root_span - - @root_span.record_exception(error) - @root_span.status = OpenTelemetry::Trace::Status.error(error.message) + if @root_span + record_error(@root_span, error) + elsif !@trace_started + record_failed_start(error) + end end def on_close @@ -121,11 +131,12 @@ def on_close @pending_prompt = nil @cost_session_id = nil @last_total_cost_usd = nil + @trace_started = false end private - def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of init-message fields to root span attributes + def start_trace(message) # A new init without an intervening ResultMessage (e.g. /clear or an # interrupted turn) supersedes the current trace; finish it so it is # exported instead of leaking as a never-ended span, and reset the @@ -142,21 +153,9 @@ def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of in # because it came first chronologically. @first_user_input = @pending_prompt if @pending_prompt @pending_prompt = nil + @trace_started = true - attrs = { - # gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.) - 'gen_ai.system' => 'anthropic', - 'gen_ai.request.model' => message.model, - # OpenInference conventions (recognized by Langfuse, Arize) - 'openinference.span.kind' => 'AGENT', - 'llm.model_name' => message.model, - 'input.mime_type' => 'text/plain', - 'output.mime_type' => 'text/plain', - # Langfuse: 'agent' type triggers the trace flow diagram (DAG graph) - 'langfuse.observation.type' => 'agent', - # Session tracking - 'session.id' => message.session_id - }.merge(@default_attributes) + attrs = session_span_attrs(model: message.model, session_id: message.session_id) if message.respond_to?(:claude_code_version) && message.claude_code_version attrs['claude_code.version'] = message.claude_code_version @@ -175,6 +174,50 @@ def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of in @root_span.set_attribute('input.value', truncate(@first_user_input)) end + # The attributes every session span starts with. The model and the + # session id come from the InitMessage, so the span of a session that + # failed before sending one (record_failed_start) has neither. + def session_span_attrs(model: nil, session_id: nil) + { + # gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.) + 'gen_ai.system' => 'anthropic', + 'gen_ai.request.model' => model, + # OpenInference conventions (recognized by Langfuse, Arize) + 'openinference.span.kind' => 'AGENT', + 'llm.model_name' => model, + 'input.mime_type' => 'text/plain', + 'output.mime_type' => 'text/plain', + # Langfuse: 'agent' type triggers the trace flow diagram (DAG graph) + 'langfuse.observation.type' => 'agent', + # Session tracking + 'session.id' => session_id + }.merge(@default_attributes) + end + + # A session that fails before its first InitMessage (the CLI cannot be + # found or started, initialize fails or times out, the process dies + # before its first frame) has no trace to record the error on, so the + # error gets a session span of its own, with the prompt if one was sent. + # + # The span is finished here and never becomes @root_span. query() does + # call on_close next, but a Client#connect that fails before the + # handshake never does, and an unfinished span is never exported; with + # no root span set, a later on_close has nothing to finish a second + # time. The buffers are reset so that the failed session's prompt cannot + # label the next trace of a reused observer. + def record_failed_start(error) + span = @tracer.start_span('claude_agent.session', attributes: compact_attrs(session_span_attrs)) + span.set_attribute('input.value', truncate(@first_user_input)) if @first_user_input + record_error(span, error) + span.finish + reset_session_buffers + end + + def record_error(span, error) + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error(error.message) + end + def handle_assistant(message) return unless @root_context diff --git a/spec/unit/instrumentation/otel_spec.rb b/spec/unit/instrumentation/otel_spec.rb index 26516cb7..1bab0279 100644 --- a/spec/unit/instrumentation/otel_spec.rb +++ b/spec/unit/instrumentation/otel_spec.rb @@ -670,9 +670,33 @@ def finish expect(root_span.status.code).to eq(OpenTelemetry::Trace::Status::ERROR) end - it 'is a no-op when no root span exists' do + it 'gives an error that arrives before any InitMessage a finished session span of its own' do fresh_observer = described_class.new - expect { fresh_observer.on_error(RuntimeError.new('test')) }.not_to raise_error + spans_before = created_spans.length + + fresh_observer.on_error(RuntimeError.new('test')) + + spans = created_spans[spans_before..] + expect(spans.map(&:name)).to eq(['claude_agent.session']) + expect(spans.first).to have_attributes(finished: true) + expect(spans.first.status).to have_attributes(code: OpenTelemetry::Trace::Status::ERROR, description: 'test') + expect(spans.first.events.map { |event| event[:name] }).to eq(['exception']) + end + + # The CLI exits non-zero after an error result, so query() raises a + # ResultError when the trace has already ended. That trace reports the + # failure; a second session span for it would be a duplicate. + it 'adds no span for an error that follows a finished trace' do + observer.on_message( + ClaudeAgentSDK::ResultMessage.new( + subtype: 'error_during_execution', is_error: true, duration_ms: 1, duration_api_ms: 1, + num_turns: 1, session_id: 'sess-1', usage: {} + ) + ) + observer.on_error(ClaudeAgentSDK::ProcessError.new('Claude Code returned an error result: boom', exit_code: 1)) + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(created_spans.first.events).to be_empty end end @@ -1247,4 +1271,191 @@ def token_usage(span) expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([false, false, false, false]) end end + + # A CLI that cannot be found or started, an initialize that fails or times + # out, a process that dies before its first frame: the session never sends + # an InitMessage, so there is no trace to record the error on. + describe 'a session that fails before its first InitMessage' do + let(:error) { ClaudeAgentSDK::CLINotFoundError.new('Claude Code not found at: /nonexistent/claude') } + let(:init_message) do + ClaudeAgentSDK::InitMessage.new(subtype: 'init', session_id: 'sess-1', model: 'claude-sonnet-4') + end + let(:result_message) do + ClaudeAgentSDK::ResultMessage.new( + subtype: 'success', is_error: false, duration_ms: 1, duration_api_ms: 1, num_turns: 1, + session_id: 'sess-1', usage: {} + ) + end + + before do + # As the outer capture, and counting how often each span is finished. + allow_any_instance_of(OpenTelemetry::MockTracer).to receive(:start_span) do |_tracer, name, **kwargs| + span = OpenTelemetry::MockSpan.new(name, kwargs[:attributes] || {}) + allow(span).to receive(:finish).and_call_original + created_spans << span + span + end + end + + def error_span?(span) + span.status&.code == OpenTelemetry::Trace::Status::ERROR && span.events.map { |event| event[:name] } == ['exception'] + end + + it 'exports a session span with the exception, error status and the prompt, and nothing an init would add' do + observer.on_user_prompt('Fix the bug') + observer.on_error(error) + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + span = created_spans.first + expect(span.attributes).to eq( + 'gen_ai.system' => 'anthropic', 'openinference.span.kind' => 'AGENT', + 'langfuse.observation.type' => 'agent', 'input.mime_type' => 'text/plain', + 'output.mime_type' => 'text/plain', 'input.value' => 'Fix the bug' + ) + expect(span.status).to have_attributes(code: OpenTelemetry::Trace::Status::ERROR, description: error.message) + expect(span.events).to eq( + [{ name: 'exception', attributes: { 'exception.type' => 'ClaudeAgentSDK::CLINotFoundError', + 'exception.message' => error.message } }] + ) + end + + # Client#connect notifies on_error and never on_close when it fails + # before the handshake; an unfinished span is never exported. + it 'finishes that span in on_error, without waiting for an on_close' do + observer.on_error(error) + + expect(created_spans.first).to have_attributes(finished: true) + end + + it 'does not finish it a second time when on_close follows, as it does in query()' do + observer.on_error(error) + observer.on_close + + expect(created_spans.length).to eq(1) + expect(created_spans.first).to have_received(:finish).once + end + + it 'adds the default attributes of the observer' do + tagged = described_class.new('langfuse.session.id' => 'checkout-42', 'user.id' => 'user-7') + tagged.on_error(error) + + expect(created_spans.first.attributes).to include('langfuse.session.id' => 'checkout-42', 'user.id' => 'user-7') + end + + it 'does not carry the prompt of the failed session into the next trace' do + observer.on_user_prompt('Failed prompt') + observer.on_error(error) + observer.on_message(init_message) # a retry on the same observer; no on_close came in between + + expect(created_spans.map { |span| span.attributes['input.value'] }).to eq(['Failed prompt', nil]) + end + + it 'labels a retry after the failure with its own prompt' do + observer.on_user_prompt('Failed prompt') + observer.on_error(error) + observer.on_close + observer.on_user_prompt('Retry prompt') + observer.on_message(init_message) + + expect(created_spans.map { |span| span.attributes['input.value'] }).to eq(['Failed prompt', 'Retry prompt']) + expect(created_spans.map { |span| error_span?(span) }).to eq([true, false]) + end + + it 'gives every error that surfaces before the first InitMessage its own finished span' do + observer.on_error(error) + observer.on_error(ClaudeAgentSDK::CLIConnectionError.new('Not connected')) + + expect(created_spans.map { |span| span.status.description }).to eq([error.message, 'Not connected']) + expect(created_spans).to all(have_attributes(finished: true)) + end + + it 'reports a failed start again once the observer has been closed' do + observer.on_message(init_message) + observer.on_message(result_message) + observer.on_close + observer.on_error(error) + + expect(created_spans.map { |span| error_span?(span) }).to eq([false, true]) + expect(created_spans).to all(have_attributes(finished: true)) + end + + it 'stays silent for an error between two traces of one session' do + observer.on_message(init_message) + observer.on_message(result_message) + observer.on_error(ClaudeAgentSDK::CLIConnectionError.new('Not connected')) + observer.on_message(init_message) + + expect(created_spans.map { |span| error_span?(span) }).to eq([false, false]) + end + + describe 'through the public entry points' do + # The real transport, pointed at a CLI that is not there. + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer], cli_path: '/nonexistent/claude') } + + it 'leaves one finished error span when query() cannot start the CLI' do + context = { span: Object.new } + + OpenTelemetry::Context.with_current(context) do + expect { ClaudeAgentSDK.query(prompt: 'hello', options: options) { |_message| nil } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError) + end + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(error_span?(created_spans.first)).to be(true) + expect(created_spans.first).to have_attributes(finished: true, parent_context: context) + expect(created_spans.first).to have_received(:finish).once + end + + it 'leaves one finished error span when Client#connect cannot start the CLI, though no on_close follows' do + notified = [] + recorder = Object.new.extend(ClaudeAgentSDK::Observer) + recorder.define_singleton_method(:on_error) { |_error| notified << :on_error } + recorder.define_singleton_method(:on_close) { notified << :on_close } + both = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [recorder, observer], cli_path: options.cli_path) + context = { span: Object.new } + + OpenTelemetry::Context.with_current(context) do + expect { ClaudeAgentSDK::Client.open(options: both) { |_client| nil } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError) + end + + expect(notified).to eq([:on_error]) + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(error_span?(created_spans.first)).to be(true) + expect(created_spans.first).to have_attributes(finished: true, parent_context: context) + expect(created_spans.first).to have_received(:finish).once + end + + it 'records the prompt when the CLI dies after the prompt was written and before any InitMessage' do + incoming = Async::Queue.new + transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) + allow(transport).to receive(:write) do |json| + message = JSON.parse(json, symbolize_names: true) + case message[:type] + when 'control_request' + incoming.enqueue(type: 'control_response', + response: { subtype: 'success', request_id: message[:request_id], response: {} }) + when 'user' + incoming.enqueue(:exit) + end + end + allow(transport).to receive(:read_messages) do |&receive| + loop do + frame = incoming.dequeue + raise ClaudeAgentSDK::ProcessError.new('Command failed', exit_code: 1, stderr: 'boom') if frame == :exit + + receive.call(frame) + end + end + + expect { ClaudeAgentSDK.query(prompt: 'hello', options: options, transport: transport) { |_message| nil } } + .to raise_error(ClaudeAgentSDK::ProcessError, /exit code: 1/) + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(error_span?(created_spans.first)).to be(true) + expect(created_spans.first.attributes).to include('input.value' => 'hello') + expect(created_spans.first).to have_received(:finish).once + end + end + end end From dec5eda95342209f36ab5aafcd2473f82d4303cd Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:57:51 +0800 Subject: [PATCH 018/194] Honor HTTPS_PROXY in the CLI installer CLIInstaller downloaded without the proxy in an environment that exports only HTTPS_PROXY / https_proxy, which is what curl, RubyGems and the CLI itself read for an https URL. Net::HTTP.start without proxy arguments looks the proxy up as if for an http:// URL: it reads http_proxy even for a TLS connection. Where direct egress is blocked the install failed after the 10 s open timeout, with a message that does not mention the proxy. Http.with_response now resolves the proxy with URI#find_proxy on the https URL, for every redirect hop, and passes its address, port, user and password to Net::HTTP.start explicitly; credentials are percent-decoded the way Net::HTTP decodes the ones it finds itself. no_proxy / NO_PROXY behave as find_proxy defines them. Nothing else changes route. With no usable HTTPS proxy, no arguments are passed and Net::HTTP's own lookup stays in charge, so an environment that only sets http_proxy keeps working. A value Net::HTTP cannot use as an HTTP proxy (no scheme, socks5://, https://, not a URL) was ignored before and still is, instead of turning a download that works directly into a failure. ALL_PROXY is not consulted, so this is not curl parity; docs/cli-installer.md gains a "Proxies and custom CAs" section that says so and also covers NO_PROXY and SSL_CERT_FILE. The new spec file stubs only Net::HTTP.start and answers with real Net::HTTPResponse objects parsed from raw bytes. User-visible: a host that sets https_proxy to an http:// proxy now downloads the CLI through it. Exploration finding: D-P6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/cli-installer.md | 11 ++ lib/claude_agent_sdk/cli_installer.rb | 35 ++++++- spec/unit/cli_installer_http_spec.rb | 145 ++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 spec/unit/cli_installer_http_spec.rb diff --git a/docs/cli-installer.md b/docs/cli-installer.md index dfbebdee..38f30fdc 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -97,6 +97,17 @@ RUN bin/rails claude_agent_sdk:install_cli The variable is deliberately not rake's conventional `VERSION`, which Rails' `db:migrate` uses and build environments often export for an app version or git SHA. An empty `CLAUDE_CLI_VERSION` means the gem's pin. +## Proxies and custom CAs + +The installer downloads over HTTPS with Ruby's `Net::HTTP` and reads the usual environment variables: + +- **`HTTPS_PROXY`** (or `https_proxy`) names the proxy to download through, as an `http://` URL: `HTTPS_PROXY=http://proxy.corp.example:3128`, with `user:password@` in front of the host for an authenticating proxy (percent-encode special characters). The download is tunnelled through it with `CONNECT`, so TLS still ends at the release endpoint. +- **`NO_PROXY`** (or `no_proxy`) lists the hosts, domain suffixes and IP ranges to reach directly, separated by commas. +- With neither spelling of `HTTPS_PROXY` set, `Net::HTTP`'s own default applies: it goes through `http_proxy` when that is set. +- **`SSL_CERT_FILE`** points OpenSSL at another CA bundle, which is what a TLS-inspecting proxy needs. Set it in the environment the process starts with. The server certificate is always verified; there is no switch to turn that off. + +This is not everything `curl` understands. `ALL_PROXY` is not read, and only an `http://` proxy URL is used: a value without a scheme (`proxy.corp.example:3128`), a `socks5://` proxy or an `https://` one is ignored. + ## Supported platforms `darwin-arm64`, `darwin-x64` (Rosetta 2 gets the arm64 build), `linux-x64`, `linux-arm64`, and the `-musl` variants. Windows is not supported. diff --git a/lib/claude_agent_sdk/cli_installer.rb b/lib/claude_agent_sdk/cli_installer.rb index 5748401c..dec1c3bb 100644 --- a/lib/claude_agent_sdk/cli_installer.rb +++ b/lib/claude_agent_sdk/cli_installer.rb @@ -200,9 +200,9 @@ def with_response(url, redirects_left = MAX_REDIRECTS, &block) uri = url.is_a?(URI::Generic) ? url : URI(url.to_s) raise CLIInstallError, "Refusing to fetch non-HTTPS URL: #{uri}" unless uri.is_a?(URI::HTTPS) - Net::HTTP.start(uri.host, uri.port, use_ssl: true, - open_timeout: OPEN_TIMEOUT_SECONDS, - read_timeout: READ_TIMEOUT_SECONDS) do |http| + Net::HTTP.start(uri.host, uri.port, *proxy_args(uri), use_ssl: true, + open_timeout: OPEN_TIMEOUT_SECONDS, + read_timeout: READ_TIMEOUT_SECONDS) do |http| http.request(Net::HTTP::Get.new(uri)) do |response| # Branch on the status BEFORE touching the body: a redirect or an # error page must never be streamed into the target file. @@ -218,6 +218,35 @@ def with_response(url, redirects_left = MAX_REDIRECTS, &block) raise CLIInstallError, "Failed to fetch #{url}: #{e.class}: #{e.message}" end + # The proxy arguments for Net::HTTP.start: address, port, user, + # password — or none. + # + # Left to itself, Net::HTTP looks its proxy up as if for an http:// + # URL: it reads http_proxy even though this connection is TLS. An + # environment that exports only HTTPS_PROXY (what curl, RubyGems and + # the CLI itself read for an https URL) was therefore bypassed. + # URI#find_proxy on the https URL reads https_proxy / HTTPS_PROXY and + # applies no_proxy / NO_PROXY; the proxy it names is passed + # explicitly, its credentials percent-decoded the way Net::HTTP + # decodes the ones it finds itself. + # + # No arguments means "as before": Net::HTTP's own lookup (http_proxy) + # stays in charge. That is the answer when the variable is unset or + # no_proxy excludes the host, and also when its value is nothing + # Net::HTTP can use as an HTTP proxy — no scheme, socks5://, https://, + # not a URL at all. Such a value was ignored before and still is, + # rather than turning a download that works directly into a failure. + # ALL_PROXY is not consulted. + def proxy_args(uri) + proxy = uri.find_proxy + return [] unless proxy.instance_of?(URI::HTTP) && !proxy.hostname.to_s.empty? + + credentials = [proxy.user, proxy.password].map { |part| part && URI.decode_www_form_component(part) } + [proxy.hostname, proxy.port, *credentials] + rescue URI::InvalidURIError + [] + end + def follow_redirect(uri, response, redirects_left, &) raise CLIInstallError, "Too many redirects while fetching #{uri}" if redirects_left <= 0 diff --git a/spec/unit/cli_installer_http_spec.rb b/spec/unit/cli_installer_http_spec.rb new file mode 100644 index 00000000..6840a2a5 --- /dev/null +++ b/spec/unit/cli_installer_http_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'net/http' +require 'stringio' + +# CLIInstaller::Http itself. The other installer specs replace +# Http.fetch_text / Http.download_to wholesale; here the only seam is +# Net::HTTP.start, so everything above the socket is the real code: proxy +# resolution, the HTTPS-only check, redirects, status handling and body +# streaming. Responses are real Net::HTTPResponse objects, parsed from the raw +# bytes a server would send — stdlib only, no HTTP stubbing library. +RSpec.describe ClaudeAgentSDK::CLIInstaller::Http do + proxy_variables = %w[http_proxy HTTP_PROXY https_proxy HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY].freeze + + # The proxy settings of whoever runs the suite must not leak into an + # example, and what an example sets must not leak out. + around do |example| + saved = proxy_variables.to_h { |name| [name, ENV.fetch(name, nil)] } + proxy_variables.each { |name| ENV.delete(name) } + example.run + ensure + saved.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value } + end + + # One entry per Net::HTTP.start call, in order: where it connected, the + # explicit proxy arguments (none = Net::HTTP's own environment lookup), the + # session options, and the request sent over it. + let(:connections) { [] } + + def raw_response(status, headers = {}, body = '') + head = { 'Content-Length' => body.bytesize }.merge(headers).map { |name, value| "#{name}: #{value}\r\n" }.join + "HTTP/1.1 #{status}\r\n#{head}\r\n#{body}" + end + + # Answers the n-th connection with the n-th raw response, read the way + # Net::HTTP#transport_request reads one off the socket. + def stub_connections(*raw_responses) + allow(Net::HTTP).to receive(:start) do |address, port, *proxy, **options, &session| + raw = raw_responses.shift or raise "unexpected connection to #{address}" + connection = { address: address, port: port, proxy: proxy, options: options } + connections << connection + session.call(connection_answering(raw, connection)) + end + end + + def connection_answering(raw, connection) + http = Object.new + http.define_singleton_method(:request) do |request, &handler| + connection[:request] = request + socket = Net::BufferedIO.new(StringIO.new(raw)) + response = Net::HTTPResponse.read_new(socket) + response.decode_content = request.decode_content + response.reading_body(socket, request.response_body_permitted?) { handler.call(response) } + end + http + end + + describe 'proxy selection' do + # TEST-NET-1 literals: URI#find_proxy resolves the host before applying + # no_proxy, and a literal resolves without DNS. (Not a loopback address — + # find_proxy never proxies those.) + let(:url) { 'https://192.0.2.1/claude-code-releases/stable' } + let(:version_response) { raw_response('200 OK', { 'Content-Type' => 'text/plain' }, "2.1.285\n") } + + def proxy_used_for(url) + stub_connections(version_response) + expect(described_class.fetch_text(url, limit: 1024)).to eq("2.1.285\n") + connections.last[:proxy] + end + + it 'connects through the proxy HTTPS_PROXY names' do + ENV['HTTPS_PROXY'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq(['proxy.corp.example', 3128, nil, nil]) + expect(connections.last).to include(address: '192.0.2.1', port: 443) + end + + it 'reads the lowercase https_proxy too' do + ENV['https_proxy'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq(['proxy.corp.example', 3128, nil, nil]) + end + + it 'passes the proxy credentials, percent-decoded' do + ENV['HTTPS_PROXY'] = 'http://alice:p%40ss%3Aword@proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq(['proxy.corp.example', 3128, 'alice', 'p@ss:word']) + end + + it 'unbrackets an IPv6 proxy address and defaults the port' do + ENV['HTTPS_PROXY'] = 'http://[2001:db8::10]' + + expect(proxy_used_for(url)).to eq(['2001:db8::10', 80, nil, nil]) + end + + it 'passes no proxy when NO_PROXY lists the host' do + ENV['HTTPS_PROXY'] = 'http://proxy.corp.example:3128' + ENV['NO_PROXY'] = 'localhost,192.0.2.1' + + expect(proxy_used_for(url)).to eq([]) + end + + it 'passes no proxy when nothing is set' do + expect(proxy_used_for(url)).to eq([]) + end + + it 'leaves an environment that only sets http_proxy to Net::HTTP, as before' do + # No explicit arguments: Net::HTTP's own lookup reads http_proxy, which + # is how the installer reached a proxy before it read HTTPS_PROXY. + ENV['http_proxy'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq([]) + end + + it 'does not consult ALL_PROXY' do + ENV['ALL_PROXY'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq([]) + end + + it 'resolves the proxy again for every redirect hop' do + ENV['HTTPS_PROXY'] = 'http://proxy.corp.example:3128' + ENV['NO_PROXY'] = '192.0.2.99' + stub_connections(raw_response('302 Found', { 'Location' => 'https://192.0.2.99/claude' }), + raw_response('200 OK', {}, 'binary')) + + expect(described_class.fetch_text(url, limit: 1024)).to eq('binary') + expect(connections.map { |c| [c[:address], c[:proxy]] }) + .to eq([['192.0.2.1', ['proxy.corp.example', 3128, nil, nil]], ['192.0.2.99', []]]) + end + + # Values that were ignored while the installer did not read HTTPS_PROXY at + # all, and that Net::HTTP could not use as an HTTP proxy anyway. Passing + # them on would turn a download that works directly into a failure. + ['proxy.corp.example:3128', '127.0.0.1:3128', 'socks5://127.0.0.1:1080', + 'https://proxy.corp.example', 'http://proxy with spaces:3128', 'http://:3128'].each do |value| + it "still ignores HTTPS_PROXY=#{value}, which is not an http:// proxy URL" do + ENV['HTTPS_PROXY'] = value + + expect(proxy_used_for(url)).to eq([]) + end + end + end +end From 3b83fd59ff48f96d0121cd93d66b837cca5b27eb Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:57:59 +0800 Subject: [PATCH 019/194] Keep the end-of-stream marker for later receive calls After the CLI went away while a Client was connected (it exited, crashed or was killed), the first receive_response / receive_messages raised the stream error or returned the last turn, the next one returned zero messages, and every call after that parked forever: no error, no timeout, a live reactor task waiting on a queue nothing writes to. A worker that retries its receive after an error, or loops on receive_messages for background-task notifications, hung for the life of the process. The read loop enqueues one end marker when it finishes and Query#receive_messages consumed it. It now puts the marker back before it stops, so every later call ends at once, as iteration over a closed stream does in the Python SDK. A stream error is still raised once, by the first call that reaches it; later calls return without messages. The marker goes back only when the caller is inside an Async task: async before 2.29 raises for an Async::Queue#enqueue outside one, which would have turned a receive called after the reactor has ended (it drains what is queued today) into a RuntimeError on those versions. That call shape keeps its previous behavior on every async version. Exploration finding: A-P6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk/query.rb | 12 +- .../client_receive_after_stream_end_spec.rb | 171 ++++++++++++++++++ 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 spec/unit/client_receive_after_stream_end_spec.rb diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 3931e64d..3cbfd56c 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -1778,7 +1778,17 @@ def receive_messages(&block) # on_error never fired. `while` propagates it like any other error. while true # rubocop:disable Style/InfiniteLoop message = @message_queue.dequeue - break if message[:type] == 'end' + if message[:type] == 'end' + # End of stream is sticky. The read loop enqueues ONE sentinel and + # is gone, so the sentinel goes back for the next caller: consumed + # here, every later receive would park on a queue nothing writes + # to any more. (Python closes the send side of its stream instead, + # so later iterations end at once there too.) Only from inside a + # task: async < 2.29 raises for an enqueue outside one, so a + # receive from outside a reactor consumes the sentinel as before. + @message_queue.enqueue(message) if Async::Task.current? + break + end raise message[:error] if message[:type] == 'error' block.call(message) diff --git a/spec/unit/client_receive_after_stream_end_spec.rb b/spec/unit/client_receive_after_stream_end_spec.rb new file mode 100644 index 00000000..683d12c1 --- /dev/null +++ b/spec/unit/client_receive_after_stream_end_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +# Once the CLI's stdout has ended — the process exited, cleanly or not — the +# read loop is gone and nothing is ever written to the message queue again. +# The read loop leaves ONE end-of-stream marker behind, so the marker has to +# stay there for every later Client#receive_response / #receive_messages: +# consumed by the first reader, the next one waits on an empty queue that has +# no producer, for as long as the process lives. +# +# Each call below runs under a bounded wait, so a call that parks fails the +# example instead of hanging the suite. A call that ends does so without +# suspending, long before that bound could matter. +RSpec.describe ClaudeAgentSDK::Client, 'receiving after the CLI stream has ended' do + # Queue-backed stand-in for the CLI process: +on_user_message+ scripts what + # it sends back for a prompt, and how its stdout ends. + let(:transport_class) do + Class.new do + def initialize(_options, on_user_message:) + @on_user_message = on_user_message + @stdout = Thread::Queue.new + end + + def connect; end + + def ready? + true + end + + def end_input; end + + def close + exit_cleanly + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + if frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + send_frame(type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + elsif frame[:type] == 'user' + @on_user_message.call(self) + end + end + + def send_frame(frame) + @stdout << frame + end + + # stdout reaches EOF after exit status 0. + def exit_cleanly + @stdout << :eof + end + + # What SubprocessCLITransport#read_messages raises at EOF when the CLI + # exited non-zero. + def crash(exit_code:, stderr:) + @stdout << ClaudeAgentSDK::ProcessError.new("Command failed with exit code #{exit_code}", + exit_code: exit_code, stderr: stderr) + end + + def read_messages + loop do + item = @stdout.pop + break if item == :eof + raise item if item.is_a?(Exception) + + yield item + end + end + end + end + + let(:session_id) { 'c0ffee00-0000-4000-8000-000000000001' } + + def assistant_frame + { type: 'assistant', parent_tool_use_id: nil, session_id: session_id, uuid: SecureRandom.uuid, + message: { id: 'msg_01', type: 'message', role: 'assistant', model: 'claude-haiku-4-5-20251001', + content: [{ type: 'text', text: 'Four.' }], stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 3 } } } + end + + def result_frame + sample_result_message.merge(session_id: session_id, result: 'Four.', stop_reason: 'end_turn', + terminal_reason: 'completed', uuid: SecureRandom.uuid) + end + + # One turn against a CLI scripted by +on_user_message+; yields the client + # once the prompt is written and the CLI's reaction is queued. + def with_session(on_user_message) + Async do |task| + client = described_class.new(transport_class: transport_class, + transport_args: { on_user_message: on_user_message }) + client.connect + client.query('What is 2 + 2?') + yield client, task + ensure + client&.disconnect + end.wait + end + + # What one receive call did: the message classes it yielded, or :parked + # when it was still waiting at the bound. + def receive(task, client, method) + seen = [] + task.with_timeout(5) { client.public_send(method) { |message| seen << message.class } } + seen + rescue Async::TimeoutError + :parked + end + + let(:cli_exits_after_the_turn) do + lambda do |cli| + cli.send_frame(assistant_frame) + cli.send_frame(result_frame) + cli.exit_cleanly + end + end + + let(:cli_crashes_mid_turn) do + lambda do |cli| + cli.send_frame(assistant_frame) + cli.crash(exit_code: 1, stderr: 'Killed') + end + end + + %i[receive_response receive_messages].each do |method| + describe "##{method}" do + it 'ends at once on every call after a clean end of stream' do + with_session(cli_exits_after_the_turn) do |client, task| + calls = Array.new(3) { receive(task, client, method) } + + expect(calls).to eq([[ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], [], []]) + end + end + + it 'raises the stream error once, then ends at once on every later call' do + with_session(cli_crashes_mid_turn) do |client, task| + seen_before_the_crash = [] + expect do + task.with_timeout(5) { client.public_send(method) { |message| seen_before_the_crash << message.class } } + end.to raise_error(ClaudeAgentSDK::ProcessError, /exit code 1/) + expect(seen_before_the_crash).to eq([ClaudeAgentSDK::AssistantMessage]) + + expect(Array.new(2) { receive(task, client, method) }).to eq([[], []]) + end + end + end + end + + # The marker goes back only from inside a task: async < 2.29 raises for an + # Async::Queue#enqueue outside one. A receive called after the run and its + # reactor are over keeps draining what is queued, as it always did. + it 'still drains the finished stream when the receive is called outside a reactor' do + client = described_class.new(transport_class: transport_class, + transport_args: { on_user_message: cli_exits_after_the_turn }) + Async do + client.connect + client.query('What is 2 + 2?') + end.wait # the CLI exited: the read loop ended, and the reactor with it + + seen = [] + client.receive_messages { |message| seen << message.class } + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + end +end From 3afd64af8c8f380fb09bf6059897f488fd0e8845 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 00:58:56 +0800 Subject: [PATCH 020/194] Bridge the Keychain for a custom CLAUDE_CONFIG_DIR on resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS the CLI keeps OAuth credentials in the Keychain. With a custom CLAUDE_CONFIG_DIR it stores them under "Claude Code-credentials-" and writes no .credentials.json. Store-backed resume consulted the Keychain only for the default config dir, and only under the fixed service name, so with a custom dir the materialized config dir got no credentials: the resumed CLI answered "Not logged in · Please run /login", and the failed attempt was mirrored into the store as a junk turn. copy_auth_files now reads the Keychain for a custom config dir too, deriving the service name from the directory the way the CLI does (the environment value as given, NFC-normalized). It does so only when that directory has no .credentials.json: which source the CLI prefers when both exist is not established, so a file that is there keeps winning, as before. Unchanged: ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN skip the bridge, the default config dir behaves as before, a non-macOS host never runs `security`, and the seeded copy has no refresh token and mode 0600. A user can notice: a store-backed resume on macOS with a custom config dir and a Keychain login now authenticates. It runs one `security find-generic-password`, which can raise a Keychain access prompt, as the default-dir lookup already could. The specs go through materialize_resume_session with capture_with_timeout stubbed; the expected service names are digests computed outside Ruby. They also pin that the output of a failed lookup is never used. A real-CLI integration example mirrors one turn into an InMemorySessionStore, removes the local transcript and resumes from the store. Exploration finding: B-P4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- docs/sessions.md | 7 +- lib/claude_agent_sdk/session_resume.rb | 51 +++++-- .../store_backed_resume_integration_spec.rb | 81 +++++++++++ spec/unit/session_resume_keychain_spec.rb | 130 ++++++++++++++++++ 4 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 spec/integration/store_backed_resume_integration_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index 7d7ddb75..8e738088 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -261,10 +261,11 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > `HOME` the subprocess will see — `options.env`'s `HOME` when it sets one): > > - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed -> subprocess can't consume it. On macOS with the default config dir and no +> subprocess can't consume it. On macOS with no > `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`, the credentials come from -> the Keychain entry when one exists (the redirected config dir would -> otherwise miss it). +> the CLI's Keychain entry for your config dir when one exists (the +> redirected config dir would otherwise miss it) — with a custom +> `CLAUDE_CONFIG_DIR`, only when that directory has no `.credentials.json`. > - `.claude.json` (from `$CLAUDE_CONFIG_DIR/.claude.json` when set, else > `~/.claude.json`). > - User `settings.json` and `cowork_settings.json` — so `apiKeyHelper`, `env`, diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 8b6d04c1..0ab02afe 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'json' +require 'digest' require 'fileutils' require 'tmpdir' require 'open3' @@ -357,16 +358,23 @@ def copy_auth_files(tmp_base, opt_env) # rubocop:disable Metrics/AbcSize, Metric # read_if_present returns raw bytes; the credentials path parses and # re-serializes JSON, so hand it a UTF-8-tagged string (invalid bytes # simply fail to parse and get written through, as before). - creds_bytes = source_config_dir && read_if_present(File.join(source_config_dir, '.credentials.json')) + creds_path = source_config_dir && File.join(source_config_dir, '.credentials.json') + creds_bytes = creds_path && read_if_present(creds_path) creds_json = creds_bytes&.dup&.force_encoding(Encoding::UTF_8) - # macOS default keeps OAuth tokens in the Keychain, not a file. Redirecting - # CLAUDE_CONFIG_DIR changes the Keychain service suffix so the subprocess's - # lookup misses; populate the plaintext file from the parent's Keychain. - # Skipped when env-based auth or a custom config dir is already in play. - if caller_config_dir.nil? && env_value(opt_env, 'ANTHROPIC_API_KEY').nil? && - env_value(opt_env, 'CLAUDE_CODE_OAUTH_TOKEN').nil? - keychain = read_keychain_credentials + # macOS keeps OAuth tokens in the Keychain, not in a file, under a + # service name that depends on the config dir (see + # keychain_service_name). Redirecting CLAUDE_CONFIG_DIR changes that + # name, so the subprocess's own lookup misses; populate the plaintext + # file from the caller's entry instead. Skipped under env-based auth. + # + # Default config dir: a Keychain hit overrides the file. Custom config + # dir: the Keychain is consulted only when the directory has no + # .credentials.json at all — which of the two the CLI prefers when both + # exist is not established, so a file that is there keeps winning. + if env_value(opt_env, 'ANTHROPIC_API_KEY').nil? && env_value(opt_env, 'CLAUDE_CODE_OAUTH_TOKEN').nil? && + (caller_config_dir.nil? || !File.exist?(creds_path)) + keychain = read_keychain_credentials(caller_config_dir) creds_json = keychain unless keychain.nil? end @@ -536,9 +544,11 @@ def redacted_credentials(creds_json) creds_json end - # Read OAuth credentials JSON from the macOS Keychain (default service name). - # Best-effort — returns nil on any error or non-macOS platforms. - def read_keychain_credentials + # Read OAuth credentials JSON from the macOS Keychain entry the CLI keeps + # for +config_dir+ (the caller's CLAUDE_CONFIG_DIR; nil for the default + # config dir). Best-effort — returns nil on any error or non-macOS + # platforms. + def read_keychain_credentials(config_dir) return nil unless RbConfig::CONFIG['host_os'].match?(/darwin/) user = (ENV['USER'] && !ENV['USER'].empty? ? ENV['USER'] : nil) || begin @@ -549,7 +559,7 @@ def read_keychain_credentials end stdout, status = capture_with_timeout( - ['security', 'find-generic-password', '-a', user, '-w', '-s', KEYCHAIN_SERVICE_NAME], + ['security', 'find-generic-password', '-a', user, '-w', '-s', keychain_service_name(config_dir)], KEYCHAIN_TIMEOUT_SECONDS ) return nil if status.nil? || !status.success? @@ -560,6 +570,21 @@ def read_keychain_credentials nil end + # The Keychain service the CLI stores its credentials under. With the + # default config dir that is KEYCHAIN_SERVICE_NAME; with a custom + # CLAUDE_CONFIG_DIR the CLI appends the first 8 hex digits of the SHA-256 + # of that directory — the string the environment carries (no realpath), + # NFC-normalized. The bytes are tagged UTF-8 first, which is how the CLI + # reads them: under a C locale Ruby tags a non-ASCII ENV value as binary, + # and unicode_normalize rejects that. + def keychain_service_name(config_dir) + return KEYCHAIN_SERVICE_NAME if config_dir.nil? + + dir = config_dir.to_s.dup.force_encoding(Encoding::UTF_8) + dir = dir.unicode_normalize(:nfc) if dir.valid_encoding? + "#{KEYCHAIN_SERVICE_NAME}-#{Digest::SHA256.hexdigest(dir)[0, 8]}" + end + # Run a command with a hard timeout, draining stdout on a side thread and # SIGKILL-ing on deadline (Timeout.timeout is unsafe under the fiber # scheduler). Returns [stdout, status] or [nil, nil] on timeout/error. @@ -782,7 +807,7 @@ def env_value(opt_env, name) private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl, :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials, - :capture_with_timeout, :materialize_subkeys, :write_subagent_files, + :keychain_service_name, :capture_with_timeout, :materialize_subkeys, :write_subagent_files, :resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value, :strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes, :redacted_credentials, :encode_candidate, :encode_jsonl_lines, :encode_entry, diff --git a/spec/integration/store_backed_resume_integration_spec.rb b/spec/integration/store_backed_resume_integration_spec.rb new file mode 100644 index 00000000..eb013bf9 --- /dev/null +++ b/spec/integration/store_backed_resume_integration_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'tmpdir' +require 'json' +require 'fileutils' + +RSpec.describe 'Store-backed resume through the real Claude CLI', :integration do + # Gated by RUN_INTEGRATION and self-skipping, like real_cli_integration_spec.rb: + # this spawns the real `claude` CLI and makes two small, budget-capped API calls. + before do + skip 'Claude CLI is not available on PATH' unless system('command -v claude >/dev/null 2>&1') + skip 'ANTHROPIC_API_KEY is required for real CLI integration tests' if ENV['ANTHROPIC_API_KEY'].to_s.empty? + end + + # A disposable config dir, so the first turn's local transcript lands where + # the example can delete it before resuming. No auth is copied: the CLI + # authenticates with ANTHROPIC_API_KEY, which also keeps the SDK's macOS + # Keychain bridge out of the picture. + around do |example| + previous_config_dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil) + Dir.mktmpdir('cas-store-resume') do |directory| + @config_dir = File.join(directory, 'config') + @project_dir = File.join(directory, 'project') + FileUtils.mkdir_p(@project_dir) + ENV['CLAUDE_CONFIG_DIR'] = @config_dir + example.run + end + ensure + if previous_config_dir + ENV['CLAUDE_CONFIG_DIR'] = previous_config_dir + else + ENV.delete('CLAUDE_CONFIG_DIR') + end + end + + def run_turn(prompt, options) + messages = [] + ClaudeAgentSDK.query(prompt: prompt, options: options) { |message| messages << message } + + result = messages.grep(ClaudeAgentSDK::ResultMessage).last + expect(result).to be_a(ClaudeAgentSDK::ResultMessage) + expect(result.is_error).to be(false) + expect(messages.grep(ClaudeAgentSDK::MirrorErrorMessage)).to be_empty + result + end + + it 'mirrors a turn into a SessionStore, then resumes the session from the store alone' do + store = ClaudeAgentSDK::InMemorySessionStore.new + marker = "MARK-#{SecureRandom.hex(4)}" + options = ClaudeAgentSDK::ClaudeAgentOptions.new(model: 'haiku', setting_sources: [], tools: [], + max_budget_usd: 0.05, cwd: @project_dir, session_store: store) + materialized = [] + allow(ClaudeAgentSDK::SessionResume).to receive(:materialize_resume_session).and_wrap_original do |original, opts| + original.call(opts).tap { |resume| materialized << resume if resume } + end + + first = run_turn("Remember this code for later: #{marker}. Reply with exactly: OK", options) + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(@project_dir), 'session_id' => first.session_id } + mirrored = store.load(key) + expect(JSON.generate(mirrored)).to include(marker) # the turn reached the store + expect(materialized).to be_empty # nothing to resume yet + + # Leave the store as the only copy: what the CLI resumes below is what the + # SDK materialized from it, not the transcript the first turn left on disk. + FileUtils.rm_rf(File.join(@config_dir, 'projects')) + + second = run_turn('What code did I ask you to remember? Reply with the code only.', + options.dup_with(resume: first.session_id)) + + expect(second.session_id).to eq(first.session_id) + expect(second.result.to_s).to include(marker) # the history came back + expect(materialized.length).to eq(1) + expect(File).not_to exist(materialized.first.config_dir) # temp config dir (credential copies) removed + resumed = store.load(key) + expect(resumed.length).to be > mirrored.length # the resumed turn was mirrored on top ... + uuids = resumed.filter_map { |entry| entry['uuid'] } + expect(uuids.uniq.length).to eq(uuids.length) # ... and the materialized history was not mirrored again + end +end diff --git a/spec/unit/session_resume_keychain_spec.rb b/spec/unit/session_resume_keychain_spec.rb index 97c19997..4419bafc 100644 --- a/spec/unit/session_resume_keychain_spec.rb +++ b/spec/unit/session_resume_keychain_spec.rb @@ -141,6 +141,7 @@ def security_argv(service) { 'the lookup exits non-zero (no such entry)' => ['', false], + 'the lookup exits non-zero after printing something' => [%({"claudeAiOauth":{"accessToken":"untrusted"}}\n), false], 'the entry is empty' => ["\n", true], 'the lookup times out or cannot be run' => [nil, nil] }.each do |outcome, (stdout, exit_ok)| @@ -184,6 +185,135 @@ def security_argv(service) expect(security_calls).to be_empty expect(File).not_to exist(seeded_credentials(materialized)) end + + it 'treats an empty CLAUDE_CONFIG_DIR as the default config dir, like the CLI' do + stub_security("#{keychain_payload}\n", true) + + materialize('CLAUDE_CONFIG_DIR' => '') + + expect(security_calls.map(&:first)).to eq([security_argv('Claude Code-credentials')]) + end + end + + # With a custom CLAUDE_CONFIG_DIR the CLI keeps its entry under + # "Claude Code-credentials-", hashing the + # directory as the environment gives it, NFC-normalized — and writes no + # .credentials.json there. The expected suffixes below are literals computed + # outside Ruby (`printf '%s' | shasum -a 256 | cut -c1-8`), so they pin + # the derivation instead of restating it. + describe 'with a custom CLAUDE_CONFIG_DIR' do + # No such directory: nothing to copy from it, and no .credentials.json. + let(:custom_dir) { '/nonexistent/claude-profiles/work' } + let(:custom_service) { 'Claude Code-credentials-43da4a82' } + + it 'reads the entry the CLI keeps for that directory and seeds it without the refresh token' do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls).to eq([[security_argv(custom_service), described_class::KEYCHAIN_TIMEOUT_SECONDS]]) + seeded = seeded_credentials(materialized) + expect(JSON.parse(File.read(seeded))).to eq('claudeAiOauth' => oauth.except('refreshToken')) + expect(format('%o', File.stat(seeded).mode & 0o777)).to eq('600') + end + + it 'reads it for a config dir the child inherits from the parent environment' do + stub_security("#{keychain_payload}\n", true) + ENV['CLAUDE_CONFIG_DIR'] = custom_dir + + materialized = materialize + + expect(security_calls.map(&:first)).to eq([security_argv(custom_service)]) + expect(File).to exist(seeded_credentials(materialized)) + end + + # "cafe" + U+0301 COMBINING ACUTE ACCENT: the decomposed spelling of the + # composed one-code-point form (U+00E9) that the CLI hashes. Built from + # the code point so no editor can quietly re-compose the literal. 0e56012f + # is the digest of the composed spelling; the bytes as given hash to + # d3d97122. + context 'when the directory is given in decomposed (NFD) form' do + let(:custom_dir) { "/nonexistent/claude-profiles/cafe#{[0x301].pack('U')}" } + let(:custom_service) { 'Claude Code-credentials-0e56012f' } + + it 'hashes its NFC form' do + stub_security("#{keychain_payload}\n", true) + + materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(custom_dir.unicode_normalize(:nfc)).not_to eq(custom_dir) + expect(security_calls.map(&:first)).to eq([security_argv(custom_service)]) + end + + it 'hashes the same form when Ruby tagged it as binary (a non-ASCII ENV value under a C locale)' do + stub_security("#{keychain_payload}\n", true) + + materialize('CLAUDE_CONFIG_DIR' => custom_dir.b) + + expect(security_calls.map(&:first)).to eq([security_argv(custom_service)]) + end + end + + it 'seeds nothing when the Keychain has no entry for that directory' do + stub_security('', false) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls.length).to eq(1) + expect(File).not_to exist(seeded_credentials(materialized)) + end + + # Which source the CLI prefers when a custom config dir has both is not + # established, so the file keeps winning there, as it always has. + context 'when that directory has a .credentials.json' do + let(:custom_dir) { Dir.mktmpdir } + + after { FileUtils.remove_entry(custom_dir) if File.directory?(custom_dir) } + + it 'seeds the file and leaves the Keychain alone' do + write_credentials_file(custom_dir, 'from-the-file') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls).to be_empty + expect(JSON.parse(File.read(seeded_credentials(materialized)))) + .to eq('claudeAiOauth' => oauth.except('refreshToken').merge('accessToken' => 'from-the-file')) + end + + it 'leaves the Keychain alone even when what is there cannot be read as a file' do + Dir.mkdir(File.join(custom_dir, '.credentials.json')) + stub_security("#{keychain_payload}\n", true) + + materialized = nil + expect { materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) } + .to output(/skipping .*\.credentials\.json \(not a regular file/).to_stderr + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end + + %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN].each do |name| + it "leaves the Keychain alone when options.env carries #{name}" do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir, name => 'from-the-env') + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end + + it 'leaves the Keychain alone off macOS' do + stub_host_os('linux-gnu') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end end describe '.capture_with_timeout' do From 770d4968d4fb8efcd80e656dd7aefb7354454b02 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:00:35 +0800 Subject: [PATCH 021/194] Say which conventions the OTel observer's attributes follow docs/observability.md and the OTelObserver class comment promised "standard gen_ai.* semantic conventions" that work with "any OTel backend". The observer emits nine gen_ai.* names, and six of them are deprecated (gen_ai.system), removed (gen_ai.completion), not defined (the two underscore-named cache counts, gen_ai.usage.cost) or defined differently (gen_ai.usage.input_tokens leaves cache tokens out; the convention expects them included) in the OTel GenAI semantic conventions. A backend that reads spans by those conventions sees 18 input tokens for a recorded turn that consumed 49,974. The page now says the attributes follow the Langfuse and OpenInference conventions plus a subset of OTel gen_ai.*, lists every attribute, event and status the observer sets per span (the old table named about half), and has a paragraph on how the gen_ai.* names compare with the conventions and where to read the inclusive input count (llm.token_count.prompt on the session span). The class comment points there. Documentation only: no attribute is added, renamed or removed. Exploration finding: H-P10 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS --- docs/observability.md | 64 +++++++++++++++++--- lib/claude_agent_sdk/instrumentation/otel.rb | 6 +- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/docs/observability.md b/docs/observability.md index 83da8b90..fec2ef21 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -1,6 +1,6 @@ # Observability (OpenTelemetry / Langfuse) -The SDK includes a built-in **observer interface** and an **OpenTelemetry observer** for tracing agent sessions. Traces are emitted using standard `gen_ai.*` semantic conventions, compatible with Langfuse, Jaeger, Datadog, and any OTel backend. +The SDK includes a built-in **observer interface** and an **OpenTelemetry observer** for tracing agent sessions. Span attributes follow the Langfuse and OpenInference conventions, plus a subset of the OTel `gen_ai.*` attributes. Any OTel backend (Jaeger, Datadog, ...) can store and display the spans; one that interprets attributes by the OTel GenAI semantic conventions reads only part of them, as [Span Attributes](#span-attributes) explains. ## Distributed Trace Context (W3C) @@ -112,13 +112,53 @@ See [docs/rails.md](rails.md) for the Rails-specific pattern. ## Span Attributes -The OTel observer sets attributes using both `gen_ai.*` (OTel GenAI) and OpenInference conventions for maximum backend compatibility: - -| Span | Type | Key Attributes | -|------|------|----------------| -| `claude_agent.session` | `agent` | `gen_ai.system`, `gen_ai.request.model`, `session.id`, `input.value`, `output.value`, `gen_ai.usage.cost`, `llm.cost.total`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens`, `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write` | -| `claude_agent.generation` | `generation` | `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens`, `output.value` | -| `claude_agent.tool.*` | `tool` | `tool.name`, `input.value`, `output.value` | +Attribute names follow the Langfuse and OpenInference conventions, plus a subset of the OTel `gen_ai.*` attributes. The tables below list every attribute the observer sets. An attribute whose value the CLI did not report is left out, and `input.value`, `output.value` and `gen_ai.completion` are cut at 4,096 characters. + +**`claude_agent.session`** + +| Attribute | Value | +|-----------|-------| +| `gen_ai.system` | `anthropic` | +| `gen_ai.request.model`, `llm.model_name` | The model named by the `InitMessage` | +| `session.id` | The CLI session ID | +| `openinference.span.kind` | `AGENT` | +| `langfuse.observation.type` | `agent` | +| `input.mime_type`, `output.mime_type` | `text/plain` | +| `claude_code.version`, `claude_code.cwd`, `claude_code.permission_mode` | From the `InitMessage` | +| Your default attributes | Whatever you passed to `OTelObserver.new`. They are set on this span only | +| `input.value` | The prompt of the trace | +| `output.value` | `ResultMessage#result`, or the last assistant text when the result has none | +| `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens` | The four counts of `ResultMessage#usage` as the API reports them, so `input_tokens` leaves cache tokens out | +| `llm.token_count.prompt` | Input, cache-creation and cache-read tokens added up | +| `llm.token_count.completion` | Output tokens | +| `llm.token_count.total` | `llm.token_count.prompt` plus `llm.token_count.completion` | +| `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write` | Cache-read and cache-creation tokens | +| `gen_ai.usage.cost`, `llm.cost.total` | The cost increase since the previous result (see below) | +| `claude_agent.duration_ms`, `claude_agent.duration_api_ms`, `claude_agent.num_turns`, `claude_agent.stop_reason` | From the `ResultMessage` | + +The span status is error when the `ResultMessage` has `is_error` (the description is its stop reason) or when `on_error` recorded an exception, which also adds an `exception` event. Three more events are recorded on this span: `api_retry` (`attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error`), `rate_limit` (`status`, `rate_limit_type`) and `tool_progress` (`tool_name`, `tool_use_id`, `elapsed_time_seconds`). + +**`claude_agent.generation`** + +| Attribute | Value | +|-----------|-------| +| `openinference.span.kind` | `LLM` | +| `langfuse.observation.type` | `generation` | +| `gen_ai.response.model`, `llm.model_name` | The model named by the `AssistantMessage` | +| `gen_ai.completion`, `output.value` | The text blocks of the message joined by newlines; an empty string when it has none (a thinking or tool-call message) | +| `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens` | The usage of the API response, on the first span of each `message_id` only (see below) | + +**`claude_agent.tool.`** + +| Attribute | Value | +|-----------|-------| +| `openinference.span.kind` | `TOOL` | +| `langfuse.observation.type` | `tool` | +| `tool.name` | The name of the tool | +| `input.value`, `input.mime_type` | The tool input as JSON, and `application/json` | +| `output.value`, `output.mime_type` | The tool result: a String as it is (`text/plain`), structured content as JSON (`application/json`). Both are left out when the result has no content | + +The span status is error when the tool result has `is_error`. **Token usage on generation spans.** The CLI sends one `AssistantMessage` per content block, so an API response with a thinking block and a tool call produces two `claude_agent.generation` spans, and both messages repeat the response's `message_id` and `usage`. The observer sets the four `gen_ai.usage.*` token attributes on the first generation span of each `message_id` and on none of that response's other spans, so a sum over generation spans counts every response once. A message without a `message_id` keeps its usage. That usage is the snapshot taken when the response started: the input, cache-read and cache-creation counts are final, but `gen_ai.usage.output_tokens` holds only the few tokens generated by then, and the response's final output count never reaches the message stream. For authoritative totals, output tokens included, read the session span, which takes them from `ResultMessage#usage`. @@ -126,7 +166,13 @@ The OTel observer sets attributes using both `gen_ai.*` (OTel GenAI) and OpenInf Missing costs omit both attributes without discarding the last known total; the next reported increment can therefore include an unreported or interrupted turn. The first observation uses the CLI's reported total. If a newer CLI restores historical spend on resume, that first span also includes it: a fresh observer cannot separate spend it never observed. These are CLI cost estimates, not billing records. -Events (`api_retry`, `rate_limit`, `tool_progress`) are recorded on the root span. +**How this compares with the OTel GenAI semantic conventions.** Nine of the attributes above are `gen_ai.*` names. Three are used as the conventions define them: `gen_ai.request.model`, `gen_ai.response.model`, and `gen_ai.usage.output_tokens` on the session span. The other six are deprecated, removed, not defined or defined differently there, and the observer keeps them for the Langfuse mappings they were added for: + +- `gen_ai.system` is deprecated in favor of `gen_ai.provider.name`, and `gen_ai.completion` has been removed. +- `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens` and `gen_ai.usage.cost` are not names the conventions define. The two cache names are the Anthropic API's field names. +- `gen_ai.usage.input_tokens` is the API's `input_tokens`, which leaves cache tokens out, while the conventions say the value should include them. A backend that follows the conventions therefore sees only part of the input of a cached turn: 18 tokens for a recorded turn that consumed 49,974. The inclusive count is `llm.token_count.prompt` on the session span. + +The observer sets neither `gen_ai.operation.name` nor `gen_ai.provider.name`. This comparison was made against `opentelemetry-semantic_conventions` 1.43.0; the GenAI conventions are still marked as in development. The `langfuse.observation.type` attribute is set on each span (`agent`/`generation`/`tool`) to enable Langfuse's **trace flow diagram** (DAG graph visualization). diff --git a/lib/claude_agent_sdk/instrumentation/otel.rb b/lib/claude_agent_sdk/instrumentation/otel.rb index 88622027..f55da16b 100644 --- a/lib/claude_agent_sdk/instrumentation/otel.rb +++ b/lib/claude_agent_sdk/instrumentation/otel.rb @@ -7,8 +7,10 @@ module ClaudeAgentSDK module Instrumentation # OpenTelemetry observer that emits spans for Claude Agent SDK messages. # - # Uses standard gen_ai.* semantic conventions recognized by Langfuse, Datadog, - # Jaeger, and other OTel-compatible backends. + # Span attributes follow the Langfuse and OpenInference conventions, plus a + # subset of the OTel gen_ai.* attributes. docs/observability.md lists every + # attribute and says where they differ from the OTel GenAI semantic + # conventions. # # Requires the `opentelemetry-api` gem at runtime. Users must configure # `opentelemetry-sdk` and an exporter (e.g., `opentelemetry-exporter-otlp`) From 13b7302d024e4300074bc97ada96e8dbaded34f1 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:00:42 +0800 Subject: [PATCH 022/194] Keep Type#inspect from raising on values it cannot render `ToolUseBlock.new(input: BasicObject.new).inspect` raised `NoMethodError: undefined method 'nil?'`: the nil check that skips unset attributes sent `nil?` to the raw value before the guarded rendering was reached. A String subclass whose #length raises went through the truncation unguarded and raised too, as did a Hash or Array subclass with a raising #size / #first / #empty?, a BasicObject Hash key, and a BasicObject in a filtered attribute (`env`, `settings`, `url`). #inspect runs inside loggers, `puts` and exception messages, and its own comment says printing must never raise. Unset attributes are now detected with `nil.equal?(value)`, and the bounded rendering of every value (and of the inspected object itself) rescues StandardError: the value shows as `#`, truncated like any other text, or `#` when it cannot be asked for its class, and the rest of the object still prints. A filtered attribute that fails while it is filtered prints as "[FILTERED]", never as itself. Interrupt, SystemExit and other non-StandardError exceptions pass through. Exploration finding: C-P14 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP --- lib/claude_agent_sdk/types/base.rb | 44 +++++-- spec/unit/type_inspect_robustness_spec.rb | 135 ++++++++++++++++++++++ 2 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 spec/unit/type_inspect_robustness_spec.rb diff --git a/lib/claude_agent_sdk/types/base.rb b/lib/claude_agent_sdk/types/base.rb index 5c77c502..0ffebc40 100644 --- a/lib/claude_agent_sdk/types/base.rb +++ b/lib/claude_agent_sdk/types/base.rb @@ -122,8 +122,11 @@ def self.option_hash_key(key, hash) # dumps every ivar recursively — those (SDK MCP server instances, store # adapters, observers) show as `#`. For display only: nothing # sent to the CLI goes through #inspect or #to_s (wire output uses #to_h). + # + # It does not raise: a value that fails while it is rendered shows as + # `#` (`#` when even its class cannot be asked). def inspect - inspect_with(0, {}.compare_by_identity) + inspect_bounded(self, 0, {}.compare_by_identity) end # Object#to_s ignores instance variables, so `puts message` would print @@ -183,7 +186,7 @@ def inspect_attributes filtered = self.class.inspect_filtered_attributes instance_variables.filter_map do |ivar| value = instance_variable_get(ivar) - next if value.nil? + next if nil.equal?(value) # not value.nil?: a BasicObject has no such method name = ivar.to_s.delete_prefix('@') [name, filtered.include?(name) ? inspect_filter(value) : value] @@ -192,9 +195,12 @@ def inspect_attributes # A credential-bearing Hash keeps its keys (useful when debugging which # variables are set) with every value replaced; anything else is replaced - # outright. Builds a new Hash; the object itself is never touched. + # outright, and so is a value that fails while it is asked for its keys. + # Builds a new Hash; the object itself is never touched. def inspect_filter(value) value.respond_to?(:each_key) ? value.each_key.to_h { |key| [key, '[FILTERED]'] } : '[FILTERED]' + rescue StandardError + '[FILTERED]' end INSPECT_URL_ORIGIN = %r{ @@ -222,6 +228,12 @@ def inspect_class_name self.class.name || self.class.inspect end + # Printing must never raise (it runs inside loggers and `puts`). The + # value's own methods run here (a String subclass's #length, a Hash + # subclass's #size, a Type subclass's #inspect_attributes), so a value + # that fails while it is rendered becomes a placeholder and the rest of + # the object still prints. Only StandardError is rescued: Interrupt, + # SystemExit and the like pass through. def inspect_bounded(value, depth, seen) case value when Type then value.inspect_with(depth, seen) @@ -234,6 +246,16 @@ def inspect_bounded(value, depth, seen) when Proc, Method, UnboundMethod then inspect_callable(value) else inspect_leaf(value) end + rescue StandardError + inspect_placeholder(value) + end + + # `#` for a value that could not be rendered; a BasicObject + # cannot even be asked for its class. + def inspect_placeholder(value) + inspect_truncated_text("#<#{value.class}>") + rescue StandardError + '#' end def inspect_container(value, open, close, depth, seen, &) @@ -253,7 +275,10 @@ def inspect_container(value, open, close, depth, seen, &) # Rendered by hand rather than via Hash#inspect, whose format differs # between Ruby 3.3 (`{:a=>1}`) and 3.4 (`{a: 1}`). def inspect_hash_key(key, depth, seen) - return "#{key.name}: " if key.is_a?(Symbol) && key.inspect.match?(/\A:\w+[?!]?\z/) + case key # not key.is_a?: a BasicObject key has no such method + when Symbol + return "#{key.name}: " if key.inspect.match?(/\A:\w+[?!]?\z/) + end "#{inspect_bounded(key, depth, seen)} => " end @@ -287,19 +312,14 @@ def inspect_truncated_text(rendered) "#{rendered[0, INSPECT_MAX_STRING]}…(+#{rendered.length - INSPECT_MAX_STRING} chars)" end - # Printing must never raise (it runs inside loggers and `puts`), so an - # object whose #inspect raises, or a BasicObject without one, falls back - # to a placeholder. + # An object whose #inspect raises, or a BasicObject without one, falls + # back to a placeholder. def inspect_leaf(value) return "#<#{value.class}>" if kernel_inspect_only?(value) inspect_truncated_text(value.inspect) rescue StandardError - begin - "#<#{value.class}>" - rescue StandardError - '#' - end + inspect_placeholder(value) end def kernel_inspect_only?(value) diff --git a/spec/unit/type_inspect_robustness_spec.rb b/spec/unit/type_inspect_robustness_spec.rb new file mode 100644 index 00000000..1a8d1d54 --- /dev/null +++ b/spec/unit/type_inspect_robustness_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Type#inspect runs inside loggers, `puts` and exception messages, so it must +# not raise, whatever an attribute holds. A value that fails while it is +# rendered shows as a placeholder and the rest of the object still prints. +RSpec.describe 'Type#inspect on a value it cannot render', + rbs_incompatible: 'plants values outside the attribute types' do + def hostile(name, base, &body) + stub_const(name, Class.new(base, &body)) + end + + describe 'an attribute that is a BasicObject' do + it 'renders a placeholder next to the other attributes' do + block = ClaudeAgentSDK::ToolUseBlock.new(id: 'toolu_1', input: BasicObject.new) + + expect(block.inspect).to eq('#>') + expect(block.to_s).to eq(block.inspect) + end + + # A BasicObject has no #hash, so only an identity Hash can be keyed by one. + it 'renders a BasicObject Hash key and keeps the rest of the Hash' do + input = {}.compare_by_identity + input[BasicObject.new] = 1 + input[:path] = '/tmp' + block = ClaudeAgentSDK::ToolUseBlock.new(input: input) + + expect(block.inspect).to eq('# => 1, path: "/tmp"}>') + end + + it 'prints a filtered attribute as filtered' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + options.env = BasicObject.new + options.settings = BasicObject.new + config = ClaudeAgentSDK::McpHttpServerConfig.new + config.url = BasicObject.new + + expect(options.inspect).to include(' env="[FILTERED]"').and include(' settings="[FILTERED]"') + expect(config.inspect).to eq('#') + end + + it 'prints mcp_servers as filtered when a server config cannot be read' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + oddly_keyed = {}.compare_by_identity + oddly_keyed[BasicObject.new] = 'x' + rendered = [BasicObject.new, { api: BasicObject.new }, { api: oddly_keyed }].map do |servers| + options.mcp_servers = servers + options.inspect[/ mcp_servers=\S+/] + end + + expect(rendered).to all(eq(' mcp_servers="[FILTERED]"')) + end + end + + describe 'a String whose own methods raise' do + it 'renders a placeholder when #length raises' do + hostile('LengthlessString', String) { def length = raise('len') } + block = ClaudeAgentSDK::TextBlock.new(text: LengthlessString.new('x')) + + expect(block.inspect).to eq('#>') + end + + it 'renders a placeholder when #inspect or #[] raises' do + hostile('UninspectableString', String) { def inspect = raise('inspect') } + hostile('UnsliceableString', String) { def [](*) = raise('slice') } + message = ClaudeAgentSDK::ResultMessage.new( + subtype: UninspectableString.new('success'), result: UnsliceableString.new('a' * 200), num_turns: 3 + ) + + expect(message.inspect).to eq('# ' \ + 'result=# num_turns=3>') + end + + it 'renders a placeholder when #length answers something that is not a number' do + hostile('OddString', String) { def length = nil } + + expect(ClaudeAgentSDK::TextBlock.new(text: OddString.new('x')).inspect) + .to eq('#>') + end + + it 'replaces only that value inside a container' do + hostile('LengthlessString', String) { def length = raise('len') } + block = ClaudeAgentSDK::ToolUseBlock.new(input: { command: LengthlessString.new('ls'), cwd: '/tmp' }) + + expect(block.inspect).to eq('#, cwd: "/tmp"}>') + end + end + + describe 'a container whose own methods raise' do + it 'renders a placeholder for the container' do + hostile('SizelessHash', Hash) { def size = raise('size') } + hostile('BottomlessArray', Array) { def first(*) = raise('first') } + hostile('EmptinessUnknownArray', Array) { def empty? = raise('empty?') } + message = ClaudeAgentSDK::AssistantMessage.new( + content: BottomlessArray.new([1, 2]), usage: SizelessHash[(1..6).to_h { |i| [:"k#{i}", i] }], + model: 'claude-opus-5', error: EmptinessUnknownArray.new + ) + + expect(message.inspect).to eq('# ' \ + 'usage=# model="claude-opus-5" error=#>') + end + end + + describe 'a nested Type that cannot list its attributes' do + it 'renders a placeholder for it, also when it is the object being inspected' do + hostile('BrokenBlock', ClaudeAgentSDK::TextBlock) do + private + + def inspect_attributes = raise('attributes') + end + broken = BrokenBlock.new(text: 'x') + message = ClaudeAgentSDK::AssistantMessage.new(content: [ClaudeAgentSDK::TextBlock.new(text: 'ok'), broken]) + + expect(message.inspect) + .to eq('#, #]>') + expect(broken.inspect).to eq('#') + end + end + + it 'keeps the placeholder bounded' do + long_named = hostile("Lengthless#{'Very' * 40}LongName", String) { def length = raise('len') } + block = ClaudeAgentSDK::TextBlock.new(text: long_named.new('x')) + + expect(block.inspect).to eq("#") + end + + it 'lets process-control exceptions through' do + hostile('InterruptingString', String) { def length = raise(Interrupt) } + hostile('ExitingString', String) { def length = exit(3) } + + expect { ClaudeAgentSDK::TextBlock.new(text: InterruptingString.new('x')).inspect }.to raise_error(Interrupt) + expect { ClaudeAgentSDK::TextBlock.new(text: ExitingString.new('x')).inspect }.to raise_error(SystemExit) + end +end From f408355aa7e3c5baa9e372795897021d411223fc Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:00:53 +0800 Subject: [PATCH 023/194] Test CLIInstaller::Http's HTTPS-only rule, redirects and failures Nothing executed Http.with_response and Http.follow_redirect: the installer specs replace Http.fetch_text / Http.download_to wholesale. Deleting the "Refusing to fetch non-HTTPS URL" check, or letting the redirect budget never decrease, left the whole suite green. The Http spec file now covers them with canned responses behind a stubbed Net::HTTP.start (stdlib only; the responses are real Net::HTTPResponse objects parsed from raw bytes): - an http:// URL is refused before any connection is opened; - a redirect from https to http is refused, not followed; - five redirects are followed, the sixth raises "Too many redirects" after exactly six requests; - a relative Location is resolved against the URL that sent it, and a redirect without Location raises; - a 404 raises with its status line, and an error page never creates the target file; a redirect body is never streamed into it; - a SocketError is wrapped in CLIInstallError with cause, while a CLIInstallError from the body handler passes through unwrapped; - every hop is opened with use_ssl: true and the timeouts, and with no option that weakens peer verification. That option, not the scheme check, is what keeps the transfer encrypted: with the check removed, Net::HTTP still speaks TLS to an http:// URL's host. No production change. Each of these mutants now fails the file: the scheme check deleted, the budget not decremented, use_ssl dropped, the status tested after the body is handed over, the missing-Location check deleted, socket errors left unwrapped. Exploration finding: F-P6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- spec/unit/cli_installer_http_spec.rb | 148 +++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/spec/unit/cli_installer_http_spec.rb b/spec/unit/cli_installer_http_spec.rb index 6840a2a5..407cf2d2 100644 --- a/spec/unit/cli_installer_http_spec.rb +++ b/spec/unit/cli_installer_http_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' require 'net/http' require 'stringio' +require 'tmpdir' # CLIInstaller::Http itself. The other installer specs replace # Http.fetch_text / Http.download_to wholesale; here the only seam is @@ -142,4 +143,151 @@ def proxy_used_for(url) end end end + + # With no proxy variable set, find_proxy answers before resolving anything, + # so these can name the real release host. + let(:release_url) { 'https://downloads.claude.ai/claude-code-releases/2.1.285/darwin-arm64/claude' } + + def redirect_to(location, body = '') + raw_response('302 Found', { 'Location' => location }, body) + end + + describe 'the HTTPS-only rule' do + it 'refuses an http:// URL before opening any connection' do + expect(Net::HTTP).not_to receive(:start) + + expect { described_class.fetch_text('http://downloads.claude.ai/claude-code-releases/stable', limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, + 'Refusing to fetch non-HTTPS URL: http://downloads.claude.ai/claude-code-releases/stable') + end + + it 'refuses a redirect from https to http without following it' do + stub_connections(redirect_to('http://mirror.example/claude'), raw_response('200 OK', {}, 'plaintext')) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, 'Refusing to fetch non-HTTPS URL: http://mirror.example/claude') + expect(connections.map { |c| c[:address] }).to eq(['downloads.claude.ai']) + end + + it 'opens every connection with TLS and peer verification, on the first hop and after a redirect' do + # use_ssl: true is what puts TLS on the wire: Net::HTTP would speak TLS + # to the host of an http:// URL too. The scheme check above keeps + # HTTP-shaped URLs out; it is not what encrypts the transfer. + stub_connections(redirect_to('https://cdn.example/claude'), raw_response('200 OK', {}, 'binary')) + + described_class.fetch_text(release_url, limit: 1024) + + expect(connections.map { |c| [c[:address], c[:port]] }).to eq([['downloads.claude.ai', 443], ['cdn.example', 443]]) + expect(connections.map { |c| c[:options] }).to all( + eq(use_ssl: true, open_timeout: described_class::OPEN_TIMEOUT_SECONDS, + read_timeout: described_class::READ_TIMEOUT_SECONDS) + ) + end + end + + describe 'redirects' do + it 'sends a GET for the URL and returns what the block makes of the response' do + stub_connections(raw_response('200 OK', { 'Content-Type' => 'text/plain' }, "2.1.285\n")) + + expect(described_class.fetch_text('https://downloads.claude.ai/claude-code-releases/stable', limit: 1024)) + .to eq("2.1.285\n") + request = connections.first[:request] + expect(request).to be_a(Net::HTTP::Get) + expect(request.path).to eq('/claude-code-releases/stable') + expect(request['host']).to eq('downloads.claude.ai') + end + + it 'follows up to five redirects' do + hops = (1..5).map { |n| redirect_to("https://hop#{n}.example/claude") } + stub_connections(*hops, raw_response('200 OK', {}, 'binary')) + + expect(described_class.fetch_text(release_url, limit: 1024)).to eq('binary') + expect(connections.map { |c| c[:address] }) + .to eq(%w[downloads.claude.ai hop1.example hop2.example hop3.example hop4.example hop5.example]) + end + + it 'raises "Too many redirects" on the sixth, after exactly six requests' do + hops = (1..8).map { |n| redirect_to("https://hop#{n}.example/claude") } + stub_connections(*hops) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, + 'Too many redirects while fetching https://hop5.example/claude') + expect(connections.size).to eq(6) + end + + it 'resolves a relative Location against the URL that sent it' do + stub_connections(redirect_to('/mirror/2.1.285/claude'), raw_response('200 OK', {}, 'binary')) + + described_class.fetch_text(release_url, limit: 1024) + + expect(connections.last[:address]).to eq('downloads.claude.ai') + expect(connections.last[:request].path).to eq('/mirror/2.1.285/claude') + end + + it 'raises on a redirect without a Location header' do + stub_connections(raw_response('302 Found')) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "Redirect from #{release_url} is missing a Location header") + end + end + + describe 'failures' do + it 'raises with the status line of a 404' do + stub_connections(raw_response('404 Not Found', { 'Content-Type' => 'application/xml' }, 'NoSuchKey')) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "HTTP 404 Not Found for #{release_url}") + end + + it 'wraps a SocketError in CLIInstallError, keeping it as the cause' do + allow(Net::HTTP).to receive(:start).and_raise( + SocketError, 'getaddrinfo: nodename nor servname provided, or not known' + ) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| + expect(error.message).to eq( + "Failed to fetch #{release_url}: SocketError: getaddrinfo: nodename nor servname provided, or not known" + ) + expect(error.cause).to be_a(SocketError) + } + end + + it 'lets a CLIInstallError from the body handler through unwrapped' do + stub_connections(raw_response('200 OK', {}, 'x' * 2048)) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "Response from #{release_url} exceeds the 1024-byte limit") + end + end + + describe 'what reaches the target file' do + around do |example| + Dir.mktmpdir('cli-installer-http') do |dir| + @target = File.join(dir, 'claude.download.0123456789abcdef') + example.run + end + end + + let(:target) { @target } + + it 'streams only the final 200 body, never a redirect body' do + chunked = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" \ + "6\r\nbinary\r\n7\r\n-bytes!\r\n0\r\n\r\n" + stub_connections(redirect_to('https://cdn.example/claude', 'Moved'), chunked) + + expect(described_class.download_to(release_url, target, max_bytes: 13)).to eq(target) + expect(File.binread(target)).to eq('binary-bytes!') + end + + it 'creates no file for an error page' do + stub_connections(raw_response('503 Service Unavailable', {}, 'Try again later')) + + expect { described_class.download_to(release_url, target) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "HTTP 503 Service Unavailable for #{release_url}") + expect(File.exist?(target)).to be false + end + end end From 9ef80da579b28e3b04053f7a4b1966201d1de045 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:01:03 +0800 Subject: [PATCH 024/194] Keep only projects/ when preserving a materialized resume dir When the transcript mirror dropped batches, teardown keeps the materialized config dir (it holds the only copy of the dropped turns) and removes the credential copies from it. That scrub was a deny-list of four names: .credentials.json, .claude.json, settings.json and cowork_settings.json. The CLI also writes into the directory. At startup it saves the seeded .claude.json, which can hold MCP header secrets, as backups/.claude.json.backup., and that copy stayed behind next to cache/ and sessions/. preserve_transcripts is now an allow-list: projects/ stays, every other entry of the temp dir is deleted, whatever its name. A symlink is removed, not followed. A user can notice: a preserved directory contains nothing but projects/. The warning that names it is unchanged. Exploration finding: B-P8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- docs/sessions.md | 7 +- lib/claude_agent_sdk/session_resume.rb | 17 +++-- spec/unit/materialized_resume_spec.rb | 92 ++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 spec/unit/materialized_resume_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index 8e738088..eb5e2455 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -287,8 +287,11 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > (terminal append failures — timeouts immediately, other failures after up to > three attempts — surfaced as `MirrorErrorMessage`): > the store copy is then incomplete and the temp dir holds the only copy of the -> dropped turns, so the SDK scrubs the credential copies, keeps the transcripts, -> and warns with the preserved path so you can import them into the store. +> dropped turns, so the SDK keeps the transcripts (`projects/`), deletes +> everything else in the temp dir — the seeded credential and settings copies +> and whatever the CLI wrote beside them, such as its `backups/` copy of +> `.claude.json` — and warns with the preserved path so you can import them +> into the store. ### Implementing an adapter diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 0ab02afe..5ed4f852 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -36,12 +36,19 @@ def cleanup # Teardown when the transcript mirror dropped batches: the CLI's # authoritative transcript lives in this temp dir, and the store copy is # missing the dropped turns — deleting the dir would permanently lose - # them. Keep the transcripts (projects/), remove the redacted credential - # copies, and tell the user where the data is so they can import it into - # the store manually. Never raises. + # them. Keep the transcripts (projects/), delete every other entry, and + # tell the user where the data is so they can import it into the store + # manually. Never raises. + # + # An allow-list on purpose. Besides the files the SDK seeds + # (.credentials.json, .claude.json, settings.json, cowork_settings.json) + # the CLI writes its own: at startup it saves the seeded .claude.json — + # which can hold MCP header secrets — as backups/.claude.json.backup.. + # A list of names to delete missed that one and would miss the next. def preserve_transcripts - ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name| - FileUtils.rm_f(File.join(@config_dir, name)) + entries = File.directory?(@config_dir) ? Dir.children(@config_dir) : [] + entries.each do |name| + FileUtils.rm_rf(File.join(@config_dir, name)) unless name == 'projects' end warn 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \ "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \ diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb new file mode 100644 index 00000000..958eebc0 --- /dev/null +++ b/spec/unit/materialized_resume_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' +require 'fileutils' + +RSpec.describe ClaudeAgentSDK::MaterializedResume do + # When the mirror dropped batches the materialized config dir is kept, since + # it holds the only copy of the dropped turns. It must then hold nothing but + # those transcripts. + describe '#preserve_transcripts' do + let(:config_dir) { Dir.mktmpdir('claude-resume-') } + let(:outside) { Dir.mktmpdir } + let(:sid) { '11111111-1111-4111-8111-111111111111' } + + # What the SDK seeds from the caller's .claude.json: it can carry MCP + # header secrets. + let(:claude_json) do + JSON.generate('oauthAccount' => { 'emailAddress' => 'user@example.invalid' }, + 'mcpServers' => { 'internal' => { 'type' => 'http', 'url' => 'https://mcp.example.invalid', + 'headers' => { 'Authorization' => 'Bearer MCP-HEADER-SECRET' } } }) + end + + # The four files the SDK seeds, then what a real CLI (2.1.286) left beside + # them after a store-backed resume — at startup it saves the seeded + # .claude.json as backups/.claude.json.backup. before rewriting + # it — and a file name nobody has heard of yet. + let(:secrets) do + { + '.credentials.json' => '{"claudeAiOauth":{"accessToken":"ACCESS-TOKEN"}}', + '.claude.json' => claude_json, + 'settings.json' => '{"env":{"INTERNAL_API_KEY":"SETTINGS-SECRET"}}', + 'cowork_settings.json' => '{"env":{"INTERNAL_API_KEY":"SETTINGS-SECRET"}}', + 'backups/.claude.json.backup.1790881328533' => claude_json, + 'cache/model-catalog/x-cc.json' => '{}', + 'written-by-a-later-cli.json' => claude_json + } + end + let(:transcripts) do + { + "projects/-proj/#{sid}.jsonl" => %({"type":"user","uuid":"u1","sessionId":"#{sid}"}\n), + "projects/-proj/#{sid}/subagents/agent-a1.jsonl" => %({"type":"user","uuid":"s1","isSidechain":true}\n), + "projects/-proj/#{sid}/subagents/agent-a1.meta.json" => '{"agentType":"worker"}' + } + end + + before do + secrets.merge(transcripts).each do |relative, content| + path = File.join(config_dir, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + Dir.mkdir(File.join(config_dir, 'sessions')) # the CLI leaves this one empty + File.write(File.join(outside, 'not-ours.txt'), 'keep me') + File.symlink(outside, File.join(config_dir, 'linked-elsewhere')) + end + + after do + [config_dir, outside].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + def preserve + described_class.new(config_dir: config_dir, resume_session_id: sid).preserve_transcripts + end + + def files_under(dir) + Dir.glob('**/*', File::FNM_DOTMATCH, base: dir).select { |relative| File.file?(File.join(dir, relative)) } + end + + it 'keeps projects/ and deletes every other entry, known or not' do + expect { preserve }.to output(/transcript mirror dropped batches.*#{Regexp.escape(File.join(config_dir, 'projects'))}/).to_stderr + + expect(Dir.children(config_dir)).to eq(['projects']) + expect(files_under(config_dir).to_h { |relative| [relative, File.read(File.join(config_dir, relative))] }) + .to eq(transcripts) + end + + it 'removes a symlink without following it' do + expect { preserve }.to output.to_stderr + + expect(File.symlink?(File.join(config_dir, 'linked-elsewhere'))).to be(false) + expect(File.read(File.join(outside, 'not-ours.txt'))).to eq('keep me') + end + + it 'still reports the incomplete store copy, without raising, when the directory is already gone' do + FileUtils.remove_entry(config_dir) + + expect { preserve }.to output(/transcript mirror dropped batches/).to_stderr + end + end +end From 9403b857fe0bd96ca67d6784b3c45ba5dcd6a480 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:01:09 +0800 Subject: [PATCH 025/194] Say which exceptions a direct call_tool lets propagate docs/mcp-servers.md said that SdkMcpServer#call_tool, called without a session, "simply lets such exceptions propagate". "Such" meant exit and signal exceptions, but the sentence now sits two lines below the one about NotImplementedError, LoadError, SystemStackError and SecurityError, which a direct call_tool reports as an isError result. Name the exceptions instead. Exploration finding: D-P1 (= A-P2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda --- docs/mcp-servers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index c2cb8d86..b01132b3 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -132,7 +132,7 @@ first gets an `isError` result naming the exception class (`"SystemExit: exit"`), so it is not left waiting on the tool call, and then the exception propagates as Ruby normally would (`exit` ends the process, Ctrl-C interrupts it). Called directly, without a session, -`SdkMcpServer#call_tool` simply lets such exceptions propagate. Cancellation of the tool call itself still propagates. +`SdkMcpServer#call_tool` simply lets `exit` and signal exceptions propagate. Cancellation of the tool call itself still propagates. ## Mixed Server Support From 150bd72e457916963232660b1d7497eac50bd51b Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:02:02 +0800 Subject: [PATCH 026/194] Document that constructor keyword signatures are not type-checked The 37 keyword constructors in sig/ (ClaudeAgentOptions, the option values, hook outputs, MCP server configs, permission types) are signed `(?name: T, ...) -> void | (Hash[Symbol | String, untyped]? attributes) -> void`. `Klass.new(typo: 1)` is itself a Symbol-keyed Hash, so it always matches the second overload: Steep reports neither a misspelled keyword nor a wrong value type, and the runtime checker behind `rake rbs:test` never reports a constructor mismatch (it only checks the attribute setters the constructor calls). Say so where a signature author and a typed consumer will read it: the comment above the value types in option_values.rbs and CONTRIBUTING's "RBS signatures" section. The keyword overload documents the accepted keys; the enforcement is the runtime ArgumentError for an unknown key. No signature changes. Exploration finding: C-P8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP --- CONTRIBUTING.md | 1 + sig/claude_agent_sdk/types/option_values.rbs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53144e54..6f71e7d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,6 +88,7 @@ The gem ships [RBS](https://github.com/ruby/rbs) signatures for exactly that pub - **Duck types are interfaces.** Transports (`_Transport`), session stores (`_SessionStore`, with the optional methods listed in its comment), and every user callback (`_CanUseTool`, `_HookCallback`, `_ToolHandler`, `_CallbackWrapper`, ...) are interfaces, so any object with the right methods fits, including a `Method` or a custom class. A proc type (`^(...) -> ...`) would accept only a `Proc`. - **Follow the Hash-key rule** in [docs/types.md](docs/types.md#hash-keys): use `wire_hash` (`Hash[Symbol, untyped]`) for a Hash passed through from the CLI stream and `transcript_hash` (`Hash[String, untyped]`) for transcript and store data. - **Type attributes are nilable.** A `Type` can be built empty, and the SDK parses CLI output leniently, so an attribute reads `nil` whenever the CLI did not send it. +- **Constructor keywords are documented, not checked.** `ClaudeAgentOptions` and the value types sign their constructor with two overloads: `(?name: T, ...) -> void | (Hash[Symbol | String, untyped]? attributes) -> void`. The keyword overload documents the accepted keys and their types, but no type checker enforces it: `Klass.new(typo: 1)` is also a Symbol-keyed Hash, which the second overload accepts whatever its keys and values. Steep reports neither a misspelled keyword nor a wrong value type, and `rbs:test` never reports a constructor mismatch (it only checks the attribute setters the constructor calls). The enforcement is the `ArgumentError` the constructor raises at runtime for an unknown key. Keep the keyword overload in step with the attributes all the same: it is what a reader and an editor's completion see. - **Use `void` for a return value the docs don't promise.** It keeps the return value out of the contract, and the runtime checker skips it. - **Deprecated methods keep their signature** until they are removed, marked with a `# @deprecated` comment. diff --git a/sig/claude_agent_sdk/types/option_values.rbs b/sig/claude_agent_sdk/types/option_values.rbs index 397618b7..dbbd277c 100644 --- a/sig/claude_agent_sdk/types/option_values.rbs +++ b/sig/claude_agent_sdk/types/option_values.rbs @@ -7,6 +7,16 @@ module ClaudeAgentSDK # the Hash form (String or camelCase keys, e.g. `klass.new(value.to_h)`) # is accepted too. An unknown key raises ArgumentError. Discriminators # (`type`) are fixed by the class and read-only. + # + # The keyword overload documents the accepted keys and their types; no + # type checker enforces it. `klass.new(typo: 1)` is also a Symbol-keyed + # Hash, which the second overload accepts whatever its keys and values, so + # Steep reports neither a misspelled keyword nor a wrong value type, and + # the runtime checker behind `rake rbs:test` never reports a constructor + # mismatch (it only checks the attribute setters the constructor calls). + # The enforcement is the ArgumentError the constructor raises at runtime + # for an unknown key. This holds for every constructor in sig/ that has + # both overloads. # Adaptive thinking: the model decides when and how much to think. class ThinkingConfigAdaptive < Type From 8e6cde6bfdf34a4a6672fd1abc5e4a31d69a235e Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:02:15 +0800 Subject: [PATCH 027/194] Read a conversation that ends on an unanswered meta entry get_session_messages (disk and session_store:) returned [] for a whole conversation whose last entry was a meta injection nobody answered: the body of a slash command or skill, a stop-hook message, a system reminder, written just before the user closed the session. The reader only accepted a leaf that was not sidechain, team or meta and gave up when there was none. The session was still listed, with its summary. Without a main leaf the reader now falls back to the other leaf candidates, as the Python SDK does; the visibility filter drops the flagged entries of the chain as before. Among those candidates a leaf whose path to the root passes a visible message is preferred, then file position: taking simply the latest one picks a later sidechain or teammate leaf when there is one and still reads the conversation as empty. Transcripts that are sidechain or teammate entries throughout keep returning []. Exploration finding: B-P3 (= U-P7) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 63 ++++++++++++--- spec/fixtures/cli_transcript.rb | 19 +++++ spec/unit/session_meta_leaf_spec.rb | 114 ++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 spec/unit/session_meta_leaf_spec.rb diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 9d1ca814..b9fbff38 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1605,21 +1605,66 @@ def build_conversation_chain(entries) walk_to_leaf(by_uuid, uuid) end - # Keep only main-chain candidates (not sidechain, team, or meta) - main_leaves = leaf_candidates.reject { |e| off_main_conversation?(e) } - return [] if main_leaves.empty? + best_leaf = pick_leaf(leaf_candidates, by_uuid, by_position) + return [] unless best_leaf - # Pick the leaf with highest file position, walk to root - best_leaf = main_leaves.max_by { |e| by_position[e['uuid']] || 0 } reattach_parallel_tool_results(walk_to_root(by_uuid, best_leaf), entries, skip_flagged: true) end + # The leaf a conversation is read back from: the main-chain candidate + # (not sidechain, team or meta) with the highest file position. + # + # Without one, fall back to the other candidates instead of reading the + # conversation as empty, as Python does (`_pick_best(main_leaves) if + # main_leaves else _pick_best(leaves)`): a session can end on a meta + # entry nobody answered (a slash-command or skill body, a stop-hook + # message, a system reminder — the user closed the session first), and + # filter_visible_messages drops the flagged entries of the chain anyway. + # Among those candidates one whose path to the root passes a visible + # message comes first, then file position: the latest of them may be a + # sidechain or teammate leaf with nothing visible above it, and taking + # it would still read the conversation as empty. + def pick_leaf(candidates, by_uuid, by_position) + latest = ->(leaves) { leaves.max_by { |e| by_position[e['uuid']] || 0 } } + main_leaves = candidates.reject { |e| off_main_conversation?(e) } + return latest.call(main_leaves) unless main_leaves.empty? + + known = {} + with_visible = candidates.select { |e| visible_ancestor?(by_uuid, e, known) } + latest.call(with_visible.empty? ? candidates : with_visible) + end + + # Whether the path from +leaf+ to its root passes an entry + # filter_visible_messages returns. +known+ carries the answer for every + # uuid already walked, so all the candidates of one transcript cost one + # pass over it. + def visible_ancestor?(by_uuid, leaf, known) + walked = [] + current = leaf + found = false + while current && !known.key?(current['uuid']) + known[current['uuid']] = false # a parentUuid cycle ends here + walked << current['uuid'] + break if (found = visible_message?(current)) + + current = by_uuid[current['parentUuid']] + end + found ||= current ? known[current['uuid']] : false + walked.each { |uuid| known[uuid] = found } + found + end + # An entry that is not part of the user's own conversation: written by a # subagent (sidechain) or a teammate, or a meta injection. def off_main_conversation?(entry) entry['isSidechain'] || entry['teamName'] || entry['isMeta'] end + # A user/assistant entry of the user's own conversation. + def visible_message?(entry) + %w[user assistant].include?(entry['type']) && !off_main_conversation?(entry) + end + # Put the results of parallel tool calls back on a leaf-to-root chain. # # The CLI writes one assistant entry per tool_use block (chained through @@ -1730,10 +1775,7 @@ def walk_to_root(by_uuid, leaf) def filter_visible_messages(chain) chain.filter_map do |entry| - next unless %w[user assistant].include?(entry['type']) - next if entry['isMeta'] - next if entry['isSidechain'] - next if entry['teamName'] + next unless visible_message?(entry) # NOTE: isCompactSummary messages are intentionally included. They contain # the summarized content from compacted conversations and are the only @@ -1755,7 +1797,8 @@ def filter_visible_messages(chain) :find_session_file, :stat_candidate, :resolve_subagents_dir, :collect_agent_files, :parse_jsonl_entries, :utf8_transcript_line, :build_conversation_chain, :walk_to_leaf, :walk_to_root, - :off_main_conversation?, :reattach_parallel_tool_results, :off_chain_tool_results, + :pick_leaf, :visible_ancestor?, :off_main_conversation?, :visible_message?, + :reattach_parallel_tool_results, :off_chain_tool_results, :content_block_values, :filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts, :valid_agent_id?, :sidechain_head?, diff --git a/spec/fixtures/cli_transcript.rb b/spec/fixtures/cli_transcript.rb index 3fb93a96..ba4f3df2 100644 --- a/spec/fixtures/cli_transcript.rb +++ b/spec/fixtures/cli_transcript.rb @@ -72,6 +72,25 @@ def prompt(label, text, parent: nil, **extra) { 'permissionMode' => 'default' }.merge(extra.transform_keys(&:to_s))) end + # A meta injection: a user entry the CLI writes itself (the body of a slash + # command or skill, a stop-hook message, a system reminder). +content+ is a + # String or an Array of content blocks. + def meta(label, content, parent:, **extra) + add(label, parent, + { 'promptId' => id('prompt-id-turn'), 'type' => 'user', + 'message' => { 'role' => 'user', 'content' => content }, 'isMeta' => true }, + extra.transform_keys(&:to_s)) + end + + # A system entry, e.g. the stop-hook summary that closes a turn. + def system(label, parent:, subtype: 'stop_hook_summary', **extra) + add(label, parent, + { 'type' => 'system', 'subtype' => subtype, 'hookCount' => 1, + 'hookInfos' => [{ 'command' => 'true', 'durationMs' => 3 }], 'hookErrors' => [], + 'preventedContinuation' => false, 'stopReason' => '', 'hasOutput' => false, 'level' => 'suggestion' }, + extra.transform_keys(&:to_s)) + end + # One content block of an assistant API message (see the header). def assistant(label, block, parent:, message: 'msg_01', **extra) add(label, parent, diff --git a/spec/unit/session_meta_leaf_spec.rb b/spec/unit/session_meta_leaf_spec.rb new file mode 100644 index 00000000..b420efc7 --- /dev/null +++ b/spec/unit/session_meta_leaf_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# A session can end on an entry the CLI wrote itself: the body of a slash +# command or skill, a stop-hook message, a system reminder (isMeta: true), +# never answered because the user closed the session first. The conversation +# before it is still there to read. +RSpec.describe 'reading a session whose last entry is a meta injection' do + include_context 'with a Claude config dir' + + let(:session_id) { '9e4b7a61-0c2d-4f38-8b15-6a7c8d9e0f12' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + def transcript + @transcript ||= CLITranscript.new(session_id: session_id, cwd: cwd) + end + + def exchange + transcript.prompt(:prompt, 'What does a.rb define?') + transcript.assistant(:answer, transcript.text('It defines Foo.'), parent: :prompt) + transcript.system(:stop_hook, parent: :answer) + end + + def from_disk + transcript.write(transcript_path(session_id)) + transcript.labels(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd)) + end + + def from_store + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, transcript.store_entries) + transcript.labels( + ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + ) + end + + # prompt ─ answer ─ stop_hook ─ reminder (isMeta, the last entry) + context 'when the meta entry is the last entry of the file' do + before do + exchange + transcript.meta(:reminder, 'The task list is empty.', parent: :stop_hook) + end + + it('returns the conversation before it from disk') { expect(from_disk).to eq(%w[prompt answer]) } + it('returns the conversation before it from a store') { expect(from_store).to eq(%w[prompt answer]) } + end + + # prompt ─ answer ─ stop_hook ─ command ─ skill_body (isMeta) ─ attachment (the last entry) + context 'when an attachment hangs off the meta entry' do + before do + exchange + transcript.prompt(:command, '/simplify', parent: :stop_hook) + transcript.meta(:skill_body, [transcript.text('# Simplify: review the changed code')], parent: :command) + transcript.attachment(:listing, parent: :skill_body, type: 'skill_listing') + end + + it('returns the conversation before it from disk') { expect(from_disk).to eq(%w[prompt answer command]) } + it('returns the conversation before it from a store') { expect(from_store).to eq(%w[prompt answer command]) } + end + + # The same ending, plus a later branch with nothing to show: a subagent + # recorded in the main transcript (isSidechain: true), written after the + # meta entry. Its leaf is the latest in the file and its path to the root + # holds no visible message. + context 'when a sidechain branch was written after the meta entry' do + before do + exchange + transcript.meta(:reminder, 'The task list is empty.', parent: :stop_hook) + transcript.prompt(:side_prompt, 'Explore the repository', parent: nil, isSidechain: true) + transcript.assistant(:side_answer, transcript.text('Explored.'), parent: :side_prompt, isSidechain: true) + end + + it('reads the conversation, not the sidechain, from disk') { expect(from_disk).to eq(%w[prompt answer]) } + it('reads the conversation, not the sidechain, from a store') { expect(from_store).to eq(%w[prompt answer]) } + end + + # A corrupt transcript: the parentUuid links of the later branch form a cycle. + it 'reads the conversation when the other branch loops back on itself' do + exchange + transcript.meta(:reminder, 'The task list is empty.', parent: :stop_hook) + transcript.prompt(:loop_a, 'Explore the repository', parent: :loop_b, isSidechain: true) + transcript.assistant(:loop_b, transcript.text('Explored.'), parent: :loop_a, isSidechain: true) + transcript.attachment(:loop_tail, parent: :loop_b, isSidechain: true) + + expect(from_disk).to eq(%w[prompt answer]) + end + + it 'still returns nothing for a transcript written by a teammate throughout' do + teammate = { teamName: 'crew', agentName: 'reviewer' } + transcript.prompt(:prompt, 'Review the diff', **teammate) + transcript.assistant(:answer, transcript.text('Looks fine.'), parent: :prompt, **teammate) + + expect(from_disk).to eq([]) + end + + it 'still returns nothing for a transcript without any user or assistant entry' do + transcript.attachment(:environment, parent: nil, type: 'environment') + transcript.attachment(:model, parent: :environment, type: 'model') + + expect(from_disk).to eq([]) + end + + it 'prefers the answered branch when the conversation went on after a meta entry' do + exchange + transcript.meta(:reminder, 'The task list is empty.', parent: :stop_hook) + transcript.prompt(:follow_up, 'And b.rb?', parent: :reminder) + transcript.assistant(:second_answer, transcript.text('It defines Bar.'), parent: :follow_up, message: 'msg_02') + + expect(from_disk).to eq(%w[prompt answer follow_up second_answer]) + end +end From 7d8cb8181416063b39f8754e5bac302816101670 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:04:11 +0800 Subject: [PATCH 028/194] Let a caller's deadline through while an observer runs With observers configured, a deadline around Client#receive_response, #receive_messages, #query or #disconnect (task.with_timeout or Timeout.timeout inside a reactor) was lost when it expired while an observer callback was still running: nothing was raised, and the receive loop went on to hand the message, and the rest of the turn, to the caller's block. A slow observer was enough. notify_observers wrapped the whole FiberBoundary hop in `rescue StandardError, ScriptError`. The calling fiber waits in that hop, and a deadline is an exception raised into the waiting fiber, so it was caught as if the observer had failed. The observer's failure is now contained inside the hop, on the execution context the observer runs on, and whatever is raised into the waiting fiber propagates. The callback_wrapper is composed inside the same rescued body instead of being handed to FiberBoundary.invoke, so a wrapper that raises around an observer is still contained and messages are still delivered, as before. ScriptError stays in the list. Not covered: with callback_scheduling: :inline the observer runs on the calling fiber, so a deadline that lands while it is suspended is raised inside the observer's own frames and is still contained with the observer's own failures. Exploration finding: A-P7 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk.rb | 35 ++++- spec/unit/observer_containment_spec.rb | 182 +++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 spec/unit/observer_containment_spec.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 1eed0e3a..fcf91034 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -173,14 +173,39 @@ def self.extract_system_prompt_snapshot(system_prompt) # Each observer is invoked through FiberBoundary so that user code runs # on a plain thread (no Fiber scheduler) even when called from inside # the SDK's Async reactor — or in place when scheduling is :inline. + # + # What the observer raises, and what its callback_wrapper raises, is + # contained where it happens: inside the hop, on the execution context + # the observer runs on. The rescue must not sit around the hop, because + # the calling fiber WAITS there, and an exception raised into a waiting + # fiber is not the observer's — a caller's `task.with_timeout` / + # `Timeout.timeout` deadline is delivered exactly that way. Rescued out + # here, an expired deadline was swallowed as if the observer had failed, + # and the turn carried on. So the wrapper is composed inside the rescued + # body rather than handed to FiberBoundary.invoke, which would run it + # outside that rescue. + # + # Known residue with `scheduling: :inline` (and with no scheduler at all): + # the observer runs on the calling fiber itself, so a deadline that lands + # while the observer is suspended is raised inside the observer's own + # frames, cannot be told from the observer's own timeout, and is still + # swallowed here. # @api private def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil) observers.each do |obs| - FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) } - rescue StandardError, ScriptError - # ScriptError too: NotImplementedError < ScriptError (not - # StandardError), and a stubbed observer must never mask the original - # error being notified or abort connect/teardown cleanup. + FiberBoundary.invoke(scheduling: scheduling) do + invocation = proc { obs.send(method, *args) } + wrapper ? wrapper.call(invocation) : invocation.call + rescue StandardError, ScriptError + # ScriptError too: NotImplementedError < ScriptError (not + # StandardError), and a stubbed observer must never mask the original + # error being notified or abort connect/teardown cleanup. + nil + end + rescue ScriptError + # A ScriptError raised around the body rather than in it (the hop's own + # plumbing). No deadline is a ScriptError, so nothing a caller injects + # into the waiting fiber is caught here. nil end end diff --git a/spec/unit/observer_containment_spec.rb b/spec/unit/observer_containment_spec.rb new file mode 100644 index 00000000..b6bae146 --- /dev/null +++ b/spec/unit/observer_containment_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +# ClaudeAgentSDK.notify_observers contains what an observer (or the +# callback_wrapper around it) raises, so observers can never break a session. +# It must contain exactly that and nothing else: the calling fiber WAITS for +# the observer's thread hop, and an exception raised into a waiting fiber is +# not the observer's. A caller's deadline (`task.with_timeout`, +# `Timeout.timeout`) arrives precisely that way. +RSpec.describe ClaudeAgentSDK, '.notify_observers' do + # Queue-backed stand-in for the CLI: answers the handshake, answers every + # prompt with one assistant message and its result, and ends its stdout + # when stdin is closed. + let(:transport_class) do + Class.new do + attr_reader :frames_handled + + def initialize + @stdout = Thread::Queue.new + @frames_handled = 0 + end + + def connect; end + + def ready? + true + end + + def end_input + @stdout << :eof + end + + def close + @stdout << :eof + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + if frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + @stdout << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] } } + elsif frame[:type] == 'user' + @stdout << { type: 'assistant', parent_tool_use_id: nil, session_id: 'c0ffee00-0000-4000-8000-000000000002', + message: { id: 'msg_01', type: 'message', role: 'assistant', + model: 'claude-haiku-4-5-20251001', content: [{ type: 'text', text: 'Four.' }], + stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 3 } } } + @stdout << { type: 'result', subtype: 'success', is_error: false, duration_ms: 1500, + duration_api_ms: 1000, num_turns: 1, result: 'Four.', stop_reason: 'end_turn', + session_id: 'c0ffee00-0000-4000-8000-000000000002', total_cost_usd: 0.001234, + usage: { input_tokens: 10, output_tokens: 3 }, terminal_reason: 'completed' } + end + end + + def read_messages + while (frame = @stdout.pop) != :eof + yield frame + @frames_handled += 1 # the read loop is done with this frame + end + end + end + end + + let(:transport) { transport_class.new } + + # Client builds its transport itself: hand it the one the example holds. + def class_returning(instance) + Class.new { define_singleton_method(:new) { |*_args, **_kwargs| instance } } + end + + def observer_with(**handlers) + Class.new do + include ClaudeAgentSDK::Observer + + handlers.each { |name, body| define_method(name, &body) } + end.new + end + + describe "a caller's deadline while an observer is running" do + it 'is raised to the caller, and the turn does not carry on' do + release = Thread::Queue.new + slow_observer = observer_with(on_message: ->(_message) { release.pop }) # runs until the example lets it go + options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [slow_observer]) + events = Thread::Queue.new + + Async do |root| + client = ClaudeAgentSDK::Client.new(options: options, transport_class: class_returning(transport)) + client.connect + client.query('What is 2 + 2?') + # The whole turn is queued before the deadline starts (handshake + # response, assistant, result), so the receive below reaches the + # observer's hop without suspending: the deadline cannot expire + # anywhere but there, however slow the machine is. + root.with_timeout(10) { root.yield until transport.frames_handled == 3 } + + receiver = root.async do |task| + task.with_timeout(0.01) do + client.receive_response { |message| events << [:block_called, message.class] } + end + events << [:receive_returned] + rescue Async::TimeoutError + events << [:deadline_raised] + end + + expect(events.pop).to eq([:deadline_raised]) + receiver.wait + expect(events).to be_empty # the message block was never called + ensure + release.close # the observer's pop returns, its thread ends + client&.disconnect + end.wait + end + end + + describe 'what an observer or its wrapper raises' do + let(:message) { ClaudeAgentSDK::MessageParser.parse(sample_assistant_message) } + + def notify(observers, scheduling:, wrapper: nil) + Async do + described_class.notify_observers(observers, :on_message, message, scheduling: scheduling, wrapper: wrapper) + end.wait + end + + %i[thread inline].each do |scheduling| + context "with callback_scheduling: :#{scheduling}" do + it 'is contained for a StandardError and for a ScriptError, and later observers still run' do + notified = [] + observers = [ + observer_with(on_message: ->(_message) { raise 'observer crashed' }), + observer_with(on_message: ->(_message) { raise NotImplementedError, 'observer stub' }), + observer_with(on_message: ->(received) { notified << received }) + ] + + expect { notify(observers, scheduling: scheduling) }.not_to raise_error + expect(notified).to eq([message]) + end + + it 'is contained when the callback_wrapper itself fails, and later observers still run' do + notified = [] + calls = 0 + failing_once = lambda do |invocation| + calls += 1 + raise 'wrapper failed' if calls == 1 + + invocation.call + end + observers = Array.new(2) { observer_with(on_message: ->(received) { notified << received }) } + + expect { notify(observers, scheduling: scheduling, wrapper: failing_once) }.not_to raise_error + expect(notified).to eq([message]) # the first observer's wrapper failed, the second ran + end + end + end + + it 'still delivers every message when the wrapper fails around an observer during query()' do + calls = 0 + # query() wraps, in order: on_user_prompt, on_message (assistant), the + # block, on_message (result), the block, on_close. The second call is + # the observer's on_message for the first message. + fails_around_on_message = lambda do |invocation| + calls += 1 + raise 'wrapper failed around the observer' if calls == 2 + + invocation.call + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer_with], + callback_wrapper: fails_around_on_message) + seen = [] + + described_class.query(prompt: 'What is 2 + 2?', options: options, transport: transport) do |received| + seen << received.class + end + + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(calls).to eq(6) + end + end +end From 8df3e6ca18e9a534d82ed1b8d82094b45701f518 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:04:22 +0800 Subject: [PATCH 029/194] Name project dirs as the CLI does for paths with non-BMP characters For a project path containing a character outside the BMP (an emoji, a CJK Extension B ideograph), the SDK derived a different project directory name than the CLI. The CLI sanitizes with JavaScript's replace(/[^a-zA-Z0-9]/g, "-") without the `u` flag, i.e. per UTF-16 code unit, so such a character becomes two hyphens; the SDK replaced per code point and wrote one. For those paths every directory-scoped session API silently came back empty (list_sessions, get_session_info, get_session_messages, the subagent readers, rename/tag/delete/fork, import_session_to_store), and project_key_for_directory returned a key that did not match the one the transcript mirror writes under, so store-backed reads and resume could not find the mirrored session. sanitize_path now writes two hyphens for a code point above U+FFFF. Checked against the directory names CLI 2.1.286 creates (emoji, CJK Extension B, and a path over 200 characters). project_key_for_directory changes for affected paths only. Store entries written under the old key by import_session_to_store or by the store mutations are no longer addressed by it; they were already unreachable from the mirror's key. Exploration finding: U-P5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 13 +++- spec/unit/session_project_dir_name_spec.rb | 70 ++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 spec/unit/session_project_dir_name_spec.rb diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index b9fbff38..1c6f0982 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -139,9 +139,18 @@ def simple_hash(str) out.join end - # Sanitize a filesystem path to a project directory name + # Sanitize a filesystem path to a project directory name. + # + # The CLI does this with JavaScript's replace(/[^a-zA-Z0-9]/g, "-"), + # without the `u` flag: the replacement runs per UTF-16 code unit, so a + # character outside the BMP (an emoji, a CJK Extension B ideograph) is a + # surrogate pair and becomes TWO hyphens. One hyphen per code point named + # a directory the CLI never created — every directory-scoped session API + # came back empty for such a path, and the store key computed here did + # not match the one the transcript mirror derives from the CLI's own + # path. (The hash below already works on code units, see simple_hash.) def sanitize_path(name) - sanitized = name.gsub(SANITIZE_RE, '-') + sanitized = name.gsub(SANITIZE_RE) { |char| char.ord > 0xFFFF ? '--' : '-' } return sanitized if sanitized.length <= MAX_SANITIZED_LENGTH "#{sanitized[0, MAX_SANITIZED_LENGTH]}-#{simple_hash(name)}" diff --git a/spec/unit/session_project_dir_name_spec.rb b/spec/unit/session_project_dir_name_spec.rb new file mode 100644 index 00000000..791b991d --- /dev/null +++ b/spec/unit/session_project_dir_name_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The CLI names a project directory with JavaScript's +# cwd.replace(/[^a-zA-Z0-9]/g, "-") — no `u` flag, so the replacement runs per +# UTF-16 code unit and a character outside the BMP (an emoji, a CJK +# Extension B ideograph) becomes TWO hyphens. The expected names below are +# the ones CLI 2.1.286 created for these directories. +RSpec.describe 'project directory names for paths with non-BMP characters' do + rocket = [0x1F680].pack('U') # an emoji + ideograph = [0x20BB7].pack('U') # CJK Extension B + + describe 'Sessions.sanitize_path' do + { + 'an emoji' => ["/work/proj-#{rocket}-x", '-work-proj----x'], + 'a CJK Extension B ideograph' => ["/work/proj-#{ideograph}-x", '-work-proj----x'], + 'two of them in a row' => ["/work/#{rocket}#{ideograph}", '-work-----'], + 'BMP characters only' => ['/work/proj-日本語-x', '-work-proj-----x'] + }.each do |label, (path, name)| + it "names the directory as the CLI does for #{label}" do + expect(ClaudeAgentSDK::Sessions.sanitize_path(path)).to eq(name) + end + end + + it 'truncates a long name after the two hyphens and keeps the hash of the path' do + path = "/work/proj-#{rocket}-#{'a' * 190}" + + name = ClaudeAgentSDK::Sessions.sanitize_path(path) + + expect(name).to eq("-work-proj----#{'a' * 186}-#{ClaudeAgentSDK::Sessions.simple_hash(path)}") + expect(name.length).to eq(201 + ClaudeAgentSDK::Sessions.simple_hash(path).length) + end + end + + context 'with a transcript in the directory the CLI created' do + include_context 'with a Claude config dir' + + let(:session_id) { '4a5b6c7d-8e9f-4012-a345-b6c7d8e9f012' } + let(:directory) { File.join(cwd, "proj-#{rocket}-x").tap { |dir| FileUtils.mkdir_p(dir) } } + let(:cli_project_dir) do + File.join(config_dir, 'projects', "#{ClaudeAgentSDK::Sessions.sanitize_path(cwd)}-proj----x") + end + + before do + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.queue_operations('hi') + transcript.prompt(:prompt, 'hi') + transcript.assistant(:answer, transcript.text('Hello.'), parent: :prompt) + transcript.last_prompt('hi', leaf: :answer) + transcript.write(File.join(cli_project_dir, "#{session_id}.jsonl")) + end + + it 'derives the same name as the store key' do + expect(ClaudeAgentSDK.project_key_for_directory(directory)).to eq(File.basename(cli_project_dir)) + end + + it 'lists the session for the directory' do + expect(ClaudeAgentSDK.list_sessions(directory: directory).map(&:session_id)).to eq([session_id]) + end + + it 'reads the session for the directory' do + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: directory)&.summary).to eq('hi') + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: directory).length).to eq(2) + end + end +end From 4d93021447cb897444d92e793c2f3403c3d06fb0 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:04:23 +0800 Subject: [PATCH 030/194] Advertise Array and Hash shorthand tool parameters correctly A shorthand tool schema such as { order_id: Integer, tags: Array } told the model that tags is a string: Array, Hash, :array and :object all fell into the default branch of ruby_type_to_json_schema. In a real session the model then sent "fragile,gift" and the handler received a String where it had declared an Array; a model that did send an array had the call rejected by argument validation ("value at `/tags` is not a string"). Array / :array now map to { type: 'array' } and Hash / :object to { type: 'object' }, the same kind of fix 8e3419c made for Integer, Float and the booleans. tools/list and the tools/call validation both derive from normalize_tool_schema, so they agree: an array or object argument is accepted and reaches the handler, anything else is rejected in-band. Checked on mcp 0.22.0 and 1.6.1. Every other shorthand value keeps its present meaning: an unrecognized Symbol or class, an Array value such as [String] and a nested schema fragment are still advertised as a string, with no warning and no error (that policy is a separate decision). { type: :object } is still read as a prebuilt accept-any-object schema, as it always was; a parameter literally named type is declared with { type: Hash }. docs/mcp-servers.md had no description of the shorthand at all; it gains a short table of the accepted types. User-visible: a tool that declared Array or Hash and coped with the String it used to receive now receives an Array or a Hash. Exploration finding: D-P8 (= U-P18) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/mcp-servers.md | 21 +++++ lib/claude_agent_sdk/sdk_mcp_server.rb | 16 +++- spec/unit/sdk_mcp_tool_schema_spec.rb | 112 +++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 spec/unit/sdk_mcp_tool_schema_spec.rb diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 296cbb6b..8e7b0a36 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -58,6 +58,27 @@ end Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key. +## Shorthand Input Schemas + +`{ name: :string }` is shorthand for a JSON Schema object in which every listed parameter is required. Each value names a type, as a Ruby class or as a Symbol: + +| Shorthand | JSON Schema type | The handler receives | +| --- | --- | --- | +| `String`, `:string` | `string` | a String | +| `Integer`, `:integer` | `integer` | an Integer | +| `Float`, `:float`, `:number` | `number` | an Integer or a Float | +| `TrueClass`, `FalseClass`, `:boolean` | `boolean` | `true` or `false` | +| `Array`, `:array` | `array` | an Array, with elements of any type | +| `Hash`, `:object` | `object` | a Hash with Symbol keys | + +```ruby +ClaudeAgentSDK.create_tool('tag_order', 'Tag an order', { order_id: Integer, tags: Array }) do |args| + "Tagged order #{args[:order_id]} with #{args[:tags].join(', ')}" +end +``` + +Arguments are validated against these types before the handler runs. For an optional parameter, the type of an Array's elements, an enum or a per-parameter description, pass a full JSON Schema instead (next section). + ## Pre-built JSON Schemas If your schemas come from another library (e.g., [RubyLLM](https://github.com/crmne/ruby_llm)) that deep-stringifies keys, the SDK handles them transparently — both symbol-keyed and string-keyed schemas are accepted and normalized: diff --git a/lib/claude_agent_sdk/sdk_mcp_server.rb b/lib/claude_agent_sdk/sdk_mcp_server.rb index cdb099e3..9a474887 100644 --- a/lib/claude_agent_sdk/sdk_mcp_server.rb +++ b/lib/claude_agent_sdk/sdk_mcp_server.rb @@ -36,6 +36,9 @@ def self.deep_normalize_schema(obj) # mangled into nonsense parameter lists ("additionalProperties" as a # required string param). A $ref-only schema without type: 'object' remains # indistinguishable from a params hash — declare the type alongside $ref. + # For the same reason { type: :object } is this prebuilt accept-any-object + # schema even though :object is also a shorthand type: a simple schema with + # an object parameter literally named type has to spell it { type: Hash }. # @api private def self.prebuilt_json_schema?(schema) return false unless schema.is_a?(Hash) @@ -68,12 +71,16 @@ def self.normalize_tool_schema(schema) def self.ruby_type_to_json_schema(type) # Class#=== matches instances, not the class object used in { id: Integer }. type = { String => :string, Integer => :integer, Float => :float, - TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type) + TrueClass => :boolean, FalseClass => :boolean, + Array => :array, Hash => :object }.fetch(type, type) case type when :string, String then { type: 'string' } when :integer, Integer then { type: 'integer' } when :float, Float, :number then { type: 'number' } when :boolean, TrueClass, FalseClass then { type: 'boolean' } + # The class or the Symbol only. An Array or Hash VALUE ([String], a nested + # schema fragment) is not a shorthand and falls through like before. + when :array, :object then { type: type.to_s } else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit end end @@ -606,7 +613,12 @@ def convert_input_schema(schema) # # @param name [String] Unique identifier for the tool # @param description [String] Human-readable description - # @param input_schema [Hash] Schema defining input parameters + # @param input_schema [Hash] Schema defining input parameters: a full JSON + # Schema (+{ type: 'object', properties: ... }+), or the shorthand + # +{ name: type }+, in which every parameter is required and each type is + # +String+ / +:string+, +Integer+ / +:integer+, +Float+ / +:float+ / + # +:number+, +TrueClass+ / +FalseClass+ / +:boolean+, +Array+ / +:array+ + # or +Hash+ / +:object+ # @param handler [Proc] Block that implements the tool logic. It returns a # String, sent to Claude as a single text block, or a Hash with a # +:content+ Array of MCP content blocks plus optional +:is_error+ / diff --git a/spec/unit/sdk_mcp_tool_schema_spec.rb b/spec/unit/sdk_mcp_tool_schema_spec.rb new file mode 100644 index 00000000..ce0cb49e --- /dev/null +++ b/spec/unit/sdk_mcp_tool_schema_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# Shorthand tool schemas ({ name: type }) for collection parameters. +# `{ tags: Array }` used to be advertised as a string: the model then sent +# "fragile,gift" where the handler expected an Array, and a model that did +# send an array had its call rejected by argument validation. +# +# A session answers tools/list from SdkMcpServer#list_tools and routes +# tools/call through #handle_message (the mcp gem, which validates the +# arguments), so the two are checked against each other here. +RSpec.describe ClaudeAgentSDK::SdkMcpServer, 'shorthand schemas for Array and Hash parameters' do + def server_with(schema, received = []) + tool = ClaudeAgentSDK.create_tool('tag_order', 'Tag an order', schema) do |args| + received << args + "tagged with #{args.inspect}" + end + described_class.new(name: 'shop', tools: [tool]) + end + + # The frame the CLI embeds in an mcp_message control request, as the + # transport hands it over: parsed with symbol keys. + def call_tool(server, arguments) + wire = JSON.generate(jsonrpc: '2.0', id: 7, method: 'tools/call', + params: { name: 'tag_order', arguments: arguments }) + server.handle_message(JSON.parse(wire, symbolize_names: true)) + end + + def advertised_type(server, param) + server.list_tools.first.dig(:inputSchema, :properties, param, :type) + end + + [ + [Array, 'array', %w[fragile gift], 'fragile,gift'], + [:array, 'array', [1, 2, 3], 7], + [Hash, 'object', { gift: true, note: 'handle with care' }, 'gift=true'], + [:object, 'object', { priority: 2 }, %w[priority 2]] + ].each do |type, json_type, valid, invalid| + it "advertises #{type.inspect} as #{json_type}, accepts one and rejects anything else" do + received = [] + server = server_with({ order_id: Integer, value: type }, received) + + expect(advertised_type(server, :value)).to eq(json_type) + expect(advertised_type(server, :order_id)).to eq('integer') + expect(server.list_tools.first.dig(:inputSchema, :required)).to eq(%w[order_id value]) + + accepted = call_tool(server, { order_id: 7, value: valid }) + expect(accepted.dig(:result, :isError)).to be(false), accepted.inspect + expect(received).to eq([{ order_id: 7, value: valid }]) + + rejected = call_tool(server, { order_id: 7, value: invalid }) + expect(rejected.dig(:result, :isError)).to be true + expect(rejected.dig(:result, :content, 0, :text)).to match(%r{Invalid arguments.*/value}m) + expect(received.size).to eq(1) + end + end + + it 'advertises the same types through the mcp gem\'s own tools/list' do + server = server_with({ tags: Array, opts: Hash, ids: :array, meta: :object }) + + response = JSON.parse(server.handle_json(JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list')), + symbolize_names: true) + properties = response.dig(:result, :tools, 0, :inputSchema, :properties) + + expect(properties.transform_values { |property| property[:type] }) + .to eq(tags: 'array', opts: 'object', ids: 'array', meta: 'object') + expect(server.list_tools.first.dig(:inputSchema, :properties)).to eq(properties) + end + + it 'accepts an empty Array and an empty Hash' do + received = [] + server = server_with({ tags: Array, opts: Hash }, received) + + response = call_tool(server, { tags: [], opts: {} }) + + expect(response.dig(:result, :isError)).to be false + expect(received).to eq([{ tags: [], opts: {} }]) + end + + # Everything else in a shorthand schema keeps its present meaning. What an + # unrecognized value should do (warn, raise) is a separate decision; until + # then it is advertised as a string, exactly as before. + it 'leaves every other shorthand value as it was' do + schema = { name: String, count: :integer, ratio: Float, flag: :boolean, + typo: :interger, short: :bool, numeric: Numeric, + list_of: [String], fragment: { type: 'array', items: { type: 'string' } } } + + types = server_with(schema).list_tools.first.dig(:inputSchema, :properties).transform_values { |p| p[:type] } + + expect(types).to eq(name: 'string', count: 'integer', ratio: 'number', flag: 'boolean', + typo: 'string', short: 'string', numeric: 'string', + list_of: 'string', fragment: 'string') + end + + it 'still reads { type: :object } as a prebuilt schema, not as a parameter named type' do + # :object is now a shorthand value too; a propertyless object schema has + # always been detected first and must stay "accept any object". + server = server_with({ type: :object }) + + expect(server.list_tools.first[:inputSchema]).to eq({ type: 'object' }) + expect(call_tool(server, { anything: 1 }).dig(:result, :isError)).to be false + end + + it 'declares an object parameter literally named type through the class form' do + schema = server_with({ type: Hash, name: String }).list_tools.first[:inputSchema] + + expect(schema).to eq(type: 'object', required: %w[type name], + properties: { type: { type: 'object' }, name: { type: 'string' } }) + end +end From 4ec5f1fc88e5b99adc1b5018f8aa0186e600e216 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:04:31 +0800 Subject: [PATCH 031/194] Accept the Ruby 4.0 answer to a real SIGTERM; run CI on Ruby 4.0 On Ruby 4.0 the suite had six failures, all in callback_process_exit_spec.rb: a real SIGTERM landing in a CPU-bound :inline callback. There the handler task is stopped, so the pending control request is answered with the `Cancelled` error and the process then ends by SIGTERM. The spec accepted only the two answers seen on Ruby 3.4 (the callback's normal answer with async 2.46, the error naming the signal with async 2.36). The documented invariant, exactly one answer for the request and then termination by that signal, holds on 4.0 too; the assertion was narrower than it. The real-signal example now also accepts `Cancelled` when it carries the request's id. The examples for an exception the callback raises itself are unchanged and still require the answer to name the exception. CI: the `deps` job gets a `latest` row on Ruby 4.0, which runs the whole suite there. Ruby 4.0 does not join the `test` matrix yet: it ships Bundler 4, and the main Gemfile cannot resolve against the gemspec's `bundler ~> 2.0` development dependency. Specs and CI only; no library change. Exploration finding: O-P1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- .github/workflows/ci.yml | 15 +++++++++---- CONTRIBUTING.md | 2 +- spec/unit/callback_process_exit_spec.rb | 28 ++++++++++++++++++------- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7f3c012..f6c1a5d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,10 +94,15 @@ jobs: run: bundle exec rake rbs:test # Dependency resolutions the main Gemfile can't reach: its RuboCop pin holds - # json at 2.x. `latest` forces the newest allowed majors (json 3.x, mcp 1.x); - # `floor` pins the oldest supported runtime dependencies, on the oldest and - # newest supported Ruby. rspec only — lint runs on the main leg. See - # gemfiles/*.gemfile. + # json at 2.x. `latest` forces the newest allowed majors (json 3.x, mcp 1.x), + # on Ruby 3.4 and on Ruby 4.0; `floor` pins the oldest supported runtime + # dependencies, on Ruby 3.2 and 3.4. rspec only — lint runs on the main leg. + # See gemfiles/*.gemfile. + # + # The `latest` row is the only Ruby 4.0 leg: the main Gemfile does not + # resolve there (Ruby 4.0 ships Bundler 4; the gemspec's development + # dependency is `bundler ~> 2.0`), so 4.0 cannot join the `test` matrix + # until that constraint is relaxed. deps: name: deps (${{ matrix.gemfile }}, Ruby ${{ matrix.ruby-version }}) runs-on: ubuntu-latest @@ -107,6 +112,8 @@ jobs: include: - gemfile: latest ruby-version: '3.4' + - gemfile: latest + ruby-version: '4.0' - gemfile: floor ruby-version: '3.2' - gemfile: floor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53144e54..fecc23c1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Run them when you change anything on the CLI wire protocol (the transport, `Quer **TLA+ models** (`formal/tla/`) model-check the CLI installer's publish/lock ordering and the outbound control-request protocol. They need Java 11+; `run.sh` fetches the pinned TLA+ tools itself. Run `formal/tla/run.sh` when you change `CLIInstaller.install` / `#publish` or `Query#send_control_request` / `#await_control_response`, and update the model if the design changed. `formal/tla/README.md` explains what each model covers. -CI runs the unit suite and RuboCop on Ruby 3.2, 3.3 and 3.4 (Linux), the unit suite on macOS, the dependency floor and latest legs, and the Rails specs on Rails 7.1 and 8. The Ruby 3.4 Linux leg also runs `rake rbs:validate`, and a separate job runs `rake rbs:test`. See [`.github/workflows/`](.github/workflows/). +CI runs the unit suite and RuboCop on Ruby 3.2, 3.3 and 3.4 (Linux), the unit suite on macOS, the dependency floor and latest legs (the latest one on Ruby 3.4 and on Ruby 4.0), and the Rails specs on Rails 7.1 and 8. The Ruby 3.4 Linux leg also runs `rake rbs:validate`, and a separate job runs `rake rbs:test`. See [`.github/workflows/`](.github/workflows/). ## Pull requests diff --git a/spec/unit/callback_process_exit_spec.rb b/spec/unit/callback_process_exit_spec.rb index d8218e38..1fcfe00c 100644 --- a/spec/unit/callback_process_exit_spec.rb +++ b/spec/unit/callback_process_exit_spec.rb @@ -107,6 +107,13 @@ def failure_response?(path, response) end end + # The answer a stopped handler task writes (Query#handle_control_request's + # Async::Stop branch): an error control response on every path, SDK MCP + # requests included. + def cancelled_response?(response) + response.fetch('response').values_at('subtype', 'error') == %w[error Cancelled] + end + harness::PATHS.each do |path| harness::MODES.each do |mode| context "#{path} with #{mode} callback scheduling" do @@ -128,12 +135,17 @@ def failure_response?(path, response) # the main thread, where MRI delivers OS signals. A real Ctrl-C / SIGTERM # landing in CPU-bound callback code must still end the process. # - # WHEN the signal interrupts is the async gem's business, not ours: newer - # releases (2.46) defer SIGTERM until the running task yields, so the - # callback completes and its normal answer goes out first; older ones - # (2.36) interrupt the callback mid-flight, which then gets the error - # answer. The invariant either way: exactly one answer, then the process - # ends by that signal — never swallowed, never left running. + # WHEN and HOW the signal interrupts is the business of Ruby and the + # async gem, not ours. Three answers have been observed for a SIGTERM: + # async 2.46 on Ruby 3.4 defers it until the running task yields, so the + # callback completes and its normal answer goes out first; async 2.36 + # interrupts the callback mid-flight, which then gets the error answer + # naming the signal; on Ruby 4.0 (async 2.46) the handler task is stopped + # instead and answers 'Cancelled'. The invariant in every case: exactly + # one answer, for this request, then the process ends by that signal — + # never swallowed, never left running. Only this example accepts + # 'Cancelled': an exception the callback raises itself (above) must + # still be reported by name. %i[sigint sigterm].each do |kind| it "lets a real #{kind.upcase} delivered during an inline #{path} callback terminate the process" do out, err, status = CallbackExitChildren.result([path, :inline, kind]) @@ -142,7 +154,9 @@ def failure_response?(path, response) expect_terminated_like_ruby(status, err, kind) written = responses(out) expect(written.length).to eq(1), "expected exactly one response, got #{written.inspect}" - if failure_response?(path, written.first) + if cancelled_response?(written.first) + expect(written.first.dig('response', 'request_id')).to eq('req_fail') + elsif failure_response?(path, written.first) expect_failure_response(path, written.first, CallbackExitHarness::KINDS.fetch(kind)[:message]) else expect(written.first.dig('response', 'subtype')).to eq('success') From 5330264308c4e33e8125352ef7d24285335244a2 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:06:59 +0800 Subject: [PATCH 032/194] Report a non-Array tool :content as an in-band error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool handler that returned { content: 'text' } — an easy slip next to the String form — or a single block Hash had its result forwarded as it was. The SDK only checked that the Hash has a :content key. The CLI then rejected the frame against its schema, and the model was told that the server "returned a malformed result that failed schema validation"; the text never reached it. Both tools/call paths (the mcp gem's tool class and the direct SdkMcpServer#call_tool) now answer such a result with an isError result that names the tool and says what is wrong: Tool 'order_status' must return :content as an Array of content blocks (got String) The legal shapes are unchanged: a String, and a Hash whose :content is an Array (an empty one included). The result is not wrapped into a text block automatically; that would be a new accepted form and is left as a separate decision. create_tool_classes builds the dynamic tool class inline, so the extra guard raised its complexity count by one; the two cops join the named disable the method already carries. Exploration finding: D-P9 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/mcp-servers.md | 2 +- lib/claude_agent_sdk/sdk_mcp_server.rb | 15 +++++- spec/unit/sdk_mcp_tool_result_spec.rb | 73 ++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 spec/unit/sdk_mcp_tool_result_spec.rb diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 8e7b0a36..170aba49 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -56,7 +56,7 @@ ClaudeAgentSDK.create_tool('lookup_order', 'Look up an order', { id: :string }) end ``` -Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key. +Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key. So is a Hash whose `:content` is not an Array — `{ content: 'text' }`, or a single block Hash — with a message saying `:content` must be an Array of content blocks; return the String itself, or wrap the block in an Array. ## Shorthand Input Schemas diff --git a/lib/claude_agent_sdk/sdk_mcp_server.rb b/lib/claude_agent_sdk/sdk_mcp_server.rb index 9a474887..c6e5aa38 100644 --- a/lib/claude_agent_sdk/sdk_mcp_server.rb +++ b/lib/claude_agent_sdk/sdk_mcp_server.rb @@ -311,6 +311,11 @@ def call_tool(name, arguments) # Guard before flexible_fetch: it raises on non-Hash inputs. content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content + # A String or a single block here is not a result an MCP client accepts. + unless content.is_a?(Array) + return error_tool_result("Tool '#{name}' must return :content as an Array of content blocks " \ + "(got #{content.class})") + end result rescue StandardError => e @@ -419,7 +424,7 @@ def normalize_tools_call_errors(message, response) end # Create dynamic Tool classes from tool definitions - def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline + def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds each dynamic MCP::Tool subclass inline # Captured so the dynamic class can resolve the effective scheduling # mode at call time — same pattern as prompt classes. sdk_server = self @@ -506,6 +511,14 @@ def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgumen content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') raise "Tool '#{@tool_def.name}' must return a hash with :content key" if content.nil? + # A String or a single block Hash would go out as it is, and the + # CLI rejects that frame against its schema: the model is told + # the SERVER returned a malformed result and never sees the text. + unless content.is_a?(Array) + raise "Tool '#{@tool_def.name}' must return :content as an Array of content blocks " \ + "(got #{content.class})" + end + is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error') structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content') diff --git a/spec/unit/sdk_mcp_tool_result_spec.rb b/spec/unit/sdk_mcp_tool_result_spec.rb new file mode 100644 index 00000000..598254b9 --- /dev/null +++ b/spec/unit/sdk_mcp_tool_result_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# A handler may return a String, or a Hash whose :content is an Array of +# content blocks. A Hash whose :content is itself a String (an easy slip next +# to the String form) or a single block Hash used to be forwarded as it was: +# the CLI rejected the frame against its schema and told the model that the +# SERVER "returned a malformed result", so the text never reached it. +RSpec.describe ClaudeAgentSDK::SdkMcpServer, 'a tool result whose :content is not an Array' do + def server_returning(result) + tool = ClaudeAgentSDK.create_tool('order_status', 'Look up an order', { id: :string }) { |_args| result } + described_class.new(name: 'shop', tools: [tool]) + end + + # tools/call the way a session routes it: the frame from the CLI's + # mcp_message control request, parsed with symbol keys, through the mcp gem. + def call_through_session(server) + wire = JSON.generate(jsonrpc: '2.0', id: 3, method: 'tools/call', + params: { name: 'order_status', arguments: { id: 'A-7' } }) + server.handle_message(JSON.parse(wire, symbolize_names: true))[:result] + end + + def expect_content_error(result, got) + expect(result[:isError]).to be true + expect(result[:content]).to eq( + [{ type: 'text', + text: "Tool 'order_status' must return :content as an Array of content blocks (got #{got})" }] + ) + end + + [ + ['a String', { content: 'Order A-7 has shipped' }, 'String'], + ['a single block Hash', { content: { type: 'text', text: 'Order A-7 has shipped' } }, 'Hash'], + ['a String under a String key', { 'content' => 'Order A-7 has shipped' }, 'String'], + ['an Integer', { content: 7 }, 'Integer'] + ].each do |label, handler_result, got| + it "answers a session's tools/call with an in-band error when :content is #{label}" do + expect_content_error(call_through_session(server_returning(handler_result)), got) + end + + it "returns the same error from #call_tool when :content is #{label}" do + expect_content_error(server_returning(handler_result).call_tool('order_status', { id: 'A-7' }), got) + end + end + + it 'keeps the handler\'s own is_error flag out of it: the result is an error either way' do + result = call_through_session(server_returning({ content: 'fine', is_error: false })) + + expect_content_error(result, 'String') + end + + # The legal shapes are untouched. + [ + ['a String', 'Order A-7 has shipped', [{ type: 'text', text: 'Order A-7 has shipped' }]], + ['an Array of blocks', { content: [{ type: 'text', text: 'shipped' }, { type: 'text', text: 'on time' }] }, + [{ type: 'text', text: 'shipped' }, { type: 'text', text: 'on time' }]], + ['an empty Array', { content: [] }, []] + ].each do |label, handler_result, content| + it "still passes #{label} through on both paths" do + server = server_returning(handler_result) + + session = call_through_session(server) + expect(session[:isError]).to be false + expect(session[:content]).to eq(content) + + direct = server.call_tool('order_status', { id: 'A-7' }) + expect(direct[:content]).to eq(content) + expect(direct).not_to have_key(:isError) + end + end +end From 3686e18da8dc16f75f1403faab2630fd9293556d Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:08:00 +0800 Subject: [PATCH 033/194] Verify a long-path project dir before using its prefix match A project path over 200 sanitized characters is stored under its first 200 characters plus a hash of the whole path. When the exact name is missing, find_project_dir falls back to any directory with the same 200-character prefix, for directories an older CLI named with another hash. It took the first such directory without checking whose it was, so for two paths sharing their first 200 characters (deep per-tenant or per-workspace trees), a directory with no sessions of its own resolved to its sibling's project dir: list_sessions(directory: B) returned A's sessions, get_session_messages read them, and rename_session / tag_session / delete_session / fork_session wrote to them. The prefix scan stays, but a candidate is used only if a session transcript in it records the requested path as its top-level cwd, and only when exactly one candidate does. The worktree lookups go through the same function. Noticeable: a directory named by an older CLI whose transcripts record no cwd no longer resolves through the fallback (no guess from the name alone), and neither does a path with two such candidates. Exploration finding: U-P8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 43 +++++++-- spec/unit/session_long_path_fallback_spec.rb | 94 ++++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) create mode 100644 spec/unit/session_long_path_fallback_spec.rb diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 1c6f0982..4a23479e 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -245,17 +245,42 @@ def find_project_dir(path) sanitized = sanitize_path(path) exact_path = File.join(projects_dir, sanitized) return exact_path if File.directory?(exact_path) + return nil unless sanitized.length > MAX_SANITIZED_LENGTH + + # A long path is stored under its first 200 characters plus a hash of + # the whole path. Older CLIs hashed with Bun.hash, so a directory with + # the same prefix and another suffix may be this path's — or that of + # ANY path sharing the prefix (a sibling in a deep per-tenant tree). + # The name cannot tell them apart; a transcript inside can: accept a + # candidate only if one records the path as its cwd, and only when + # exactly one candidate does. Taking the first prefix match listed, + # read and renamed another project's sessions for a directory that had + # none of its own. A directory whose transcripts record no cwd is not + # used: no guess from the name alone. + prefix = sanitized[0, MAX_SANITIZED_LENGTH + 1] # includes the trailing '-' + verified = Dir.children(projects_dir).select do |child| + candidate = File.join(projects_dir, child) + child.start_with?(prefix) && File.directory?(candidate) && project_dir_records_cwd?(candidate, path) + end + verified.length == 1 ? File.join(projects_dir, verified.first) : nil + end - # For long paths, scan for prefix match - if sanitized.length > MAX_SANITIZED_LENGTH - prefix = sanitized[0, MAX_SANITIZED_LENGTH + 1] # includes the trailing '-' - Dir.children(projects_dir).each do |child| - candidate = File.join(projects_dir, child) - return candidate if File.directory?(candidate) && child.start_with?(prefix) + # Whether a session transcript in +project_dir+ was recorded for +path+: + # its first non-blank top-level cwd (the one the listing reports) is the + # path, compared in NFC like every path the SDK derives a name from. + def project_dir_records_cwd?(project_dir, path) + Dir.children(project_dir).any? do |name| + next false unless name.end_with?('.jsonl') && valid_session_id?(name.delete_suffix('.jsonl')) + + head = File.open(File.join(project_dir, name), 'rb') do |file| + (file.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8') end + extract_top_level_string_field(head, 'cwd', skip_blank: true)&.unicode_normalize(:nfc) == path + rescue SystemCallError + false # unreadable, or removed between the listing and the read end - - nil + rescue SystemCallError + false end # Extract a JSON string field value from raw text without full JSON parse @@ -1800,7 +1825,7 @@ def filter_visible_messages(chain) end end - private_class_method :get_session_info_for_directory, + private_class_method :project_dir_records_cwd?, :get_session_info_for_directory, :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, :find_session_file, :stat_candidate, :resolve_subagents_dir, diff --git a/spec/unit/session_long_path_fallback_spec.rb b/spec/unit/session_long_path_fallback_spec.rb new file mode 100644 index 00000000..a6a085f6 --- /dev/null +++ b/spec/unit/session_long_path_fallback_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# A project path longer than 200 sanitized characters is stored under its +# first 200 characters plus a hash of the whole path. Older CLIs hashed with +# Bun.hash, so when the exact name is missing the SDK looks for a directory +# with the same 200-character prefix — which every sibling path shares. +RSpec.describe 'project directory lookup for paths over 200 characters' do + include_context 'with a Claude config dir' + + let(:session_id) { '6d7e8f90-a1b2-4c3d-8e4f-5a6b7c8d9e0f' } + let(:tenant) { File.join(cwd, "tenant-#{'a' * 200}") } + let(:project_a) { File.join(tenant, 'one').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:project_b) { File.join(tenant, 'two').tap { |dir| FileUtils.mkdir_p(dir) } } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def session_recorded_in(directory) + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.queue_operations('the private question of project one') + transcript.prompt(:prompt, 'the private question of project one') + transcript.assistant(:answer, transcript.text('Answered.'), parent: :prompt) + transcript.last_prompt('the private question of project one', leaf: :answer) + transcript + end + + # The name an older CLI gave the project dir of +directory+: same 200 + # characters, another hash. + def old_cli_project_dir(directory, hash = 'bunhash1') + name = ClaudeAgentSDK::Sessions.sanitize_path(directory) + File.join(config_dir, 'projects', "#{name[0, 200]}-#{hash}") + end + + context 'when a sibling project shares the first 200 characters' do + let!(:transcript_a) { session_recorded_in(project_a).write(transcript_path(session_id, project_a)) } + + it 'has two different project keys with one prefix' do + key_a = ClaudeAgentSDK.project_key_for_directory(project_a) + key_b = ClaudeAgentSDK.project_key_for_directory(project_b) + + expect(key_a).not_to eq(key_b) + expect(key_a[0, 201]).to eq(key_b[0, 201]) + end + + it 'does not list the sibling project for a directory without sessions' do + expect(ClaudeAgentSDK.list_sessions(directory: project_b)).to eq([]) + expect(ClaudeAgentSDK.list_sessions(directory: project_a).map(&:session_id)).to eq([session_id]) + end + + it "does not read the sibling project's session through that directory" do + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: project_b)).to be_nil + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_b)).to eq([]) + end + + it "does not write to the sibling project's session through that directory" do + before_rename = File.binread(transcript_a) + + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Mine now', directory: project_b) } + .to raise_error(Errno::ENOENT) + expect { ClaudeAgentSDK.delete_session(session_id: session_id, directory: project_b) } + .to raise_error(Errno::ENOENT) + expect(File.binread(transcript_a)).to eq(before_rename) + end + end + + context 'when an older CLI named the project dir with another hash' do + it 'still finds it when a transcript in it records the directory as its cwd' do + session_recorded_in(project_a).write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + + expect(ClaudeAgentSDK.list_sessions(directory: project_a).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_a).length).to eq(2) + end + + it 'does not use a directory whose transcripts record no cwd' do + transcript = CLITranscript.new(session_id: session_id, cwd: project_a) + transcript.ai_title('Titled, with no entry that carries a cwd') + transcript.last_prompt('the private question of project one') + transcript.write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + + expect(ClaudeAgentSDK.list_sessions(directory: project_a)).to eq([]) + end + + it 'does not choose between two directories that both record the directory' do + %w[bunhash1 bunhash2].each do |hash| + session_recorded_in(project_a).write(File.join(old_cli_project_dir(project_a, hash), "#{session_id}.jsonl")) + end + + expect(ClaudeAgentSDK.list_sessions(directory: project_a)).to eq([]) + end + end +end From 536afa2a7711c75a01612999f284102cb3ed74b4 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:08:19 +0800 Subject: [PATCH 034/194] Detach the mirror buffer inside the lock, not before queueing on it TranscriptMirrorBatcher#drain took the pending buffer first and then waited for the lock. A #flush cancelled while it was queued behind an in-flight append (its task stopped, a timeout around the caller) took that batch down with it: the entries never reached the store, a later #close could not deliver them, and no MirrorErrorMessage was produced. The loss was only counted in #batches_dropped?. #drain now detaches once it holds the lock. A drain cancelled while it waits has taken nothing, so its frames stay buffered for the next drain. Ordering is unchanged: the lock is FIFO and each holder takes everything pending at that moment. A batch whose drain is cancelled inside the append is still lost and still counted. A user can notice two things. Entries buffered behind a cancelled flush now reach the store. And a frame that arrives while #close is waiting behind an in-flight append goes out with #close's own batch instead of staying buffered and being reported as dropped, so fewer teardowns preserve the materialized resume dir. That second case was pinned the other way round by one example in transcript_mirror_batcher_spec.rb; its expectation now says the frame is delivered. The case that remains a drop, a frame arriving after #close detached, gets its own example next to the new regression specs, which force the interleaving with gates and no clock. Upstream: Python PR #1289. Exploration finding: U-P17 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- .../transcript_mirror_batcher.rb | 76 ++++---- ...script_mirror_batcher_cancellation_spec.rb | 168 ++++++++++++++++++ spec/unit/transcript_mirror_batcher_spec.rb | 39 ++-- 3 files changed, 231 insertions(+), 52 deletions(-) create mode 100644 spec/unit/transcript_mirror_batcher_cancellation_spec.rb diff --git a/lib/claude_agent_sdk/transcript_mirror_batcher.rb b/lib/claude_agent_sdk/transcript_mirror_batcher.rb index d2f4cb58..44035957 100644 --- a/lib/claude_agent_sdk/transcript_mirror_batcher.rb +++ b/lib/claude_agent_sdk/transcript_mirror_batcher.rb @@ -31,8 +31,8 @@ module ClaudeAgentSDK # (which surfaces them as a MirrorErrorMessage). Adapters should dedupe by # entry["uuid"] when present, since a retried batch may overlap a prior write. # - # The semaphore serializes appends (FIFO, and every drain detaches its batch - # immediately before queueing on it, so append order matches enqueue order), + # The semaphore serializes appends (FIFO, and every drain detaches the whole + # buffer once it holds the lock, so append order matches enqueue order), # but a #send that exceeds send_timeout is abandoned (its worker thread keeps # running) and the next drain proceeds, so two #append calls for the SAME # key can briefly overlap. SessionStore#append must be thread-safe per key @@ -107,14 +107,16 @@ def initialize(store:, projects_dir:, on_error:, send_timeout: SEND_TIMEOUT_SECO # # Frames still buffered once #close has started count too. Query#close # stops the read task (the background drainer's parent) right after - # #close returns, so a frame the read loop enqueued during the close - # window — or any below-threshold frame after it — is never appended. - # The old per-frame drain detached it into a parked task whose - # cancellation counted it; with coalescing it stays in @pending, so it is - # accounted here. Evaluated lazily: frames the drainer still manages to - # deliver before the check are not drops, and nothing past #close has to - # run for a stranded frame to be counted. (A plain ivar/Array#empty? read - # is safe cross-thread under the GVL, like @dropped_batches.) + # #close returns, so a frame the read loop enqueued after #close detached + # — during its final append, or any below-threshold frame after it — is + # never appended. (One that arrives while #close is still waiting for the + # lock goes out with #close's own batch.) The old per-frame drain detached + # such a frame into a parked task whose cancellation counted it; with + # coalescing it stays in @pending, so it is accounted here. Evaluated + # lazily: frames the drainer still manages to deliver before the check + # are not drops, and nothing past #close has to run for a stranded frame + # to be counted. (A plain ivar/Array#empty? read is safe cross-thread + # under the GVL, like @dropped_batches.) def batches_dropped? @dropped_batches.positive? || (@closed && !@pending.empty?) end @@ -150,9 +152,10 @@ def flush drain end - # Final flush before teardown. Never raises. Bounded: it waits behind at - # most the in-flight append, and frames arriving after it detached are - # not chased (they count as dropped unless delivered; #batches_dropped?). + # Final flush before teardown. Never raises. Bounded: it waits its turn on + # the lock, takes whatever is pending at that moment, and does not chase + # frames that arrive after that (they count as dropped unless delivered; + # #batches_dropped?). def close @closed = true flush @@ -168,16 +171,16 @@ def over_threshold? # Fire-and-forget on the reactor. The drainer loops until the buffer is # back under the thresholds, so while it is live every later frame is - # simply buffered and coalesced into its next #drain — live background - # tasks <= 1 and detached batches <= 1 + one per #flush / #close caller - # parked on @lock, independent of frame rate and store latency. + # simply buffered and coalesced into the next #drain — live background + # tasks <= 1 and detached batches <= 1 (only the holder of @lock has + # one), independent of frame rate and store latency. # # No lost wakeup: the loop's final over_threshold? check and the ensure # clearing the flag run with no suspension point between them, so an # #enqueue that saw the flag set is always observed by that check. - # Every drainer (this one, #flush, #close) keeps detach-then-acquire, so - # detach order == @lock FIFO order and append ordering holds. #drain - # never raises. + # Every drainer (this one, #flush, #close) detaches inside @lock and + # takes everything pending at that moment, so batches leave in @lock FIFO + # order and append ordering holds. #drain never raises. def schedule_background_drain(task) return if @background_drain_live @@ -194,25 +197,29 @@ def schedule_background_drain(task) raise end - # Detach the pending buffer, await any prior flush, then send. Detaching - # before acquiring the lock lets #enqueue keep accumulating into a fresh - # buffer while a prior flush is in flight. Never raises. + # Await any prior flush, detach the pending buffer, then send. The buffer + # is detached INSIDE the lock: a drain cancelled while it waits for the + # lock has taken nothing, so its frames stay in @pending for the next + # drain. (It used to detach first, and a #flush cancelled while queued + # behind an in-flight append lost its batch — upstream Python PR #1289.) + # #enqueue never takes the lock, so it keeps accumulating while a flush + # is in flight. Never raises. def drain - items = @pending - @pending = [] - @pending_entries = 0 - @pending_bytes = 0 - errors = [] - # Cancellation (Async::Stop — not a StandardError) delivered while - # waiting on the lock or inside the append's thread join loses the - # detached items without either rescue firing. Teardown would then read - # batches_dropped? as false and delete a materialized resume dir holding - # the only copy of these entries — count the batch as dropped unless the - # flush path ran to completion (do_flush counts its own failures). - accounted = items.empty? + # Cancellation (Async::Stop — not a StandardError) delivered inside the + # append's thread join loses the detached items without either rescue + # firing. Teardown would then read batches_dropped? as false and delete + # a materialized resume dir holding the only copy of these entries — + # count the batch as dropped unless the flush path ran to completion + # (do_flush counts its own failures). Until a batch is detached there + # is nothing to account for. + accounted = true begin @lock.acquire do + items = @pending + @pending = [] + @pending_entries = 0 + @pending_bytes = 0 # Emptiness is checked INSIDE the lock (matching the Python batcher): # an empty #flush/#close still serializes behind any in-flight or # queued drain, so they are true barriers — at result-yield and at @@ -220,6 +227,7 @@ def drain # the read task while a detached batch is still being appended. next if items.empty? + accounted = false begin do_flush(items, errors) rescue StandardError => e diff --git a/spec/unit/transcript_mirror_batcher_cancellation_spec.rb b/spec/unit/transcript_mirror_batcher_cancellation_spec.rb new file mode 100644 index 00000000..a2ea58dc --- /dev/null +++ b/spec/unit/transcript_mirror_batcher_cancellation_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'async/condition' + +# A drain takes the pending buffer only once it holds the lock. It used to +# detach first and then queue for the lock, so a #flush cancelled while it +# waited behind an in-flight append (a task stopped, a timeout around the +# caller) took its batch down with it: counted by #batches_dropped?, but never +# appended and never reported through on_error (upstream Python PR #1289). +# +# Every interleaving below is forced with a gate the example controls — a +# Thread::Queue for the default thread-hop store, an Async::Condition for a +# fiber-native one. Nothing waits on a clock. +RSpec.describe ClaudeAgentSDK::TranscriptMirrorBatcher do + let(:projects) { '/cfg/projects' } + let(:file_path) { "#{projects}/-Users-dev-app/11111111-1111-4111-8111-111111111111.jsonl" } + let(:errors) { [] } + let(:on_error) { ->(key, message) { errors << [key, message] } } + + # Each append announces itself on `started`, then parks its (batcher-spawned) + # worker thread on `gate` until the example closes it. + let(:gated_store) do + Class.new(ClaudeAgentSDK::SessionStore) do + attr_reader :started, :gate + + def initialize + super + @entries = [] + @mutex = Mutex.new + @started = Thread::Queue.new + @gate = Thread::Queue.new + end + + def append(_key, entries) + @started.push(true) + @gate.pop # nil once the gate is closed + @mutex.synchronize { @entries.concat(entries) } + end + + def load(_key) = @mutex.synchronize { @entries.dup } + def uuids = load(nil).map { |entry| entry['uuid'] } + end.new + end + + # Fiber-native adapter: #append runs in place on the reactor and parks on a + # condition until the example releases it. + let(:inline_store) do + Class.new(ClaudeAgentSDK::SessionStore) do + attr_reader :uuids + + def initialize + super + @uuids = [] + @entered = false + @released = false + @gate = Async::Condition.new + end + + def callback_scheduling = :inline + def entered? = @entered + def load(_key) = nil + + def append(_key, entries) + @entered = true + @gate.wait until @released + @uuids.concat(entries.map { |entry| entry['uuid'] }) + end + + def release! + @released = true + @gate.signal + end + end.new + end + + def batcher(store) + described_class.new(store: store, projects_dir: projects, on_error: on_error) + end + + # A frame as the transport delivers it: symbol keys. + def frame(uuid) + [{ type: 'user', uuid: uuid, message: { role: 'user', content: uuid } }] + end + + it 'keeps the batch of a flush cancelled while it waits behind an in-flight append' do + b = batcher(gated_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('one')) + first = task.async { b.flush } # holds the lock; its append is parked on the gate + gated_store.started.pop + b.enqueue(file_path, frame('two')) + waiter = task.async { b.flush } # queued on the lock behind `first` + waiter.stop # ... and cancelled there + b.enqueue(file_path, frame('three')) + + gated_store.gate.close + first.wait + b.close + end + end.wait + + expect(gated_store.uuids).to eq(%w[one two three]) + expect(b.batches_dropped?).to be(false) + expect(errors).to be_empty + end + + it 'keeps it for a fiber-native (inline) store too' do + b = batcher(inline_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('one')) + first = task.async { b.flush } # in place on the reactor, parked on the condition + expect(inline_store).to be_entered + b.enqueue(file_path, frame('two')) + waiter = task.async { b.flush } + waiter.stop + b.enqueue(file_path, frame('three')) + + inline_store.release! + first.wait + b.close + end + end.wait + + expect(inline_store.uuids).to eq(%w[one two three]) + expect(b.batches_dropped?).to be(false) + expect(errors).to be_empty + end + + # What a drain HAS detached is still lost with it, and counted: the + # materialized resume dir then holds the only copy and must be kept. + it 'still counts a batch as dropped when its drain is cancelled inside the append' do + b = batcher(gated_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('one')) + flusher = task.async { b.flush } + gated_store.started.pop # the append is in flight: `one` is detached + flusher.stop + gated_store.gate.close + end + end.wait + + expect(b.batches_dropped?).to be(true) + end + + # #close does not chase frames that arrive after it detached. With nothing + # left to drain them they stay buffered, and that counts as a drop. + it 'counts a frame that arrives during the final append as dropped when nothing drains it' do + b = batcher(gated_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('a')) + closer = task.async { b.close } # detaches `a` under the lock; its append is parked on the gate + gated_store.started.pop + b.enqueue(file_path, frame('late')) # below the thresholds: no drain is scheduled for it + gated_store.gate.close + closer.wait + end + end.wait + + expect(gated_store.uuids).to eq(%w[a]) + expect(b.batches_dropped?).to be(true) + end +end diff --git a/spec/unit/transcript_mirror_batcher_spec.rb b/spec/unit/transcript_mirror_batcher_spec.rb index 350bce5a..fb1e564e 100644 --- a/spec/unit/transcript_mirror_batcher_spec.rb +++ b/spec/unit/transcript_mirror_batcher_spec.rb @@ -165,8 +165,8 @@ def load(_key) = nil # Latency-injecting store: append sleeps on its (batcher-spawned) worker # thread, so a drain is in flight — holding the semaphore — while later # frames enqueue and schedule their own drains. This exercises the - # detach-before-lock + Async::Semaphore(1) ordering guarantee under genuine - # concurrency (the sole reason that machinery exists). + # detach-under-the-lock + Async::Semaphore(1) ordering guarantee under + # genuine concurrency (the sole reason that machinery exists). slow_store = Class.new(ClaudeAgentSDK::SessionStore) do def initialize super @@ -302,10 +302,11 @@ def load(_key) = @mutex.synchronize { @entries.dup } end # The looping drainer re-detaches after its append completes. A #flush - # barrier that detached OLDER frames is already parked on the lock by - # then, and Semaphore#release hands the lock straight to it (FIFO), so the - # drainer's NEWER frames must land after the barrier's — not ahead of it. - it 'appends a parked flush barrier batch before frames the drainer picks up later' do + # barrier is already parked on the lock by then; Semaphore#release hands + # the lock straight to it (FIFO) and it takes everything pending, older + # frames first — so nothing the drainer picks up later can land ahead of + # a frame that was enqueued before it. + it 'keeps enqueue order across a flush barrier parked behind the looping drainer' do Async do |task| task.with_timeout(10) do b = described_class.new(store: gated_store, projects_dir: projects, on_error: on_error, @@ -313,12 +314,12 @@ def load(_key) = @mutex.synchronize { @entries.dup } b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'a' }]) gated_store.started.pop # drainer's append of `a` parked on the gate b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'b' }]) - flusher = task.async { b.flush } # detaches `b`, parks on the lock - b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'c' }]) # buffered for the drainer + flusher = task.async { b.flush } # parks on the lock + b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'c' }]) # buffered behind `b` gated_store.gate.close flusher.wait - b.flush # barrier covering the drainer's `c` iteration + b.flush # barrier: nothing the drainer took is still in flight end end @@ -328,12 +329,14 @@ def load(_key) = @mutex.synchronize { @entries.dup } end # Teardown corner: Query#close runs batcher.close, then stops the read - # task (the drainer's parent). A frame the read loop enqueued during the - # close window sits in @pending (the live drainer would have taken it - # next), so stopping the read task first loses it — and that loss must - # surface through batches_dropped?, as the old per-frame parked drain's - # cancellation did, so resume-from-store teardown preserves the temp dir. - it 'counts a frame buffered during close as dropped when the read task is stopped first' do + # task (the drainer's parent). A frame the read loop enqueues while close + # is still queued behind the drainer's in-flight append used to sit in + # @pending until the read task was stopped, and was lost (reported through + # batches_dropped?). close detaches inside the lock now, so it takes that + # frame along and delivers it before the read task is stopped. (A frame + # that arrives after close detached is still a counted drop — see + # transcript_mirror_batcher_cancellation_spec.rb.) + it 'delivers a frame buffered while close waits behind an in-flight append, before the read task is stopped' do b = nil pending_at_stop = nil Async do |task| @@ -363,9 +366,9 @@ def load(_key) = @mutex.synchronize { @entries.dup } end end - expect(pending_at_stop).to eq(1) # the corner was reached: `late` never detached - expect(gated_store.entries.map { |e| e['uuid'] }).to eq(%w[a]) - expect(b.batches_dropped?).to be(true) + expect(pending_at_stop).to eq(0) # `late` went out with close's own batch + expect(gated_store.entries.map { |e| e['uuid'] }).to eq(%w[a late]) + expect(b.batches_dropped?).to be(false) end it 'does not report a drop when frames buffered during close are drained before teardown ends' do From 76bab3a8048f8b677d4c8a1ae7cdb4141f66ce2d Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:09:28 +0800 Subject: [PATCH 035/194] Back off between mirror retries with Kernel#sleep Inside a reactor the transcript mirror's retry backoff called Async::Task#sleep. async deprecated that method in favor of Kernel#sleep, and current versions warn about it when warnings are on: under `ruby -w` every retried append printed "`Async::Task#sleep` is deprecated, use `Kernel#sleep` instead." Kernel#sleep is scheduler-aware (it parks the fiber under a reactor and blocks the thread without one), so the backoff calls it in both cases. Behavior is otherwise unchanged; on the async floor (2.10.0) Task#sleep already was a plain call to Kernel#sleep. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- .../transcript_mirror_batcher.rb | 6 +-- .../transcript_mirror_batcher_backoff_spec.rb | 50 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 spec/unit/transcript_mirror_batcher_backoff_spec.rb diff --git a/lib/claude_agent_sdk/transcript_mirror_batcher.rb b/lib/claude_agent_sdk/transcript_mirror_batcher.rb index 44035957..682e1908 100644 --- a/lib/claude_agent_sdk/transcript_mirror_batcher.rb +++ b/lib/claude_agent_sdk/transcript_mirror_batcher.rb @@ -357,10 +357,10 @@ def invoke_append(key, entries) [:error, e] end - # Sleep that yields the reactor when one is active, else a plain sleep. + # Kernel#sleep is scheduler-aware: it parks only this fiber when a reactor + # is active and blocks the thread otherwise. def sleep_backoff(seconds) - task = Async::Task.current? - task ? task.sleep(seconds) : sleep(seconds) + sleep(seconds) end def deep_stringify(obj) diff --git a/spec/unit/transcript_mirror_batcher_backoff_spec.rb b/spec/unit/transcript_mirror_batcher_backoff_spec.rb new file mode 100644 index 00000000..967e188d --- /dev/null +++ b/spec/unit/transcript_mirror_batcher_backoff_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +RSpec.describe ClaudeAgentSDK::TranscriptMirrorBatcher do + # Between attempts the batcher slept with Async::Task#sleep when a reactor + # was running. async deprecated that in favor of Kernel#sleep, which is + # scheduler-aware, and warns about it when warnings are on (`ruby -w`): + # one line on stderr per retry. + describe 'retry backoff' do + let(:projects) { '/cfg/projects' } + let(:file_path) { "#{projects}/-Users-dev-app/11111111-1111-4111-8111-111111111111.jsonl" } + + # What `ruby -w` turns on. + around do |example| + verbose = $VERBOSE + deprecated = Warning[:deprecated] + $VERBOSE = true + Warning[:deprecated] = true + example.run + ensure + $VERBOSE = verbose + Warning[:deprecated] = deprecated + end + + it 'retries inside a reactor without a deprecation warning' do + stub_const("#{described_class}::MIRROR_APPEND_BACKOFF_S", [0, 0].freeze) # nothing to wait for here + attempts = 0 + store = Class.new(ClaudeAgentSDK::SessionStore) do + define_method(:append) do |_key, _entries| + attempts += 1 + raise IOError, 'connection reset' if attempts == 1 + end + + def load(_key) = nil + end.new + batcher = described_class.new(store: store, projects_dir: projects, on_error: ->(_key, _message) {}) + + expect do + Async do + batcher.enqueue(file_path, [{ type: 'user', uuid: 'a' }]) + batcher.flush + end.wait + end.not_to output(/Async::Task#sleep/).to_stderr + + expect(attempts).to eq(2) # the backoff ran: one failure, one retry + end + end +end From accd6557266d30a2dbfefd9f8858566689632e78 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:10:08 +0800 Subject: [PATCH 036/194] Merge create_tool's meta: with the size hint from annotations create_tool(annotations: { maxResultSizeChars: 500_000 }, meta: { 'acme/team' => 'billing' }) advertised _meta as { 'acme/team' => 'billing' }: the size hint derived from the annotation was only added when no meta: was passed, so any explicit meta:, even one about something else, dropped it. The annotation itself is not read by MCP clients; _meta 'anthropic/maxResultSizeChars' is what raises the CLI's inline result size, so large results went back to being persisted and previewed, with no diagnostic. The derived key is now merged with the explicit meta:. A size key the caller sets wins, as a String or as a Symbol; in the Symbol case the String key is not added beside it, which would put the same JSON key in the frame twice (json 3.x refuses to generate that). The Hash passed as meta: is not modified. Without a size annotation meta: is passed through as before. Reflows the class map in ruby_type_to_json_schema onto two lines to stay inside the module-length budget. Exploration finding: D-P12 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- lib/claude_agent_sdk/sdk_mcp_server.rb | 21 +++-- .../skills/claude-agent-ruby/SKILL.md | 2 +- skills/SKILL.md | 2 +- spec/unit/sdk_mcp_tool_meta_spec.rb | 85 +++++++++++++++++++ 4 files changed, 102 insertions(+), 8 deletions(-) create mode 100644 spec/unit/sdk_mcp_tool_meta_spec.rb diff --git a/lib/claude_agent_sdk/sdk_mcp_server.rb b/lib/claude_agent_sdk/sdk_mcp_server.rb index c6e5aa38..94cbcf3f 100644 --- a/lib/claude_agent_sdk/sdk_mcp_server.rb +++ b/lib/claude_agent_sdk/sdk_mcp_server.rb @@ -70,9 +70,8 @@ def self.normalize_tool_schema(schema) # @api private def self.ruby_type_to_json_schema(type) # Class#=== matches instances, not the class object used in { id: Integer }. - type = { String => :string, Integer => :integer, Float => :float, - TrueClass => :boolean, FalseClass => :boolean, - Array => :array, Hash => :object }.fetch(type, type) + type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean, + FalseClass => :boolean, Array => :array, Hash => :object }.fetch(type, type) case type when :string, String then { type: 'string' } when :integer, Integer then { type: 'integer' } @@ -632,6 +631,11 @@ def convert_input_schema(schema) # +String+ / +:string+, +Integer+ / +:integer+, +Float+ / +:float+ / # +:number+, +TrueClass+ / +FalseClass+ / +:boolean+, +Array+ / +:array+ # or +Hash+ / +:object+ + # @param annotations [Hash, nil] MCP tool annotations (+title+, + # +readOnlyHint+, ...). +maxResultSizeChars+ is also forwarded as + # +_meta['anthropic/maxResultSizeChars']+, the form the CLI reads + # @param meta [Hash, nil] The tool's +_meta+. Merged with the size hint + # derived from +annotations+; a key given here wins # @param handler [Proc] Block that implements the tool logic. It returns a # String, sent to Claude as a single text block, or a Hash with a # +:content+ Array of MCP content blocks plus optional +:is_error+ / @@ -666,11 +670,16 @@ def convert_input_schema(schema) def self.create_tool(name, description, input_schema, annotations: nil, meta: nil, &handler) raise ArgumentError, 'Block required for tool handler' unless handler - # Auto-populate _meta with maxResultSizeChars from annotations if present + # Auto-populate _meta with maxResultSizeChars from annotations if present. + # An explicit meta: is merged with that hint rather than replacing it (an + # unrelated key used to drop it); a size key the caller sets itself wins, + # in either spelling — adding the String key beside a Symbol one would + # put the same JSON key in the frame twice. + size_key = 'anthropic/maxResultSizeChars' resolved_meta = meta - if resolved_meta.nil? && annotations + if annotations.is_a?(Hash) && (meta.nil? || (meta.is_a?(Hash) && meta.keys.none? { |key| key.to_s == size_key })) max_chars = annotations[:maxResultSizeChars] || annotations['maxResultSizeChars'] - resolved_meta = { 'anthropic/maxResultSizeChars' => max_chars } if max_chars + resolved_meta = { size_key => max_chars }.merge(meta || {}) if max_chars end # tools/call arrives from the CLI with the name as a JSON String; the mcp diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md index 79aa1c70..2555e078 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md @@ -68,7 +68,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( - Include `mcp____` in `allowed_tools` - Use `annotations:` on `create_tool` for MCP tool annotations - Use `meta:` on `create_tool` for `_meta` field forwarding (e.g., `{ 'anthropic/maxResultSizeChars' => 100000 }` to prevent truncation of large results) -- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated +- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated with it; an explicit `meta:` is merged with that hint, and a key set in `meta:` wins - Both symbol-keyed and string-keyed `input_schema` hashes are accepted ## Session Management diff --git a/skills/SKILL.md b/skills/SKILL.md index 79aa1c70..2555e078 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -68,7 +68,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( - Include `mcp____` in `allowed_tools` - Use `annotations:` on `create_tool` for MCP tool annotations - Use `meta:` on `create_tool` for `_meta` field forwarding (e.g., `{ 'anthropic/maxResultSizeChars' => 100000 }` to prevent truncation of large results) -- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated +- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated with it; an explicit `meta:` is merged with that hint, and a key set in `meta:` wins - Both symbol-keyed and string-keyed `input_schema` hashes are accepted ## Session Management diff --git a/spec/unit/sdk_mcp_tool_meta_spec.rb b/spec/unit/sdk_mcp_tool_meta_spec.rb new file mode 100644 index 00000000..78f7be77 --- /dev/null +++ b/spec/unit/sdk_mcp_tool_meta_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# create_tool forwards annotations[:maxResultSizeChars] to the CLI as +# _meta['anthropic/maxResultSizeChars'] (MCP clients drop the annotation +# itself). It only did so when no meta: was passed: any explicit meta:, even +# one about something else, replaced the size hint. +RSpec.describe ClaudeAgentSDK, '.create_tool _meta from annotations and meta:' do + size_key = 'anthropic/maxResultSizeChars' + + def tool_with(**options) + described_class.create_tool('export_rows', 'Export rows', {}, **options) { |_args| 'rows' } + end + + def advertised_meta(tool) + described_class::SdkMcpServer.new(name: 'reports', tools: [tool]).list_tools.first[:_meta] + end + + it 'derives the size hint from the annotation when no meta: is given' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }) + + expect(tool.meta).to eq(size_key => 500_000) + end + + it 'merges an unrelated meta: with the derived size hint instead of dropping it' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { 'acme/team' => 'billing' }) + + expect(tool.meta).to eq(size_key => 500_000, 'acme/team' => 'billing') + expect(advertised_meta(tool)).to eq(size_key => 500_000, 'acme/team' => 'billing') + end + + it 'advertises both keys through the mcp gem\'s tools/list too' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { 'acme/team' => 'billing' }) + server = described_class::SdkMcpServer.new(name: 'reports', tools: [tool]) + + listed = JSON.parse(server.handle_json(JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list'))) + + expect(listed.dig('result', 'tools', 0, '_meta')).to eq(size_key => 500_000, 'acme/team' => 'billing') + end + + it 'reads the annotation under a String key as well' do + tool = tool_with(annotations: { 'maxResultSizeChars' => 500_000 }, meta: { 'acme/team' => 'billing' }) + + expect(tool.meta).to eq(size_key => 500_000, 'acme/team' => 'billing') + end + + it 'lets a size key set in meta: win over the annotation' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { size_key => 100, 'acme/team' => 'billing' }) + + expect(tool.meta).to eq(size_key => 100, 'acme/team' => 'billing') + end + + it 'lets a Symbol-keyed size key in meta: win, without a second spelling of the key' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { size_key.to_sym => 100 }) + + expect(tool.meta).to eq(size_key.to_sym => 100) + # Counted in the serialized frame: two spellings of one key are a + # duplicate JSON key, which json 3.x refuses to generate. + expect(JSON.generate(advertised_meta(tool)).scan(size_key).size).to eq(1) + end + + it 'does not modify the Hash passed as meta:' do + meta = { 'acme/team' => 'billing' }.freeze + + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: meta) + + expect(meta).to eq('acme/team' => 'billing') + expect(tool.meta).not_to equal(meta) + end + + it 'passes meta: through untouched when the annotations carry no size' do + meta = { 'acme/team' => 'billing' } + + expect(tool_with(annotations: { readOnlyHint: true }, meta: meta).meta).to equal(meta) + expect(tool_with(meta: meta).meta).to equal(meta) + end + + it 'leaves _meta out when there is neither a size annotation nor meta:' do + expect(tool_with(annotations: { readOnlyHint: true }).meta).to be_nil + expect(tool_with.meta).to be_nil + expect(described_class::SdkMcpServer.new(name: 'reports', tools: [tool_with]).list_tools.first).not_to have_key(:_meta) + end +end From 7b63834cf4d4237374e38d94c26a7ec99323eaed Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:10:11 +0800 Subject: [PATCH 037/194] Let the session mutations work on a removed project directory The CLI keys a project by its path, so transcripts outlive the directory. After `rm -rf `, list_sessions, get_session_info and get_session_messages still returned that directory's sessions, but rename_session, tag_session, fork_session and delete_session given the same directory: raised Errno::ENOENT from File.realpath before looking for the session. Listing the sessions of a removed worktree and then deleting them failed at the second step. Both mutation lookups now use Sessions.canonicalize_path, like the readers. canonicalize_path itself resolves a path that cannot be resolved as a whole as far as it exists (symlinks in the nearest existing ancestor are followed, the missing rest is appended), which is what Python's os.path.realpath does. That closes a gap the readers had too: a removed directory named through a symlinked parent (macOS /tmp, /var) canonicalized without resolving the symlink and missed the project dir recorded under the real path. Noticeable: - a directory: that never held the session still raises Errno::ENOENT, now "Session not found in project directory for " instead of the realpath error; - project_key_for_directory for a missing directory under a symlinked parent now returns the key the directory has once it exists. Exploration finding: B-P6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 2 +- lib/claude_agent_sdk/session_mutations.rb | 8 +- lib/claude_agent_sdk/sessions.rb | 32 ++++-- spec/unit/session_deleted_directory_spec.rb | 103 ++++++++++++++++++++ 4 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 spec/unit/session_deleted_directory_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index aeccfa4c..aa61c017 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -2,7 +2,7 @@ Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. By default these APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable (an empty value is treated as unset, falling back to `~/.claude`) and auto-detecting git worktrees. On a host with no usable home directory (`HOME` unset with no passwd entry — e.g. `docker --user` in a minimal image — or an empty/relative `HOME`) and no `CLAUDE_CONFIG_DIR`, the local-disk path raises `ClaudeAgentSDK::ConfigDirError`; set `CLAUDE_CONFIG_DIR` there. Every one of them also takes an optional `session_store:` to operate on a [`SessionStore`](#mirroring-to-a-sessionstore) instead (see [Store-backed sessions](#store-backed-sessions)). -Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String. +Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. A `directory:` that was removed after its sessions were recorded (a deleted worktree) still names its project: the path is resolved as far as it exists, so those sessions can be listed, read, renamed, tagged, forked and deleted through it. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String. ## Listing Sessions diff --git a/lib/claude_agent_sdk/session_mutations.rb b/lib/claude_agent_sdk/session_mutations.rb index ddd0ecce..591d9ee4 100644 --- a/lib/claude_agent_sdk/session_mutations.rb +++ b/lib/claude_agent_sdk/session_mutations.rb @@ -293,7 +293,11 @@ def find_session_file_with_dir(session_id, directory) end def find_in_directory(file_name, directory) - path = File.realpath(directory).unicode_normalize(:nfc) + # canonicalize_path, not File.realpath: the transcripts outlive the + # directory (a removed worktree), and realpath raised Errno::ENOENT for + # it before the session was even looked for — while the readers, which + # canonicalize, still found the session through the same directory. + path = Sessions.canonicalize_path(directory) result = try_project_dir(file_name, Sessions.find_project_dir(path)) return result if result @@ -587,7 +591,7 @@ def append_to_session(session_id, data, directory) end def append_to_session_in_directory(session_id, data, file_name, directory) - path = File.realpath(directory).unicode_normalize(:nfc) + path = Sessions.canonicalize_path(directory) # see find_in_directory # Try the exact/prefix-matched project directory first. project_dir = Sessions.find_project_dir(path) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 4a23479e..e0f4e58c 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -157,19 +157,31 @@ def sanitize_path(name) end # Resolve a directory to its canonical form (realpath + NFC), matching the - # CLI's project-directory naming. Falls back to an absolute NFC path when - # realpath can't resolve it (e.g. the directory does not exist yet) — Ruby's - # File.realpath raises on missing paths whereas Python's os.path.realpath is - # lexical for the missing suffix, so expand_path restores that behavior. - # Known divergence: for a MISSING path Python still resolves symlinks in - # the existing prefix (so a deleted /tmp/proj on macOS canonicalizes to - # /private/tmp/proj and its project dir is found); the expand_path - # fallback resolves none, so deleted-directory lookups under symlinked - # prefixes can miss. + # CLI's project-directory naming. + # + # A path that cannot be resolved as a whole (the directory was removed, or + # does not exist yet) is resolved as far as it exists: symlinks in the + # nearest existing ancestor are followed and the missing rest is appended + # as written — what Python's os.path.realpath does, where Ruby's + # File.realpath raises. The CLI keyed the project by the real path while + # the directory existed, so a removed /tmp/proj on macOS must still + # canonicalize to /private/tmp/proj for its sessions to be found; a plain + # expand_path (the earlier fallback) resolved no symlink at all. def canonicalize_path(dir) File.realpath(dir).unicode_normalize(:nfc) rescue SystemCallError - File.expand_path(dir).unicode_normalize(:nfc) + existing = File.expand_path(dir) + missing = [] + until File.exist?(existing) || existing == File.dirname(existing) + missing.unshift(File.basename(existing)) + existing = File.dirname(existing) + end + resolved = begin + File.realpath(existing) + rescue SystemCallError + existing + end + File.join(resolved, *missing).unicode_normalize(:nfc) end # Derive the SessionStore +project_key+ for a directory (default: cwd). diff --git a/spec/unit/session_deleted_directory_spec.rb b/spec/unit/session_deleted_directory_spec.rb new file mode 100644 index 00000000..4cbcae01 --- /dev/null +++ b/spec/unit/session_deleted_directory_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The CLI keys a project by its path, so the transcripts outlive the +# directory: after `rm -rf ` its sessions are still there to list, +# read, and clean up. +RSpec.describe 'sessions of a project directory that no longer exists' do + include_context 'with a Claude config dir' + + let(:session_id) { '2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091' } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + # A session the CLI recorded while +directory+ existed: keyed by the + # directory's real path. + def record_session(directory) + FileUtils.mkdir_p(directory) + real = File.realpath(directory) + transcript = CLITranscript.new(session_id: session_id, cwd: real) + transcript.queue_operations('clean up the old worktree') + transcript.prompt(:prompt, 'clean up the old worktree') + transcript.assistant(:answer, transcript.text('Done.'), parent: :prompt) + transcript.last_prompt('clean up the old worktree', leaf: :answer) + transcript.write(transcript_path(session_id, real)) + end + + context 'when the directory was removed' do + let(:removed) { File.join(cwd, 'removed-worktree') } + let!(:transcript_file) { record_session(removed).tap { FileUtils.rm_rf(removed) } } + + it 'is still found by the readers' do + expect(ClaudeAgentSDK.list_sessions(directory: removed).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: removed).length).to eq(2) + end + + it 'can be renamed through the directory' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Old worktree', directory: removed) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed).custom_title) + .to eq('Old worktree') + end + + it 'can be tagged through the directory' do + ClaudeAgentSDK.tag_session(session_id: session_id, tag: 'stale', directory: removed) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed).tag).to eq('stale') + end + + it 'can be forked through the directory' do + fork = ClaudeAgentSDK.fork_session(session_id: session_id, directory: removed) + + expect(ClaudeAgentSDK.get_session_messages(session_id: fork.session_id, directory: removed).length).to eq(2) + end + + it 'can be deleted through the directory' do + ClaudeAgentSDK.delete_session(session_id: session_id, directory: removed) + + expect(File.exist?(transcript_file)).to be(false) + end + end + + # The CLI recorded the real path; the caller names the directory through a + # symlink (macOS /tmp and /var are symlinks). With the directory gone there + # is nothing left to resolve at the end of the path, only above it. + context 'when the removed directory is named through a symlinked parent' do + let(:real_parent) { File.join(cwd, 'volumes', 'data').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:link) { File.join(cwd, 'data').tap { |path| File.symlink(real_parent, path) } } + let(:removed) { File.join(link, 'removed-worktree') } + + before { record_session(removed).tap { FileUtils.rm_rf(removed) } } + + it 'is found by the readers' do + expect(ClaudeAgentSDK.list_sessions(directory: removed).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed)&.summary) + .to eq('clean up the old worktree') + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: removed).length).to eq(2) + end + + it 'can be renamed through the directory' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Old worktree', directory: removed) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed).custom_title) + .to eq('Old worktree') + end + + it 'has the project key the directory had while it existed' do + expect(ClaudeAgentSDK.project_key_for_directory(removed)) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(real_parent, 'removed-worktree'))) + end + end + + it 'reports a directory that never held the session as a missing session' do + typo = File.join(cwd, 'no-such-project') + + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'x', directory: typo) } + .to raise_error(Errno::ENOENT, /Session #{session_id} not found in project directory/) + expect { ClaudeAgentSDK.delete_session(session_id: session_id, directory: typo) } + .to raise_error(Errno::ENOENT, /Session #{session_id} not found in project directory/) + end +end From 9f193cb0b9a64122ee5bebb19f5bedfb22aa0192 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:11:25 +0800 Subject: [PATCH 038/194] Resume from a store when load_timeout_ms or env is nil ClaudeAgentOptions fills load_timeout_ms (60_000) and env ({}) in its constructor only. Both attributes are nilable, and clearing one afterwards (options.dup_with(env: nil), options.load_timeout_ms = nil) leaves nil in place. A store-backed resume then raised NoMethodError from inside the SDK: "undefined method '/' for nil" while computing the store-call timeout, or "undefined method 'merge' for nil" while pointing the options at the materialized config dir. Both use sites now read nil as the constructor default: 60 s per store call, and no environment overrides. The readers still return nil, and an explicit load_timeout_ms of 0 is still honored. Seeding the temp config dir was already nil-safe for env (it resolves the child's environment through the parent's); the new spec pins that. Exploration finding: C-P6 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- lib/claude_agent_sdk/session_resume.rb | 12 ++- spec/unit/session_resume_nil_options_spec.rb | 87 ++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 spec/unit/session_resume_nil_options_spec.rb diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 5ed4f852..a7d51456 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -83,6 +83,10 @@ module SessionResume # rubocop:disable Metrics/ModuleLength -- resume materializ KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials' KEYCHAIN_TIMEOUT_SECONDS = 5 + # The default ClaudeAgentOptions gives load_timeout_ms; used when a caller + # set the attribute back to nil. + DEFAULT_LOAD_TIMEOUT_MS = 60_000 + # SystemCallError classes that indicate a transiently-held handle (Windows # AV/indexer scanning a freshly-written file) or a recoverable resource # shortage (file-table exhaustion) rather than a permanent failure. EMFILE/ @@ -97,9 +101,11 @@ module SessionResume # rubocop:disable Metrics/ModuleLength -- resume materializ # Return a copy of +options+ repointed at a materialized temp config dir: # CLAUDE_CONFIG_DIR in env, resume set to the materialized session id, and # continue_conversation cleared (already resolved to a concrete session id). + # options.env reads nil once a caller set it back to nil (the constructor + # default is {}); that means no overrides. def apply_materialized_options(options, materialized) options.dup_with( - env: options.env.merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s), + env: (options.env || {}).merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s), resume: materialized.resume_session_id, continue_conversation: false ) @@ -134,7 +140,9 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat return nil if store.nil? return nil if options.resume.nil? && !options.continue_conversation - timeout_s = options.load_timeout_ms / 1000.0 + # load_timeout_ms reads nil once a caller set it back to nil; the + # constructor default applies then. 0 is a valid (immediate) timeout. + timeout_s = (options.load_timeout_ms || DEFAULT_LOAD_TIMEOUT_MS) / 1000.0 # Probed ONCE at materialization entry (the resume path's construction # point) so an invalid callback_scheduling declaration fails fast here, # before any store IO or temp-dir work. diff --git a/spec/unit/session_resume_nil_options_spec.rb b/spec/unit/session_resume_nil_options_spec.rb new file mode 100644 index 00000000..7b066d20 --- /dev/null +++ b/spec/unit/session_resume_nil_options_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'tmpdir' +require 'fileutils' + +# ClaudeAgentOptions fills `env` and `load_timeout_ms` with their defaults in +# the constructor only. Both attributes are nilable, and the natural ways to +# clear one afterwards — `options.dup_with(env: nil)`, `options.env = nil` — +# leave nil in place. Store-backed resume has to read them as their defaults +# (60 s; no overrides) rather than raise NoMethodError on nil. +RSpec.describe ClaudeAgentSDK::SessionResume do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:cwd) { Dir.mktmpdir } + let(:caller_config_dir) { Dir.mktmpdir } + let(:sid) { SecureRandom.uuid } + let(:materializations) { [] } + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: sid, cwd: cwd) } + + # Without options.env the child's config dir is the parent's: point that at + # a scratch directory, so nothing is seeded from the developer's own. + around do |example| + previous = ENV.fetch('CLAUDE_CONFIG_DIR', nil) + ENV['CLAUDE_CONFIG_DIR'] = caller_config_dir + example.run + ensure + previous.nil? ? ENV.delete('CLAUDE_CONFIG_DIR') : (ENV['CLAUDE_CONFIG_DIR'] = previous) + end + + before do + allow(described_class).to receive(:read_keychain_credentials).and_return(nil) + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => sid }, + [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'sessionId' => sid, + 'message' => { 'role' => 'user', 'content' => 'hello' } }]) + end + + after do + materializations.each(&:cleanup) + [cwd, caller_config_dir].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + def materialize(options) + described_class.materialize_resume_session(options).tap { |materialized| materializations << materialized if materialized } + end + + it 'bounds the store calls by the 60 s default when load_timeout_ms is nil' do + allow(ClaudeAgentSDK::FiberBoundary).to receive(:invoke).and_call_original + + materialized = materialize(options.dup_with(load_timeout_ms: nil)) + + expect(materialized.resume_session_id).to eq(sid) + expect(ClaudeAgentSDK::FiberBoundary).to have_received(:invoke).with(hash_including(timeout: 60.0)).at_least(:once) + end + + it 'still honors an explicit load_timeout_ms of 0' do + allow(ClaudeAgentSDK::FiberBoundary).to receive(:invoke).and_return([]) + + materialize(options.dup_with(load_timeout_ms: 0)) + + expect(ClaudeAgentSDK::FiberBoundary).to have_received(:invoke).with(hash_including(timeout: 0.0)).at_least(:once) + end + + it 'materializes from the parent environment and repoints the options when env is nil' do + File.write(File.join(caller_config_dir, 'settings.json'), '{"apiKeyHelper":"/bin/print-key"}') + without_env = options.dup_with(env: nil) + + materialized = materialize(without_env) + applied = described_class.apply_materialized_options(without_env, materialized) + + expect(File.read(File.join(materialized.config_dir, 'settings.json'))).to eq('{"apiKeyHelper":"/bin/print-key"}') + expect(applied.env).to eq('CLAUDE_CONFIG_DIR' => materialized.config_dir) + expect(applied.resume).to eq(sid) + expect(without_env.env).to be_nil # the caller's options are left as they were + end + + it 'gets Client past materialization with both set back to nil' do + cleared = options.dup_with(env: nil, load_timeout_ms: nil) + client = ClaudeAgentSDK::Client.new(options: cleared) + + applied = client.send(:materialize_resume, cleared) + materializations << client.instance_variable_get(:@materialized) + + expect(applied.env).to eq('CLAUDE_CONFIG_DIR' => materializations.first.config_dir) + expect(applied.resume).to eq(sid) + end +end From 6c8b5300543aef21f0f20b529e6ff4c3debd2488 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:13:39 +0800 Subject: [PATCH 039/194] Describe the credential filtering in Type#inspect's YARD What #inspect filters was documented only on Type.inspect_filtered, which is `@api private` and so absent from the rendered docs. Now that the filter covers settings, extra_args, MCP args and urls, say on the public method what prints as "[FILTERED]" and that an MCP url prints its scheme and host only. Comment only. Exploration finding: C-P10 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP --- lib/claude_agent_sdk/types/base.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/claude_agent_sdk/types/base.rb b/lib/claude_agent_sdk/types/base.rb index 0ffebc40..5315a9c9 100644 --- a/lib/claude_agent_sdk/types/base.rb +++ b/lib/claude_agent_sdk/types/base.rb @@ -123,6 +123,12 @@ def self.option_hash_key(key, hash) # adapters, observers) show as `#`. For display only: nothing # sent to the CLI goes through #inspect or #to_s (wire output uses #to_h). # + # Attributes that carry credentials print as `[FILTERED]` (a Hash keeps + # its keys): `env`, `settings` and `extra_args` of ClaudeAgentOptions, + # and `env`, `args` and `headers` of the MCP server configs. An MCP + # server `url` prints its scheme and host only, and a Hash server config + # in `mcp_servers` is filtered like a typed one. + # # It does not raise: a value that fails while it is rendered shows as # `#` (`#` when even its class cannot be asked). def inspect From e46f5c5026d743fb80ee25503810231d160a946e Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:15:04 +0800 Subject: [PATCH 040/194] Take created_at from a top-level timestamp on the disk path list_sessions / get_session_info on disk took created_at from the first `"timestamp":"` byte match in the head of the transcript. An interactive session starts with metadata lines, among them a file-history-snapshot entry that has no timestamp of its own but nests one (snapshot.timestamp), so the disk path reported the snapshot's time while the store fold, which reads top-level keys, reported the first entry's. The disk path now uses the top-level field scanner the other listing fields already go through, so both paths report the same created_at for the same transcript. The value moves for sessions whose first timestamp match was a nested one. Exploration finding: B-P5 (part 2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 7 +++- spec/fixtures/cli_transcript.rb | 8 ++++ spec/unit/session_info_disk_vs_store_spec.rb | 41 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 spec/unit/session_info_disk_vs_store_spec.rb diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index e0f4e58c..22f0fb3a 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -628,8 +628,11 @@ def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disa # filesystems. Scans the whole head rather than only the first line # because the first record may be a metadata-only entry (e.g. # permission-mode) with no timestamp field; the first user/assistant - # record that follows does carry one (Python #907). - first_timestamp = extract_json_string_field(head, 'timestamp', last: false) + # record that follows does carry one (Python #907). TOP-LEVEL only, as + # the store fold reads it: a file-history-snapshot entry, common near + # the start of an interactive session, has no timestamp of its own but + # nests one (snapshot.timestamp), and the raw scan reported that one. + first_timestamp = extract_top_level_string_field(head, 'timestamp') created_at = parse_iso_timestamp_ms(first_timestamp) if first_timestamp SDKSessionInfo.new( diff --git a/spec/fixtures/cli_transcript.rb b/spec/fixtures/cli_transcript.rb index ba4f3df2..e29b210f 100644 --- a/spec/fixtures/cli_transcript.rb +++ b/spec/fixtures/cli_transcript.rb @@ -35,6 +35,9 @@ class CLITranscript attr_reader :session_id, :cwd, :entries + # The branch stamped on the entries built from here on. + attr_writer :git_branch + # @param agent_id [String, nil] build a SUBAGENT transcript (agent-.jsonl) def initialize(session_id:, cwd: '/work/app', agent_id: nil, git_branch: 'main', start: Time.utc(2026, 9, 8, 5, 19, 30)) @@ -149,6 +152,11 @@ def queue_operations(text) raw({ 'type' => 'queue-operation', 'operation' => 'dequeue', 'timestamp' => tick, 'sessionId' => @session_id }) end + # The first line of an interactive session. + def mode(mode = 'default') + raw({ 'type' => 'mode', 'mode' => mode, 'sessionId' => @session_id }) + end + def permission_mode(mode = 'default') raw({ 'type' => 'permission-mode', 'permissionMode' => mode, 'sessionId' => @session_id }) end diff --git a/spec/unit/session_info_disk_vs_store_spec.rb b/spec/unit/session_info_disk_vs_store_spec.rb new file mode 100644 index 00000000..71f142fb --- /dev/null +++ b/spec/unit/session_info_disk_vs_store_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The disk reader looks at the first and the last 64 KiB of a transcript (and, +# for the first prompt, at most the first 1 MiB); the store fold sees every +# entry. Same transcript on both sides: written to disk, then imported. +RSpec.describe 'session info from disk and from a store, for one transcript' do + include_context 'with a Claude config dir' + + let(:session_id) { '8f9a0b1c-2d3e-4f50-8a61-7b8c9d0e1f20' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + # [disk info, store info] for the transcript the block builds. The session + # was killed before the CLI wrote its exit-time lines (last-prompt, ...), + # unless the block adds them. + def read_both + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + yield transcript + transcript.write(transcript_path(session_id)) + ClaudeAgentSDK.import_session_to_store(session_id: session_id, session_store: store, directory: cwd) + [ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd), + ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store)] + end + + # An interactive session starts with metadata lines; the snapshot's + # timestamp is nested, the entry itself has none. + it 'takes created_at from the first entry with a timestamp of its own, not from a file-history-snapshot' do + disk, stored = read_both do |t| + t.mode + t.file_history_snapshot(timestamp: '2026-09-01T12:34:56.789Z') + t.prompt(:prompt, 'after the snapshot') + t.assistant(:answer, t.text('Done.'), parent: :prompt) + end + + expect(disk.created_at).to eq(stored.created_at) + expect(Time.at(disk.created_at / 1000).utc.strftime('%F %T')).to eq('2026-09-08 05:19:30') + end +end From 4ede5bf1caf00cf811432b1c7ad794e51e7e7753 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:15:46 +0800 Subject: [PATCH 041/194] Store entries as json in the Postgres reference adapter The Postgres reference adapter kept transcript entries in a jsonb column. jsonb rejects the JSON escape for U+0000, and transcripts carry it: a NUL character in binary tool output is serialized that way. One such entry made the whole multi-row INSERT fail with PG::UntranslatableCharacter, so the mirror dropped every entry of that batch, the clean ones included, and a later store-backed resume replayed a transcript with that turn missing. The adapter now declares the column as json and casts to json on insert. json keeps the text as given; the adapter only reads whole entries, so it needs nothing jsonb offers. create_schema is CREATE TABLE IF NOT EXISTS, so a table created by an earlier copy of the adapter keeps its jsonb column and keeps rejecting those entries. The adapter's header and README now say so and give the migration: ALTER TABLE claude_session_store ALTER COLUMN entry TYPE json USING entry::json; Example code only; the gem is unaffected. A new live-gated spec (it needs the pg gem and SESSION_STORE_POSTGRES_URL, like the existing one) covers a batch with a NUL entry, and an old jsonb table before and after that migration. Exploration finding: B-P7 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- examples/session_stores/README.md | 38 +++++++-- .../session_stores/postgres_session_store.rb | 27 +++--- .../postgres_session_store_payload_spec.rb | 84 +++++++++++++++++++ 3 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 spec/examples/postgres_session_store_payload_spec.rb diff --git a/examples/session_stores/README.md b/examples/session_stores/README.md index c27cba48..95edcedb 100644 --- a/examples/session_stores/README.md +++ b/examples/session_stores/README.md @@ -131,7 +131,11 @@ through the relevant items below. - Pass a connection backed by a pool sized ≥ expected concurrent sessions for heavy use; don't share one connection with request-handler code that holds it. -- `jsonb` reorders keys — contract-safe, but don't byte-compare entries. +- Keep `entry` a `json` column. `jsonb` rejects the `\u0000` escape, so one + entry with a NUL character (binary tool output) fails the whole `INSERT` and + the mirror drops that batch. A table created by an earlier copy of this + adapter still has `jsonb`, and `create_schema` does not alter an existing + table: run the migration under [`json`, not `jsonb`](#json-not-jsonb). - Add a retention job (`DELETE WHERE mtime < ...`) — the table grows unbounded. --- @@ -250,7 +254,7 @@ CREATE TABLE IF NOT EXISTS claude_session_store ( session_id text NOT NULL, subpath text NOT NULL DEFAULT '', seq bigserial, - entry jsonb NOT NULL, + entry json NOT NULL, mtime bigint NOT NULL, PRIMARY KEY (project_key, session_id, subpath, seq) ); @@ -262,14 +266,30 @@ CREATE INDEX IF NOT EXISTS claude_session_store_list_idx `#load` is `SELECT entry ... ORDER BY seq`. The empty string is the `subpath` sentinel for the main transcript so the composite primary key is total. -### JSONB key ordering +### `json`, not `jsonb` -Entries are stored as `jsonb`, which **reorders object keys** on read-back. This -is explicitly allowed by the SessionStore contract — `#load` requires -*deep-equal*, not *byte-equal*, returns. The Ruby SDK reads entry fields by key -from the parsed Hash (it never does a byte/prefix scan over stored entries), so -reordering is transparent. Use a `json` or `text` column if you need byte-stable -storage. +Entries are stored in a `json` column. `jsonb` rejects the `\u0000` escape, and +transcripts do carry it: a NUL character in binary tool output is serialized +that way. One such entry fails the whole multi-row `INSERT`, so the mirror +drops every entry of that batch (surfaced as a `MirrorErrorMessage`) and a +later store-backed resume replays a transcript with that turn missing. `json` +keeps the text exactly as given, key order included. The adapter only ever +reads whole entries, so it needs none of `jsonb`'s operators or indexes. If you +add SQL that looks inside entries, note that `->` and `->>` still fail on an +entry that contains the escape. + +`create_schema` is `CREATE TABLE IF NOT EXISTS`, so it does **not** change a +table created by an earlier copy of this adapter. That table keeps its `jsonb` +column, and keeps rejecting those entries, until you migrate it: + +```sql +ALTER TABLE claude_session_store ALTER COLUMN entry TYPE json USING entry::json; +``` + +The statement rewrites the table under an `ACCESS EXCLUSIVE` lock, so pick a +quiet moment for a large one. Rows written while the column was `jsonb` come +back with the key order `jsonb` gave them, which the SessionStore contract +allows: `#load` requires *deep-equal*, not *byte-equal*, returns. `#delete` cascades to subpath rows; it is only invoked via `ClaudeAgentSDK.delete_session(session_id:, session_store: store)`. diff --git a/examples/session_stores/postgres_session_store.rb b/examples/session_stores/postgres_session_store.rb index 08a10a76..668e617c 100644 --- a/examples/session_stores/postgres_session_store.rb +++ b/examples/session_stores/postgres_session_store.rb @@ -34,7 +34,7 @@ # session_id text NOT NULL, # subpath text NOT NULL DEFAULT '', # seq bigserial, -# entry jsonb NOT NULL, +# entry json NOT NULL, # mtime bigint NOT NULL, # PRIMARY KEY (project_key, session_id, subpath, seq) # ); @@ -53,11 +53,18 @@ # many concurrent sessions, use one adapter instance (with its own # connection) per session instead. # -# JSONB key ordering: entries are stored as `jsonb`, which REORDERS object keys -# on read-back. This is explicitly allowed by the SessionStore contract — #load -# requires deep-equal, not byte-equal, returns. The Ruby SDK reads entry fields -# by key from the parsed Hash (never a byte/prefix scan), so reordering is -# transparent. Use a `json` or `text` column if you need byte-stable storage. +# Column type: entries are stored as `json`, not `jsonb`. `jsonb` rejects the +# \u0000 escape, and transcripts do carry it (a NUL character in binary tool +# output): one such entry fails the whole multi-row INSERT, so the mirror drops +# every entry of that batch. `json` keeps the text as given, key order +# included; this adapter only ever reads whole entries, so it needs none of +# jsonb's operators or indexes. +# +# A table created by an earlier copy of this adapter keeps its `jsonb` column: +# #create_schema is CREATE TABLE IF NOT EXISTS and never alters an existing +# table. Migrate it explicitly: +# +# ALTER TABLE claude_session_store ALTER COLUMN entry TYPE json USING entry::json; # # Retention: this adapter never deletes rows on its own. Add a scheduled # DELETE ... WHERE mtime < $cutoff (or partition by mtime). @@ -103,7 +110,7 @@ def create_schema session_id text NOT NULL, subpath text NOT NULL DEFAULT '', seq bigserial, - entry jsonb NOT NULL, + entry json NOT NULL, mtime bigint NOT NULL, PRIMARY KEY (project_key, session_id, subpath, seq) ); @@ -128,7 +135,7 @@ def append(key, entries) db_exec_params( <<~SQL, INSERT INTO #{@table} (project_key, session_id, subpath, entry, mtime) - SELECT $1, $2, $3, e::jsonb, $5 + SELECT $1, $2, $3, e::json, $5 FROM unnest($4::text[]) WITH ORDINALITY AS t(e, ord) ORDER BY ord SQL @@ -145,8 +152,8 @@ def load(key) ) return nil if result.ntuples.zero? - # The pg gem returns jsonb as its JSON text; parse each row. (If a type map - # decodes jsonb to a Hash already, pass it through.) + # The pg gem returns json as its JSON text; parse each row. (If a type map + # decodes json to a Hash already, pass it through.) out = result.map do |row| v = row['entry'] v.is_a?(String) ? JSON.parse(v) : v diff --git a/spec/examples/postgres_session_store_payload_spec.rb b/spec/examples/postgres_session_store_payload_spec.rb new file mode 100644 index 00000000..e95e5ccf --- /dev/null +++ b/spec/examples/postgres_session_store_payload_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' + +# Gated live spec, like postgres_session_store_spec.rb: runs only when the `pg` +# gem is installed (optional :examples Bundler group) AND +# SESSION_STORE_POSTGRES_URL points at a reachable server. Each example uses a +# random-suffixed table and DROPs it on teardown. +postgres_url = ENV.fetch('SESSION_STORE_POSTGRES_URL', nil) +postgres_reachable = begin + require 'pg' + require_relative '../../examples/session_stores/postgres_session_store' + if postgres_url + probe = PG.connect(postgres_url) + probe.exec('SELECT 1') + probe.close + true + else + false + end +rescue LoadError, StandardError + false +end + +# Transcripts carry NUL characters (binary tool output). JSON.generate writes +# one as the six-character escape for U+0000, which PostgreSQL's `jsonb` input +# rejects and `json` stores as given. One such entry used to fail the whole +# multi-row INSERT, so the mirror dropped every entry of that batch. +RSpec.describe 'PostgresSessionStore with a NUL character in a transcript entry', if: postgres_reachable do + let(:conn) { PG.connect(postgres_url) } + let(:table) { "cst_test_#{SecureRandom.hex(6)}" } + let(:store) { PostgresSessionStore.new(conn: conn, table: table) } + let(:key) { { 'project_key' => '-Users-dev-app', 'session_id' => SecureRandom.uuid } } + let(:binary_output) do + { 'type' => 'user', 'uuid' => 'u-nul', + 'message' => { 'role' => 'user', + 'content' => [{ 'type' => 'tool_result', 'tool_use_id' => 'toolu_01', + 'content' => "ELF#{0.chr}#{1.chr}binary" }] } } + end + + after do + conn.exec("DROP TABLE IF EXISTS #{table}") + conn.close + end + + def turn(uuid) + { 'type' => 'user', 'uuid' => uuid, 'message' => { 'role' => 'user', 'content' => "turn #{uuid}" } } + end + + it 'stores the entry, and the clean entries appended with it' do + store.create_schema + batch = [turn('u-1'), binary_output, turn('u-2')] + + store.append(key, batch) + + expect(store.load(key)).to eq(batch) + end + + # #create_schema is CREATE TABLE IF NOT EXISTS: a table created by an earlier + # copy of the adapter keeps its jsonb column until it is migrated. This is + # the migration the README gives. + it 'still rejects it on a table with the old jsonb column, until that column is migrated' do + conn.exec(<<~SQL) + CREATE TABLE #{table} ( + project_key text NOT NULL, + session_id text NOT NULL, + subpath text NOT NULL DEFAULT '', + seq bigserial, + entry jsonb NOT NULL, + mtime bigint NOT NULL, + PRIMARY KEY (project_key, session_id, subpath, seq) + ) + SQL + store.create_schema # leaves the existing table as it is + store.append(key, [turn('u-1')]) + expect { store.append(key, [binary_output]) }.to raise_error(PG::UntranslatableCharacter) + + conn.exec("ALTER TABLE #{table} ALTER COLUMN entry TYPE json USING entry::json") + + store.append(key, [binary_output]) + expect(store.load(key)).to eq([turn('u-1'), binary_output]) + end +end From dcca6a135c481a51c10c4d26afefd3f08754fccb Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:16:19 +0800 Subject: [PATCH 042/194] Document that a store-backed resume has no project auto-memory docs/sessions.md lists what a store-backed resume does not see from the caller's config dir (user CLAUDE.md, agents/, skills/, plugins/) and says everything else is invisible as well. The project's auto-memory was not named. It lives under projects//memory/ in the config dir, the materialized temp config dir has no such directory, and the temp dir is deleted at disconnect, so such a session neither loads existing memory nor keeps what it writes. One sentence added to that paragraph. Documentation only: nothing is copied or symlinked into the temp dir. Exploration finding: U-P16 (refuted as a defect; the docs gap is real) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- docs/sessions.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/sessions.md b/docs/sessions.md index eb5e2455..902f0f70 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -277,7 +277,10 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > Everything else in your config dir is **not** visible to the subprocess — > notably user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` (so, with the > plugin keys stripped, user plugins are off) — so a store-backed resume can -> still behave differently from a plain `resume:` of the same session. +> still behave differently from a plain `resume:` of the same session. The +> project's auto-memory is part of that: the temp config dir has no +> `projects//memory/`, so a store-backed resume neither loads the memory +> you already have nor keeps what the session writes to it. > Project-level `.claude/*` still applies (it resolves from `cwd`), and > hooks/options passed programmatically via `ClaudeAgentOptions` are unaffected. > Seeded files are written owner-only (`0600`); a missing source file is simply From c48b5ffea5acf2d5b8d3e68735de1b499b7b3740 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:17:19 +0800 Subject: [PATCH 043/194] Fsync the installed CLI and its VERSION before publishing them Nothing was fsynced before the installer renamed the downloaded binary into place. rename() is atomic for concurrent readers and across a process crash, but across a power loss it only orders metadata: the directory could come back holding a zero-length or partial executable `claude` next to a VERSION naming it. Lock-free discovery (installed_path checks only "file and executable") then handed that file to every spawn until something called install again. The TLA model nevertheless listed power loss among the things its Crash step covers. Now: - Http.download_to fsyncs the temp file before closing it; - Metadata.write fsyncs the VERSION temp file before renaming it; - #publish fsyncs the directory after the binary's rename, best-effort. It runs where nothing may fail the install, and not every platform or filesystem can sync a directory, so SystemCallError, IOError and NotImplementedError are swallowed there. The order of operations is unchanged (download, verify, record VERSION, rename last), and the rename is still the last step that can fail. A failed fsync of the download or of VERSION fails the install like any other write error, before anything is published; the previous install stays intact. The Crash comment in formal/tla/CLIInstaller.tla now says what holds for a power loss and what it rests on (those fsyncs, fsync(2) reaching stable storage, ordered directory operations), and names what is not modelled; formal/tla/README.md lists durability among the assumptions. Comments only: no modelled step and no run.sh expectation changes. User-visible: an install that downloads pays for one fsync of the binary. The idempotent shortcut writes nothing and syncs nothing. Exploration finding: D-P11 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/cli-installer.md | 2 +- formal/tla/CLIInstaller.tla | 21 ++- formal/tla/README.md | 1 + lib/claude_agent_sdk/cli_installer.rb | 28 ++++ spec/unit/cli_installer_durability_spec.rb | 172 +++++++++++++++++++++ 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 spec/unit/cli_installer_durability_spec.rb diff --git a/docs/cli-installer.md b/docs/cli-installer.md index 38f30fdc..c03e913b 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -32,7 +32,7 @@ ClaudeAgentSDK::CLIInstaller.installed_path Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`. -**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. A first install that fails leaves nothing behind at all. +**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. The binary and `VERSION` are also flushed to disk (`fsync`) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails leaves nothing behind at all. ## When the pin moves diff --git a/formal/tla/CLIInstaller.tla b/formal/tla/CLIInstaller.tla index 86ccfdf4..766ddb8d 100644 --- a/formal/tla/CLIInstaller.tla +++ b/formal/tla/CLIInstaller.tla @@ -183,7 +183,26 @@ Fail(i) == /\ Leave(i, "fail") /\ UNCHANGED <> -\* SIGKILL / OOM / power loss: no `ensure`, the temp file stays behind. +\* The process dies with no `ensure`: SIGKILL, OOM, or the machine losing +\* power. The temp file stays behind and the flock is gone (released by the +\* kernel, or lost with it). +\* +\* UNCHANGED is exact for a dead process: the files are what the completed +\* steps left. For a power loss it holds for the two files the properties +\* read, because of what the code fsyncs: the downloaded bytes before Rename +\* and the VERSION bytes before Record's rename. A name that survives the +\* outage therefore holds a complete file -- `binary` and `vfile` are each the +\* old value or the new one, never an empty or partial file. (Before those +\* fsyncs a first install could come back as an empty executable `claude` +\* beside a VERSION naming it, which breaks ReadersSeeVerifiedBinary.) +\* A rename that had not reached the disk is undone; on a filesystem that +\* commits directory operations in order, that is this step at an earlier pc. +\* +\* Not modelled: the directory fsync after Rename, which is what keeps a +\* FINISHED install across an outage (best-effort; skipped where a directory +\* cannot be synced), and `tmps` after an outage -- a leftover temp file may be +\* missing or short, which is harmless because temp files are only ever +\* swept, never read. All of it assumes fsync(2) reaches stable storage. Crash(i) == /\ pc[i] # "idle" /\ Leave(i, "crash") diff --git a/formal/tla/README.md b/formal/tla/README.md index 306cb209..a3f7bb02 100644 --- a/formal/tla/README.md +++ b/formal/tla/README.md @@ -45,6 +45,7 @@ There are three properties. `EverySenderFinishes` says every control method retu - File contents are abstracted to "the version they are the bytes of", so a checksum match is equality. Platform detection is not modelled. - The models assume that POSIX `rename` is atomic and that the kernel releases a dead process's flock. `ReadersSeeVerifiedBinary` holds by construction given those assumptions. +- Durability is assumed, not modelled. `Crash` leaves the files as the completed steps left them, which is exact when a process dies. Across a power loss it rests on the installer's `fsync`s — the downloaded bytes before `Rename`, the `VERSION` bytes before `Record`'s rename, and (best-effort) the directory after `Rename` — and on `fsync(2)` reaching stable storage: a surviving name then holds a complete file, and a rename that had not reached the disk is undone, which is `Crash` at an earlier step on a filesystem that commits directory operations in order. - Not modelled: `install`'s read-only shortcut. When the lock file cannot be opened (`EACCES`, `EROFS`, `EPERM`) and the request is a concrete version that is already installed and re-hashes to its recorded checksum, `install` returns the binary without the lock. That path takes no lock and writes nothing, so it is one more lock-free reader, which `ReadersSeeVerifiedBinary` already covers; no modelled ordering and no `run.sh` expectation changes. - Not modelled: control-request timeouts (see above); the inbound half (`handle_control_request`, the #119 process-exit path, cancellation during a response write, which the transport answers by poisoning itself so no partial frame is ever followed by another); `Metadata.write`'s own temp file; `TranscriptMirrorBatcher`. - **The Ruby is not verified.** A model can drift from the code it describes. When you change one of the orderings the tables cite, update the model and `run.sh`'s expectations in the same change. Each counterexample trace can also be turned into an RSpec regression by stubbing `CLIInstaller::Http` / `Release` / `Metadata`, or with a scripted transport for `Query`, so that the step TLC found is exercised against the real code. diff --git a/lib/claude_agent_sdk/cli_installer.rb b/lib/claude_agent_sdk/cli_installer.rb index dec1c3bb..4182495a 100644 --- a/lib/claude_agent_sdk/cli_installer.rb +++ b/lib/claude_agent_sdk/cli_installer.rb @@ -173,6 +173,11 @@ def fetch_text(url, limit:) # +max_bytes+ (the manifest's declared size, when it has one) aborts a # response that runs long instead of filling the disk before the # checksum gets a chance to reject it. + # + # The file is fsynced before it is closed: the caller renames it into + # place, and a rename only orders metadata. Without the sync, a power + # loss shortly after an install could leave the published name + # pointing at an empty or partial file. def download_to(url, path, max_bytes: nil) with_response(url) do |response| written = 0 @@ -185,6 +190,7 @@ def download_to(url, path, max_bytes: nil) file.write(chunk) end + file.fsync end end path @@ -356,11 +362,14 @@ def read(dir) # Atomic: an unpredictable temp name opened O_EXCL, then renamed over # the old file. Without this a reader could observe a half-written # VERSION, or (worse) the previous version paired with a new binary. + # Fsynced before the rename, so that after a power loss VERSION is + # the old file or the new one, not an empty one. def write(dir, version, checksum, platform) tmp = File.join(dir, "#{VERSION_FILE}.#{SecureRandom.hex(8)}.tmp") begin File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file| file.write("#{version}\n#{checksum}\n#{platform}\n") + file.fsync end File.rename(tmp, File.join(dir, VERSION_FILE)) ensure @@ -585,17 +594,36 @@ def installed?(dir, version, platform) # (The reverse order — rename then record — briefly published a binary # nothing vouched for, and a metadata failure then had to delete the # freshly renamed file, taking the previous working install with it.) + # + # Across a power loss, a rename only orders metadata. So the downloaded + # bytes (Http.download_to) and the VERSION bytes (Metadata.write) are + # fsynced before their renames: each name then holds a complete file, + # the old one or the new one, never an empty or partial one. The + # directory is synced after the rename so that an install that returned + # is still there afterwards; that sync is best-effort and cannot fail + # the install, which keeps the rename the last step that can. def publish(dir, binary, version, platform, entry) tmp = "#{binary}.download.#{SecureRandom.hex(8)}" begin fetch_verified(version, platform, entry, tmp) Metadata.write(dir, version, entry[:checksum], platform) File.rename(tmp, binary) + sync_directory(dir) ensure FileUtils.rm_f(tmp) end end + # Makes the renames in +dir+ durable (VERSION's and the binary's). + # Best-effort by necessity: it runs after the rename, where nothing may + # fail the install, and not every platform or filesystem can fsync a + # directory — some refuse to open one, others answer EINVAL or EBADF. + def sync_directory(dir) + File.open(dir, File::RDONLY, &:fsync) + rescue SystemCallError, IOError, NotImplementedError + nil + end + # Download to an unpredictable sibling temp name (same filesystem, so the # rename is atomic; O_EXCL, so a pre-planted path or symlink cannot be # written through), bounded by the manifest's declared size, then verify diff --git a/spec/unit/cli_installer_durability_spec.rb b/spec/unit/cli_installer_durability_spec.rb new file mode 100644 index 00000000..03072099 --- /dev/null +++ b/spec/unit/cli_installer_durability_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'digest' +require 'json' +require 'tmpdir' + +# Durability of CLIInstaller's publish step. rename() is atomic for concurrent +# readers and across a process crash, but across a power loss it only orders +# metadata: unless the bytes were fsynced first, the published name can come +# back pointing at an empty or partial file — a zero-length executable +# `claude` beside a VERSION that names it, which lock-free discovery then +# hands out. Nothing was fsynced before. +# +# A power cut cannot be staged in a spec, so these examples spy on fsync and +# rename and check that the syncs happen, and where in the sequence. +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'durability of the publish step' do + let(:base) { 'https://downloads.claude.ai/claude-code-releases' } + let(:http) { ClaudeAgentSDK::CLIInstaller::Http } + let(:metadata) { ClaudeAgentSDK::CLIInstaller::Metadata } + let(:dir) { @dir } + let(:binary_path) { File.join(dir, 'claude') } + # [:fsync, name, size at that moment], [:fsync_failed, name] and + # [:rename, from, to], in order; the random part of a temp name is replaced + # by . + let(:events) { [] } + + around do |example| + Dir.mktmpdir('cli-installer-durability') do |tmp| + @dir = File.join(tmp, 'vendor', 'claude') + example.run + end + end + + def label(path) + return 'the install directory' if File.expand_path(path) == dir + + File.basename(path).sub(/[0-9a-f]{16}/, '') + end + + # Records every fsync and every rename, then lets the real one run. + # +failing+ maps a label to the error its fsync raises instead. + def spy_on_syncs_and_renames(failing: {}) + spy_on_fsync(failing) + log = events + labeller = method(:label) + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + original.call(from, to) + log << [:rename, labeller.call(from), labeller.call(to)] + 0 + end + end + + def spy_on_fsync(failing) + log = events + labeller = method(:label) + allow_any_instance_of(File).to receive(:fsync).and_wrap_original do |original, *args| + path = original.receiver.path + name = labeller.call(path) + if failing.key?(name) + log << [:fsync_failed, name] + raise failing[name] + end + + original.call(*args).tap { log << [:fsync, name, File.directory?(path) ? nil : File.size(path)] } + end + end + + # The manifest GET is stubbed at Http.fetch_text; the binary comes through + # the real Http.download_to (only the response is canned), so the file is + # written and synced by the code under test. + def stub_release(version:, body:) + allow(ClaudeAgentSDK::CLIInstaller::Platform).to receive(:detect).and_return('linux-x64') + allow(http).to receive(:fetch_text).with("#{base}/#{version}/manifest.json", any_args).and_return( + JSON.generate('platforms' => { 'linux-x64' => { 'checksum' => Digest::SHA256.hexdigest(body), + 'size' => body.bytesize } }) + ) + response = Object.new + response.define_singleton_method(:read_body) { |&chunk| body.scan(/.{1,7}/m).each(&chunk) } + allow(http).to receive(:with_response) { |_url, &handler| handler.call(response) } + end + + it 'syncs the download and the metadata before their renames, and the directory after the last one' do + body = 'not-really-280MB-of-claude' + stub_release(version: '2.1.220', body: body) + spy_on_syncs_and_renames + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + + metadata_size = "2.1.220\n#{Digest::SHA256.hexdigest(body)}\nlinux-x64\n".bytesize + expect(events).to eq( + [ + [:fsync, 'claude.download.', body.bytesize], + [:fsync, 'VERSION..tmp', metadata_size], + [:rename, 'VERSION..tmp', 'VERSION'], + [:rename, 'claude.download.', 'claude'], + [:fsync, 'the install directory', nil] + ] + ) + end + + it 'syncs nothing on the idempotent shortcut, which writes nothing' do + stub_release(version: '2.1.220', body: 'not-really-280MB-of-claude') + described_class.install(version: '2.1.220', dir: dir) + spy_on_syncs_and_renames + + described_class.install(version: '2.1.220', dir: dir) + + expect(events).to be_empty + end + + # After the rename nothing may fail the install: the binary is published and + # VERSION vouches for it. Platforms and filesystems that cannot fsync a + # directory say so in different ways. + [Errno::EINVAL, Errno::EACCES, Errno::EBADF, Errno::ENOTSUP, IOError, NotImplementedError].each do |error| + it "still succeeds when the directory cannot be synced (#{error})" do + stub_release(version: '2.1.220', body: 'not-really-280MB-of-claude') + spy_on_syncs_and_renames(failing: { 'the install directory' => error }) + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + expect(File.binread(binary_path)).to eq('not-really-280MB-of-claude') + expect(events.last(2)).to eq([[:rename, 'claude.download.', 'claude'], + [:fsync_failed, 'the install directory']]) + end + end + + it 'still succeeds when the directory cannot even be opened for the sync' do + stub_release(version: '2.1.220', body: 'not-really-280MB-of-claude') + spy_on_syncs_and_renames + allow(File).to receive(:open).and_call_original + allow(File).to receive(:open).with(dir, File::RDONLY).and_raise(Errno::EACCES.new(dir)) + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + expect(File).to have_received(:open).with(dir, File::RDONLY) + expect(events.last).to eq([:rename, 'claude.download.', 'claude']) + end + + context 'with a working install in place' do + before do + stub_release(version: '2.1.220', body: 'the installed build') + described_class.install(version: '2.1.220', dir: dir) + stub_release(version: '2.1.226', body: 'a newer build') + end + + def expect_previous_install_intact + expect(File.binread(binary_path)).to eq('the installed build') + expect(metadata.read(dir)).to include(version: '2.1.220') + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION', 'claude') + end + + it 'fails the upgrade, publishing nothing, when the download cannot be synced' do + spy_on_syncs_and_renames(failing: { 'claude.download.' => Errno::EIO }) + + expect { described_class.install(version: '2.1.226', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events).to eq([[:fsync_failed, 'claude.download.']]) + expect_previous_install_intact + end + + it 'fails the upgrade, publishing nothing, when the metadata cannot be synced' do + spy_on_syncs_and_renames(failing: { 'VERSION..tmp' => Errno::EIO }) + + expect { described_class.install(version: '2.1.226', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events).to eq([[:fsync, 'claude.download.', 'a newer build'.bytesize], + [:fsync_failed, 'VERSION..tmp']]) + expect_previous_install_intact + end + end +end From ec57f15b9a36bd98e2398277044b4ea55cde7232 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:18:16 +0800 Subject: [PATCH 044/194] Neutralize ids and account state in the recorded CLI fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec/fixtures/cli_frames/tool_turn.jsonl is a sanitized recording of a real CLI turn, and this repository is public. It still carried values from the recording account that no spec reads: the API-issued message, tool-use and request ids, and the account's overage state and rate-limit reset times. The ids are now placeholders of the same form and length (msg_fixture…, toolu_fixture…, req_fixture…). Every equality of the recording holds: all frames of one API response share one message.id and one request_id, and the tool_use block id, its wire_tool_inputs key and the tool result's tool_use_id are one id. rate_limit_info keeps all its keys. overageStatus is "allowed" with a null overageDisabledReason, the shape message_parser_spec.rb already uses, and the three resetsAt values are round epoch numbers, the top-level one still equal to the five-hour window's. The fixture README lists the ids and the account state under what is replaced rather than what is kept as recorded. No example changes: none of them reads an id or a rate-limit value. Review follow-up to the H-P3 fixture (orchestrator ruling R1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS --- spec/fixtures/cli_frames/README.md | 5 +++-- spec/fixtures/cli_frames/tool_turn.jsonl | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/spec/fixtures/cli_frames/README.md b/spec/fixtures/cli_frames/README.md index 033eb9b7..c40d369a 100644 --- a/spec/fixtures/cli_frames/README.md +++ b/spec/fixtures/cli_frames/README.md @@ -11,8 +11,9 @@ Each `.jsonl` file is the stdout of one real `claude` run in stream-JSON mode: o A recording is sanitized before it is committed. - **Left out:** the `control_response` that answers `initialize` (the first line the CLI writes). It lists the installed commands, agents and models and names the account. `Query` consumes it, so no message consumer or observer ever sees it. -- **Replaced with placeholders:** every text, thinking signature, tool input and tool output, and the result text; the session id and the per-frame `uuid`s; the local paths in `init` (`cwd`, `memory_paths`, `scratchpad_path`, `messaging_socket_path`); the rate-limit utilization figures. +- **Replaced with placeholders:** every text, thinking signature, tool input and tool output, and the result text; the session id and the per-frame `uuid`s; the local paths in `init` (`cwd`, `memory_paths`, `scratchpad_path`, `messaging_socket_path`); the account state in `rate_limit_info` (the utilization figures, the overage status and reason, the reset times). +- **Replaced with neutral ids that keep the grouping:** `message.id`, tool-use ids and request ids are placeholders of the same form as the real ones (`msg_fixture…`, `toolu_fixture…`, `req_fixture…`). Every equality of the recording still holds: all frames of one API response share one `message.id` and one `request_id`, and a tool result's `tool_use_id` matches its `tool_use` block. - **Trimmed:** the `tools`, `slash_commands`, `skills`, `agents` and `plugins` lists in `init`. -- **Kept as recorded:** the order, type and keys of every frame; `message.id`, tool-use ids and request ids; the model; usage, cost and duration numbers; stop reasons; timestamps. +- **Kept as recorded:** the order, type and keys of every frame; the model; usage, cost and duration numbers; stop reasons; timestamps. When you add a recording, read the sanitized file line by line before committing it. diff --git a/spec/fixtures/cli_frames/tool_turn.jsonl b/spec/fixtures/cli_frames/tool_turn.jsonl index e609167c..54c9a996 100644 --- a/spec/fixtures/cli_frames/tool_turn.jsonl +++ b/spec/fixtures/cli_frames/tool_turn.jsonl @@ -1,10 +1,10 @@ {"type":"system","subtype":"session_state_changed","state":"running","sdk_host_only":true,"uuid":"00000000-0000-4000-8000-000000000001","session_id":"00000000-0000-4000-8000-000000000000"} {"type":"system","subtype":"init","cwd":"/workspace/project","session_id":"00000000-0000-4000-8000-000000000000","tools":["Task","Bash","Read"],"mcp_servers":[],"model":"claude-haiku-4-5-20251001","permissionMode":"bypassPermissions","slash_commands":["compact","context","clear"],"terminal_slash_commands":["doctor","color","focus","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.286","output_style":"default","agents":["general-purpose","Explore"],"skills":["code-review","simplify"],"plugins":[{"name":"cc-plugin-agents-md","path":"builtin","source":"cc-plugin-agents-md@builtin"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1","sdk_mcp_tools_list_changed","sdk_mcp_manifests","mcp_read_resource_v1","mcp_tool_ui_meta_v1","ui_surface_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"00000000-0000-4000-8000-000000000002","memory_paths":{"auto":"/home/user/.claude/projects/-workspace-project/memory/"},"scratchpad_path":"/tmp/claude/-workspace-project/00000000-0000-4000-8000-000000000000/scratchpad","messaging_socket_path":"/tmp/cc-socks/1.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":false,"view_mode":"default"} -{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1790897400,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.1,"resetsAt":1790897400},"seven_day":{"utilization":0.2,"resetsAt":1790917200}}},"uuid":"00000000-0000-4000-8000-000000000003","session_id":"00000000-0000-4000-8000-000000000000"} -{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2qvqiaFFYGxduAzxDr","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000004","timestamp":"2026-10-01T19:00:24.619Z","request_id":"req_011Cfc2quEieCQViU34wPvb9"} -{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2qvqiaFFYGxduAzxDr","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01CHuGKVaNkChVspFMWK3zcj","name":"Bash","input":{"command":"echo placeholder","description":"placeholder"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000005","timestamp":"2026-10-01T19:00:25.035Z","request_id":"req_011Cfc2quEieCQViU34wPvb9","wire_tool_inputs":{"toolu_01CHuGKVaNkChVspFMWK3zcj":{"command":"echo placeholder","description":"placeholder"}}} -{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01CHuGKVaNkChVspFMWK3zcj","type":"tool_result","content":"placeholder","is_error":false}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000006","timestamp":"2026-10-01T19:00:27.782Z","tool_use_result":{"stdout":"placeholder","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} -{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2rFTtZTztTpTJF3xPG","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000007","timestamp":"2026-10-01T19:00:28.733Z","request_id":"req_011Cfc2rF4ayP3qLD5K7Bu4f"} -{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_011Cfc2rFTtZTztTpTJF3xPG","type":"message","role":"assistant","content":[{"type":"text","text":"Placeholder answer."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000008","timestamp":"2026-10-01T19:00:28.741Z","request_id":"req_011Cfc2rF4ayP3qLD5K7Bu4f"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1800000000,"rateLimitType":"five_hour","overageStatus":"allowed","overageDisabledReason":null,"isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.1,"resetsAt":1800000000},"seven_day":{"utilization":0.2,"resetsAt":1800500000}}},"uuid":"00000000-0000-4000-8000-000000000003","session_id":"00000000-0000-4000-8000-000000000000"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000001","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000004","timestamp":"2026-10-01T19:00:24.619Z","request_id":"req_fixture00000000000000001"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000001","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_fixture00000000000000001","name":"Bash","input":{"command":"echo placeholder","description":"placeholder"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000005","timestamp":"2026-10-01T19:00:25.035Z","request_id":"req_fixture00000000000000001","wire_tool_inputs":{"toolu_fixture00000000000000001":{"command":"echo placeholder","description":"placeholder"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_fixture00000000000000001","type":"tool_result","content":"placeholder","is_error":false}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000006","timestamp":"2026-10-01T19:00:27.782Z","tool_use_result":{"stdout":"placeholder","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000002","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000007","timestamp":"2026-10-01T19:00:28.733Z","request_id":"req_fixture00000000000000002"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000002","type":"message","role":"assistant","content":[{"type":"text","text":"Placeholder answer."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000008","timestamp":"2026-10-01T19:00:28.741Z","request_id":"req_fixture00000000000000002"} {"duration_api_ms":5094,"stop_reason":"end_turn","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0.0161638,"usage":{"input_tokens":18,"cache_creation_input_tokens":4928,"cache_read_input_tokens":45028,"output_tokens":161,"output_tokens_details":{"thinking_tokens":74},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":4928,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":8,"output_tokens":39,"cache_read_input_tokens":24871,"cache_creation_input_tokens":214,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":935,"outputTokens":174,"cacheReadInputTokens":45028,"cacheCreationInputTokens":4928,"webSearchRequests":0,"costUSD":0.0161638,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":74,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"Placeholder answer.","ttft_ms":2446,"type":"result","duration_ms":6591,"uuid":"00000000-0000-4000-8000-000000000009","ttft_stream_ms":1907,"time_to_request_ms":196,"first_content_frame_ms":1908,"queued_turn_count":0,"result_index":0} {"type":"system","subtype":"session_state_changed","state":"idle","sdk_host_only":true,"uuid":"00000000-0000-4000-8000-000000000010","session_id":"00000000-0000-4000-8000-000000000000"} From f8f0a5c72843905a965ccb17547e14c24c2a8703 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:21:37 +0800 Subject: [PATCH 045/194] Correct installer and SDK MCP comments that contradict the code Text fixes, plus one error message: - docs/cli-installer.md and install's YARD described the locked sequence as check, download, place, record. The code records VERSION and then places the binary, deliberately: that order is what keeps a failed upgrade from destroying a working install, and the paragraph a few lines below already said so. - docs/cli-installer.md said a first install that fails "leaves nothing behind at all". It leaves no binary and no VERSION, but the install directory and its lock file can remain. - The comment on the tool input schema said the mcp gem validates against the draft4 metaschema and that a numeric exclusiveMinimum or $ref/$defs falls back to a permissive schema. Every mcp version this gem supports (0.22 and later) validates against JSON Schema 2020-12: those schemas are accepted and validated. The fallback is still reachable, for what that metaschema refuses (a draft4-style boolean exclusiveMinimum, an unknown type, a $ref that leaves the document, an invalid pattern); the comment now names those. - When the working directory has been deleted, resolving the default install directory raises before `dir` is assigned, and the wrapped error read "Failed to install the Claude Code CLI into : Errno::ENOENT ... getcwd". It now names the default directory by its relative path (vendor/claude). The fifth comment of this kind, "threads contend like processes" on the install lock, was corrected with the polling lock. Exploration finding: D-P15 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/cli-installer.md | 4 +-- lib/claude_agent_sdk/cli_installer.rb | 9 +++-- lib/claude_agent_sdk/sdk_mcp_server.rb | 26 ++++++++------ spec/unit/cli_installer_error_message_spec.rb | 34 +++++++++++++++++++ 4 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 spec/unit/cli_installer_error_message_spec.rb diff --git a/docs/cli-installer.md b/docs/cli-installer.md index c03e913b..171dfa09 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -27,12 +27,12 @@ ClaudeAgentSDK::CLIInstaller.installed_path `install` is idempotent and safe to run concurrently, so it fits `bin/setup`, a cached Docker layer, and every process of a multi-process boot: - The install directory's `VERSION` file records the installed version, verified SHA-256, and target platform (OS, architecture, libc). The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when all three match — a truncated binary or a cache copied from another platform is reinstalled instead of trusted. It makes **no network request**, so same-platform repeat boots work offline — with a pinned concrete version; `'stable'`/`'latest'` must always re-resolve through the endpoint, which is one more reason to pin in production. Older one- or two-line metadata lacks a platform and requires one online reinstall to migrate; subsequent pinned installs work offline again. -- An exclusive `flock` on `/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race — whether they come from separate processes, threads, or fibers of one `Async` reactor (a waiting installer polls the lock rather than blocking its thread on it); the loser simply observes the finished install. +- An exclusive `flock` on `/.install.lock` covers the whole check → download → record → place sequence, so parallel installs into one directory don't race — whether they come from separate processes, threads, or fibers of one `Async` reactor (a waiting installer polls the lock rather than blocking its thread on it); the loser simply observes the finished install. - The shortcut also works where the running process cannot write the install directory: an image built as root and run as another user, or a read-only root filesystem. `install` cannot open its lock file there (`EACCES`, `EROFS` or `EPERM`), and still returns the binary when the request is a concrete version that is already installed and intact — the same `VERSION` and SHA-256 check, without the lock. Anything that would need a write raises `CLIInstallError` as before: a dist-tag (`'stable'` / `'latest'`, which has to be resolved and may have to be installed), a different version, a damaged binary, an empty directory. Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`. -**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. The binary and `VERSION` are also flushed to disk (`fsync`) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails leaves nothing behind at all. +**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. The binary and `VERSION` are also flushed to disk (`fsync`) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails leaves no binary and no `VERSION` behind — at most the install directory and its `.install.lock`. ## When the pin moves diff --git a/lib/claude_agent_sdk/cli_installer.rb b/lib/claude_agent_sdk/cli_installer.rb index 4182495a..817ea9cc 100644 --- a/lib/claude_agent_sdk/cli_installer.rb +++ b/lib/claude_agent_sdk/cli_installer.rb @@ -419,7 +419,7 @@ def default_dir # +version+ is 'stable', 'latest', or a concrete version like '2.1.220'. # # Idempotent and safe to run concurrently: an exclusive lock on - # dir/.install.lock covers the whole check-download-place-record + # dir/.install.lock covers the whole check-download-record-place # sequence, so parallel boots (Docker layers, `foreman start`, CI matrix # jobs sharing a cache) never race each other into a partially written # binary — the loser of the race observes a finished install. @@ -471,8 +471,11 @@ def install(version: 'stable', dir: nil) rescue SystemCallError, IOError => e # Filesystem failures (EACCES on the install dir, ENOSPC mid-download, # a read-only mount) reach callers as CLIInstallError like every other - # install failure; `cause` keeps the original for debugging. - raise CLIInstallError, "Failed to install the Claude Code CLI into #{dir}: #{e.class}: #{e.message}" + # install failure; `cause` keeps the original for debugging. +dir+ is + # still nil when resolving the default directory is what failed (the + # working directory was deleted): name it by its relative path then. + raise CLIInstallError, + "Failed to install the Claude Code CLI into #{dir || DEFAULT_DIR}: #{e.class}: #{e.message}" end # Install PINNED_CLI_VERSION — the version this gem release was tested diff --git a/lib/claude_agent_sdk/sdk_mcp_server.rb b/lib/claude_agent_sdk/sdk_mcp_server.rb index 94cbcf3f..32eba563 100644 --- a/lib/claude_agent_sdk/sdk_mcp_server.rb +++ b/lib/claude_agent_sdk/sdk_mcp_server.rb @@ -455,16 +455,22 @@ def description_value end def input_schema_value - # Full-schema construction: the gem JSON-round-trips and - # validates against the draft4 metaschema. additionalProperties/ - # enum/description survive. Empty required arrays are stripped — - # draft4's metaschema mandates non-empty required (Python's - # modern jsonschema accepts []). Schemas the draft4 metaschema - # rejects (numeric exclusiveMinimum, $ref/$defs — valid modern - # JSON Schema that Python accepts) fall back to a permissive - # schema with a one-time warning: the tool keeps working with - # argument validation disabled instead of being permanently - # uncallable while tools/list advertises it as healthy. + # Full-schema construction: the gem JSON-round-trips the schema + # and validates it against the JSON Schema 2020-12 metaschema + # (every mcp version this gem supports; older ones used draft4). + # additionalProperties/enum/description survive, and so do a + # numeric exclusiveMinimum and same-document $ref/$defs. Empty + # required arrays are stripped — a holdover from draft4, whose + # metaschema mandated a non-empty required. + # + # A schema the gem refuses (ArgumentError: a draft4-style + # boolean exclusiveMinimum, an unknown type, a $ref that leaves + # the document, an invalid pattern, ...) falls back to a + # permissive schema with a warning, once per tool: the tool + # keeps working with argument validation disabled instead of + # being permanently uncallable while tools/list advertises it + # as healthy. (The warning's "not draft4-compatible" wording + # dates from the draft4 days.) @input_schema_value ||= begin schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema) schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty? diff --git a/spec/unit/cli_installer_error_message_spec.rb b/spec/unit/cli_installer_error_message_spec.rb new file mode 100644 index 00000000..d3672c74 --- /dev/null +++ b/spec/unit/cli_installer_error_message_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# The message of the CLIInstallError that wraps a filesystem failure names the +# install directory. When resolving that directory is itself what failed (the +# default one, with the process's working directory deleted under it), the +# message used to read "… into : Errno::ENOENT: … getcwd". +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'the message of a wrapped filesystem failure' do + it 'names the default directory when the working directory no longer exists' do + gone = Dir.mktmpdir('cli-installer-cwd') + + Dir.chdir(gone) do + Dir.rmdir(gone) + + expect { described_class.install(version: '2.1.220') } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| + expect(error.message).to start_with('Failed to install the Claude Code CLI into vendor/claude: Errno::ENOENT') + expect(error.cause).to be_a(Errno::ENOENT) + } + end + end + + it 'names the explicit directory otherwise' do + Dir.mktmpdir('cli-installer-message') do |dir| + blocked = File.join(dir, 'a-file') + File.write(blocked, 'not a directory') + + expect { described_class.install(version: '2.1.220', dir: File.join(blocked, 'claude')) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /\AFailed to install the Claude Code CLI into #{Regexp.escape(blocked)}/) + end + end +end From bf615282c06246aba739216c141d7d0af2f97ebc Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:23:52 +0800 Subject: [PATCH 046/194] Look for the first prompt past the 64 KiB head on the disk path list_sessions / get_session_info on disk took first_prompt from the first 64 KiB of a transcript only. CLI 2.1.x transcripts often carry a large attachment before the first prompt (a SessionStart hook's output), and an SDK prompt that inlines a document is a single line longer than the window. For those sessions the disk path reported first_prompt nil or a slash-command name, and a session with no title or last-prompt entry in its tail was not listed at all, while the store paths, which fold every entry, reported the prompt and listed the session. When the head holds no real prompt and the file goes on, the disk reader now continues from the last complete head line up to the first 1 MiB of the file. It reads fixed-size chunks, never line by line (one line can be gigabytes), and an IO failure there leaves the head's answer. A slash-command name found on the way stays the fallback. Noticeable: first_prompt, and summary where it falls back to it, change for affected sessions; sessions that were missing from disk listings appear. A prompt whose line ends beyond the first 1 MiB is still not found; docs/sessions.md now lists what the disk path does not see (this, and metadata entries in the middle of a file). The spec group runs one transcript through both paths: the cases where they now agree, and the ones where the windows still decide. Exploration finding: B-P5 (part 1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 9 +- lib/claude_agent_sdk/sessions.rb | 94 +++++++++-- spec/unit/session_info_disk_vs_store_spec.rb | 165 +++++++++++++++++++ 3 files changed, 249 insertions(+), 19 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index aa61c017..d4ab754c 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -25,7 +25,14 @@ ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true) Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at`. -Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths. +Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt; `created_at` is the first top-level `timestamp`. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths. + +The disk path does not read whole transcripts to list them: it looks at the first and the last 64 KiB of each file, and for `first_prompt` at up to the first 1 MiB when those first 64 KiB hold no prompt (a large hook attachment, or a long prompt, often comes first). The store paths fold every entry. The two report the same values unless the entry that decides a field lies outside what the disk path reads: + +- a custom title or tag appended while the session was still running stops being seen once more than 64 KiB of transcript follows it (`custom_title` falls back to the AI title or `nil`, `summary` to the next source, `tag` to `nil`) until the CLI resumes the session, which re-appends them at the end; +- a last-prompt entry that is only at the start of the file is not used for `summary`; +- `cwd` and `created_at` come from the first 64 KiB: a first entry larger than that leaves `cwd` at the project path and `created_at` at `nil`; +- a first prompt whose transcript line ends beyond the first 1 MiB is not found: `first_prompt` is the name of a slash command seen before it, or `nil` — and a session with neither that nor a title or last-prompt entry in its last 64 KiB is not listed from disk at all. When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (`directory:` listings: the directory's own copy, then the worktrees in the order `git worktree list` reports them, main worktree first). diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 22f0fb3a..f0109491 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -89,6 +89,10 @@ module Sessions # rubocop:disable Metrics/ModuleLength -- session listing/readin LITE_READ_BUF_SIZE = 65_536 MAX_SANITIZED_LENGTH = 200 + # How far into a transcript the disk listing looks for the first prompt + # when the head window holds none (see first_prompt_from_file). + FIRST_PROMPT_SCAN_LIMIT = 1_048_576 + UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i # Subagent ids as the CLI writes them (agent-.jsonl): hex ids and @@ -483,10 +487,17 @@ def listing_sort_key(mtime, session_id) end # Extract the first meaningful user prompt from the head of a JSONL file - def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold - command_fallback = nil + def extract_first_prompt_from_head(head) + prompt, command_fallback = first_prompt_in(head) + prompt || command_fallback || '' + end - head.each_line do |line| + # The first real user prompt among the lines of +text+, and the name of + # the first slash command seen on the way (what a session without a real + # prompt reports): [prompt or nil, command name or nil]. +command_fallback+ + # carries a name found in an earlier part of the same transcript. + def first_prompt_in(text, command_fallback = nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold + text.each_line do |line| next unless line.include?('"type":"user"') || line.include?('"type": "user"') next if line.include?('"tool_result"') next if line.include?('"isMeta":true') || line.include?('"isMeta": true') @@ -507,13 +518,59 @@ def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticCom next if text.match?(SKIP_FIRST_PROMPT_PATTERN) - return text.length > 200 ? "#{text[0, 200]}…" : text + return [text.length > 200 ? "#{text[0, 200]}…" : text, command_fallback] end rescue JSON::ParserError next end - command_fallback || '' + [nil, command_fallback] + end + + # first_prompt for the disk listing: from the head window, and when that + # holds no real prompt while the file goes on, from a bounded scan past + # it. CLI 2.1.x transcripts often carry a large attachment (a + # SessionStart hook's output) before the first prompt, and an SDK prompt + # that inlines a document is one line longer than the window; the head + # alone reported nil or a slash-command name for those, and a session + # with no other summary source was not listed at all — while the store + # fold, which sees every entry, reported the prompt. + def first_prompt_from_file(file_path, head, size) + prompt, command_fallback = first_prompt_in(head) + if prompt.nil? && size > head.bytesize + limit = [size, FIRST_PROMPT_SCAN_LIMIT].min + prompt, command_fallback = first_prompt_past_head(file_path, head, limit, command_fallback) + end + prompt || command_fallback || '' + end + + # Continue the first-prompt scan from the end of the last complete line + # of +head+ up to byte +limit+ of the file. Read in fixed-size chunks, + # never line by line: one transcript line can be gigabytes, and the + # limit has to hold before the bytes are in memory. What is left open at + # the end is the last line of a file without a final newline — or a line + # cut by the limit, which does not parse and is skipped like any other + # bad line. An IO failure here leaves the answer the head gave: this + # read is an extra, and must not hide a session. + def first_prompt_past_head(file_path, head, limit, command_fallback) + offset = (head.byterindex("\n") || -1) + 1 + open_line = String.new(encoding: Encoding::BINARY) + File.open(file_path, 'rb') do |file| + file.seek(offset) + while offset < limit && (chunk = file.read([LITE_READ_BUF_SIZE, limit - offset].min)) + offset += chunk.bytesize + open_line << chunk + newline = open_line.rindex("\n") + next unless newline + + complete = utf8_transcript_text(open_line.slice!(0, newline + 1)) + prompt, command_fallback = first_prompt_in(complete, command_fallback) + return [prompt, command_fallback] if prompt + end + first_prompt_in(utf8_transcript_text(open_line), command_fallback) + end + rescue SystemCallError + [nil, command_fallback] end # Text blocks of a genuine user entry, or nil when the line should be @@ -610,7 +667,7 @@ def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disa extract_top_level_string_field(head, 'aiTitle', last: true)) # nil, not '', when there is no prompt — the store path's answer, and # Python's (`_extract_first_prompt_from_head(head) or None`). - first_prompt = presence(extract_first_prompt_from_head(head)) + first_prompt = presence(first_prompt_from_file(file_path, head, stat.size)) # lastPrompt tail entry shows what the user was most recently doing. summary = custom_title || presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) || @@ -1314,7 +1371,7 @@ def append_jsonl_file_in_batches(file_path, key, store, batch_size) # adapter that serializes what it is given raise JSON::GeneratorError # from #append — after the batches before it were already stored. File.foreach(file_path, mode: 'rb').with_index(1) do |line, lineno| - line = utf8_transcript_line(line).chomp + line = utf8_transcript_text(line).chomp next if line.empty? begin @@ -1600,7 +1657,7 @@ def parse_jsonl_entries(file_path) entries = [] File.foreach(file_path, mode: 'rb') do |line| - entry = JSON.parse(utf8_transcript_line(line).strip, symbolize_names: false) + entry = JSON.parse(utf8_transcript_text(line).strip, symbolize_names: false) next unless entry.is_a?(Hash) next unless TRANSCRIPT_ENTRY_TYPES.include?(entry['type']) next unless entry['uuid'].is_a?(String) @@ -1612,18 +1669,18 @@ def parse_jsonl_entries(file_path) entries end - # One transcript line, read in binary mode, as UTF-8 text. Transcripts are - # UTF-8 whatever the process locale says: a line tagged with the locale's - # encoding (File.foreach's default) raised from String#strip on the first - # non-ASCII character under LANG=C. Bytes that are not valid UTF-8 — a - # final line the CLI was killed in the middle of, raw binary in a tool - # result — become U+FFFD, the policy + # Transcript text (one line, or a run of lines) read in binary mode, as + # UTF-8. Transcripts are UTF-8 whatever the process locale says: a line + # tagged with the locale's encoding (File.foreach's default) raised from + # String#strip on the first non-ASCII character under LANG=C. Bytes that + # are not valid UTF-8 — a final line the CLI was killed in the middle of, + # raw binary in a tool result — become U+FFFD, the policy # SessionMutations.parse_fork_transcript already has: a torn line then # fails JSON.parse and is skipped like any other bad line instead of # raising, and a complete line keeps its entry. - def utf8_transcript_line(line) - line.force_encoding(Encoding::UTF_8) - line.valid_encoding? ? line : line.scrub + def utf8_transcript_text(text) + text.force_encoding(Encoding::UTF_8) + text.valid_encoding? ? text : text.scrub end # Build the conversation chain by finding the leaf and walking parentUuid. @@ -1844,12 +1901,13 @@ def filter_visible_messages(chain) :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, :find_session_file, :stat_candidate, :resolve_subagents_dir, - :collect_agent_files, :parse_jsonl_entries, :utf8_transcript_line, + :collect_agent_files, :parse_jsonl_entries, :utf8_transcript_text, :build_conversation_chain, :walk_to_leaf, :walk_to_root, :pick_leaf, :visible_ancestor?, :off_main_conversation?, :visible_message?, :reattach_parallel_tool_results, :off_chain_tool_results, :content_block_values, :filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts, + :first_prompt_in, :first_prompt_from_file, :first_prompt_past_head, :valid_agent_id?, :sidechain_head?, :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot, :derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset, diff --git a/spec/unit/session_info_disk_vs_store_spec.rb b/spec/unit/session_info_disk_vs_store_spec.rb index 71f142fb..5abe44e5 100644 --- a/spec/unit/session_info_disk_vs_store_spec.rb +++ b/spec/unit/session_info_disk_vs_store_spec.rb @@ -13,6 +13,20 @@ let(:session_id) { '8f9a0b1c-2d3e-4f50-8a61-7b8c9d0e1f20' } let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + fields = %i[summary custom_title first_prompt git_branch cwd tag created_at] + padding = 'x' * 1_000 + + # +turns+ exchanges of about 2 KiB each, continuing from +parent+. + filler = lambda do |transcript, parent, turns, name| + turns.times do |turn| + transcript.prompt(:"#{name}_q#{turn}", "filler #{turn} #{padding}", parent: parent) + transcript.assistant(:"#{name}_a#{turn}", transcript.text("reply #{turn} #{padding}"), + parent: :"#{name}_q#{turn}", message: "msg_#{name}_#{turn}") + parent = :"#{name}_a#{turn}" + end + parent + end + # [disk info, store info] for the transcript the block builds. The session # was killed before the CLI wrote its exit-time lines (last-prompt, ...), # unless the block adds them. @@ -25,6 +39,63 @@ def read_both ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store)] end + describe 'fields both paths report alike' do + { + 'a large SessionStart hook attachment before the first prompt' => [ + lambda do |t| + t.queue_operations('what does a.rb define?') + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + t.prompt(:prompt, 'what does a.rb define?', parent: :hook) + t.assistant(:answer, t.text('Foo.'), parent: :prompt) + end, + { first_prompt: 'what does a.rb define?', summary: 'what does a.rb define?' } + ], + 'a slash command in the head and the real prompt beyond it' => [ + lambda do |t| + t.queue_operations('/model') + t.prompt(:command, '/model') + t.attachment(:hook, parent: :command, hook: 'SessionStart', stdout: 'A' * 70_000) + t.prompt(:prompt, 'the actual question', parent: :hook) + t.assistant(:answer, t.text('The answer.'), parent: :prompt) + end, + { first_prompt: 'the actual question', summary: 'the actual question' } + ], + 'a branch switched in the middle of a 300 KB session' => [ + lambda do |t| + t.prompt(:prompt, 'start on main') + t.assistant(:answer, t.text('Started.'), parent: :prompt) + last = filler.call(t, :answer, 40, 'early') + t.git_branch = 'feature/x' + filler.call(t, last, 60, 'late') + end, + { git_branch: 'feature/x', first_prompt: 'start on main' } + ] + }.each do |name, (build, expected)| + it "agrees for #{name}" do + disk, stored = read_both(&build) + + expect(disk).not_to be_nil + expect(fields.to_h { |f| [f, disk.public_send(f)] }).to eq(fields.to_h { |f| [f, stored.public_send(f)] }) + expect(fields.to_h { |f| [f, disk.public_send(f)] }).to include(expected) + end + end + + # An SDK prompt that inlines a document. The prompt is found by the scan + # past the head; the entry's own timestamp follows its message on the + # same line, beyond the head window. + it 'agrees on the prompt of a 300 KB first line, and has no created_at for it on disk' do + disk, stored = read_both do |t| + t.prompt(:prompt, "#{'Q' * 300_000} end") + t.assistant(:answer, t.text('Read.'), parent: :prompt) + end + + expect(disk.first_prompt).to eq("#{'Q' * 200}…") + expect([disk.first_prompt, disk.summary, disk.cwd]).to eq([stored.first_prompt, stored.summary, stored.cwd]) + expect(disk.created_at).to be_nil + expect(stored.created_at).not_to be_nil + end + end + # An interactive session starts with metadata lines; the snapshot's # timestamp is nested, the entry itself has none. it 'takes created_at from the first entry with a timestamp of its own, not from a file-history-snapshot' do @@ -38,4 +109,98 @@ def read_both expect(disk.created_at).to eq(stored.created_at) expect(Time.at(disk.created_at / 1000).utc.strftime('%F %T')).to eq('2026-09-08 05:19:30') end + + # What only a full read would find. The entries below have more than 64 KiB + # of transcript both before and after them, or sit past the first-prompt + # scan; the CLI re-appends its own metadata when it resumes a session, an + # entry the SDK appended while the session was running is not re-appended. + describe 'fields the disk reader takes from its windows only' do + { + 'a custom-title entry in the middle of the file' => [ + lambda do |t| + t.prompt(:prompt, 'first prompt') + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + last = filler.call(t, :answer, 40, 'early') + t.custom_title('Renamed mid-session') + filler.call(t, last, 60, 'late') + end, + :custom_title, nil, 'Renamed mid-session' + ], + 'a tag entry in the middle of the file' => [ + lambda do |t| + t.prompt(:prompt, 'first prompt') + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + last = filler.call(t, :answer, 40, 'early') + t.tag('experiment') + filler.call(t, last, 60, 'late') + end, + :tag, nil, 'experiment' + ], + 'a last-prompt entry at the head of the file only' => [ + lambda do |t| + t.last_prompt('what I asked last') + t.prompt(:prompt, 'first prompt') + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + filler.call(t, :answer, 100, 'late') + end, + :summary, 'first prompt', 'what I asked last' + ] + }.each do |name, (build, field, on_disk, in_store)| + it "differs in #{field} for #{name}" do + disk, stored = read_both(&build) + + expect(disk.public_send(field)).to eq(on_disk) + expect(stored.public_send(field)).to eq(in_store) + end + end + + it 'does not find a first prompt whose line ends past the first 1 MiB' do + disk, stored = read_both do |t| + t.queue_operations('the late question') + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 1_100_000) + t.prompt(:prompt, 'the late question', parent: :hook) + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + end + + expect(disk).to be_nil # no title, no last-prompt, no prompt in reach: not listed + expect(stored.first_prompt).to eq('the late question') + end + end + + describe 'the first-prompt scan past the head' do + it 'finds a prompt on the last line of a file that does not end with a newline' do + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + transcript.prompt(:prompt, 'cut short', parent: :hook) + File.binwrite(transcript.write(transcript_path(session_id)), transcript.to_jsonl.chomp) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd)&.first_prompt).to eq('cut short') + end + + it 'keeps the slash command as the fallback when no real prompt follows' do + disk, stored = read_both do |t| + t.prompt(:command, '/model') + t.attachment(:hook, parent: :command, hook: 'SessionStart', stdout: 'A' * 92_000) + t.assistant(:answer, t.text('Model set.'), parent: :hook) + end + + expect(disk.first_prompt).to eq('/model') + expect(stored.first_prompt).to eq('/model') + end + + it 'does not take a later tool result, meta entry or compact summary for the prompt' do + disk, stored = read_both do |t| + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + t.meta(:reminder, 'context', parent: :hook) + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :reminder) + t.tool_result(:result_a, 'toolu_a', 'file contents', parent: :use_a) + t.prompt(:compact, 'This session is being continued from a previous conversation', + parent: :result_a, isCompactSummary: true) + t.prompt(:prompt, 'the real prompt', parent: :compact) + end + + expect(disk.first_prompt).to eq('the real prompt') + expect(stored.first_prompt).to eq('the real prompt') + end + end end From c0fa1743930ff3d8c43774fd81af116d616c87b1 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:23:55 +0800 Subject: [PATCH 047/194] Correct two comments about what the disk listing sees rename_session's comment said list_sessions reads the last custom-title from the file tail, "so repeated calls are safe". The tail is a 64 KiB window: a title (or tag) appended to a session that is still running drops out of it once that much transcript follows, and the listing shows the previous title until the CLI resumes the session. The comment now says so, and that this is not fixed here. list_sessions_from_store's comment said the disk and store paths agree for identical transcript content. They agree unless the deciding entry lies outside the windows the disk path reads; the comment points at the list in docs/sessions.md. Comments only. Exploration finding: B-P5 (part 3, comment correction only; U-P11 is not fixed) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/session_mutations.rb | 11 +++++++++-- lib/claude_agent_sdk/sessions.rb | 9 +++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/claude_agent_sdk/session_mutations.rb b/lib/claude_agent_sdk/session_mutations.rb index 591d9ee4..46c6019c 100644 --- a/lib/claude_agent_sdk/session_mutations.rb +++ b/lib/claude_agent_sdk/session_mutations.rb @@ -27,8 +27,15 @@ module SessionMutations # rubocop:disable Metrics/ModuleLength -- rename/tag/del # Rename a session by appending a custom-title entry. # - # list_sessions reads the LAST custom-title from the file tail, so - # repeated calls are safe — the most recent wins. + # Repeated calls are safe: the disk listing takes the LAST custom-title + # in the final 64 KiB of the file (then in the first 64 KiB), the store + # fold the last one overall. On disk the entry is only seen while it + # stays inside one of those windows: rename a session that is still + # running, let 64 KiB of transcript follow, and list_sessions / + # get_session_info report the previous title again until the CLI resumes + # the session and re-appends its metadata at the end (the same holds for + # tag_session). Not fixed here: finding it again means reading the whole + # file on every listing. # # @param session_id [String] UUID of the session to rename # @param title [String] New session title (whitespace stripped) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index f0109491..d065d66d 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -937,8 +937,13 @@ def split_agent_metadata(entries) # List sessions from a SessionStore. Store-backed counterpart to # list_sessions. Uses the store's incremental summaries (one batch call + # gap-fill) when available, else falls back to list_sessions + one load per - # session. Sessions are derived through the same fold the disk path uses, so - # both paths agree for identical transcript content. + # session. Sessions are derived by folding EVERY entry + # (SessionSummary.fold_session_summary), field by field under the rules + # of the disk reader — which only reads the head and tail windows of a + # transcript (plus the bounded first-prompt scan). The two paths agree + # for identical transcript content unless the entry deciding a field + # lies outside those windows; docs/sessions.md ("Listing Sessions") lists + # the cases. # # @param session_store [SessionStore] store implementing list_session_summaries and/or list_sessions # @return [Array] sorted by last_modified descending From 0a78faa91877ad557e81c47a514bfe6629b25343 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:25:59 +0800 Subject: [PATCH 048/194] List sessions under a config dir whose path has glob characters list_sessions (with or without directory:) returned [] when the Claude config dir path contained `[` or `{` (`/Volumes/Data [SSD]/claude`, `/srv/{tenant}/claude`): the transcripts were globbed with a pattern built from the directory, so the directory part was read as a character class or an alternation and matched nothing. get_session_info, get_session_messages and the mutations build the path with File.join and found the same sessions. The glob now takes the project dir as base:, as the CLI installer's already does. Exploration finding: B-P9 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 8 +++-- spec/unit/session_read_boundaries_spec.rb | 38 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 spec/unit/session_read_boundaries_spec.rb diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index d065d66d..dedc5d06 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -732,10 +732,14 @@ def read_sessions_from_dir(project_dir, project_path = nil) return [] unless File.directory?(project_dir) sessions = [] - Dir.glob(File.join(project_dir, '*.jsonl')).each do |file_path| - stem = File.basename(file_path, '.jsonl') + # base:, not a pattern built from the directory: a config dir path with + # glob characters in it (`/Volumes/Data [SSD]/…`, `/srv/{tenant}/…`) + # is a path, and as part of the pattern it matched nothing. + Dir.glob('*.jsonl', base: project_dir).each do |name| + stem = File.basename(name, '.jsonl') next unless stem.match?(UUID_RE) + file_path = File.join(project_dir, name) session = read_session_lite(file_path, project_path) sessions << session if session end diff --git a/spec/unit/session_read_boundaries_spec.rb b/spec/unit/session_read_boundaries_spec.rb new file mode 100644 index 00000000..6b8b041b --- /dev/null +++ b/spec/unit/session_read_boundaries_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Boundary cases of the session readers and mutations, one group per case. +RSpec.describe 'session API boundaries' do + include_context 'with a Claude config dir' + + let(:session_id) { 'c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f' } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def conversation(directory = cwd) + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.queue_operations('what does a.rb define?') + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + transcript.last_prompt('what does a.rb define?', leaf: :answer) + transcript + end + + # A config dir path is data, not a pattern: `/Volumes/Data [SSD]/claude`, + # `/srv/{tenant}/claude`. + describe 'a config dir whose path contains glob characters' do + ['cfg [prod]', 'tenant{a,b}'].each do |name| + it "lists the sessions under #{name.inspect}" do + ENV['CLAUDE_CONFIG_DIR'] = File.join(config_dir, name) + conversation.write(File.join(ENV.fetch('CLAUDE_CONFIG_DIR'), 'projects', + ClaudeAgentSDK::Sessions.sanitize_path(cwd), "#{session_id}.jsonl")) + + expect(ClaudeAgentSDK.list_sessions(directory: cwd).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.list_sessions.map(&:session_id)).to eq([session_id]) + end + end + end +end From f8e8a58113bc193e25647bded8a4873aa5f152ce Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:27:29 +0800 Subject: [PATCH 049/194] Convert ISO timestamps to epoch milliseconds without a Float parse_iso_timestamp_ms computed (Time.iso8601(ts).to_f * 1000).to_i. For about one millisecond value in eight the Float product lands just under the integer and the truncation loses a millisecond: ...T05:19:30.933Z came out as ...932. created_at (disk and store) and the store fallback for last_modified were 1 ms low for those timestamps, and differed from the value the Python and TypeScript SDKs report. The conversion is now integer arithmetic on the parsed Time (seconds * 1000 + nanoseconds / 1_000_000). Summaries an adapter already persisted keep the old value until they are folded again. Exploration finding: B-P15 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 5 +++- spec/unit/session_read_boundaries_spec.rb | 36 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index dedc5d06..9c20fb60 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -722,7 +722,10 @@ def parse_iso_timestamp_ms(timestamp_str) return nil unless timestamp_str.is_a?(String) require 'time' - (Time.iso8601(timestamp_str).to_f * 1000).to_i + # Integer arithmetic: through a Float (to_f * 1000, truncated) about one + # millisecond value in eight came out 1 ms low — ...30.933Z as ...932. + time = Time.iso8601(timestamp_str) + (time.to_i * 1000) + (time.nsec / 1_000_000) rescue ArgumentError nil end diff --git a/spec/unit/session_read_boundaries_spec.rb b/spec/unit/session_read_boundaries_spec.rb index 6b8b041b..8828e511 100644 --- a/spec/unit/session_read_boundaries_spec.rb +++ b/spec/unit/session_read_boundaries_spec.rb @@ -35,4 +35,40 @@ def conversation(directory = cwd) end end end + + # created_at (and the store fallback for last_modified) are epoch + # milliseconds parsed from the ISO timestamps the CLI writes. + describe 'the millisecond of an ISO timestamp' do + let(:base) { Time.utc(2026, 9, 8, 5, 19, 30).to_i * 1000 } + + it 'is exact for every millisecond of a second' do + parsed = (0..999).map do |ms| + ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms(format('2026-09-08T05:19:30.%03dZ', ms)) + end + + expect(parsed).to eq((0..999).map { |ms| base + ms }) + end + + it 'is exact with a UTC offset, and truncates what is finer than a millisecond' do + expect(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms('2026-09-08T13:19:30.933+08:00')) + .to eq(base + 933) + expect(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms('2026-09-08T05:19:30.933999Z')) + .to eq(base + 933) + end + + it 'gives a session the created_at of its first entry, on disk and from a store' do + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, start: Time.utc(2026, 9, 8, 5, 19, 30.683r)) + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + transcript.write(transcript_path(session_id)) + store = ClaudeAgentSDK::InMemorySessionStore.new + ClaudeAgentSDK.import_session_to_store(session_id: session_id, session_store: store, directory: cwd) + exact = base + 933 + + expect(transcript.entries.first['timestamp']).to eq('2026-09-08T05:19:30.933Z') + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd).created_at).to eq(exact) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store).created_at) + .to eq(exact) + end + end end From 2900353697c1b2e76b949a1009d9ca5736b1c2fe Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:28:47 +0800 Subject: [PATCH 050/194] Skip subkeys that are not Strings in the store subagent readers list_subagents, get_subagent_messages and get_subagent_metadata with session_store: raised NoMethodError ("undefined method 'rpartition' for an instance of Symbol") when the adapter's list_subkeys returned a value that is not a String. Resume materialization has skipped such subkeys since #91; the readers called String methods on them. Both readers now ignore a subkey that is not a String and go on with the ones they can name. Exploration finding: B-P16 (item 1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 7 +++-- spec/unit/session_read_boundaries_spec.rb | 33 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 9c20fb60..48091f8c 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1024,7 +1024,10 @@ def list_subagents_from_store(session_store:, session_id:, directory: nil) subkeys = Array(session_store.list_subkeys('project_key' => project_key, 'session_id' => session_id)) seen = {} subkeys.filter_map do |subpath| - next unless subpath.start_with?('subagents/') + # A non-String subkey (Symbol, nil, Integer) is an adapter contract + # violation; skip it like resume does instead of calling String + # methods on it. + next unless subpath.is_a?(String) && subpath.start_with?('subagents/') last = subpath.rpartition('/').last next unless last.start_with?('agent-') @@ -1300,7 +1303,7 @@ def resolve_subagent_subpath(store, project_key, session_id, agent_id) target = "agent-#{agent_id}" matches = Array(store.list_subkeys('project_key' => project_key, 'session_id' => session_id)) - .select { |sk| sk.start_with?('subagents/') && sk.rpartition('/').last == target } + .select { |sk| sk.is_a?(String) && sk.start_with?('subagents/') && sk.rpartition('/').last == target } # Several subpaths can share a trailing agent- (a top-level agent and a # nested subagents/workflows//agent-). Prefer the canonical # top-level path, else pick deterministically (shortest, then lexical) so diff --git a/spec/unit/session_read_boundaries_spec.rb b/spec/unit/session_read_boundaries_spec.rb index 8828e511..a060a7a8 100644 --- a/spec/unit/session_read_boundaries_spec.rb +++ b/spec/unit/session_read_boundaries_spec.rb @@ -71,4 +71,37 @@ def conversation(directory = cwd) .to eq(exact) end end + + # list_subkeys is documented to return Strings. Resume already skips + # anything else an adapter hands back; the readers called String methods + # on it. + describe 'a store whose list_subkeys returns values that are not Strings' do + let(:agent_id) { 'a1b2c3d4e5f60718' } + let(:store) do + Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def list_subkeys(key) = [:'subagents/agent-symbol', nil, 7] + super + end.new + end + + before do + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, conversation.store_entries) + subagent = CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id) + subagent.prompt(:task, 'Read a.rb') + subagent.assistant(:report, subagent.text('It defines Foo.'), parent: :task) + store.append(key.merge('subpath' => "subagents/agent-#{agent_id}"), subagent.store_entries) + end + + it 'lists the subagents it can name' do + expect(ClaudeAgentSDK.list_subagents(session_id: session_id, directory: cwd, session_store: store)) + .to eq([agent_id]) + end + + it 'reads a subagent it can name' do + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd, + session_store: store) + + expect(messages.map(&:text)).to eq(['Read a.rb', 'It defines Foo.']) + end + end end From 747e8d7f3361846ac68e6fcbdd6209cf6d51aeb5 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:30:08 +0800 Subject: [PATCH 051/194] Report the adapter's mtime from get_session_info with a store get_session_info(session_store:) stamped last_modified with the timestamp of the session's last entry, or 0 when no entry carried one, while list_sessions(session_store:) reports the adapter's storage mtime for the same session. The two disagreed for every session (another clock), and a session whose stored entries were metadata lines came back with last_modified 0 from one call and a real mtime from the other. docs/sessions.md promises the adapter's mtime on the store paths. get_session_info now takes the mtime of the session's row in the store's list_sessions (else list_session_summaries) result. That is one extra adapter call per lookup. A store that implements neither, or does not list the session, keeps the previous value; the docs say so. Exploration finding: B-P16 (item 2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 2 +- lib/claude_agent_sdk/sessions.rb | 29 ++++++++- spec/unit/session_read_boundaries_spec.rb | 77 +++++++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index d4ab754c..c5bf44c5 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -25,7 +25,7 @@ ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true) Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at`. -Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt; `created_at` is the first top-level `timestamp`. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths. +Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt; `created_at` is the first top-level `timestamp`. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths (`get_session_info` takes it from the store's `list_sessions`, or else `list_session_summaries`, row for the session — one extra adapter call; a store that implements neither reports the timestamp of the session's last entry instead, `0` when no entry has one). The disk path does not read whole transcripts to list them: it looks at the first and the last 64 KiB of each file, and for `first_prompt` at up to the first 1 MiB when those first 64 KiB hold no prompt (a large hook attachment, or a long prompt, often comes first). The store paths fold every entry. The two report the same values unless the entry that decides a field lies outside what the disk path reads: diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 48091f8c..3ca6a5d5 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -992,10 +992,12 @@ def get_session_info_from_store(session_store:, session_id:, directory: nil) return nil unless valid_session_id?(session_id) project_path = canonicalize_path(directory.nil? ? '.' : directory.to_s) - entries = session_store.load('project_key' => sanitize_path(project_path), 'session_id' => session_id) + project_key = sanitize_path(project_path) + entries = session_store.load('project_key' => project_key, 'session_id' => session_id) return nil if entries.nil? || entries.empty? - derive_info_from_entries(session_id, entries, mtime_from_entries(entries), project_path) + mtime = store_session_mtime(session_store, project_key, session_id) || mtime_from_entries(entries) + derive_info_from_entries(session_id, entries, mtime, project_path) end # Read a session's conversation messages from a SessionStore. Store-backed @@ -1219,6 +1221,26 @@ def derive_info_from_entries(session_id, entries, mtime, project_path) SessionSummary.summary_entry_to_sdk_info(summary, project_path) end + # The adapter's own mtime for one session, as its listing reports it, or + # nil when the store cannot be asked (it implements neither listing + # method) or does not list the session. + # + # get_session_info(session_store:) stamps this as last_modified: it is the + # clock list_sessions(session_store:) reports and orders by, and what the + # docs promise on the store paths. The entries' own timestamps are another + # clock — and absent from metadata entries, which gave last_modified 0 for + # a session the listing showed with a real mtime. They remain the fallback + # (mtime_from_entries) for a store with nothing but #append and #load. + def store_session_mtime(store, project_key, session_id) + rows = if SessionStore.implements?(store, :list_sessions) + store.list_sessions(project_key) + elsif SessionStore.implements?(store, :list_session_summaries) + store.list_session_summaries(project_key) + end + row = Array(rows).find { |candidate| candidate.is_a?(Hash) && candidate['session_id'] == session_id } + row && row['mtime'] + end + # Last parseable entry timestamp (epoch ms), scanning from the tail; 0 if none. def mtime_from_entries(entries) entries.reverse_each do |entry| @@ -1925,7 +1947,8 @@ def filter_visible_messages(chain) :first_prompt_in, :first_prompt_from_file, :first_prompt_past_head, :valid_agent_id?, :sidechain_head?, :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot, - :derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset, + :derive_info_from_entries, :store_session_mtime, :mtime_from_entries, + :apply_sort_limit_offset, :filter_transcript_entries, :entries_to_messages, :entries_to_subagent_messages, :build_subagent_chain, :resolve_subagent_subpath, :import_subagent_files, :append_jsonl_file_in_batches, :collect_jsonl_files, diff --git a/spec/unit/session_read_boundaries_spec.rb b/spec/unit/session_read_boundaries_spec.rb index a060a7a8..f77a44eb 100644 --- a/spec/unit/session_read_boundaries_spec.rb +++ b/spec/unit/session_read_boundaries_spec.rb @@ -104,4 +104,81 @@ def list_subkeys(key) = [:'subagents/agent-symbol', nil, 7] + super expect(messages.map(&:text)).to eq(['Read a.rb', 'It defines Foo.']) end end + + # last_modified on the store paths is the adapter's mtime for the session: + # the value the store listing reports and orders by. + describe 'last_modified of a session read from a store' do + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + + def info_from(store) + ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store) + end + + # A session renamed before it has any timestamped entry in the store: + # metadata lines carry no timestamp. + def untimed_entries + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.ai_title('Reading a.rb') + transcript.last_prompt('what does a.rb define?') + transcript.store_entries + end + + context 'with a store that lists its sessions' do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + it 'is the same from get_session_info and from list_sessions' do + store.append(key, conversation.store_entries) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(info_from(store).last_modified).to eq(listed.last_modified) + expect(Time.at(listed.last_modified / 1000).year).to eq(Time.now.year) # the adapter's clock, not the entries' + end + + it 'is the same for a session whose entries carry no timestamp' do + store.append(key, untimed_entries) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(listed.last_modified).to be > 0 + expect(info_from(store).last_modified).to eq(listed.last_modified) + end + end + + context 'with a store that only summarizes its sessions' do + let(:store) do + Class.new(ClaudeAgentSDK::InMemorySessionStore) { undef_method :list_sessions }.new + end + + it 'takes the mtime of the summary' do + store.append(key, conversation.store_entries) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(info_from(store).last_modified).to eq(listed.last_modified) + end + end + + # Only #append and #load are required of an adapter. + context 'with a store that cannot list' do + let(:store) do + Class.new do + def initialize = @sessions = {} + def append(key, entries) = (@sessions[key] ||= []).concat(entries) + def load(key) = @sessions[key] + end.new + end + + it 'falls back to the timestamp of the last entry' do + entries = conversation.store_entries + store.append(key, entries) + + expect(info_from(store).last_modified) + .to eq(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms(entries[-2]['timestamp'])) + end + + it 'is 0 when no entry carries a timestamp' do + store.append(key, untimed_entries) + + expect(info_from(store).last_modified).to eq(0) + end + end + end end From 91632494af7e3c7a4d5178cdddb14adc92ac2e36 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:30:39 +0800 Subject: [PATCH 052/194] Make a hang fail the suite and CI instead of stalling them A regression that leaves something parked did not fail a spec: the rspec process never finished, and no CI job had a time limit, so the result was a job stuck for GitHub's six-hour default with no example named. Two one-line mutations show it: the read loop no longer ending the run when it exits, and SubprocessCLITransport#close no longer closing stdin. CI: timeout-minutes: 15 on the four jobs in ci.yml (a healthy job takes about a minute). Specs: the waits those two mutations hang are bounded, and what is left parked is stopped so the reactor can exit and report the failure: - query_spec.rb, "unblocks a parked waiter when the read loop dies without a result": the wait for the waiter; - query_run_end_spec.rb: the shared with_query helper waits, bounded, for the tasks an example leaves parked once the query is closed; - subprocess_cli_transport_spec.rb, the group about a writer parked mid-frame: the three pops of the writer's outcome, and one wait for a queued fiber writer. With either mutation applied the whole suite now finishes in about 70 seconds with ordinary failures (3 and 2 examples) where it used to hang. Specs and CI only; no library change. Exploration finding: F-P4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- .github/workflows/ci.yml | 9 +++++++++ spec/unit/query_run_end_spec.rb | 15 +++++++++++++++ spec/unit/query_spec.rb | 6 +++++- spec/unit/subprocess_cli_transport_spec.rb | 17 +++++++++++++---- 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6c1a5d6..db0b011a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] +# Every job has a time limit. This suite is built on subprocesses, pipes and +# a reactor, and the regressions it guards against tend to hang rather than +# fail; without a limit a stuck example would hold its job for GitHub's +# default of six hours and name nothing. A healthy job finishes in about a +# minute, so 15 leaves ample room for a cold bundle install. jobs: # Main suite + lint on every supported Ruby (Linux). The Ruby 3.4 Linux leg # also collects coverage (simplecov, COVERAGE=1): the total goes to the job @@ -15,6 +20,7 @@ jobs: test: name: test (${{ matrix.os }}, Ruby ${{ matrix.ruby-version }}) runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -80,6 +86,7 @@ jobs: rbs-test: name: rbs runtime type check (Ruby 3.4) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -106,6 +113,7 @@ jobs: deps: name: deps (${{ matrix.gemfile }}, Ruby ${{ matrix.ruby-version }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -145,6 +153,7 @@ jobs: rails: name: rails (${{ matrix.gemfile }}, Ruby ${{ matrix.ruby-version }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/spec/unit/query_run_end_spec.rb b/spec/unit/query_run_end_spec.rb index e658453f..ad53db0e 100644 --- a/spec/unit/query_run_end_spec.rb +++ b/spec/unit/query_run_end_spec.rb @@ -72,9 +72,24 @@ def with_query(**kwargs) yield query, feed, ended, task ensure query.close + release_parked_tasks(task) end.wait end + # Closing the query ends the run for good, which releases every task the + # example left parked on it (a wait_for_result_and_end_input, a + # stream_input). Bounded, and what is still parked is stopped: a task that + # close did not release would keep this reactor alive forever, and the + # example would hang the suite instead of failing. + def release_parked_tasks(task) + parked = [] + task.children&.each { |child| parked << child } + task.with_timeout(10) { task.yield while parked.any?(&:alive?) } + rescue Async::TimeoutError + parked.each(&:stop) + raise + end + describe 'stdin stays open until idle' do it 'keeps stdin open past a result when a task settled just before it, and closes at idle (#1190)' do with_query do |query, feed, ended, task| diff --git a/spec/unit/query_spec.rb b/spec/unit/query_spec.rb index c992297c..0a620fd3 100644 --- a/spec/unit/query_spec.rb +++ b/spec/unit/query_spec.rb @@ -220,9 +220,13 @@ def hooks_config expect(ended).to be_empty queue.enqueue(:crash) - waiter.wait + # Bounded, and the waiter is stopped below: the regression leaves it + # parked forever, which must fail this example rather than hang the + # suite (a parked task keeps the reactor alive). + task.with_timeout(10) { waiter.wait } expect(ended).not_to be_empty ensure + waiter&.stop query.close end.wait end diff --git a/spec/unit/subprocess_cli_transport_spec.rb b/spec/unit/subprocess_cli_transport_spec.rb index dbcb967f..3d9cd74e 100644 --- a/spec/unit/subprocess_cli_transport_spec.rb +++ b/spec/unit/subprocess_cli_transport_spec.rb @@ -1458,6 +1458,15 @@ def park_thread_writer(transport, payload, outcome) writer end + # What the parked thread writer ended with. Bounded: a shutdown that + # never interrupts the writer leaves the queue empty forever, and that + # must fail the example, not hang the suite. + def writer_outcome(outcome) + result = outcome.pop(timeout: 10) + expect(result).not_to be_nil, 'the writer parked in IO#write was never interrupted' + result + end + def expect_woken_with_connection_error(writer, caught) expect(writer).to be_finished expect(caught.size).to eq(1) @@ -1514,7 +1523,7 @@ def expect_woken_with_connection_error(writer, caught) expect(second).not_to be_finished task.with_timeout(5, hang) { transport.end_input } - second.wait + task.with_timeout(10) { second.wait } # bounded: a writer left parked must fail here, not hang expect(first).to be_finished expect(caught.size).to eq(2) @@ -1542,7 +1551,7 @@ def expect_woken_with_connection_error(writer, caught) task.with_timeout(5, hang) { transport.close } end.wait - expect(outcome.pop).to be_a(ClaudeAgentSDK::CLIConnectionError) + expect(writer_outcome(outcome)).to be_a(ClaudeAgentSDK::CLIConnectionError) expect(w).to be_closed ensure writer&.join(5) @@ -1558,7 +1567,7 @@ def expect_woken_with_connection_error(writer, caught) task.with_timeout(5, hang) { transport.end_input } end.wait - expect(outcome.pop).to be_a(ClaudeAgentSDK::CLIConnectionError) + expect(writer_outcome(outcome)).to be_a(ClaudeAgentSDK::CLIConnectionError) expect(w).to be_closed expect(transport.instance_variable_get(:@stdin)).to be_nil ensure @@ -1621,7 +1630,7 @@ def expect_woken_with_connection_error(writer, caught) end.wait # The detached helper's close interrupts the writer. - expect(outcome.pop).to be_a(ClaudeAgentSDK::CLIConnectionError) + expect(writer_outcome(outcome)).to be_a(ClaudeAgentSDK::CLIConnectionError) ensure writer&.join(5) [r, w].each { |io| io&.close unless io&.closed? } From bea4521c0329ef064bb4d5ba30cb91b0b2fe8f80 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:31:13 +0800 Subject: [PATCH 053/194] Wait in FiberBoundary.invoke until the worker really finished A thread hop could return nil while its callback was still running, and a timeout-bounded hop could raise JoinTimeout with no time passed. FiberBoundary.invoke trusted a single Thread#value / Thread#join. Under a fiber scheduler join parks the fiber, and MRI treats any wakeup that finds the thread still alive as a timeout: join returns nil, and so does value. The wakeup need not belong to that join. When a hop's thread ends it queues a wakeup for the waiting fiber; if an exception (Async::Stop, a deadline) is raised into the fiber before the wakeup is consumed, it stays queued and resumes the next hop of that fiber instead. Reached, for example, when query() is stopped while it waits for a message block or an observer: the on_close hop in its ensure was dispatched but not awaited. Only "the thread has finished" or "the deadline has really passed" now ends the wait: the unbounded form joins until the thread is done, the bounded form joins against a monotonic deadline and raises JoinTimeout only once it has passed. A worker's exception is re-raised as before. The wait moved into FiberBoundary.await_worker (private API). The new spec queues a real stale wakeup (a finished first hop, then Async::Stop into the not-yet-resumed fiber) and pins both forms: public ClaudeAgentSDK.offload and invoke(timeout:). Exploration finding: A-P8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk/fiber_boundary.rb | 30 +++++- spec/unit/fiber_boundary_join_spec.rb | 133 +++++++++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 spec/unit/fiber_boundary_join_spec.rb diff --git a/lib/claude_agent_sdk/fiber_boundary.rb b/lib/claude_agent_sdk/fiber_boundary.rb index 878db026..b3328886 100644 --- a/lib/claude_agent_sdk/fiber_boundary.rb +++ b/lib/claude_agent_sdk/fiber_boundary.rb @@ -335,9 +335,35 @@ def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block) Thread.current.report_on_exception = false work.call end - return thread.value if timeout.nil? - raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout) + await_worker(thread, timeout) + end + # Wait for the worker thread of one hop and return its value (or re-raise + # what it raised); with +timeout+, raise JoinTimeout once that many + # seconds have passed. + # + # Only "the thread has finished" or "the deadline has really passed" ends + # the wait. Under a fiber scheduler Thread#join parks the fiber, and MRI + # reports ANY wakeup that finds the thread alive as a timeout: join + # returns nil, and Thread#value returns nil with it. The wakeup need not + # belong to this join — one queued by an earlier hop's thread stays behind + # when an exception (Async::Stop, a deadline) is raised into the fiber + # before it is consumed, and resumes this hop instead. Trusting a single + # join then returned nil while the callback was still running, or raised + # JoinTimeout with no time passed. + # @api private + def await_worker(thread, timeout) + if timeout.nil? + nil until thread.join + return thread.value + end + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + until thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) + next if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + + raise JoinTimeout, "timed out after #{timeout}s" + end thread.value end diff --git a/spec/unit/fiber_boundary_join_spec.rb b/spec/unit/fiber_boundary_join_spec.rb new file mode 100644 index 00000000..49838ba4 --- /dev/null +++ b/spec/unit/fiber_boundary_join_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +# FiberBoundary.invoke waits for its worker thread with Thread#join. Under a +# fiber scheduler, join parks the fiber, and MRI treats ANY wakeup that finds +# the thread still alive as "timed out": join returns nil (and Thread#value +# then returns nil too) although nothing has finished. Such a wakeup does not +# have to belong to this join. When a hop's thread ends it queues a wakeup +# for the waiting fiber; if an exception (Async::Stop, a deadline) is raised +# into the fiber before that wakeup is consumed, the wakeup stays queued and +# resumes whatever the fiber waits on next — the next hop. +# +# So only "the thread has finished" or "the deadline has really passed" may +# end the wait. +RSpec.describe ClaudeAgentSDK::FiberBoundary, 'waiting for the worker thread' do + def monotonic + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + # Runs the given hop on a fiber that has a stale wakeup queued for it. The + # hop gets two queues: it must push to +started+ from its worker and then + # block that worker on +release+.pop. Returns whether the hop was over + # before its worker was released, and what it returned or raised. + # + # No clock is involved: every step waits on a queue or on thread state. + def hop_after_stale_wakeup # rubocop:disable Metrics/AbcSize -- one linear interleaving, annotated step by step + first_started = Thread::Queue.new + first_release = Thread::Queue.new + started = Thread::Queue.new + release = Thread::Queue.new + outcome = Thread::Queue.new + + Sync do |root| + child = root.async do + ClaudeAgentSDK.offload do + first_started << Thread.current + first_release.pop + end + ensure + # The second hop of the same fiber, entered while the first hop's + # wakeup is still queued. + outcome << begin + yield started, release + rescue StandardError => e + e + end + end + + first_thread = root.with_timeout(10) { first_started.pop } + first_release << true + # The first worker ends and queues the wakeup for the child's fiber. + # This fiber keeps the reactor thread meanwhile, so the child is not + # resumed yet. + Thread.pass while first_thread.alive? + stopper = root.async { child.stop } # Async::Stop into the not-yet-resumed fiber: it unwinds into the ensure + root.with_timeout(10) { started.pop } # the second worker is running, and blocked + 3.times { root.yield } # the stale wakeup, queued before all of this, has had its turn + + over_before_release = !outcome.empty? + release << true + stopper.wait + [over_before_release, root.with_timeout(10) { outcome.pop }] + end + end + + it 'does not return from an unbounded hop (ClaudeAgentSDK.offload) while its block is still running' do + over_before_release, result = hop_after_stale_wakeup do |started, release| + ClaudeAgentSDK.offload do + started << true + release.pop + :finished + end + end + + aggregate_failures do + expect(over_before_release).to be(false) + expect(result).to eq(:finished) + end + end + + it 'does not report a timeout for a bounded hop whose deadline has not passed' do + over_before_release, result = hop_after_stale_wakeup do |started, release| + described_class.invoke(timeout: 60) do + started << true + release.pop + :finished + end + end + + aggregate_failures do + expect(over_before_release).to be(false) + expect(result).to eq(:finished) + end + end + + describe 'a deadline that really passes' do + let(:release) { Thread::Queue.new } + + after { release.close } # the abandoned worker's pop returns and its thread ends + + # The worker outlives the deadline by a wide margin but not forever: on a + # Ruby whose Thread#join(limit) cannot expire under a scheduler (3.2.0) + # the example then fails after that margin instead of hanging the suite. + def outlive_the_deadline + release.pop(timeout: 5) + end + + it 'raises JoinTimeout inside a reactor, and not before the deadline' do + started = monotonic + + expect do + Sync { described_class.invoke(timeout: 0.05) { outlive_the_deadline } } + end.to raise_error(described_class::JoinTimeout, 'timed out after 0.05s') + expect(monotonic - started).to be > 0.049 + end + + it 'raises JoinTimeout outside a reactor, and not before the deadline' do + started = monotonic + + expect do + described_class.invoke(timeout: 0.05) { outlive_the_deadline } + end.to raise_error(described_class::JoinTimeout, 'timed out after 0.05s') + expect(monotonic - started).to be > 0.049 + end + end + + it 'returns the value of a bounded hop that finishes in time' do + expect(Sync { described_class.invoke(timeout: 60) { :in_time } }).to eq(:in_time) + expect(described_class.invoke(timeout: 60) { :in_time }).to eq(:in_time) + end +end From acbe26f5a645023640ef2f9f2d503a3a0a4d4309 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:31:29 +0800 Subject: [PATCH 054/194] Reject a title or tag that is not a usable String with ArgumentError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rename_session(title: nil) raised NoMethodError ("undefined method 'strip' for nil") from inside the mutation, where an empty title gets ArgumentError ("title must be non-empty"). The same held for a title of another type, for a title with bytes invalid in its encoding (Encoding::CompatibilityError), and for tag_session with a tag that is not a String or is invalidly encoded — on disk and with session_store:. Both mutations now check the value at the boundary, as they do for the session id, and raise the ArgumentError the blank value already gets. Nothing is written. tag: nil still clears the tag. Exploration finding: B-P16 (item 4) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 2 +- lib/claude_agent_sdk/session_mutations.rb | 58 +++++++++++++---------- spec/unit/session_read_boundaries_spec.rb | 41 ++++++++++++++++ 3 files changed, 75 insertions(+), 26 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index c5bf44c5..a1636e1c 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -2,7 +2,7 @@ Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. By default these APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable (an empty value is treated as unset, falling back to `~/.claude`) and auto-detecting git worktrees. On a host with no usable home directory (`HOME` unset with no passwd entry — e.g. `docker --user` in a minimal image — or an empty/relative `HOME`) and no `CLAUDE_CONFIG_DIR`, the local-disk path raises `ClaudeAgentSDK::ConfigDirError`; set `CLAUDE_CONFIG_DIR` there. Every one of them also takes an optional `session_store:` to operate on a [`SessionStore`](#mirroring-to-a-sessionstore) instead (see [Store-backed sessions](#store-backed-sessions)). -Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. A `directory:` that was removed after its sessions were recorded (a deleted worktree) still names its project: the path is resolved as far as it exists, so those sessions can be listed, read, renamed, tagged, forked and deleted through it. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String. +Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. A `directory:` that was removed after its sessions were recorded (a deleted worktree) still names its project: the path is resolved as far as it exists, so those sessions can be listed, read, renamed, tagged, forked and deleted through it. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String. `rename_session` raises `ArgumentError` for a `title:` that is blank or not a usable String (`nil`, another type, invalidly encoded), and `tag_session` for such a `tag:` — except `nil`, which clears the tag. ## Listing Sessions diff --git a/lib/claude_agent_sdk/session_mutations.rb b/lib/claude_agent_sdk/session_mutations.rb index 46c6019c..1c332a42 100644 --- a/lib/claude_agent_sdk/session_mutations.rb +++ b/lib/claude_agent_sdk/session_mutations.rb @@ -40,13 +40,12 @@ module SessionMutations # rubocop:disable Metrics/ModuleLength -- rename/tag/del # @param session_id [String] UUID of the session to rename # @param title [String] New session title (whitespace stripped) # @param directory [String, nil] Project directory path - # @raise [ArgumentError] if session_id is invalid or title is empty + # @raise [ArgumentError] if session_id is invalid, or title is blank or not a String # @raise [Errno::ENOENT] if the session file cannot be found def rename_session(session_id:, title:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - stripped = title.strip - raise ArgumentError, 'title must be non-empty' if stripped.empty? + stripped = stripped_title(title) data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id })}\n" @@ -61,19 +60,12 @@ def rename_session(session_id:, title:, directory: nil) # @param session_id [String] UUID of the session to tag # @param tag [String, nil] Tag string, or nil to clear # @param directory [String, nil] Project directory path - # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization + # @raise [ArgumentError] if session_id is invalid, or tag is not a String or is empty after sanitization # @raise [Errno::ENOENT] if the session file cannot be found def tag_session(session_id:, tag:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - if tag - sanitized = sanitize_unicode(tag).strip - raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? - - tag = sanitized - end - - data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id })}\n" + data = "#{JSON.generate({ type: 'tag', tag: sanitized_tag(tag), sessionId: session_id })}\n" append_to_session(session_id, data, directory) end @@ -171,13 +163,12 @@ def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) # appended entry carries a fresh uuid + ISO timestamp so adapters that dedupe # by entry["uuid"] (per the SessionStore#append contract) treat it correctly. # - # @raise [ArgumentError] if session_id is invalid or title is empty + # @raise [ArgumentError] if session_id is invalid, or title is blank or not a String # @raise [Errno::ENOENT] if the session is not found in the store def rename_session_via_store(session_store:, session_id:, title:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - stripped = title.strip - raise ArgumentError, 'title must be non-empty' if stripped.empty? + stripped = stripped_title(title) key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } ensure_store_session_exists(session_store, key) @@ -195,23 +186,17 @@ def rename_session_via_store(session_store:, session_id:, title:, directory: nil # counterpart to tag_session. Pass nil to clear the tag. Tags are # Unicode-sanitized before storing. # - # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization + # @raise [ArgumentError] if session_id is invalid, or tag is not a String or is empty after sanitization # @raise [Errno::ENOENT] if the session is not found in the store def tag_session_via_store(session_store:, session_id:, tag:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - if tag - sanitized = sanitize_unicode(tag).strip - raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? - - tag = sanitized - end - + tag = sanitized_tag(tag) key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } ensure_store_session_exists(session_store, key) session_store.append(key, [{ 'type' => 'tag', - 'tag' => tag || '', + 'tag' => tag, 'sessionId' => session_id, 'uuid' => SecureRandom.uuid, 'timestamp' => iso_now @@ -270,6 +255,29 @@ def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_me # -- Private helpers -- + # The title to store: stripped and non-empty. A value that is not a usable + # String (nil, another type, bytes invalid in their encoding) gets the + # ArgumentError an empty title gets — the boundary check the session ids + # have — where calling #strip on it raised NoMethodError or an encoding + # error from inside. + def stripped_title(title) + stripped = title.is_a?(String) && title.valid_encoding? ? title.strip : '' + raise ArgumentError, 'title must be non-empty' if stripped.empty? + + stripped + end + + # The tag to store: Unicode-sanitized and stripped, or '' (which clears + # the tag) for nil. Same boundary check as stripped_title. + def sanitized_tag(tag) + return '' unless tag + + sanitized = tag.is_a?(String) && tag.valid_encoding? ? sanitize_unicode(tag).strip : '' + raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? + + sanitized + end + # Raise Errno::ENOENT (as the disk counterparts and fork_session_via_store # do) unless the store holds entries for +key+. Without this probe, a # rename/tag of a typo'd or stale id APPENDED metadata to a never-written @@ -686,7 +694,7 @@ def unicode_category(char) 'Other' end - private_class_method :find_session_file_with_dir, + private_class_method :stripped_title, :sanitized_tag, :find_session_file_with_dir, :find_in_directory, :try_project_dir, :find_in_all_projects, :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid, :append_to_session, :append_to_session_in_directory, diff --git a/spec/unit/session_read_boundaries_spec.rb b/spec/unit/session_read_boundaries_spec.rb index f77a44eb..3136f273 100644 --- a/spec/unit/session_read_boundaries_spec.rb +++ b/spec/unit/session_read_boundaries_spec.rb @@ -181,4 +181,45 @@ def load(key) = @sessions[key] end end end + + # A title or tag that is not a usable String is rejected like an empty + # one, before anything is written. + describe 'rename_session and tag_session with a value that is not a usable String', + rbs_incompatible: 'passes out-of-signature input to test its rejection' do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + let!(:transcript_file) { conversation.write(transcript_path(session_id)) } + + before { store.append(key, conversation.store_entries) } + + { 'nil' => nil, 'an Integer' => 123, 'invalid bytes' => "caf\xC3".dup.force_encoding('UTF-8') }.each do |label, title| + it "raises ArgumentError for a title that is #{label}, on disk and with a store" do + on_disk = File.binread(transcript_file) + + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: title, directory: cwd) } + .to raise_error(ArgumentError, 'title must be non-empty') + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: title, directory: cwd, session_store: store) } + .to raise_error(ArgumentError, 'title must be non-empty') + expect(File.binread(transcript_file)).to eq(on_disk) + expect(store.load(key).length).to eq(conversation.store_entries.length) + end + end + + { 'an Integer' => 123, 'invalid bytes' => "caf\xC3".dup.force_encoding('UTF-8') }.each do |label, tag| + it "raises ArgumentError for a tag that is #{label}, on disk and with a store" do + expect { ClaudeAgentSDK.tag_session(session_id: session_id, tag: tag, directory: cwd) } + .to raise_error(ArgumentError, 'tag must be non-empty (use nil to clear)') + expect { ClaudeAgentSDK.tag_session(session_id: session_id, tag: tag, directory: cwd, session_store: store) } + .to raise_error(ArgumentError, 'tag must be non-empty (use nil to clear)') + expect(store.load(key).length).to eq(conversation.store_entries.length) + end + end + + it 'still clears the tag for nil' do + ClaudeAgentSDK.tag_session(session_id: session_id, tag: 'keep', directory: cwd) + ClaudeAgentSDK.tag_session(session_id: session_id, tag: nil, directory: cwd) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd).tag).to be_nil + end + end end From db2ee69d770b2523a3310b5b48ecc3317fd84b7a Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:33:22 +0800 Subject: [PATCH 055/194] Filter the server config the CLI echoes in McpServerStatus#inspect `mcp_status` returns each MCP server's config as the CLI holds it: `headers`, the full `url` and `args` come back (the stdio `env` does not). `McpStatusResponse.parse(client.mcp_status)` is the documented typed view, and printing one of its servers showed all of it, e.g. `headers: {Authorization: "Bearer ..."}`. Inside a McpStatusResponse the config was hidden by the nesting limit only. McpServerStatus#inspect now renders a Hash `config` through the renderer ClaudeAgentOptions already uses for a raw Hash config in `mcp_servers`: `type`, `command`, `name` and `instance` are shown, `url` prints its scheme and host, every other value prints as "[FILTERED]". The renderer and its key list move from ClaudeAgentOptions to Type so both can use them. The typed proxy and SDK configs print as before. Display only: `status.config` still holds what the CLI sent. Exploration finding: C-P10 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP --- lib/claude_agent_sdk/types/base.rb | 23 +++++- lib/claude_agent_sdk/types/mcp.rb | 19 +++++ lib/claude_agent_sdk/types/options.rb | 20 +---- spec/unit/type_inspect_filtering_spec.rb | 96 ++++++++++++++++++++++- spec/unit/type_inspect_robustness_spec.rb | 11 +++ 5 files changed, 148 insertions(+), 21 deletions(-) diff --git a/lib/claude_agent_sdk/types/base.rb b/lib/claude_agent_sdk/types/base.rb index 5315a9c9..12c511f2 100644 --- a/lib/claude_agent_sdk/types/base.rb +++ b/lib/claude_agent_sdk/types/base.rb @@ -127,7 +127,8 @@ def self.option_hash_key(key, hash) # its keys): `env`, `settings` and `extra_args` of ClaudeAgentOptions, # and `env`, `args` and `headers` of the MCP server configs. An MCP # server `url` prints its scheme and host only, and a Hash server config - # in `mcp_servers` is filtered like a typed one. + # (in `mcp_servers`, or echoed by the CLI in McpServerStatus#config) is + # filtered like a typed one. # # It does not raise: a value that fails while it is rendered shows as # `#` (`#` when even its class cannot be asked). @@ -230,6 +231,26 @@ def inspect_filter_url(value) '[FILTERED]' end + # Keys of a raw Hash MCP server config whose values #inspect shows as they are. + INSPECT_MCP_CONFIG_KEYS = %w[type command name instance].freeze + private_constant :INSPECT_MCP_CONFIG_KEYS + + # An MCP server config held as a raw Hash (in ClaudeAgentOptions#mcp_servers, + # or echoed by the CLI in McpServerStatus#config) carries the same + # credentials as a typed one: env, headers, args, a token in the url. It + # keeps its keys and shows what identifies the server: its type, its + # command (or the SDK server's name and instance) and the scheme and host + # of its url. Every other value is filtered here rather than left to the + # nesting limit of #inspect. + def inspect_mcp_server_config(config) + config.to_h do |key, value| + name = key.to_s + next [key, value] if INSPECT_MCP_CONFIG_KEYS.include?(name) + + [key, name == 'url' ? inspect_filter_url(value) : '[FILTERED]'] + end + end + def inspect_class_name self.class.name || self.class.inspect end diff --git a/lib/claude_agent_sdk/types/mcp.rb b/lib/claude_agent_sdk/types/mcp.rb index bffeb7a4..16a87f87 100644 --- a/lib/claude_agent_sdk/types/mcp.rb +++ b/lib/claude_agent_sdk/types/mcp.rb @@ -109,6 +109,25 @@ def self.parse_config(config) config end end + + private + + # The CLI echoes a server's config back with its credentials (headers, a + # token in the url, args). A Hash config prints like a raw Hash config in + # ClaudeAgentOptions#mcp_servers; the typed proxy and SDK configs print + # as they are. + def inspect_attributes + super.map { |name, value| [name, name == 'config' ? inspect_config(value) : value] } + end + + def inspect_config(config) + case config # not config.is_a?: a BasicObject has no such method + when Hash then inspect_mcp_server_config(config) + else config + end + rescue StandardError + '[FILTERED]' + end end # Response from get_mcp_status containing all server statuses diff --git a/lib/claude_agent_sdk/types/options.rb b/lib/claude_agent_sdk/types/options.rb index c6822287..5bf16c62 100644 --- a/lib/claude_agent_sdk/types/options.rb +++ b/lib/claude_agent_sdk/types/options.rb @@ -347,16 +347,9 @@ def fill_nil_defaults self.callback_scheduling = :thread if callback_scheduling.nil? end - # Keys of a raw Hash server config whose values #inspect shows as they are. - INSPECT_MCP_CONFIG_KEYS = %w[type command name instance].freeze - private_constant :INSPECT_MCP_CONFIG_KEYS - # `mcp_servers` holds typed configs, which filter themselves, next to raw - # Hash configs carrying the same credentials (env, headers, args, a token - # in the url). A raw config keeps its keys and shows what identifies the - # server: its type, its command (or the SDK server's name and instance) - # and the scheme and host of its url. Every other value is filtered - # here rather than left to the nesting limit of #inspect. + # Hash configs carrying the same credentials: those are rendered by + # Type#inspect_mcp_server_config. def inspect_attributes super.map { |name, value| [name, name == 'mcp_servers' ? inspect_mcp_servers(value) : value] } end @@ -369,15 +362,6 @@ def inspect_mcp_servers(servers) '[FILTERED]' end - def inspect_mcp_server_config(config) - config.to_h do |key, value| - name = key.to_s - next [key, value] if INSPECT_MCP_CONFIG_KEYS.include?(name) - - [key, name == 'url' ? inspect_filter_url(value) : '[FILTERED]'] - end - end - # Strict key validation: unlike other Type subclasses (which silently drop # unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions # is a developer-facing config object — typos should fail loudly. diff --git a/spec/unit/type_inspect_filtering_spec.rb b/spec/unit/type_inspect_filtering_spec.rb index cb15de47..91fe1e0d 100644 --- a/spec/unit/type_inspect_filtering_spec.rb +++ b/spec/unit/type_inspect_filtering_spec.rb @@ -8,11 +8,41 @@ # Options objects and MCP server configs end up in logs, so Type#inspect # filters every attribute that can carry a credential: env and headers, and # also settings, extra_args, a stdio server's args, the URL of an HTTP / SSE -# server, and the raw Hash server configs inside mcp_servers. Filtering is -# for display only: nothing sent to the CLI changes. +# server, the raw Hash server configs inside mcp_servers, and the server +# configs the CLI echoes back in mcp_status. Filtering is for display only: +# nothing sent to the CLI changes. RSpec.describe 'Type#inspect credential filtering' do let(:secret) { 'sk-ant-api03-SECRET0123' } + # The mcp_status frame CLI 2.1.287 sends for an http, an sse and a stdio + # server that failed to connect (a handshake without a model turn). Each + # config comes back with its headers, full url and args; the stdio env is + # left out. The CLI redacts the url in its own error text only. + let(:status_frame) do + { + mcpServers: [ + { + name: 'api', status: 'failed', + error: 'SdkHttpError dialing http://127.0.0.1:9[redacted]?key=REDACTED (CLIENT_HTTP_NOT_IMPLEMENTED)', + config: { + type: 'http', url: "http://127.0.0.1:9/mcp?key=#{secret}", headers: { Authorization: "Bearer #{secret}" } + }, + scope: 'dynamic', source: 'dynamic' + }, + { + name: 'events', status: 'failed', error: 'Error dialing http://127.0.0.1:9[redacted]?key=REDACTED', + config: { type: 'sse', url: "http://127.0.0.1:9/sse?key=#{secret}", headers: { 'X-Api-Key': secret } }, + scope: 'dynamic', source: 'dynamic' + }, + { + name: 'fs', status: 'failed', error: 'Connection closed', + config: { type: 'stdio', command: '/usr/bin/true', args: ['--token', secret] }, + scope: 'dynamic', source: 'dynamic' + } + ] + } + end + # url template (%s is the secret) => what #inspect shows for it url_rows = { 'a token in the query string' => ['https://mcp.example.com/v1/mcp?api_key=%s', 'https://mcp.example.com/[FILTERED]'], @@ -234,6 +264,51 @@ end end + # McpStatusResponse.parse(client.mcp_status) is the typed view of that + # frame: printing one of its servers must not print what its config holds. + describe ClaudeAgentSDK::McpServerStatus do + let(:servers) { ClaudeAgentSDK::McpStatusResponse.parse(status_frame).mcp_servers } + + it 'shows type and host of an echoed http config and filters headers' do + expect(servers[0].inspect).to eq( + '#' + ) + end + + it 'shows type and host of an echoed sse config and filters headers' do + expect(servers[1].inspect).to eq( + '#' + ) + end + + it 'shows type and command of an echoed stdio config and filters args' do + expect(servers[2].inspect).to eq( + '#' + ) + end + + it 'filters the same way through #to_s, interpolation and pp' do + servers.each do |server| + [server.to_s, "#{server}", PP.pp(server, +'')].each do |rendered| # rubocop:disable Style/RedundantInterpolation + expect(rendered).to include('[FILTERED]') + expect(rendered).not_to include('SECRET') + end + end + end + + it 'still collapses the configs inside a McpStatusResponse' do + rendered = ClaudeAgentSDK::McpStatusResponse.parse(status_frame).inspect + + expect(rendered.scan('config={…(3)}').size).to eq(3) + expect(rendered).not_to include('SECRET') + end + end + # What the CLI receives is built from the attributes and #to_h, never from # #inspect: the filters above change no byte of it. describe 'the wire form' do @@ -316,5 +391,22 @@ def command_line(options) headers: { 'Authorization' => "Bearer #{secret}" }) expect(options.mcp_servers[:github][:env]).to eq('GITHUB_PERSONAL_ACCESS_TOKEN' => secret) end + + # A parsed mcp_status frame is never sent anywhere; what must not change + # is what the application reads from it. + it 'leaves the configs of a parsed mcp_status frame as the CLI sent them' do + response = ClaudeAgentSDK::McpStatusResponse.parse(status_frame) + response.inspect + response.mcp_servers.each(&:inspect) + + expect(response.mcp_servers.map(&:config)).to eq( + [ + { type: 'http', url: "http://127.0.0.1:9/mcp?key=#{secret}", headers: { Authorization: "Bearer #{secret}" } }, + { type: 'sse', url: "http://127.0.0.1:9/sse?key=#{secret}", headers: { 'X-Api-Key': secret } }, + { type: 'stdio', command: '/usr/bin/true', args: ['--token', secret] } + ] + ) + expect(response.mcp_servers.first.config).to equal(status_frame[:mcpServers].first[:config]) + end end end diff --git a/spec/unit/type_inspect_robustness_spec.rb b/spec/unit/type_inspect_robustness_spec.rb index 1a8d1d54..fbe51be4 100644 --- a/spec/unit/type_inspect_robustness_spec.rb +++ b/spec/unit/type_inspect_robustness_spec.rb @@ -51,6 +51,17 @@ def hostile(name, base, &body) expect(rendered).to all(eq(' mcp_servers="[FILTERED]"')) end + + it 'prints the config of an McpServerStatus as filtered when it cannot be read' do + oddly_keyed = {}.compare_by_identity + oddly_keyed[BasicObject.new] = 'x' + status = ClaudeAgentSDK::McpServerStatus.new(name: 'api', config: oddly_keyed) + opaque = ClaudeAgentSDK::McpServerStatus.new(name: 'api') + opaque.instance_variable_set(:@config, BasicObject.new) + + expect(status.inspect).to eq('#') + expect(opaque.inspect).to eq('#>') + end end describe 'a String whose own methods raise' do From c76d3ed2fd74b0a9416bc41308a5ed0b865c4374 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:35:09 +0800 Subject: [PATCH 056/194] Filter mcp_servers given as a String in #inspect `mcp_servers` may be a String instead of a Hash: the JSON of a config, or the path to a config file, passed to `--mcp-config` as it is. ClaudeAgentOptions#inspect printed its first 80 characters, so a JSON config showed whatever credential fell inside them, e.g. `mcp_servers="{\"mcpServers\":{\"gh\":{\"command\":\"npx\",\"env\": {\"TOKEN\":\"sk-...\"}}}"`. Anything but a Hash now prints as "[FILTERED]", the way a String `settings` already does; a config file path is hidden with it. A Hash keeps its per-server rendering, and the empty default still prints `{}`. Display only: the String still reaches `--mcp-config` unchanged. Exploration finding: C-P10 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP --- lib/claude_agent_sdk/types/base.rb | 3 ++- lib/claude_agent_sdk/types/options.rb | 6 ++++-- spec/unit/type_inspect_filtering_spec.rb | 25 ++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lib/claude_agent_sdk/types/base.rb b/lib/claude_agent_sdk/types/base.rb index 12c511f2..fb4da6a4 100644 --- a/lib/claude_agent_sdk/types/base.rb +++ b/lib/claude_agent_sdk/types/base.rb @@ -128,7 +128,8 @@ def self.option_hash_key(key, hash) # and `env`, `args` and `headers` of the MCP server configs. An MCP # server `url` prints its scheme and host only, and a Hash server config # (in `mcp_servers`, or echoed by the CLI in McpServerStatus#config) is - # filtered like a typed one. + # filtered like a typed one. `mcp_servers` given as a String prints as + # `[FILTERED]`. # # It does not raise: a value that fails while it is rendered shows as # `#` (`#` when even its class cannot be asked). diff --git a/lib/claude_agent_sdk/types/options.rb b/lib/claude_agent_sdk/types/options.rb index 5bf16c62..1353185c 100644 --- a/lib/claude_agent_sdk/types/options.rb +++ b/lib/claude_agent_sdk/types/options.rb @@ -349,13 +349,15 @@ def fill_nil_defaults # `mcp_servers` holds typed configs, which filter themselves, next to raw # Hash configs carrying the same credentials: those are rendered by - # Type#inspect_mcp_server_config. + # Type#inspect_mcp_server_config. Anything but a Hash (the JSON of a + # config as a String, or the path to one) is replaced outright, like a + # String `settings`. def inspect_attributes super.map { |name, value| [name, name == 'mcp_servers' ? inspect_mcp_servers(value) : value] } end def inspect_mcp_servers(servers) - return servers unless servers.is_a?(Hash) + return '[FILTERED]' unless servers.is_a?(Hash) servers.to_h { |name, config| [name, config.is_a?(Hash) ? inspect_mcp_server_config(config) : config] } rescue StandardError diff --git a/spec/unit/type_inspect_filtering_spec.rb b/spec/unit/type_inspect_filtering_spec.rb index 91fe1e0d..eee38d17 100644 --- a/spec/unit/type_inspect_filtering_spec.rb +++ b/spec/unit/type_inspect_filtering_spec.rb @@ -110,6 +110,18 @@ expect(rendered).not_to include('settings=') end + it 'filters mcp_servers given as a String: the JSON of a config, or the path to one' do + json = JSON.generate(mcpServers: { gh: { command: 'npx', env: { TOKEN: secret } } }) + + [json, '/etc/claude/mcp.json'].each do |mcp_servers| + rendered = described_class.new(mcp_servers: mcp_servers).inspect + + expect(rendered).to include(' mcp_servers="[FILTERED]"') + expect(rendered).not_to include('SECRET') + expect(rendered).not_to include('/etc/claude') + end + end + describe 'raw Hash server configs in mcp_servers' do it 'shows type and command of a stdio config and filters args and env' do options = described_class.new( @@ -392,6 +404,19 @@ def command_line(options) expect(options.mcp_servers[:github][:env]).to eq('GITHUB_PERSONAL_ACCESS_TOKEN' => secret) end + it 'passes mcp_servers given as a String to --mcp-config as it is' do + json = JSON.generate(mcpServers: { gh: { command: 'npx', env: { TOKEN: secret } } }) + string_options = ClaudeAgentSDK::ClaudeAgentOptions.new(mcp_servers: json) + before = command_line(string_options) + string_options.inspect + string_options.to_s + + expect(command_line(string_options)).to eq(before) + expect(before.each_cons(2)) + .to include(['--mcp-config', %({"mcpServers":{"gh":{"command":"npx","env":{"TOKEN":"#{secret}"}}}})]) + expect(string_options.mcp_servers).to equal(json) + end + # A parsed mcp_status frame is never sent anywhere; what must not change # is what the application reads from it. it 'leaves the configs of a parsed mcp_status frame as the CLI sent them' do From 8684167233fe86b44bb51b7329334d1b31fec849 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:36:26 +0800 Subject: [PATCH 057/194] Answer a process exit whose message is not ASCII-compatible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a hook, a can_use_tool callback or an SDK MCP handler raised SystemExit, Interrupt or another SignalException whose message was valid text in an encoding that is not ASCII-compatible (UTF-16, UTF-32), the request was still left unanswered and the exit was still replaced. respond_to_process_exit took its text from FiberBoundary.process_exit_message, which joins the class name to the raw message: that raises Encoding::CompatibilityError before the text reaches wire_text. With :thread scheduling the process died of that error (status 1 instead of the exit's own); with :inline it kept running after `exit`. respond_to_process_exit now normalizes the message first and puts the class name in front itself, in the same format: the bare class name for an empty message or one equal to it, "Class: message" otherwise. A spec pins that format to process_exit_message for ordinary UTF-8 messages. The exception is only read; the caller re-raises the original as before. The format is mirrored rather than shared. Calling process_exit_message on a copy that carries the normalized message (error.exception(text)) would keep one source, but it raises again for a frozen exception and still gets the raw text from a class that overrides #message, in the one rescue clause where nothing may raise. Users can notice: such an exit is answered with its text in UTF-8 ("SystemExit: café") and then ends the process with its own status. Exploration finding: A-P5 (review round 1, B1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda --- lib/claude_agent_sdk/query.rb | 12 +++- spec/support/callback_exit_harness.rb | 13 +++- spec/unit/control_error_encoding_spec.rb | 85 ++++++++++++++++++++---- 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index ebf10d29..9580b9d0 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -857,8 +857,18 @@ def handle_control_request(request) # rubocop:disable Metrics/MethodLength -- su # Answers the request, then leaves the process-exit exception to its # caller to re-raise: the response an ordinary exception from the # callback would have produced, naming the exception by class. + # + # The text has the format of FiberBoundary.process_exit_message, built + # here from the normalized message instead of calling it: that method + # joins the class name to the raw message, which raises + # Encoding::CompatibilityError for an encoding that is not + # ASCII-compatible (UTF-16). Raised in the rescue clause this runs in, + # that error would leave the request unanswered and replace the exit. + # +error+ is only read, never changed. def respond_to_process_exit(request_id, request_data, error) - respond_to_callback_failure(request_id, request_data, FiberBoundary.process_exit_message(error)) + detail = wire_text(error.message) + message = detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}" + respond_to_callback_failure(request_id, request_data, message) end # The response an ordinary exception from the callback would have diff --git a/spec/support/callback_exit_harness.rb b/spec/support/callback_exit_harness.rb index d9506aaa..d9669693 100644 --- a/spec/support/callback_exit_harness.rb +++ b/spec/support/callback_exit_harness.rb @@ -57,6 +57,17 @@ module CallbackExitHarness # rubocop:disable Metrics/ModuleLength -- one self-co text: 'no such order: 注文'.byteslice(0, 16), message: "SystemExit: no such order: \uFFFD", exitstatus: 3 }.freeze + # A process exit whose message is valid text in an encoding that is not + # ASCII-compatible (UTF-16): it cannot be joined to the UTF-8 class name as + # it is. The CLI is told the same text in UTF-8, and the exit still ends + # the process. + UTF16_EXIT = { + text: 'no such order: 注文'.encode(Encoding::UTF_16LE), message: 'SystemExit: no such order: 注文', exitstatus: 3 + }.freeze + + # The kinds that raise the two exits above. + ENCODED_EXITS = { exit_invalid_utf8: INVALID_UTF8_EXIT, exit_utf16: UTF16_EXIT }.freeze + RESPONSE = 'RESPONSE ' SURVIVED = 'CALLBACK_EXIT_HARNESS_SURVIVED' @@ -85,7 +96,7 @@ def trigger(kind) when :interrupt then raise Interrupt when :signal then raise SignalException, 'TERM' when :sigint, :sigterm then busy_until_signalled(kind == :sigint ? 'INT' : 'TERM') - when :exit_invalid_utf8 then raise SystemExit.new(INVALID_UTF8_EXIT[:exitstatus], INVALID_UTF8_EXIT[:text]) + when *ENCODED_EXITS.keys then raise SystemExit.new(*ENCODED_EXITS.fetch(kind).values_at(:exitstatus, :text)) when :none then nil # the callback succeeds when :standard_error then raise ORDINARY_FAILURE when :not_implemented, :security_error then raise(*FAILURE_KINDS.fetch(kind)) diff --git a/spec/unit/control_error_encoding_spec.rb b/spec/unit/control_error_encoding_spec.rb index e70f3f6c..e2c0ad26 100644 --- a/spec/unit/control_error_encoding_spec.rb +++ b/spec/unit/control_error_encoding_spec.rb @@ -7,8 +7,11 @@ # The process-exit cells of this file end their process, so each one runs in # a child (spec/support/callback_exit_harness.rb): all of them in parallel, # once, the first time an example asks for a result. -module UnencodableExitChildren - CELLS = %i[hook read_resource call_tool].product(CallbackExitHarness::MODES).freeze +module EncodedExitChildren + # [path, scheduling, kind]: an error control response (hook) and both + # MCP-shaped answers, for every exit in CallbackExitHarness::ENCODED_EXITS. + CELLS = %i[hook read_resource call_tool] + .product(CallbackExitHarness::MODES, CallbackExitHarness::ENCODED_EXITS.keys).freeze def self.result(cell) results.fetch(cell) @@ -18,14 +21,13 @@ def self.results @results ||= CELLS.zip(CELLS.map { |cell| Thread.new { run(cell) } }.map(&:value)).to_h end - # Read as bytes: the answer carries U+FFFD, and how a text-mode read would - # tag it depends on the locale. + # Read as bytes: the answers carry non-ASCII text, and how a text-mode read + # would tag it depends on the locale. def self.run(cell) lib_dir = File.expand_path('../../lib', __dir__) harness_file = File.expand_path('../support/callback_exit_harness.rb', __dir__) out, err, status = Open3.capture3(RbConfig.ruby, '-I', lib_dir, '-r', 'claude_agent_sdk', '-r', harness_file, - '-e', 'CallbackExitHarness.main(ARGV)', *cell.map(&:to_s), 'exit_invalid_utf8', - binmode: true) + '-e', 'CallbackExitHarness.main(ARGV)', *cell.map(&:to_s), binmode: true) [out.force_encoding(Encoding::UTF_8), err, status] end end @@ -37,6 +39,9 @@ def self.run(cell) # rescue clause that was answering the request, where nothing catches it. # The request was never answered, and on the process-exit path the # GeneratorError also replaced the SystemExit that has to end the process. +# A process exit with valid text in an encoding that is not ASCII-compatible +# (UTF-16) failed one step earlier, with Encoding::CompatibilityError, when +# the exception's class name was put in front of the message. RSpec.describe 'control error responses for messages that are not valid UTF-8' do # Records every frame the SDK writes. let(:transport_class) do @@ -144,8 +149,8 @@ def fail_first_generate end end - # Issue #119's promise, kept for a message that cannot be encoded: the CLI - # is answered, then the ORIGINAL exception ends the process. The answer is + # Issue #119's promise, kept whatever the exit's message holds: the CLI is + # answered, then the ORIGINAL exception ends the process. The answer is # the one an ordinary failure gets on each path — an error control response # (hook), a JSON-RPC internal error (resources/read) or an isError result # (tools/call) inside a successful control response. @@ -166,15 +171,71 @@ def failure_response(path, message) 'response' => { 'mcp_response' => { 'jsonrpc' => '2.0', 'id' => 7 }.merge(mcp_response) }) end - UnencodableExitChildren::CELLS.each do |path, mode| - it "answers #{path} once (#{mode} scheduling), then exits with the original SystemExit" do - out, err, status = UnencodableExitChildren.result([path, mode]) - exit_kind = CallbackExitHarness::INVALID_UTF8_EXIT + messages = { + exit_invalid_utf8: 'cut inside a multibyte character (the stray byte is replaced)', + exit_utf16: 'valid UTF-16LE text (transcoded, so it stays readable)' + } + + EncodedExitChildren::CELLS.each do |path, mode, kind| + it "answers #{path} once (#{mode} scheduling), then exits with the original SystemExit: #{messages.fetch(kind)}" do + out, err, status = EncodedExitChildren.result([path, mode, kind]) + exit_kind = CallbackExitHarness::ENCODED_EXITS.fetch(kind) expect(out).not_to include(CallbackExitHarness::SURVIVED) expect(status.exitstatus).to eq(exit_kind[:exitstatus]), "child: #{status.inspect}\n#{err}" expect(responses(out)).to eq([failure_response(path, exit_kind[:message])]) end end + + # In-process from here on, so :inline and Interrupt only, the dispatch + # inside `rescue Exception` (as in callback_process_exit_spec.rb): RSpec + # does not rescue Interrupt, so a leak has to become an expectation. + it 're-raises the very exception the callback raised, its message untouched' do + original = Interrupt.new('arrêt demandé'.encode(Encoding::UTF_16LE)) + transport = transport_class.new + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true, callback_scheduling: :inline) + query.instance_variable_set(:@hook_callbacks, { 'hook' => ->(*) { raise original } }) + + raised = begin + Sync { query.send(:handle_control_request, CallbackExitHarness.control_request(:hook)) } + nil + rescue Exception => e # rubocop:disable Lint/RescueException + e + end + + # Compared without letting RSpec print the UTF-16 text on a failure. + expect(raised.equal?(original)).to be(true), "expected the callback's own Interrupt back, got #{raised.class}" + message = original.message + expect([message.encoding, message.encode(Encoding::UTF_8)]).to eq([Encoding::UTF_16LE, 'arrêt demandé']) + expect(transport.frames).to eq([error_response('Interrupt: arrêt demandé')]) + end + end + + # Query puts the class name in front of the message itself, because the + # message has to be normalized first. The format is the one + # FiberBoundary.process_exit_message gives the carrier a callback_wrapper + # sees; this keeps the two from drifting apart. + describe 'the text a process exit is reported with' do + def frames_for_exit(error) + transport = transport_class.new + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true) + query.send(:respond_to_process_exit, 'req_fail', CallbackExitHarness.control_request(:hook)[:request], error) + transport.frames + end + + [ + SystemExit.new(3, 'exit'), SystemExit.new(3, 'bye'), SystemExit.new(3, 'arrêt à 5 %'), + Interrupt.new, Interrupt.new(''), SignalException.new('TERM') + ].each do |error| + it "is FiberBoundary.process_exit_message's for #{error.class} with the message #{error.message.inspect}" do + expect(frames_for_exit(error)).to eq([error_response(ClaudeAgentSDK::FiberBoundary.process_exit_message(error))]) + end + end + + it 'has the same form when the message had to be transcoded' do + error = SystemExit.new(3, 'café'.encode(Encoding::UTF_16LE)) + + expect(frames_for_exit(error)).to eq([error_response('SystemExit: café')]) + end end end From 4ca843406e65084a598358a59d97450ce4f5abfc Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:37:11 +0800 Subject: [PATCH 058/194] Treat NotImplementedError from an optional store method as absent Only #append and #load are required of a SessionStore; the SDK probes the optional methods with SessionStore.implements?. Two adapter shapes defeat the probe: behind a delegating wrapper (SimpleDelegator around a SessionStore subclass, e.g. a metrics or tenancy decorator) every stub inherited from SessionStore reads as implemented, and an adapter may decline a method at run time by raising NotImplementedError itself. Calling the method then raised NotImplementedError, a ScriptError that passes `rescue StandardError` and the SessionStoreError wrapping: - delete_session(session_store:) raised instead of being the documented no-op; - list_sessions, list_subagents, get_subagent_messages and get_subagent_metadata with session_store: raised it raw; - a store-backed resume raised it while materializing; - run_session_store_conformance raised it instead of ConformanceError. SessionStores.optional_call (private) runs a block around an optional method and reports "not implemented" for a missing method, the base stub, or a NotImplementedError from the call. The optional call sites in the store readers, delete_session, get_session_info's mtime lookup and the two in resume materialization go through it and take the fallback they already had for a store without the method. The conformance suite calls each optional method once on an empty store and skips its contracts when that raises NotImplementedError; what it asks of adapters is unchanged. A NotImplementedError from #append or #load still surfaces. Exploration finding: B-P11 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 4 + lib/claude_agent_sdk/session_mutations.rb | 3 +- lib/claude_agent_sdk/session_resume.rb | 20 +- lib/claude_agent_sdk/session_store.rb | 26 +++ lib/claude_agent_sdk/sessions.rb | 70 ++++--- .../testing/session_store_conformance.rb | 25 ++- .../session_store_optional_methods_spec.rb | 196 ++++++++++++++++++ 7 files changed, 298 insertions(+), 46 deletions(-) create mode 100644 spec/unit/session_store_optional_methods_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index a1636e1c..02bfead6 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -301,6 +301,10 @@ normal spawn path and `continue_conversation` moves on to the next candidate. Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and `#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and `#list_session_summaries` are optional and probed via `SessionStore.implements?`. +An optional method that raises `NotImplementedError` when called counts as not +implemented too (the stub a delegating wrapper inherits, or an adapter declining +it at run time): the SDK takes the same fallback, and the conformance suite +skips that method's contracts. Report `mtime` as epoch milliseconds; the SDK also accepts numeric-string, ISO-8601-string and `Time` mtimes (ordering them correctly and reporting them as Integer epoch ms in `last_modified`), but anything else sorts as oldest and diff --git a/lib/claude_agent_sdk/session_mutations.rb b/lib/claude_agent_sdk/session_mutations.rb index 1c332a42..d324d062 100644 --- a/lib/claude_agent_sdk/session_mutations.rb +++ b/lib/claude_agent_sdk/session_mutations.rb @@ -214,10 +214,9 @@ def tag_session_via_store(session_store:, session_id:, tag:, directory: nil) # @raise [ArgumentError] if session_id is invalid def delete_session_via_store(session_store:, session_id:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - return unless SessionStore.implements?(session_store, :delete) key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } - session_store.delete(key) + SessionStores.optional_call(session_store, :delete) { session_store.delete(key) } nil end diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 8b6d04c1..766632f5 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -160,7 +160,10 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat # so it can authenticate. Missing files are fine (API-key auth, etc.). copy_auth_files(tmp_base, options.env) - if SessionStore.implements?(store, :list_subkeys) + # list_subkeys is optional: without it (or when it raises + # NotImplementedError, see optional_call) only the main transcript + # is materialized. + SessionStores.optional_call(store, :list_subkeys) do materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) end rescue Exception # rubocop:disable Lint/RescueException @@ -276,17 +279,20 @@ def encode_entry(entry, what) # isSidechain check above stays even on the summary path: a missing or # stale sidecar row costs one extra load, never a wrong resume. def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper) - return nil unless SessionStore.implements?(store, :list_session_summaries) - - rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do - store.list_session_summaries(project_key) + # optional_call: NotImplementedError is a ScriptError that with_timeout + # does not wrap. + implemented, rows = SessionStores.optional_call(store, :list_session_summaries) do + with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do + store.list_session_summaries(project_key) + end end + return nil unless implemented + Array(rows).each_with_object({}) do |row, acc| sid = row.is_a?(Hash) ? row['session_id'] : nil acc[sid] = row.dig('data', 'is_sidechain') == true if sid end - # NotImplementedError is a ScriptError that with_timeout does not wrap. - rescue StandardError, NotImplementedError + rescue StandardError nil end diff --git a/lib/claude_agent_sdk/session_store.rb b/lib/claude_agent_sdk/session_store.rb index 58b695e3..d8907be4 100644 --- a/lib/claude_agent_sdk/session_store.rb +++ b/lib/claude_agent_sdk/session_store.rb @@ -318,6 +318,32 @@ def store_callback_scheduling(store) mode end + # Run a block that calls the OPTIONAL adapter method +method+ + # (list_sessions, list_session_summaries, delete, list_subkeys) and report + # whether the adapter implements it: [true, the block's value], or + # [false, nil] when the method is missing, is the stub inherited from + # SessionStore, or raises NotImplementedError when called. + # + # The last case is the one SessionStore.implements? cannot see. Behind a + # delegating wrapper (a SimpleDelegator around a SessionStore subclass: + # a metrics or tenancy decorator) every inherited stub answers + # respond_to? with the wrapper as its owner, so each optional method + # reads as implemented; and an adapter may decline one at run time by + # raising the marker itself. NotImplementedError is a ScriptError: it + # passed every `rescue StandardError`, the SessionStoreError wrapping + # included, and reached the caller raw where the documented behavior is + # the fallback of a store without the method. + # + # Only for an optional method: a NotImplementedError from #append or + # #load is an adapter bug and must surface. + def optional_call(store, method) + return [false, nil] unless SessionStore.implements?(store, method) + + [true, yield] + rescue NotImplementedError + [false, nil] + end + # Derive a SessionKey from an absolute transcript file path. # # Main: //.jsonl diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 3ca6a5d5..526836b6 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -959,17 +959,18 @@ def list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: project_path = canonicalize_path(directory.nil? ? '.' : directory.to_s) project_key = sanitize_path(project_path) - if SessionStore.implements?(session_store, :list_session_summaries) - via = list_sessions_via_summaries(session_store, project_key, project_path, limit, offset) - return via unless via.nil? - end + via = list_sessions_via_summaries(session_store, project_key, project_path, limit, offset) + return via unless via.nil? - unless SessionStore.implements?(session_store, :list_sessions) + listed, listing = SessionStores.optional_call(session_store, :list_sessions) do + session_store.list_sessions(project_key) + end + unless listed raise ArgumentError, 'session_store implements neither list_session_summaries nor list_sessions -- cannot list sessions' end - listing = Array(session_store.list_sessions(project_key)) + listing = Array(listing) # Build all-placeholder slots (the shape the summaries fast path uses) and # reuse its bounded pagination: sessions are loaded newest-first only # until the page fills (~offset + limit + dropped), instead of one full @@ -1017,15 +1018,17 @@ def get_session_messages_from_store(session_store:, session_id:, directory: nil, def list_subagents_from_store(session_store:, session_id:, directory: nil) return [] unless valid_session_id?(session_id) - unless SessionStore.implements?(session_store, :list_subkeys) + project_key = project_key_for_directory(directory) + implemented, subkeys = SessionStores.optional_call(session_store, :list_subkeys) do + session_store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + end + unless implemented raise ArgumentError, 'session_store does not implement list_subkeys -- cannot list subagents' end - project_key = project_key_for_directory(directory) - subkeys = Array(session_store.list_subkeys('project_key' => project_key, 'session_id' => session_id)) seen = {} - subkeys.filter_map do |subpath| + Array(subkeys).filter_map do |subpath| # A non-String subkey (Symbol, nil, Integer) is an adapter contract # violation; skip it like resume does instead of calling String # methods on it. @@ -1119,22 +1122,25 @@ def import_session_to_store(session_id:, session_store:, directory: nil, include # -- Private helpers -- # Summary fast-path for list_sessions_from_store. Returns the paginated - # result, or nil if the store's list_session_summaries raises - # NotImplementedError (caller falls back to the slow path). Sessions missing + # result, or nil if the store does not implement list_session_summaries + # (see SessionStores.optional_call; the caller falls back to the slow + # path). Sessions missing # a sidecar or whose sidecar is stale (summary.mtime < the session's current # mtime) are routed through gap-fill so the fold is recomputed from source. - def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- fast path plus stale/missing-sidecar gap-fill - begin - # Array(): a non-conformant store returning nil (e.g. a NULL JSONB read) - # degrades to gap-fill instead of crashing on nil.each, matching the - # defensive Array() already applied to list_sessions / list_subkeys. - summaries = Array(store.list_session_summaries(project_key)) - rescue NotImplementedError - return nil + def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize -- fast path plus stale/missing-sidecar gap-fill + implemented, summaries = SessionStores.optional_call(store, :list_session_summaries) do + store.list_session_summaries(project_key) end - - has_list_sessions = SessionStore.implements?(store, :list_sessions) - listing = has_list_sessions ? Array(store.list_sessions(project_key)) : [] + return nil unless implemented + + # Array(): a non-conformant store returning nil (e.g. a NULL JSONB read) + # degrades to gap-fill instead of crashing on nil.each, matching the + # defensive Array() already applied to list_sessions / list_subkeys. + summaries = Array(summaries) + has_list_sessions, listing = SessionStores.optional_call(store, :list_sessions) do + store.list_sessions(project_key) + end + listing = Array(listing) known_mtimes = listing.to_h { |e| [e['session_id'], e['mtime']] } slots = [] @@ -1232,11 +1238,12 @@ def derive_info_from_entries(session_id, entries, mtime, project_path) # a session the listing showed with a real mtime. They remain the fallback # (mtime_from_entries) for a store with nothing but #append and #load. def store_session_mtime(store, project_key, session_id) - rows = if SessionStore.implements?(store, :list_sessions) - store.list_sessions(project_key) - elsif SessionStore.implements?(store, :list_session_summaries) - store.list_session_summaries(project_key) - end + listed, rows = SessionStores.optional_call(store, :list_sessions) { store.list_sessions(project_key) } + unless listed + _, rows = SessionStores.optional_call(store, :list_session_summaries) do + store.list_session_summaries(project_key) + end + end row = Array(rows).find { |candidate| candidate.is_a?(Hash) && candidate['session_id'] == session_id } row && row['mtime'] end @@ -1321,10 +1328,13 @@ def build_subagent_chain(entries) # under subagents/workflows//agent-) when list_subkeys is # available, else falling back to the direct subagents/agent- path. def resolve_subagent_subpath(store, project_key, session_id, agent_id) - return "subagents/agent-#{agent_id}" unless SessionStore.implements?(store, :list_subkeys) + implemented, subkeys = SessionStores.optional_call(store, :list_subkeys) do + store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + end + return "subagents/agent-#{agent_id}" unless implemented target = "agent-#{agent_id}" - matches = Array(store.list_subkeys('project_key' => project_key, 'session_id' => session_id)) + matches = Array(subkeys) .select { |sk| sk.is_a?(String) && sk.start_with?('subagents/') && sk.rpartition('/').last == target } # Several subpaths can share a trailing agent- (a top-level agent and a # nested subagents/workflows//agent-). Prefer the canonical diff --git a/lib/claude_agent_sdk/testing/session_store_conformance.rb b/lib/claude_agent_sdk/testing/session_store_conformance.rb index e93d816d..c5d8f0fa 100644 --- a/lib/claude_agent_sdk/testing/session_store_conformance.rb +++ b/lib/claude_agent_sdk/testing/session_store_conformance.rb @@ -42,7 +42,7 @@ class ConformanceError < StandardError; end # returns a fresh SessionStore (or duck-typed adapter). # @param skip_optional [Array] optional method names to skip. # Contracts for an optional method are also skipped automatically when the - # store does not override it. + # store does not override it, or raises NotImplementedError from it. # @param check_uuid_dedupe [Boolean] additionally assert the ADVISORY # uuid-dedupe recommendation: re-appending a batch that overlaps a prior # write (exactly what the mirror batcher's retry can produce) must not @@ -58,11 +58,22 @@ def run_session_store_conformance(make_store, skip_optional: [], check_uuid_dedu fresh = -> { make_store.call } + # Each optional method is called once on an empty store before its + # contracts are selected: an adapter that inherits the SessionStore stub + # behind a delegating wrapper, or declines the method at run time, + # raises NotImplementedError — "not implemented", so its contracts are + # skipped as for a method the adapter does not define. The calls are + # ones the contracts make anyway (unknown project / never-written key). probe = fresh.call - has_list_sessions = optional?(probe, 'list_sessions', skip_optional) - has_list_summaries = optional?(probe, 'list_session_summaries', skip_optional) - has_delete = optional?(probe, 'delete', skip_optional) - has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional) + unwritten = { 'project_key' => 'proj', 'session_id' => 'never-written' } + has_list_sessions = optional?(probe, 'list_sessions', skip_optional) do + probe.list_sessions('never-appended-project') + end + has_list_summaries = optional?(probe, 'list_session_summaries', skip_optional) do + probe.list_session_summaries('never-appended-project') + end + has_delete = optional?(probe, 'delete', skip_optional) { probe.delete(unwritten) } + has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional) { probe.list_subkeys(unwritten) } check_callback_scheduling_declaration(fresh) check_append_and_load(fresh, has_list_sessions) @@ -370,10 +381,10 @@ def summaries_by_id(store, project, expected_ids, message) rows.to_h { |s| [s['session_id'], s] } end - def optional?(store, method, skip_optional) + def optional?(store, method, skip_optional, &) return false if skip_optional.include?(method) - SessionStore.implements?(store, method.to_sym) + SessionStores.optional_call(store, method.to_sym, &).first end def assert(condition, message) diff --git a/spec/unit/session_store_optional_methods_spec.rb b/spec/unit/session_store_optional_methods_spec.rb new file mode 100644 index 00000000..01619a2e --- /dev/null +++ b/spec/unit/session_store_optional_methods_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'delegate' +require 'claude_agent_sdk/testing/session_store_conformance' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Only #append and #load are required of a SessionStore. An optional method +# the adapter does not implement can still LOOK implemented: behind a +# delegating wrapper every stub inherited from SessionStore answers +# respond_to?, and an adapter may opt out of one at run time by raising +# NotImplementedError itself. NotImplementedError is a ScriptError, so it +# passes every `rescue StandardError`. +RSpec.describe 'a session store whose optional methods raise NotImplementedError' do + include_context 'with a Claude config dir' + + let(:session_id) { 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b' } + let(:agent_id) { 'c3d4e5f6a7b8c9d0' } + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + + # An adapter with the two required methods, the optional ones inherited. + let(:minimal_store_class) do + Class.new(ClaudeAgentSDK::SessionStore) do + def initialize + super + @sessions = {} + end + + def append(key, entries) + (@sessions[key] ||= []).concat(entries) unless entries.empty? + end + + def load(key) = @sessions[key] + end + end + + def conversation + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.queue_operations('what does a.rb define?') + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + transcript.last_prompt('what does a.rb define?', leaf: :answer) + transcript.store_entries + end + + def subagent_conversation + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id) + transcript.prompt(:task, 'Read a.rb') + transcript.assistant(:report, transcript.text('It defines Foo.'), parent: :task) + transcript.store_entries + end + + def seed(store) + store.append(key, conversation) + store.append(key.merge('subpath' => "subagents/agent-#{agent_id}"), subagent_conversation) + store + end + + # A metrics or tenancy decorator around a minimal adapter. + context 'with a delegating wrapper around an adapter that implements only append and load' do + let(:store) { seed(SimpleDelegator.new(minimal_store_class.new)) } + + it 'looks as if it implemented every optional method' do + %i[list_sessions list_session_summaries delete list_subkeys].each do |method| + expect(ClaudeAgentSDK::SessionStore.implements?(store, method)).to be(true) + expect { store.public_send(method, key) }.to raise_error(NotImplementedError) + end + end + + it 'delete_session is the no-op it is for a store without #delete' do + expect(ClaudeAgentSDK.delete_session(session_id: session_id, directory: cwd, session_store: store)).to be_nil + expect(store.load(key).length).to eq(conversation.length) + end + + it 'list_sessions raises the ArgumentError of a store that cannot list' do + expect { ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store) } + .to raise_error(ArgumentError, /implements neither list_session_summaries nor list_sessions/) + end + + it 'list_subagents raises the ArgumentError of a store without list_subkeys' do + expect { ClaudeAgentSDK.list_subagents(session_id: session_id, directory: cwd, session_store: store) } + .to raise_error(ArgumentError, /does not implement list_subkeys/) + end + + it 'get_subagent_messages reads the direct subagent path' do + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd, + session_store: store) + + expect(messages.map(&:text)).to eq(['Read a.rb', 'It defines Foo.']) + end + + it 'get_session_info falls back to the timestamp of the last entry for last_modified' do + info = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store) + + expect(info.summary).to eq('what does a.rb define?') + expect(info.last_modified).to eq(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms(conversation[-2]['timestamp'])) + end + + it 'resume materializes the main transcript' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: session_id, cwd: cwd, + env: { 'CLAUDE_CONFIG_DIR' => config_dir }) + + materialized = ClaudeAgentSDK::SessionResume.materialize_resume_session(options) + begin + transcript = File.join(materialized.config_dir, 'projects', key['project_key'], "#{session_id}.jsonl") + expect(File.readlines(transcript).length).to eq(conversation.length) + ensure + materialized&.cleanup + end + end + end + + # An adapter that has the method and declines it at run time. + context 'with an adapter that raises NotImplementedError from one optional method' do + def opting_out_of(method) + seed(Class.new(ClaudeAgentSDK::InMemorySessionStore) do + define_method(method) { |*| raise NotImplementedError, "#{method} is disabled for this tenant" } + end.new) + end + + it 'list_sessions falls back from list_session_summaries to list_sessions' do + store = opting_out_of(:list_session_summaries) + + expect(ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).map(&:session_id)).to eq([session_id]) + end + + it 'list_sessions uses the summaries without the listing' do + store = opting_out_of(:list_sessions) + + expect(ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).map(&:session_id)).to eq([session_id]) + end + + it 'get_session_info takes last_modified from the summaries when the listing is declined' do + store = opting_out_of(:list_sessions) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store).last_modified) + .to eq(listed.last_modified) + end + + it 'delete_session is a no-op' do + store = opting_out_of(:delete) + + expect(ClaudeAgentSDK.delete_session(session_id: session_id, directory: cwd, session_store: store)).to be_nil + expect(store.load(key)).not_to be_nil + end + + it 'resume continues the newest session without the summaries' do + store = opting_out_of(:list_session_summaries) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, continue_conversation: true, cwd: cwd, + env: { 'CLAUDE_CONFIG_DIR' => config_dir }) + + materialized = ClaudeAgentSDK::SessionResume.materialize_resume_session(options) + begin + expect(materialized.resume_session_id).to eq(session_id) + ensure + materialized&.cleanup + end + end + end + + it 'still lets a NotImplementedError from a required method through' do + store = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def load(_key) = raise(NotImplementedError, 'load is not available') + end.new + + expect { ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) } + .to raise_error(NotImplementedError) + end + + describe 'the conformance suite' do + it 'skips the contracts of an optional method the adapter declines at run time' do + adapter = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def list_session_summaries(_project_key) = raise(NotImplementedError, 'summaries are disabled for this tenant') + end + + expect(ClaudeAgentSDK::Testing.run_session_store_conformance(-> { adapter.new })).to be_nil + end + + it 'runs the required contracts only for a delegating wrapper around a minimal adapter' do + make_store = -> { SimpleDelegator.new(minimal_store_class.new) } + + expect(ClaudeAgentSDK::Testing.run_session_store_conformance(make_store)).to be_nil + end + + it 'still fails an adapter whose optional method breaks a contract' do + adapter = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def list_subkeys(_key) = ['subagents/agent-of-another-session'] + end + + expect { ClaudeAgentSDK::Testing.run_session_store_conformance(-> { adapter.new }) } + .to raise_error(ClaudeAgentSDK::Testing::ConformanceError) + end + end +end From cb101ae6167fef1b44c3c13e78744626eb4b70eb Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:37:31 +0800 Subject: [PATCH 059/194] Do not log a failed query() / ask() as an unhandled task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every error raised out of ClaudeAgentSDK.query or .ask from synchronous code (CLI not found, ProcessError, ResultError, an exception in the caller's own block) was reported twice: raised to the caller, and also written to stderr by Async as "Task may have ended with unhandled exception." — a warn line with the message and a backtrace, even when the application rescued the error. Inside an existing reactor the same happened for failures that precede the task's first suspension, such as a transport that cannot connect. query() runs its body in a task and waits for it. Outside a reactor Kernel#Async runs the whole task before it returns, so the task has failed before .wait registers as its waiter, and Async logs a failure nobody is waiting for. The task is now created with finished: false, which is what Kernel#Sync passes for its own task: the failure is handled by a waiter. The error is raised to the caller exactly as before; only the log line is gone. Checked on async 2.10.0, 2.36.0 and 2.46.0. Exploration finding: A-P10 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk.rb | 9 +- spec/unit/query_failure_logging_spec.rb | 196 ++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 spec/unit/query_failure_logging_spec.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index fcf91034..f0943915 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -728,7 +728,14 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' end - Async(&FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query() + # finished: false tells Async that a waiter handles this task's failure + # (the .wait at the end re-raises it), as Kernel#Sync does for its own + # task. Without it a task that fails before anyone waits for it — always + # the case outside a reactor, where Async runs the whole task before it + # returns — is also logged as "Task may have ended with unhandled + # exception", message and backtrace included, although the caller gets + # the same error raised and may well rescue it. + Async(finished: false, &FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query() materialized = nil query_handler = nil begin diff --git a/spec/unit/query_failure_logging_spec.rb b/spec/unit/query_failure_logging_spec.rb new file mode 100644 index 00000000..30bcfbaa --- /dev/null +++ b/spec/unit/query_failure_logging_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'tempfile' + +# query() runs its body in a task and waits for it. A failure of that task is +# raised to the caller by the wait, so it is a handled failure: Async must not +# also log it as "Task may have ended with unhandled exception." (a warn line +# carrying the message and a backtrace, on stderr, even when the application +# rescued the error). Outside a reactor the task has always finished before +# anyone waits for it, which is exactly the case Async logs. +RSpec.describe ClaudeAgentSDK, 'failures of query() / ask()' do + # Queue-backed stand-in for the CLI. +connect_error+ makes #connect raise; + # +on_user_message+ scripts what the CLI does with the prompt. + let(:transport_class) do + Class.new do + def initialize(connect_error: nil, on_user_message: nil) + @connect_error = connect_error + @on_user_message = on_user_message + @stdout = Thread::Queue.new + end + + def connect + raise @connect_error if @connect_error + end + + def ready? + true + end + + def end_input + @stdout << :eof # the CLI exits once stdin is closed + end + + def close + @stdout << :eof + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + if frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + send_frame(type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + elsif frame[:type] == 'user' + @on_user_message&.call(self) + end + end + + def send_frame(frame) + @stdout << frame + end + + # What SubprocessCLITransport#read_messages raises at EOF when the CLI + # exited non-zero. + def crash(exit_code:) + @stdout << ClaudeAgentSDK::ProcessError.new("Command failed with exit code #{exit_code}", + exit_code: exit_code, stderr: 'boom') + end + + def read_messages + loop do + item = @stdout.pop + break if item == :eof + raise item if item.is_a?(Exception) + + yield item + end + end + end + end + + let(:unhandled_task_warning) { 'Task may have ended with unhandled exception' } + let(:connect_error) { ClaudeAgentSDK::CLIConnectionError.new('Failed to start Claude Code: no such file') } + let(:session_id) { 'c0ffee00-0000-4000-8000-000000000003' } + + let(:answers_the_prompt) do + lambda do |cli| + cli.send_frame(type: 'assistant', parent_tool_use_id: nil, session_id: session_id, + message: { id: 'msg_01', type: 'message', role: 'assistant', + model: 'claude-haiku-4-5-20251001', content: [{ type: 'text', text: 'Four.' }], + stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 3 } }) + cli.send_frame(sample_result_message.merge(session_id: session_id, result: 'Four.', + stop_reason: 'end_turn', terminal_reason: 'completed')) + end + end + + let(:crashes_on_the_prompt) { ->(cli) { cli.crash(exit_code: 1) } } + + # Runs the block with file descriptor 2 pointed at a file and returns what + # was written to it. spec_helper has bound the console logger (through + # which Async warns) to the real $stderr object, so swapping $stderr for a + # StringIO would capture nothing; redirecting its descriptor captures every + # writer, on every console version. + def stderr_during + log = Tempfile.new('query-failure-log') + original = $stderr.dup + $stderr.reopen(log) + begin + yield + ensure + $stderr.flush + $stderr.reopen(original) + original.close + end + File.read(log.path) + ensure + log&.close! + end + + # The block must raise +error_class+ to its caller, as an application that + # rescues it would see, without Async reporting the same failure itself. + def expect_handled_failure(error_class, message) + raised = nil + log = stderr_during do + yield + rescue StandardError => e + raised = e + end + + aggregate_failures do + expect(raised).to be_a(error_class) + expect(raised&.message).to match(message) + expect(log).not_to include(unhandled_task_warning) + end + end + + context 'when called from plain synchronous code' do + it 'raises a connect failure to the caller and logs nothing as unhandled' do + transport = transport_class.new(connect_error: connect_error) + + expect_handled_failure(ClaudeAgentSDK::CLIConnectionError, /Failed to start Claude Code/) do + described_class.query(prompt: 'What is 2 + 2?', transport: transport) { |_message| nil } + end + end + + it 'raises a CLI crash to the caller and logs nothing as unhandled' do + transport = transport_class.new(on_user_message: crashes_on_the_prompt) + + expect_handled_failure(ClaudeAgentSDK::ProcessError, /exit code 1/) do + described_class.query(prompt: 'What is 2 + 2?', transport: transport) { |_message| nil } + end + end + + it "raises the error of the caller's own block and logs nothing as unhandled" do + transport = transport_class.new(on_user_message: answers_the_prompt) + + expect_handled_failure(ArgumentError, /my block failed/) do + described_class.query(prompt: 'What is 2 + 2?', transport: transport) { raise ArgumentError, 'my block failed' } + end + end + + it 'raises from ask() and logs nothing as unhandled' do + transport = transport_class.new(on_user_message: crashes_on_the_prompt) + + expect_handled_failure(ClaudeAgentSDK::ProcessError, /exit code 1/) do + described_class.ask('What is 2 + 2?', transport: transport) + end + end + end + + context 'when called inside an existing reactor' do + it 'raises a connect failure to the caller and logs nothing as unhandled' do + transport = transport_class.new(connect_error: connect_error) + + expect_handled_failure(ClaudeAgentSDK::CLIConnectionError, /Failed to start Claude Code/) do + Sync { described_class.query(prompt: 'What is 2 + 2?', transport: transport) { |_message| nil } } + end + end + end + + describe 'a successful call' do + def break_at_the_result(transport) + described_class.query(prompt: 'What is 2 + 2?', transport: transport) do |message| + break :stopped_at_the_result if message.is_a?(ClaudeAgentSDK::ResultMessage) + end + end + + it 'still returns the final ResultMessage from ask()' do + result = described_class.ask('What is 2 + 2?', transport: transport_class.new(on_user_message: answers_the_prompt)) + + expect(result).to be_a(ClaudeAgentSDK::ResultMessage) + expect(result.result).to eq('Four.') + end + + it "still returns the block's break value from query(), outside and inside a reactor" do + outside = break_at_the_result(transport_class.new(on_user_message: answers_the_prompt)) + inside = Sync { break_at_the_result(transport_class.new(on_user_message: answers_the_prompt)) } + + expect([outside, inside]).to eq(%i[stopped_at_the_result stopped_at_the_result]) + end + end +end From 7e5401db06bfa511eeebb2595c00a25de4140d9b Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:38:32 +0800 Subject: [PATCH 060/194] Describe the empty-stream stdin close as the trade-off it is Comment only, no behavior change. The comment in Query#stream_input called closing stdin for an empty prompt stream a "deliberate improvement over Python, which leaves stdin open and hangs". Both halves are out of date. The Python SDK closes stdin there as well, and upstream has since found what the early close costs (anthropics/claude-agent-sdk-python#1226): an empty stream is also how a caller resumes a session without sending anything, and when that session has a deferred tool call served by an SDK MCP server, the CLI re-runs the call, finds stdin closed, and exits with an error. The comment now states both sides, why waiting is not the fix (a CLI that idles with no input reports no session state, so an empty resume with nothing pending would hang), and that Client, which keeps stdin open, is the working way to resume a deferred tool. Exploration finding: U-P4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk/query.rb | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 3cbfd56c..656825ca 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -1746,10 +1746,21 @@ def stream_input(stream) # open until the run ends so hooks/SDK MCP control replies can still # be written (the run's end or process exit is guaranteed to signal). # - No complete message ever reached the CLI (empty stream, or the - # stream raised before the first write): no result can ever arrive, - # so waiting would park query() forever beside an idle CLI. Close - # stdin so the CLI sees EOF and exits. Deliberate improvement over - # Python, which leaves stdin open and hangs on this path. + # stream raised before the first write): no result is owed, so there + # is nothing to wait for. Close stdin at once so the CLI sees EOF and + # exits, as the Python and TypeScript SDKs do. + # This is a trade-off, not a free win. An empty stream is also how a + # caller says "send nothing, just resume", and a resumed session can + # have work of its own: a tool call that a PreToolUse hook deferred + # is re-run by the CLI on resume. If that tool is served by an SDK + # MCP server, the CLI's request for it finds stdin already closed and + # the CLI exits with an error (ProcessError, exit code 1; + # anthropics/claude-agent-sdk-python#1226). Waiting instead would + # hang every empty resume that has nothing pending: a CLI that is + # idle with no input reports no session state (seen with 2.1.286), + # so the two cases cannot be told apart without a signal from the + # CLI. Until there is one, resume a deferred tool through Client, + # which keeps stdin open. unless @closed if wrote_message wait_for_result_and_end_input From 2f0e77cc293da9fca0f2c7b159be3cbed091b394 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sat, 3 Oct 2026 01:43:19 +0800 Subject: [PATCH 061/194] Derive a fork's title by the listing's rule on both paths fork_session without title: names the fork " (fork)". The disk path and the session_store: path each had their own way to find that title, and neither was the one the listing uses: - after a clearing entry (customTitle ""), the disk fork was titled "Forked session (fork)" although an AI title or a first prompt existed (Ruby's || stops on ""), and the store fork brought back the title that had been cleared; - the disk fork took a "customTitle" key nested in a tool input for the title (the listing was moved to the top-level scan for this; the fork was not); - the disk fork did not find a first prompt beyond the 64 KiB head. Both now go through the listing's own code: the disk fork reads the title and first prompt the disk listing reports (Sessions.title_and_first_prompt), the store fork folds the entries as the store listing does, and Sessions.display_title is the one rule for custom title vs AI title. The source is taken from those fields, not from the listing's whole result, which is nil for a session that has no summary and can still be forked. Also: a stray non-UTF-8 byte inside the first prompt made the disk listing drop the session (the text handling raised); the head is now scrubbed for parsing, so the session is listed, and forked, with U+FFFD in its place. Fork titles change only for the inputs above. Exploration finding: B-P13 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 2 + lib/claude_agent_sdk/session_mutations.rb | 78 +++++---------- lib/claude_agent_sdk/sessions.rb | 54 ++++++---- spec/unit/session_fork_title_spec.rb | 99 +++++++++++++++++++ spec/unit/session_transcript_encoding_spec.rb | 25 +++++ 5 files changed, 187 insertions(+), 71 deletions(-) create mode 100644 spec/unit/session_fork_title_spec.rb diff --git a/docs/sessions.md b/docs/sessions.md index 02bfead6..dad45f7f 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -136,6 +136,8 @@ ClaudeAgentSDK.fork_session( ) ``` +Without `title:` the fork is named after its source, ` (fork)`: the title the listing shows for the source (its custom title, else its AI title), else its first prompt, else `Forked session`. The disk and the store path use the same rule. + > Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session` writes and closes a private staging file before atomically publishing it with a hard link, so partial forks are not discoverable and existing sessions are never overwritten. The project filesystem must support hard links; publication failures leave the source and any existing destination untouched. ## Resuming at a Specific Message diff --git a/lib/claude_agent_sdk/session_mutations.rb b/lib/claude_agent_sdk/session_mutations.rb index d324d062..b6e310b8 100644 --- a/lib/claude_agent_sdk/session_mutations.rb +++ b/lib/claude_agent_sdk/session_mutations.rb @@ -451,9 +451,10 @@ def build_fork_lines(transcript, content_replacements, session_id, up_to_message }) end - # Derive title: explicit > original customTitle > original aiTitle > first - # prompt, suffixed with " (fork)" when derived. listSessions reads the LAST - # custom-title from the tail, so this trailer is what surfaces. + # Derive title: explicit > the source's listed title (custom, else AI) > + # its first prompt, suffixed with " (fork)" when derived. listSessions + # reads the LAST custom-title from the tail, so this trailer is what + # surfaces. fork_title = title&.strip fork_title = "#{derive_title.call || 'Forked session'} (fork)" if fork_title.nil? || fork_title.empty? @@ -490,61 +491,30 @@ def partition_fork_entries(raw, source_session_id) [transcript, content_replacements] end - # Derive a fork title by scanning already-parsed store entries — the store - # path's analogue of derive_fork_title's head/tail byte scan. Last occurrence - # wins for both customTitle and aiTitle; customTitle beats aiTitle; the first - # user prompt is the final fallback. Returns nil when nothing is found (the - # caller supplies the "Forked session" default). This scans the RAW entries, - # not the partitioned transcript (which drops customTitle/aiTitle metadata) — - # the store half of #837's P0-1 fix. + # Derive a fork title from already-parsed store entries: the title the + # store listing shows for the session (custom title, else AI title — the + # latest occurrence of each, a blank one counting as absent), else its + # first prompt. Folds the RAW entries (the partitioned transcript has + # dropped the customTitle/aiTitle metadata — the store half of #837's + # P0-1 fix) with the fold the listing uses, and takes the title from the + # folded fields rather than from summary_entry_to_sdk_info: that returns + # nil for a sidechain or summary-less session, which can still be forked. + # nil when the session has none of the three (the caller supplies the + # "Forked session" default). def derive_title_from_entries(raw) - custom = nil - ai = nil - raw.each do |e| - next unless e.is_a?(Hash) - - ct = e['customTitle'] - custom = ct if ct.is_a?(String) && !ct.empty? - at = e['aiTitle'] - ai = at if at.is_a?(String) && !at.empty? - end - return custom if custom - return ai if ai - - # First-prompt fallback: re-serialize to a JSONL string and reuse the head - # extractor so skip-patterns/truncation match the disk path exactly. - # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no - # prompt qualifies — normalize to nil so the caller's 'Forked session' - # default actually fires (Python appends `or None` here for this reason). - jsonl = "#{raw.map { |e| JSON.generate(e) }.join("\n")}\n" - title = Sessions.extract_first_prompt_from_head(jsonl) - title.nil? || title.empty? ? nil : title + data = SessionSummary.fold_session_summary(nil, {}, raw)['data'] + first_prompt = data['first_prompt_locked'] ? data['first_prompt'] : data['command_fallback'] + Sessions.display_title(data['custom_title'], data['ai_title']) || Sessions.presence(first_prompt) end - # Derive a fork title from the source file's head/tail chunks without - # slurping the entire file. Matches the lookup order used for - # SDKSessionInfo.custom_title / ai_title / first_prompt. Returns nil when - # nothing is found (build_fork_lines supplies the "Forked session" default). + # Derive a fork title from the source file without slurping it: the title + # and first prompt the disk listing reports for the session, taken from + # the same head/tail windows by the same rule. nil when it has neither + # (build_fork_lines supplies the "Forked session" default). def derive_fork_title(file_path, file_size) - buf_size = [Sessions::LITE_READ_BUF_SIZE, file_size].min - File.open(file_path, 'rb') do |f| - head = (f.read(buf_size) || '').force_encoding('UTF-8').scrub - tail = if file_size > Sessions::LITE_READ_BUF_SIZE - f.seek(-buf_size, IO::SEEK_END) - (f.read(buf_size) || '').force_encoding('UTF-8').scrub - else - head - end - title = Sessions.extract_json_string_field(tail, 'customTitle', last: true) || - Sessions.extract_json_string_field(head, 'customTitle', last: true) || - Sessions.extract_json_string_field(tail, 'aiTitle', last: true) || - Sessions.extract_json_string_field(head, 'aiTitle', last: true) || - Sessions.extract_first_prompt_from_head(head) - # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no - # prompt qualifies — normalize to nil so the 'Forked session' default - # fires (Python appends `or None` here for the same reason). - title.nil? || title.empty? ? nil : title - end + head, tail = Sessions.read_head_tail(file_path, file_size) + title, first_prompt = Sessions.title_and_first_prompt(file_path, head, tail, file_size) + title || first_prompt end # Build a single forked entry with remapped UUIDs. diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 526836b6..88388343 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -536,7 +536,11 @@ def first_prompt_in(text, command_fallback = nil) # rubocop:disable Metrics/Cycl # with no other summary source was not listed at all — while the store # fold, which sees every entry, reported the prompt. def first_prompt_from_file(file_path, head, size) - prompt, command_fallback = first_prompt_in(head) + # Scrubbed for parsing (the offsets below are those of +head+ itself): + # a stray non-UTF-8 byte inside a prompt made the text handling raise, + # which the listing turned into "no such session" and which a fork must + # not die of. + prompt, command_fallback = first_prompt_in(head.valid_encoding? ? head : head.scrub) if prompt.nil? && size > head.bytesize limit = [size, FIRST_PROMPT_SCAN_LIMIT].min prompt, command_fallback = first_prompt_past_head(file_path, head, limit, command_fallback) @@ -652,22 +656,37 @@ def read_head_tail(file_path, size) [head, tail] end - def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional field per SDKSessionInfo attribute - # User-set title (customTitle) wins over AI-generated title (aiTitle). - # Consult the head only when the tail has no occurrence of that field. - # Normalize blanks AFTER choosing the latest occurrence: an explicit - # clearing entry must not resurrect an older title from the head. - # Summary-chain fields use the top-level-verified scan: a raw byte scan - # also matches these keys nested inside tool_use inputs, reporting tool - # arguments as the session title/summary (and diverging from the store - # fold, which reads top-level keys only). - custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true) || - extract_top_level_string_field(head, 'customTitle', last: true)) || - presence(extract_top_level_string_field(tail, 'aiTitle', last: true) || - extract_top_level_string_field(head, 'aiTitle', last: true)) + # [title, first prompt] of a transcript on disk, each nil when absent: the + # two values a disk listing reports as custom_title and first_prompt, and + # the ones fork_session names a fork after (SessionMutations). + # + # Title: the user-set title (customTitle) wins over the AI-generated one + # (aiTitle). The head is consulted only when the tail has no occurrence + # of the field, and blanks are normalized AFTER the latest occurrence was + # chosen (display_title): an explicit clearing entry must not resurrect an + # older title from the head. The top-level-verified scan is used: a raw + # byte scan also matches these keys nested inside tool_use inputs, + # reporting tool arguments as the session title (and diverging from the + # store fold, which reads top-level keys only). + def title_and_first_prompt(file_path, head, tail, size) + custom, generated = %w[customTitle aiTitle].map do |key| + extract_top_level_string_field(tail, key, last: true) || extract_top_level_string_field(head, key, last: true) + end # nil, not '', when there is no prompt — the store path's answer, and # Python's (`_extract_first_prompt_from_head(head) or None`). - first_prompt = presence(first_prompt_from_file(file_path, head, stat.size)) + [display_title(custom, generated), presence(first_prompt_from_file(file_path, head, size))] + end + + # The ONE rule for a session's title, given the latest custom title and the + # latest AI title of its transcript: blank counts as absent, custom first. + # Shared by the disk listing, and by fork_session on the disk and the + # store path (the store listing applies it in SessionSummary). + def display_title(custom_title, ai_title) + presence(custom_title) || presence(ai_title) + end + + def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize -- one optional field per SDKSessionInfo attribute + custom_title, first_prompt = title_and_first_prompt(file_path, head, tail, stat.size) # lastPrompt tail entry shows what the user was most recently doing. summary = custom_title || presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) || @@ -1953,7 +1972,7 @@ def filter_visible_messages(chain) :pick_leaf, :visible_ancestor?, :off_main_conversation?, :visible_message?, :reattach_parallel_tool_results, :off_chain_tool_results, :content_block_values, - :filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts, + :filter_visible_messages, :build_session_info, :user_entry_texts, :first_prompt_in, :first_prompt_from_file, :first_prompt_past_head, :valid_agent_id?, :sidechain_head?, :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot, @@ -1967,6 +1986,7 @@ def filter_visible_messages(chain) # These remain accessible for SessionMutations / SessionResume: # config_dir, sanitize_path, find_project_dir, detect_worktrees, # valid_session_id? (mutation boundary checks), listing_sort_key - # (--continue candidate order) + # (--continue candidate order), read_head_tail, title_and_first_prompt + # and display_title (the fork title) end end diff --git a/spec/unit/session_fork_title_spec.rb b/spec/unit/session_fork_title_spec.rb new file mode 100644 index 00000000..39ef948d --- /dev/null +++ b/spec/unit/session_fork_title_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# A fork made without a title is named after its source: "<title> (fork)". +# The title is the one the listing shows for the source — the custom title, +# else the AI title, else the first prompt — whichever path forks it. +RSpec.describe 'the title fork_session derives for a fork' do + include_context 'with a Claude config dir' + + let(:session_id) { 'f1e2d3c4-b5a6-4978-8695-a4b3c2d1e0f9' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + + # [title shown for the source, title of its disk fork, title of its store fork] + def titles(title: nil) + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + yield transcript + transcript.write(transcript_path(session_id)) + store.append(key, transcript.store_entries) + source = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd) + [source && (source.custom_title || source.first_prompt), fork_title(title), fork_title(title, session_store: store)] + end + + # The title of a fork of the source, made on disk or in the store. + def fork_title(title, **where) + fork = ClaudeAgentSDK.fork_session(session_id: session_id, directory: cwd, title: title, **where) + ClaudeAgentSDK.get_session_info(session_id: fork.session_id, directory: cwd, **where).custom_title + end + + def exchange(transcript) + transcript.prompt(:prompt, 'my first prompt') + transcript.assistant(:answer, transcript.text('Answered.'), parent: :prompt) + end + + it 'is the custom title of the source' do + expect(titles { |t| exchange(t) && t.custom_title('Refactoring') }) + .to eq(['Refactoring', 'Refactoring (fork)', 'Refactoring (fork)']) + end + + it 'is the AI title when the custom title was cleared' do + shown = titles do |t| + exchange(t) + t.custom_title('Old title') + t.ai_title('AI title') + t.custom_title('') + end + + expect(shown).to eq(['AI title', 'AI title (fork)', 'AI title (fork)']) + end + + it 'is the first prompt when the custom title was cleared and there is no AI title' do + shown = titles do |t| + exchange(t) + t.custom_title('Old title') + t.custom_title('') + end + + expect(shown).to eq(['my first prompt', 'my first prompt (fork)', 'my first prompt (fork)']) + end + + it 'is not a customTitle key nested in a tool input' do + shown = titles do |t| + t.prompt(:prompt, 'my first prompt') + t.assistant(:use, t.tool_use('toolu_a', 'RenameThing', { 'customTitle' => 'TOOL ARGUMENT' }), parent: :prompt) + t.tool_result(:result, 'toolu_a', 'renamed', parent: :use) + t.assistant(:answer, t.text('Renamed.'), parent: :result, message: 'msg_02') + end + + expect(shown).to eq(['my first prompt', 'my first prompt (fork)', 'my first prompt (fork)']) + end + + it 'is the first prompt when it lies beyond the 64 KiB head' do + shown = titles do |t| + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + t.prompt(:prompt, 'my first prompt', parent: :hook) + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + end + + expect(shown).to eq(['my first prompt', 'my first prompt (fork)', 'my first prompt (fork)']) + end + + # Nothing to list it under, still a conversation to fork. + it 'is the default when the source has no title and no prompt' do + shown = titles do |t| + t.meta(:skill_body, '<system-reminder>context</system-reminder>', parent: nil) + t.assistant(:answer, t.text('Noted.'), parent: :skill_body) + end + + expect(shown).to eq([nil, 'Forked session (fork)', 'Forked session (fork)']) + end + + it 'is the title the caller gives' do + expect(titles(title: 'Experiment') { |t| exchange(t) && t.custom_title('Refactoring') }) + .to eq(%w[Refactoring Experiment Experiment]) + end +end diff --git a/spec/unit/session_transcript_encoding_spec.rb b/spec/unit/session_transcript_encoding_spec.rb index 42e8a39c..057076c9 100644 --- a/spec/unit/session_transcript_encoding_spec.rb +++ b/spec/unit/session_transcript_encoding_spec.rb @@ -119,6 +119,31 @@ def cut_inside(line, char) expect(result.content).to be_valid_encoding end + # The same stray byte inside the first prompt, which the listing and the + # fork title are taken from. The session was killed before the CLI wrote + # its exit-time lines, so the prompt is all there is to list it under. + describe 'a raw invalid byte inside the first prompt' do + before do + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + conversation(transcript) + path = transcript.write(transcript_path(session_id)) + File.binwrite(path, File.binread(path).sub('你好'.b, "\xFF好".b)) + end + + it 'lists the session, with the byte replaced by U+FFFD' do + info = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd) + + expect(info&.first_prompt).to eq(question.sub('你', replacement)) + end + + it 'forks the session under that prompt' do + fork = ClaudeAgentSDK.fork_session(session_id: session_id, directory: cwd) + + expect(ClaudeAgentSDK.get_session_info(session_id: fork.session_id, directory: cwd).custom_title) + .to eq("#{question.sub('你', replacement)} (fork)") + end + end + describe 'import_session_to_store' do # A networked adapter serializes what it is given, and JSON.generate # rejects a String that is not valid UTF-8. From 8803808bbbd4ebf68ca2390fe3c40abf0f67cf44 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:48:23 +0800 Subject: [PATCH 062/194] Stub the Keychain bridge suite-wide, not only in one spec file The default stub of SessionResume.read_keychain_credentials lived in session_resume_spec.rb, so it covered that file only. Three examples in other files materialize a store-backed resume without stubbing the bridge: callback_wrapper_spec.rb:899, session_store_inline_spec.rb:343 and session_import_spec.rb:142. Since the bridge covers a custom CLAUDE_CONFIG_DIR, they ran `security find-generic-password` on macOS whatever the developer's setup, and with a custom config dir that has a Keychain login two of them read that real entry in the middle of the suite. spec/support/keychain_bridge_stub.rb (loaded by spec_helper.rb's support glob, so by the Rails specs too) now stubs the bridge to nil before every example that does not carry `keychain: true`, on itself or on its group. The file-level stub in session_resume_spec.rb and the one in session_resume_nil_options_spec.rb are redundant and gone. session_resume_keychain_spec.rb keeps its `keychain: true` tag and gains two examples that give the tag up again, to pin the default where the bridge would otherwise run (macOS, no env auth; default and custom config dir). Specs only. The full unit suite under an interceptor that records every `security` invocation now reports none, with and without CLAUDE_CONFIG_DIR in the environment (it was 3 in both before). Exploration finding: F-C1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- spec/support/keychain_bridge_stub.rb | 21 +++++++++++++++ spec/unit/session_resume_keychain_spec.rb | 28 ++++++++++++++++++-- spec/unit/session_resume_nil_options_spec.rb | 1 - spec/unit/session_resume_spec.rb | 12 --------- 4 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 spec/support/keychain_bridge_stub.rb diff --git a/spec/support/keychain_bridge_stub.rb b/spec/support/keychain_bridge_stub.rb new file mode 100644 index 00000000..093f6aa6 --- /dev/null +++ b/spec/support/keychain_bridge_stub.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Store-backed resume bridges the macOS Keychain: unless the caller +# authenticates through the environment, materialization runs +# `security find-generic-password` for the config dir's entry. An example that +# reaches SessionResume.materialize_resume_session without stubbing that — +# any example pairing a session_store with resume — would read the developer's +# real credentials on macOS, copy them into a temp dir and depend on the state +# of the local Keychain. +# +# So the bridge is off for every example by default. The examples that test +# the bridge itself opt in with `keychain: true`, on the example or its group, +# and replace the layer below it (capture_with_timeout) instead: see +# spec/unit/session_resume_keychain_spec.rb, which also pins this default. +RSpec.configure do |config| + config.before do |example| + next if example.metadata[:keychain] + + allow(ClaudeAgentSDK::SessionResume).to receive(:read_keychain_credentials).and_return(nil) + end +end diff --git a/spec/unit/session_resume_keychain_spec.rb b/spec/unit/session_resume_keychain_spec.rb index 4419bafc..dddf78fe 100644 --- a/spec/unit/session_resume_keychain_spec.rb +++ b/spec/unit/session_resume_keychain_spec.rb @@ -20,8 +20,8 @@ # `security find-generic-password -w` does. capture_with_timeout itself is # exercised against a real child process that is not `security`. # -# `keychain: true` opts out of a default stub of read_keychain_credentials -# (session_resume_spec.rb installs one). +# `keychain: true` opts out of the suite-wide default stub of +# read_keychain_credentials (spec/support/keychain_bridge_stub.rb). RSpec.describe ClaudeAgentSDK::SessionResume, keychain: true do let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } let(:cwd) { Dir.mktmpdir } @@ -370,4 +370,28 @@ def capture(argv, timeout_s) expect(capture([File.join(home, 'no-such-command')], ample)).to eq([nil, nil]) end end + + # spec/support/keychain_bridge_stub.rb switches the bridge off for every + # example in the suite that does not carry `keychain: true`. This group gives + # the tag up again, to pin that default exactly where the bridge would + # otherwise run: macOS, no env auth, and a config dir it covers. + describe 'the suite-wide default, without keychain: true', keychain: false do + it 'keeps materialization away from the lookup' do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + + it 'does so for a custom config dir without a .credentials.json as well' do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => '/nonexistent/claude-profiles/work') + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end end diff --git a/spec/unit/session_resume_nil_options_spec.rb b/spec/unit/session_resume_nil_options_spec.rb index 7b066d20..85eef26a 100644 --- a/spec/unit/session_resume_nil_options_spec.rb +++ b/spec/unit/session_resume_nil_options_spec.rb @@ -29,7 +29,6 @@ end before do - allow(described_class).to receive(:read_keychain_credentials).and_return(nil) store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => sid }, [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'sessionId' => sid, 'message' => { 'role' => 'user', 'content' => 'hello' } }]) diff --git a/spec/unit/session_resume_spec.rb b/spec/unit/session_resume_spec.rb index 834e86d6..f30c1228 100644 --- a/spec/unit/session_resume_spec.rb +++ b/spec/unit/session_resume_spec.rb @@ -13,18 +13,6 @@ let(:project_key) { ClaudeAgentSDK.project_key_for_directory(cwd) } let(:sid) { SecureRandom.uuid } - # Hermetic by default. Materialization runs the macOS Keychain bridge - # (`security find-generic-password`) whenever the caller has no - # CLAUDE_CONFIG_DIR and no env auth — a contributor's default macOS setup — - # so these examples used to read the developer's real credentials and to - # depend on the local Keychain. The bridge has its own examples in - # session_resume_keychain_spec.rb, which stub one level lower - # (capture_with_timeout); an example in this file that needs the real - # method opts in with `keychain: true` and does the same. - before do |example| - allow(described_class).to receive(:read_keychain_credentials).and_return(nil) unless example.metadata[:keychain] - end - after { FileUtils.remove_entry(cwd) if File.directory?(cwd) } def entry(text, **extra) From b5cf871d2b78f252b5d5df2e66ed7260c0ca36db Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:52:46 +0800 Subject: [PATCH 063/194] Scan the 64 KiB listing windows as bytes list_sessions and get_session_info read the first and last 64 KiB of every transcript and scan them for a handful of fields. The scanners step through the window by offset (String#index with a position, text[pos], #length). The windows were tagged UTF-8, and Ruby keeps no character index for a UTF-8 String that is not ASCII-only: there each of those steps walks the bytes from the start, so a scan with many matches is quadratic in the window. One multibyte character anywhere in a window is enough, and nearly every real transcript has one. The windows now stay binary while they are scanned. The patterns are ASCII and JSON.parse reads a binary source as UTF-8, so the values come out as UTF-8 Strings as before; the one path that returns a raw slice of the window (a value that does not parse as a JSON string) tags it UTF-8, and the first-prompt scan makes each line UTF-8 where it parses it. No result changes: over the 6,346 transcripts of one machine the old and the new reader return the same seven fields, the same encodings included; the CPU time for reading them all went from 36.1 s to 6.2 s. Exploration finding: B-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 40 ++++--- spec/unit/session_lite_reader_windows_spec.rb | 104 ++++++++++++++++++ 2 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 spec/unit/session_lite_reader_windows_spec.rb diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 88388343..e81df395 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -288,9 +288,7 @@ def project_dir_records_cwd?(project_dir, path) Dir.children(project_dir).any? do |name| next false unless name.end_with?('.jsonl') && valid_session_id?(name.delete_suffix('.jsonl')) - head = File.open(File.join(project_dir, name), 'rb') do |file| - (file.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8') - end + head = File.open(File.join(project_dir, name), 'rb') { |file| file.read(LITE_READ_BUF_SIZE) || '' } extract_top_level_string_field(head, 'cwd', skip_blank: true)&.unicode_normalize(:nfc) == path rescue SystemCallError false # unreadable, or removed between the listing and the read @@ -404,11 +402,13 @@ def extract_json_string_value(text, start) nil end - # Unescape a JSON string value + # Unescape a JSON string value. A slice that does not parse as a JSON + # string (a raw control character in it) is returned as it is — tagged + # UTF-8: the windows it is cut from are binary (read_head_tail). def unescape_json_string(str) JSON.parse("\"#{str}\"") rescue JSON::ParserError - str + str.encoding == Encoding::UTF_8 ? str : str.dup.force_encoding(Encoding::UTF_8) end # Python's `x or None` for the summary/title fallback chains: Ruby's || @@ -503,7 +503,9 @@ def first_prompt_in(text, command_fallback = nil) # rubocop:disable Metrics/Cycl next if line.include?('"isMeta":true') || line.include?('"isMeta": true') next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true') - entry = JSON.parse(line, symbolize_names: false) + # +text+ may be a binary window or chunk: the line becomes UTF-8 here, + # scrubbed, so the text handling below never meets a stray byte. + entry = JSON.parse(utf8_transcript_text(line), symbolize_names: false) texts = user_entry_texts(entry) next unless texts @@ -536,11 +538,7 @@ def first_prompt_in(text, command_fallback = nil) # rubocop:disable Metrics/Cycl # with no other summary source was not listed at all — while the store # fold, which sees every entry, reported the prompt. def first_prompt_from_file(file_path, head, size) - # Scrubbed for parsing (the offsets below are those of +head+ itself): - # a stray non-UTF-8 byte inside a prompt made the text handling raise, - # which the listing turned into "no such session" and which a fork must - # not die of. - prompt, command_fallback = first_prompt_in(head.valid_encoding? ? head : head.scrub) + prompt, command_fallback = first_prompt_in(head) if prompt.nil? && size > head.bytesize limit = [size, FIRST_PROMPT_SCAN_LIMIT].min prompt, command_fallback = first_prompt_past_head(file_path, head, limit, command_fallback) @@ -567,11 +565,10 @@ def first_prompt_past_head(file_path, head, limit, command_fallback) newline = open_line.rindex("\n") next unless newline - complete = utf8_transcript_text(open_line.slice!(0, newline + 1)) - prompt, command_fallback = first_prompt_in(complete, command_fallback) + prompt, command_fallback = first_prompt_in(open_line.slice!(0, newline + 1), command_fallback) return [prompt, command_fallback] if prompt end - first_prompt_in(utf8_transcript_text(open_line), command_fallback) + first_prompt_in(open_line, command_fallback) end rescue SystemCallError [nil, command_fallback] @@ -642,13 +639,24 @@ def sidechain_head?(head, window_truncated) false end + # The first and the last LITE_READ_BUF_SIZE bytes of a transcript, as + # BINARY Strings — on purpose. The field scanners step through a window + # by offset (String#index with a position, text[pos], #length), and Ruby + # keeps no character index for a UTF-8 String that is not ASCII-only: + # there every one of those steps walks the bytes from the start, so a + # scan with many matches is quadratic in the window, and one multibyte + # character anywhere in it is enough (nearly every real transcript has + # one). On bytes each step is O(1). The patterns are ASCII, JSON.parse + # reads a binary source as UTF-8, and the two places that return a raw + # slice of the window tag it UTF-8 (unescape_json_string), so every value + # that leaves the scanners is a UTF-8 String as before. def read_head_tail(file_path, size) head = tail = nil File.open(file_path, 'rb') do |f| - head = (f.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8') + head = f.read(LITE_READ_BUF_SIZE) || String.new(encoding: Encoding::BINARY) tail = if size > LITE_READ_BUF_SIZE f.seek([0, size - LITE_READ_BUF_SIZE].max) - (f.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8') + f.read(LITE_READ_BUF_SIZE) || String.new(encoding: Encoding::BINARY) else head end diff --git a/spec/unit/session_lite_reader_windows_spec.rb b/spec/unit/session_lite_reader_windows_spec.rb new file mode 100644 index 00000000..cc436068 --- /dev/null +++ b/spec/unit/session_lite_reader_windows_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The disk listing scans the first and the last 64 KiB of a transcript for a +# handful of fields. The scan steps through the window by offset; Ruby keeps +# no character index for a UTF-8 String that is not ASCII-only, so on such a +# window every offset step walks the bytes from the start. The windows are +# therefore scanned as bytes, and only what is returned is UTF-8. +RSpec.describe 'the 64 KiB windows of the disk session reader' do + include_context 'with a Claude config dir' + + let(:session_id) { 'a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d' } + let(:prompt) { '请总结这个仓库的结构' } + let(:title) { '仓库结构总结' } + let(:branch) { '功能/会话读取' } + + # A session of about 350 KB of CJK text: neither window is ASCII-only. + # +head_pad+ / +tail_pad+ ASCII bytes shift where the two windows are cut. + def transcript_bytes(head_pad, tail_pad) + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, git_branch: branch) + transcript.queue_operations(prompt) + transcript.prompt(:prompt, prompt) + parent = :prompt + 60.times do |turn| + answer = transcript.text("#{'x' * head_pad if turn.zero?}#{'好的。' * 500}") + transcript.assistant(:"answer_#{turn}", answer, parent: parent, message: "msg_#{turn}") + transcript.prompt(:"next_#{turn}", "继续 #{turn} #{'请继续说明' * 100}", parent: :"answer_#{turn}") + parent = :"next_#{turn}" + end + transcript.custom_title(title) + transcript.tag('重要') + transcript.last_prompt("最后一个问题#{'x' * tail_pad}", leaf: parent) + transcript.to_jsonl + end + + def cut_inside_a_character?(bytes) + window = ClaudeAgentSDK::Sessions::LITE_READ_BUF_SIZE + [bytes.byteslice(0, window), bytes.byteslice(-window, window)].none? do |part| + part.force_encoding('UTF-8').valid_encoding? + end + end + + # The fixture with both of its windows cut inside a 3-byte character. + def write_transcript + candidates = [0, 1, 2].product([0, 1, 2]).lazy.map { |pads| transcript_bytes(*pads) } + bytes = candidates.find { |candidate| cut_inside_a_character?(candidate) } + raise 'no padding cuts both windows inside a character' unless bytes + + path = transcript_path(session_id) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, bytes) + path + end + + def utf8_strings(info) + %i[summary custom_title first_prompt git_branch cwd tag].to_h { |field| [field, info.public_send(field)] } + end + + it 'scans the windows as bytes' do + path = write_transcript + + windows = ClaudeAgentSDK::Sessions.read_head_tail(path, File.size(path)) + + expect(windows.map(&:encoding)).to eq([Encoding::BINARY, Encoding::BINARY]) + end + + it 'returns every field as a valid UTF-8 String with its value' do + write_transcript + + info = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd) + + expect(File.size(transcript_path(session_id))).to be > 2 * ClaudeAgentSDK::Sessions::LITE_READ_BUF_SIZE + expect(utf8_strings(info)).to eq(summary: title, custom_title: title, first_prompt: prompt, git_branch: branch, + cwd: cwd, tag: '重要') + expect(utf8_strings(info).values.map(&:encoding).uniq).to eq([Encoding::UTF_8]) + expect(utf8_strings(info).values).to all(be_valid_encoding) + end + + # The value of a field on a line the window cut off, and a value that is + # not a well-formed JSON string (a raw tab), come back as slices of the + # window rather than from a JSON parse. + describe 'values returned as slices of the window' do + let(:window) { "{\"type\":\"custom-title\",\"customTitle\":\"#{title}\",\"sessionId\":\"x\"}\n".b } + + it 'returns a value from a line cut by the window as UTF-8' do + cut = window[0, window.index('sessionId'.b)] + + value = ClaudeAgentSDK::Sessions.extract_top_level_string_field(cut, 'customTitle', last: true) + + expect([value, value.encoding, value.valid_encoding?]).to eq([title, Encoding::UTF_8, true]) + end + + it 'returns a value that does not parse as a JSON string as UTF-8' do + raw = window.sub(title.b, "#{title}\t原文".b) + + value = ClaudeAgentSDK::Sessions.extract_json_string_field(raw, 'customTitle', last: true) + + expect([value, value.encoding, value.valid_encoding?]).to eq(["#{title}\t原文", Encoding::UTF_8, true]) + end + end +end From 34153bf818c64ae0227a4f36d4d8479dfd97981b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:54:55 +0800 Subject: [PATCH 064/194] Return each subagent id once from list_subagents on disk list_subagents returned the same id twice when a subagent's transcript existed both directly under subagents/ and under workflows/<runId>/. The store variant returns it once, and get_subagent_messages / get_subagent_metadata read the first match either way. The disk listing now keeps the first position of each id in the sorted walk. Exploration finding: U-P19 (duplicate subagent ids) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 12 ++++--- spec/unit/session_subagent_listing_spec.rb | 37 ++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 spec/unit/session_subagent_listing_spec.rb diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index e81df395..bbfe5dd7 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -861,10 +861,12 @@ def get_session_messages(session_id:, directory: nil, limit: nil, offset: 0) # List subagent IDs recorded for a session on local disk (counterpart to # list_subagents_from_store). Scans # <projectDir>/<sessionId>/subagents/**/agent-<id>.jsonl, including nested - # workflows/<runId>/ paths, in sorted walk order. Mirrors the Python SDK's - # list_subagents (#825) — no dedupe (the store variant dedupes because - # adapter subkey ordering is adapter-defined; the sorted disk walk is - # already deterministic). + # workflows/<runId>/ paths, in sorted walk order (the Python SDK's + # list_subagents, #825). Each id once, at its first position in the walk + # — the transcript the message and metadata readers take for it. Python + # does not dedupe here; an id whose transcript exists both directly and + # under workflows/<runId>/ came back twice, where the store variant + # returns it once. # @param session_id [String] The session UUID # @param directory [String, nil] Working directory to search in (strictly # scopes to that project + its worktrees; nil searches all projects) @@ -875,7 +877,7 @@ def list_subagents(session_id:, directory: nil) subagents_dir = resolve_subagents_dir(session_id, directory) return [] if subagents_dir.nil? - collect_agent_files(subagents_dir).map(&:first) + collect_agent_files(subagents_dir).map(&:first).uniq end # Read the optional subagent metadata sidecar without reading its transcript. diff --git a/spec/unit/session_subagent_listing_spec.rb b/spec/unit/session_subagent_listing_spec.rb new file mode 100644 index 00000000..34fc0aea --- /dev/null +++ b/spec/unit/session_subagent_listing_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Subagent transcripts live at <session>/subagents/agent-<id>.jsonl and may +# nest under workflows/<runId>/. An id is one subagent wherever its +# transcripts sit: the message and metadata readers take the first match. +RSpec.describe 'list_subagents on disk' do + include_context 'with a Claude config dir' + + let(:session_id) { 'b2a1c0d9-e8f7-4a6b-9c5d-4e3f2a1b0c9d' } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def write_subagent(agent_id, *nesting) + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id) + transcript.prompt(:task, 'Read a.rb') + transcript.assistant(:report, transcript.text('It defines Foo.'), parent: :task) + transcript.write(File.join(project_dir_for(cwd), session_id, 'subagents', *nesting, "agent-#{agent_id}.jsonl")) + end + + before do + parent = CLITranscript.new(session_id: session_id, cwd: cwd) + parent.prompt(:prompt, 'Use subagents to read a.rb') + parent.write(transcript_path(session_id)) + end + + it 'returns an id once when its transcript exists at two depths' do + write_subagent('a1b2c3') + write_subagent('a1b2c3', 'workflows', 'run-1') + write_subagent('d4e5f6', 'workflows', 'run-1') + + expect(ClaudeAgentSDK.list_subagents(session_id: session_id, directory: cwd)).to eq(%w[a1b2c3 d4e5f6]) + end +end From af66495c46f2046677d0c10c26f802615414ad0b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:55:32 +0800 Subject: [PATCH 065/194] Pin the typed input of every hook event Query#parse_hook_input maps each `hook_callback` request to a typed input, one branch per event, field by field. The suite executed 8 of the 27 branches: a field read from the wrong key, or two fields swapped, passed all 2454 examples for the other 19 events (UserPromptSubmit losing its prompt and CwdChanged swapping old_cwd and new_cwd both did). query_hook_input_spec.rb has one row per event in HOOK_EVENTS. Each row sends a hook_callback frame whose fields all carry different values and checks every reader the input class declares, plus the tool_use_id and context the callback is called with. An event without a row fails, and so does a reader its row gives no value to, so the table has to follow when an event or a reader is added. The frames are sent the way the CLI sends them. spec/support/scripted_cli.rb is a transport that plays the CLI's end of the control protocol in process: a real Client connects to it from ordinary ClaudeAgentOptions, `initialize` is answered as the CLI answers it, and the spec gets back what the SDK wrote in reply. Nothing private is called and nothing is stubbed. All 90 single-field mutations of parse_hook_input (each `fetch.call(:x)` reading a misspelled key) now fail this spec. Specs only; no library change. Exploration finding: F-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/support/scripted_cli.rb | 155 +++++++++++++++++++++++++++++ spec/unit/query_hook_input_spec.rb | 138 +++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 spec/support/scripted_cli.rb create mode 100644 spec/unit/query_hook_input_spec.rb diff --git a/spec/support/scripted_cli.rb b/spec/support/scripted_cli.rb new file mode 100644 index 00000000..66360ede --- /dev/null +++ b/spec/support/scripted_cli.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'json' +require 'securerandom' + +# The CLI's end of the control protocol, in process. A spec opens a real +# Client on it, sends the control requests the CLI would send (hook_callback, +# can_use_tool, mcp_message) and gets back the frame the SDK wrote in reply: +# +# ScriptedCLI.session(options) do |cli| +# reply = cli.request(subtype: 'can_use_tool', tool_name: 'Bash', input: { command: 'ls' }) +# reply # => { 'subtype' => 'success', 'request_id' => '…', 'response' => { 'behavior' => 'allow', … } } +# end +# +# Nothing on the SDK side is stubbed or called privately: Client#connect +# builds the Query from the options a user writes, `initialize` is answered +# the way the CLI answers it, and each request goes through Query's read +# loop, its handler task and the hop to the user's callback. Frames cross in +# both directions as they cross SubprocessCLITransport: serialized to JSON +# and parsed back (Symbol keys towards the SDK, String keys on this side). +# +# This is the control channel only. For a child process that also speaks the +# message stream, see FakeClaude (spec/support/fake_claude.rb). +class ScriptedCLI + # How long #request waits for the SDK's answer. Only a regression that + # leaves a request unanswered ever waits this long. + REPLY_BOUND_SECONDS = 10 + + # What CLI 2.1.286 answers `initialize` with when it has no credentials, + # cut down to one entry per list. + INITIALIZE_RESPONSE = { + commands: [{ name: 'design', description: 'Grant or revoke Claude agent access to your Design projects', + argumentHint: 'consent | revoke', builtin: true }], + agents: [{ name: 'claude', description: "Catch-all for any task that doesn't fit a more specific agent." }], + output_style: 'default', + available_output_styles: %w[default Proactive Concise Explanatory Learning], + models: [{ value: 'default', resolvedModel: 'claude-opus-5-5', displayName: 'Default (recommended)', + description: 'Use the default model', supportsEffort: true, + supportedEffortLevels: %w[low medium high xhigh max], supportsAdaptiveThinking: true, + supportsFastMode: true, supportsAutoMode: true }], + account: { tokenSource: 'none', apiProvider: 'firstParty' }, + pid: 4242, + current_permission_mode: 'auto', + session_state: 'idle' + }.freeze + + # Opens a Client on a new ScriptedCLI, yields the ScriptedCLI (and the + # Client), disconnects. Returns the block's value. + def self.session(options = ClaudeAgentSDK::ClaudeAgentOptions.new) + cli = new + ClaudeAgentSDK::Client.open(options: options, transport_class: Transport, transport_args: { cli: cli }) do |client| + yield cli, client + end + end + + # Every frame the SDK wrote, in order, as the CLI parses them (String keys). + attr_reader :received + + def initialize + @to_sdk = Thread::Queue.new + @replies = Thread::Queue.new + @received = [] + end + + # Sends one control request and returns the body of the SDK's + # control_response: 'subtype', 'request_id' and 'response' (or 'error'). + def request(request, request_id: SecureRandom.uuid) + deliver(type: 'control_request', request_id: request_id, request: request) + reply = @replies.pop(timeout: REPLY_BOUND_SECONDS) + raise "the SDK did not answer the #{request[:subtype]} request within #{REPLY_BOUND_SECONDS}s" unless reply + + reply.fetch('response') + end + + # The `initialize` control request the SDK sent on connect. + def initialize_request + @received.find { |frame| frame.dig('request', 'subtype') == 'initialize' } + end + + # The callback ids the SDK registered for +event+ in `initialize`, which + # is how the CLI learns what to put in a hook_callback request. + def hook_callback_ids(event) + Array(initialize_request.dig('request', 'hooks', event)).flat_map { |matcher| matcher.fetch('hookCallbackIds') } + end + + # The three methods below are the transport's side. + + def next_frame + @to_sdk.pop + end + + def hang_up + @to_sdk.push(:eof) + end + + def take(data) + data.each_line do |line| + frame = JSON.parse(line) + @received << frame + case frame['type'] + when 'control_response' then @replies.push(frame) + when 'control_request' then answer(frame) + end + end + end + + private + + def deliver(frame) + @to_sdk.push(JSON.parse(JSON.generate(frame), symbolize_names: true)) + end + + def answer(frame) + payload = frame.dig('request', 'subtype') == 'initialize' ? INITIALIZE_RESPONSE : {} + deliver(type: 'control_response', + response: { subtype: 'success', request_id: frame.fetch('request_id'), response: payload }) + end + + # What Client instantiates: `transport_class.new(options, **transport_args)`. + class Transport < ClaudeAgentSDK::Transport + def initialize(_options, cli:) + super() + @cli = cli + @ready = false + end + + def connect + @ready = true + end + + def ready? + @ready + end + + def write(data) + @cli.take(data) + end + + def read_messages + loop do + frame = @cli.next_frame + break if frame == :eof + + yield frame + end + end + + def end_input; end + + def close + @ready = false + @cli.hang_up + end + end +end diff --git a/spec/unit/query_hook_input_spec.rb b/spec/unit/query_hook_input_spec.rb new file mode 100644 index 00000000..7281fc30 --- /dev/null +++ b/spec/unit/query_hook_input_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# What a hook callback is handed, for every hook event: the typed input the +# SDK builds from a `hook_callback` control request, checked reader by +# reader. A field mapped from the wrong key, or dropped, used to pass the +# whole suite for 19 of the 27 events. +# +# One row per event in ClaudeAgentSDK::HOOK_EVENTS. An event without a row, +# and a reader its row gives no value to, both fail here — so the table has +# to be extended when an event or a reader is added. +RSpec.describe ClaudeAgentSDK::Query do + describe 'the typed input of a hook_callback request' do + # The event's own fields, by the names the CLI uses on the wire, with the + # JSON type each one has there. The fields every event carries are in + # #base_fields. + fields = { + 'PreToolUse' => { tool_name: :string, tool_input: :object, tool_use_id: :string, + agent_id: :string, agent_type: :string }, + 'PostToolUse' => { tool_name: :string, tool_input: :object, tool_response: :object, tool_use_id: :string, + agent_id: :string, agent_type: :string }, + 'PostToolUseFailure' => { tool_name: :string, tool_input: :object, tool_use_id: :string, error: :string, + is_interrupt: :boolean, agent_id: :string, agent_type: :string }, + 'Notification' => { message: :string, title: :string, notification_type: :string }, + 'UserPromptSubmit' => { prompt: :string }, + 'SessionStart' => { source: :string, agent_type: :string, model: :string }, + 'SessionEnd' => { reason: :string }, + 'Stop' => { stop_hook_active: :boolean, last_assistant_message: :string, background_tasks: :objects, + session_crons: :objects }, + 'StopFailure' => { error: :string, error_details: :string, last_assistant_message: :string }, + 'SubagentStart' => { agent_id: :string, agent_type: :string }, + 'SubagentStop' => { stop_hook_active: :boolean, agent_id: :string, agent_transcript_path: :string, + agent_type: :string, last_assistant_message: :string, background_tasks: :objects, + session_crons: :objects }, + 'PreCompact' => { trigger: :string, custom_instructions: :string }, + 'PostCompact' => { trigger: :string, compact_summary: :string }, + 'PermissionRequest' => { tool_name: :string, tool_input: :object, permission_suggestions: :objects, + agent_id: :string, agent_type: :string }, + 'PermissionDenied' => { tool_name: :string, tool_input: :object, tool_use_id: :string, reason: :string, + agent_id: :string, agent_type: :string }, + 'Setup' => { trigger: :string }, + 'TeammateIdle' => { teammate_name: :string, team_name: :string }, + 'TaskCreated' => { task_id: :string, task_subject: :string, task_description: :string, + teammate_name: :string, team_name: :string }, + 'TaskCompleted' => { task_id: :string, task_subject: :string, task_description: :string, + teammate_name: :string, team_name: :string }, + 'Elicitation' => { mcp_server_name: :string, message: :string, mode: :string, url: :string, + elicitation_id: :string, requested_schema: :object }, + 'ElicitationResult' => { mcp_server_name: :string, elicitation_id: :string, mode: :string, action: :string, + content: :object }, + 'ConfigChange' => { source: :string, file_path: :string }, + 'WorktreeCreate' => { name: :string }, + 'WorktreeRemove' => { worktree_path: :string }, + 'InstructionsLoaded' => { file_path: :string, memory_type: :string, load_reason: :string, globs: :strings, + trigger_file_path: :string }, + 'CwdChanged' => { old_cwd: :string, new_cwd: :string }, + 'FileChanged' => { file_path: :string, event: :string } + }.freeze + + # A value of the field's JSON type that names the field, so no two fields + # of a frame carry the same one and a field read from the wrong key shows. + def sentinel(event, field, type) + marker = "#{field} of #{event}" + case type + when :string then marker + when :strings then [marker] + when :object then { marker: marker } + when :objects then [{ marker: marker }] + when :boolean then true # the inputs default it to false or nil + else raise ArgumentError, "unknown wire type #{type.inspect} for #{marker}" + end + end + + # What every hook input carries. scratchpad_dir and prompt_id are on the + # frames CLI 2.1.286 sends; the typed inputs have no reader for them. + def base_fields(event) + { session_id: "session_id of #{event}", transcript_path: "transcript_path of #{event}", + cwd: "cwd of #{event}", scratchpad_dir: "scratchpad_dir of #{event}", prompt_id: "prompt_id of #{event}", + permission_mode: "permission_mode of #{event}", hook_event_name: event } + end + + # Registers one hook for +event+, sends it one hook_callback request the + # way the CLI does and returns what the callback was called with, plus + # the SDK's reply. + def dispatch_hook(event, input) + calls = [] + hook = lambda do |hook_input, tool_use_id, context| + calls << { input: hook_input, tool_use_id: tool_use_id, context: context } + {} + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + hooks: { event => [ClaudeAgentSDK::HookMatcher.new(hooks: [hook])] } + ) + reply = ScriptedCLI.session(options) do |cli| + cli.request({ subtype: 'hook_callback', callback_id: cli.hook_callback_ids(event).fetch(0), + tool_use_id: "request tool_use_id of #{event}", input: input }, + request_id: "request_id of #{event}") + end + [calls, reply] + end + + it 'has a row for every event in HOOK_EVENTS, and no other' do + expect(fields.keys).to match_array(ClaudeAgentSDK::HOOK_EVENTS) + end + + ClaudeAgentSDK::HOOK_EVENTS.each do |event| + it "hands a #{event} hook every field of the frame through its typed input" do + own = fields.fetch(event) { raise "no row for #{event}: add its wire fields to the table above" } + input = base_fields(event).merge(own.to_h { |field, type| [field, sentinel(event, field, type)] }) + input_class = ClaudeAgentSDK.const_get("#{event}HookInput") + + calls, reply = dispatch_hook(event, input) + + expect(calls.length).to eq(1) + hook_input = calls.first[:input] + expect(hook_input).to be_an_instance_of(input_class) + # Every reader the class declares, not only the ones the row names: a + # reader without a value in the frame is a row that fell behind. + input_class.attribute_names.each do |reader| + expected = input.fetch(reader.to_sym) do + raise "#{input_class} has a reader ##{reader} the #{event} row gives no value: add the wire field" + end + actual = hook_input.public_send(reader) + expect(actual).to eq(expected), "#{input_class}##{reader}: expected #{expected.inspect}, got #{actual.inspect}" + end + # ...and no field in the row the class cannot read, which would + # leave that column of the table unchecked. + expect(input_class.attribute_names).to include(*own.keys.map(&:to_s)) + + expect(calls.first[:tool_use_id]).to eq("request tool_use_id of #{event}") + expect(calls.first[:context]).to be_a(ClaudeAgentSDK::HookContext) + expect(calls.first[:context].request_id).to eq("request_id of #{event}") + expect(reply).to include('subtype' => 'success', 'request_id' => "request_id of #{event}") + end + end + end +end From c8998d5e9e96f39a03d6289e0c1620e42fd0447b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:55:33 +0800 Subject: [PATCH 066/194] Pin the SDK MCP JSON-RPC exchange over the control channel The CLI talks to an in-process SDK MCP server through `mcp_message` control requests. The suite only ever sent tools/call and notifications/initialized: `initialize`, tools/list, resources/list, resources/read, prompts/list, prompts/get and the unknown-method answer were never executed. An `initialize` answer without protocolVersion, which makes the real CLI report the whole server as failed, passed every example. query_sdk_mcp_messages_spec.rb sends each of those requests through the scripted CLI and checks the JSON-RPC response the SDK writes back, for a server with tools, resources and prompts, a tools-only server and a server without tools. The initialize, notifications/initialized and tools/list requests are the frames CLI 2.1.286 sends on connect; the tools are declared with :number shorthand. Specs only; no library change. Exploration finding: F-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/unit/query_sdk_mcp_messages_spec.rb | 207 +++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 spec/unit/query_sdk_mcp_messages_spec.rb diff --git a/spec/unit/query_sdk_mcp_messages_spec.rb b/spec/unit/query_sdk_mcp_messages_spec.rb new file mode 100644 index 00000000..b2c4a629 --- /dev/null +++ b/spec/unit/query_sdk_mcp_messages_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# The JSON-RPC conversation the CLI has with an SDK MCP server, which travels +# as `mcp_message` control requests: each example sends one request the way +# the CLI does and checks the frame the SDK writes back. Before this file +# only tools/call and notifications/initialized were ever executed by the +# suite, so an `initialize` answer without protocolVersion — which makes the +# real CLI report the whole in-process server as failed — passed it. +# +# The initialize, notifications/initialized and tools/list requests are the +# frames CLI 2.1.286 sends when it connects to an SDK server; the other +# methods use the same envelope with the parameters the MCP specification +# gives them. +RSpec.describe ClaudeAgentSDK::Query do + describe 'mcp_message control requests for an SDK MCP server' do + let(:tool_calls) { [] } + let(:prompt_calls) { [] } + + let(:add_tool) do + calls = tool_calls + ClaudeAgentSDK.create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args| + calls << args + (args[:a] + args[:b]).to_s + end + end + + let(:config_resource) do + ClaudeAgentSDK.create_resource(uri: 'config://app', name: 'App Config', description: 'Current settings', + mime_type: 'text/plain') do + { contents: [{ uri: 'config://app', mimeType: 'text/plain', text: 'debug=false' }] } + end + end + + let(:review_prompt) do + calls = prompt_calls + ClaudeAgentSDK.create_prompt(name: 'review', description: 'Code review', + arguments: [{ name: 'focus', description: 'What to look at', + required: true }]) do |args| + calls << args + { messages: [{ role: 'user', content: { type: 'text', text: "Review #{args[:focus]}" } }] } + end + end + + let(:full_server) do + ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: [add_tool], resources: [config_resource], + prompts: [review_prompt]) + end + + # What CLI 2.1.286 sends as the first message to every SDK server. + let(:initialize_message) do + { method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, + clientInfo: { name: 'claude-code', title: 'Claude Code', + description: "Anthropic's agentic coding tool", + websiteUrl: 'https://claude.com/claude-code', version: '2.1.286' } }, + jsonrpc: '2.0', id: 0 } + end + + # Sends +message+ to the server registered as +server_name+ and returns + # the JSON-RPC response the SDK wrote, after checking the envelope: an + # mcp_message is always answered with a successful control response, + # whatever the JSON-RPC outcome inside it. + def mcp_exchange(message, servers: { 'calc' => full_server }, server_name: 'calc') + options = ClaudeAgentSDK::ClaudeAgentOptions.new(mcp_servers: servers) + reply = ScriptedCLI.session(options) do |cli| + cli.request({ subtype: 'mcp_message', server_name: server_name, message: message }, request_id: 'req_mcp') + end + expect(reply).to include('subtype' => 'success', 'request_id' => 'req_mcp') + expect(reply.fetch('response').keys).to eq(['mcp_response']) + reply.dig('response', 'mcp_response') + end + + describe 'initialize' do + it 'answers with the protocol version, the capabilities of what the server offers, and its identity' do + expect(mcp_exchange(initialize_message)).to eq( + 'jsonrpc' => '2.0', 'id' => 0, + 'result' => { 'protocolVersion' => '2024-11-05', + 'capabilities' => { 'tools' => {}, 'resources' => {}, 'prompts' => {} }, + 'serverInfo' => { 'name' => 'calc', 'version' => '1.0.0' } } + ) + end + + it 'advertises only tools for a server that has only tools' do + tools_only = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', version: '2.3.0', tools: [add_tool]) + + result = mcp_exchange(initialize_message, servers: { 'calc' => tools_only }).fetch('result') + + expect(result.fetch('capabilities')).to eq('tools' => {}) + expect(result.fetch('serverInfo')).to eq('name' => 'calc', 'version' => '2.3.0') + expect(result.fetch('protocolVersion')).to eq('2024-11-05') + end + + it 'does not advertise tools for a server that has none' do + no_tools = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', resources: [config_resource]) + + result = mcp_exchange(initialize_message, servers: { 'calc' => no_tools }).fetch('result') + + expect(result.fetch('capabilities')).to eq('resources' => {}) + end + end + + it 'acknowledges notifications/initialized' do + response = mcp_exchange({ jsonrpc: '2.0', method: 'notifications/initialized' }) + + expect(response).to eq('jsonrpc' => '2.0', 'result' => {}) + end + + it 'lists the tools with their JSON schemas for tools/list' do + response = mcp_exchange({ method: 'tools/list', jsonrpc: '2.0', id: 1 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 1, + 'result' => { 'tools' => [{ 'name' => 'add', 'description' => 'Add two numbers', + 'inputSchema' => { 'type' => 'object', + 'properties' => { 'a' => { 'type' => 'number' }, + 'b' => { 'type' => 'number' } }, + 'required' => %w[a b] } }] } + ) + end + + it 'runs the tool for tools/call and returns its content' do + response = mcp_exchange({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 3 } }, + jsonrpc: '2.0', id: 2 }) + + expect(tool_calls).to eq([{ a: 2, b: 3 }]) + expect(response).to include('jsonrpc' => '2.0', 'id' => 2) + expect(response).not_to have_key('error') + expect(response.dig('result', 'content')).to eq([{ 'type' => 'text', 'text' => '5' }]) + expect(response.dig('result', 'isError')).not_to be(true) + end + + it 'lists the resources for resources/list' do + response = mcp_exchange({ method: 'resources/list', jsonrpc: '2.0', id: 3 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 3, + 'result' => { 'resources' => [{ 'uri' => 'config://app', 'name' => 'App Config', + 'description' => 'Current settings', 'mimeType' => 'text/plain' }] } + ) + end + + it 'reads the resource named by params.uri for resources/read' do + response = mcp_exchange({ method: 'resources/read', params: { uri: 'config://app' }, jsonrpc: '2.0', id: 4 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 4, + 'result' => { 'contents' => [{ 'uri' => 'config://app', 'mimeType' => 'text/plain', + 'text' => 'debug=false' }] } + ) + end + + it 'answers resources/read without a uri with a JSON-RPC internal error' do + response = mcp_exchange({ method: 'resources/read', params: {}, jsonrpc: '2.0', id: 5 }) + + expect(response).to eq('jsonrpc' => '2.0', 'id' => 5, + 'error' => { 'code' => -32_603, 'message' => 'Missing uri parameter for resources/read' }) + end + + it 'lists the prompts with their arguments for prompts/list' do + response = mcp_exchange({ method: 'prompts/list', jsonrpc: '2.0', id: 6 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 6, + 'result' => { 'prompts' => [{ 'name' => 'review', 'description' => 'Code review', + 'arguments' => [{ 'name' => 'focus', 'description' => 'What to look at', + 'required' => true }] }] } + ) + end + + it 'renders the prompt named by params.name with params.arguments for prompts/get' do + response = mcp_exchange({ method: 'prompts/get', params: { name: 'review', arguments: { focus: 'the diff' } }, + jsonrpc: '2.0', id: 7 }) + + expect(prompt_calls).to eq([{ focus: 'the diff' }]) + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 7, + 'result' => { 'messages' => [{ 'role' => 'user', + 'content' => { 'type' => 'text', 'text' => 'Review the diff' } }] } + ) + end + + it 'answers prompts/get without a name with a JSON-RPC internal error' do + response = mcp_exchange({ method: 'prompts/get', params: { arguments: { focus: 'the diff' } }, + jsonrpc: '2.0', id: 8 }) + + expect(prompt_calls).to be_empty + expect(response).to eq('jsonrpc' => '2.0', 'id' => 8, + 'error' => { 'code' => -32_603, 'message' => 'Missing name parameter for prompts/get' }) + end + + it 'answers a method it does not implement with method-not-found' do + response = mcp_exchange({ method: 'completion/complete', params: {}, jsonrpc: '2.0', id: 9 }) + + expect(response).to eq('jsonrpc' => '2.0', 'id' => 9, + 'error' => { 'code' => -32_601, 'message' => "Method 'completion/complete' not found" }) + end + + it 'answers a request for a server it does not have with method-not-found' do + response = mcp_exchange({ method: 'tools/list', jsonrpc: '2.0', id: 10 }, server_name: 'other') + + expect(response).to eq('jsonrpc' => '2.0', 'id' => 10, + 'error' => { 'code' => -32_601, 'message' => "Server 'other' not found" }) + end + end +end From ed9a559347e9bc8760a0b3d77f879dbb52137ed1 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:55:33 +0800 Subject: [PATCH 067/194] Pin the fail-closed answer to a can_use_tool callback gone wrong A can_use_tool callback has to return a PermissionResult. When it returns anything else the SDK answers the CLI with an error control response, so the tool does not run. No spec executed that branch: answering `allow` instead passed the whole suite. query_permission_result_spec.rb sends a can_use_tool request through the scripted CLI for a callback returning true, nil and a Hash shaped like an allow, and requires an error response naming the offending class, with no `behavior` in anything the SDK wrote. A fourth example answers the same request with a real PermissionResultAllow, so the three cannot pass because the channel is broken. Specs only; no library change. Exploration finding: F-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/unit/query_permission_result_spec.rb | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 spec/unit/query_permission_result_spec.rb diff --git a/spec/unit/query_permission_result_spec.rb b/spec/unit/query_permission_result_spec.rb new file mode 100644 index 00000000..7ed2a9fa --- /dev/null +++ b/spec/unit/query_permission_result_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# The fail-closed branch of the permission round trip: a can_use_tool +# callback that returns anything but a PermissionResult must not be read as a +# decision. The CLI gets an error control response, never `behavior: allow` — +# `true`, `nil` and a Hash are what a callback written from memory returns, +# and answering any of them with an allow would run the tool unasked. +RSpec.describe ClaudeAgentSDK::Query do + describe 'a can_use_tool request whose callback does not return a PermissionResult' do + # The request the CLI sends before a tool call it has no rule for. + let(:request) do + { subtype: 'can_use_tool', tool_name: 'Bash', input: { command: 'rm -rf build' }, + permission_suggestions: [{ type: 'addRules', behavior: 'allow', destination: 'session', + rules: [{ toolName: 'Bash', ruleContent: 'rm -rf build' }] }], + tool_use_id: 'toolu_01A2b3C4d5E6f7G8h9J0kLmN' } + end + + # Returns the SDK's reply and every frame it wrote during the session. + def ask_permission(callback) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(can_use_tool: callback) + ScriptedCLI.session(options) do |cli| + [cli.request(request, request_id: 'req_permission'), cli.received] + end + end + + { + 'true' => true, + 'nil' => nil, + 'a Hash shaped like an allow' => { behavior: 'allow', updatedInput: { command: 'rm -rf build' } } + }.each do |label, returned| + it "answers #{label} with an error, never an allow" do + asked = [] + callback = lambda do |tool_name, input, _context| + asked << [tool_name, input] + returned + end + + reply, written = ask_permission(callback) + + expect(asked).to eq([['Bash', { command: 'rm -rf build' }]]) + expect(reply).to include('subtype' => 'error', 'request_id' => 'req_permission') + expect(reply.fetch('error')) + .to eq("Tool permission callback must return PermissionResult, got #{returned.class}") + expect(reply).not_to have_key('response') + expect(JSON.generate(written)).not_to include('"behavior"') + end + end + + # The same request through the same channel, answered by a real + # PermissionResult: without it, a harness that turned every request into + # an error would make the examples above pass for the wrong reason. + it 'answers a PermissionResultAllow with an allow' do + reply, = ask_permission(->(_tool_name, _input, _context) { ClaudeAgentSDK::PermissionResultAllow.new }) + + expect(reply).to include('subtype' => 'success', 'request_id' => 'req_permission') + expect(reply.fetch('response')).to eq('behavior' => 'allow', 'updatedInput' => { 'command' => 'rm -rf build' }) + end + end +end From 4344a9432289ef5296ef6bf9f0a0fac55beb6729 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:40:33 +0800 Subject: [PATCH 068/194] Normalize Hash hook outputs at both levels, for any key style A hook callback may return a Hash instead of a typed output, and the signature promises "snake_case or camelCase keys". The converter renamed five top-level keys, only when they were Symbols, and forwarded a nested hook_specific_output Hash as written. So { hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' } } reached the CLI as hookSpecificOutput.permission_decision, which it does not read: the object was ignored and the tool ran. A String top-level key ('hook_specific_output', e.g. after JSON.parse) and a typed SyncHookJSONOutput holding a snake_case Hash failed the same way. Hook outputs are now normalized from a vocabulary kept next to the typed output classes (HookOutputKeys, @api private): their attribute names and the wire keys their #to_h emits, for Symbol and String keys, at the top level and inside hookSpecificOutput, and also after a typed output's #to_h. Keys outside the vocabulary are sent as written, the keys inside updatedInput, the tool outputs and a PermissionRequest decision are not renamed, and every key is written once: a Symbol and a String spelling the same key no longer reach JSON.generate together (json 3.x raised on that and the hook was answered with an error; 2.x emitted the key twice). User-visible: a Hash in Ruby spelling now takes effect instead of being ignored. When one Hash spells a field both ways the camelCase spelling is sent, whichever comes first; until now the later of two top-level Symbol spellings won. Exploration finding: C-P1 / E-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- docs/hooks-and-permissions.md | 19 + lib/claude_agent_sdk/query.rb | 44 +- lib/claude_agent_sdk/types/hooks.rb | 73 +++ .../claude-agent-ruby/references/options.md | 2 +- sig/claude_agent_sdk/types/hooks.rbs | 9 +- skills/references/options.md | 2 +- spec/integration/real_cli_integration_spec.rb | 40 ++ spec/unit/hook_output_normalization_spec.rb | 464 ++++++++++++++++++ 8 files changed, 620 insertions(+), 33 deletions(-) create mode 100644 spec/unit/hook_output_normalization_spec.rb diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index 20e4034b..fc754fa4 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -79,6 +79,25 @@ end.wait See [examples/hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/hooks_example.rb), [examples/advanced_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/advanced_hooks_example.rb), and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb). +### Hook output + +A callback returns a typed output (`SyncHookJSONOutput` or `AsyncHookJSONOutput`, holding a `*HookSpecificOutput`), a Hash, or `nil` (the same as `{}`). A Hash stands for the typed output with the same fields. Its keys may be Symbols or Strings, and each field may be spelled as the typed class's attribute (`permission_decision`) or as the CLI reads it (`permissionDecision`), at the top level and inside `hook_specific_output`. The deny in the example above can also be written as: + +```ruby +{ + hook_specific_output: { + hook_event_name: 'PreToolUse', # a typed output sets this itself; a Hash has to carry it + permission_decision: 'deny', + permission_decision_reason: "Command contains forbidden pattern: #{pattern}" + } +} +``` + +- `continue_` and `async_` stand for `continue` and `async`. +- A key the typed classes do not have is sent as written, so a CLI field the SDK does not model has to be spelled the way the CLI spells it. +- Values are sent as written: the keys inside `updated_input`, `updated_tool_output`, `updated_mcp_tool_output` and a `PermissionRequest` `decision` are never renamed. +- If one Hash spells the same field both ways, the camelCase one is sent. + ### Hook cancellation Dispatched hooks receive `HookContext#request_id` and `HookContext#signal`, using diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 5be6be42..3dbdb914 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -1272,36 +1272,24 @@ def handle_mcp_message(request_data) { mcp_response: mcp_response } end - def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key - # Handle typed output objects - return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash) - + # What a hook callback returned, as the object the CLI reads. A typed + # output (or anything else with #to_h) contributes its own Hash; a typed + # value inside a Hash, such as a *HookSpecificOutput under + # hook_specific_output, does the same. The keys of the result are then + # normalized, so a Hash written in snake_case or with String keys means + # what the typed output means (HookOutputKeys) — and so does the Hash a + # typed output carried through as its hook_specific_output. + def convert_hook_output_for_cli(hook_output) + if hook_output.is_a?(Hash) + hook_output = hook_output.transform_values do |value| + value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value + end + elsif hook_output.respond_to?(:to_h) + hook_output = hook_output.to_h + end return {} unless hook_output.is_a?(Hash) - # Convert Ruby hash with symbol keys to CLI format - # Handle special keywords that might be Ruby-safe versions - converted = {} - hook_output.each do |key, value| - converted_key = case key - when :async_, 'async_' then 'async' - when :continue_, 'continue_' then 'continue' - when :hook_specific_output then 'hookSpecificOutput' - when :suppress_output then 'suppressOutput' - when :stop_reason then 'stopReason' - when :system_message then 'systemMessage' - when :async_timeout then 'asyncTimeout' - else key.to_s - end - - # Recursively convert nested objects - converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash) - value.to_h - else - value - end - converted[converted_key] = converted_value - end - converted + HookOutputKeys.normalize(hook_output) end def send_control_request(request) diff --git a/lib/claude_agent_sdk/types/hooks.rb b/lib/claude_agent_sdk/types/hooks.rb index a1baf03b..88bc6170 100644 --- a/lib/claude_agent_sdk/types/hooks.rb +++ b/lib/claude_agent_sdk/types/hooks.rb @@ -637,4 +637,77 @@ def to_h result end end + + # The spellings a hook callback's return value may use for a field of the + # typed output classes above, mapped to the key the CLI reads. A Hash a + # callback returns stands for the typed output with the same fields: its + # keys may be Symbols or Strings, the attribute names (snake_case) or what + # #to_h emits (camelCase), at the top level and inside hook_specific_output. + # + # Only names that differ from their wire key are listed. A key that is not + # listed is sent as written, so a field of a newer CLI that the typed + # classes do not model still gets through, in the CLI's own spelling. + # spec/unit/hook_output_normalization_spec.rb walks every typed output + # class and fails when an attribute and these tables disagree. + # + # @api private + module HookOutputKeys + # SyncHookJSONOutput and AsyncHookJSONOutput attributes, plus the + # Ruby-safe spellings of the two keywords. + TOP_LEVEL = { + 'continue_' => 'continue', + 'async_' => 'async', + 'suppress_output' => 'suppressOutput', + 'stop_reason' => 'stopReason', + 'system_message' => 'systemMessage', + 'hook_specific_output' => 'hookSpecificOutput', + 'async_timeout' => 'asyncTimeout' + }.freeze + + # Attributes of the *HookSpecificOutput classes. + HOOK_SPECIFIC = { + 'hook_event_name' => 'hookEventName', + 'permission_decision' => 'permissionDecision', + 'permission_decision_reason' => 'permissionDecisionReason', + 'updated_input' => 'updatedInput', + 'additional_context' => 'additionalContext', + 'updated_tool_output' => 'updatedToolOutput', + 'updated_mcp_tool_output' => 'updatedMCPToolOutput', + 'watch_paths' => 'watchPaths' + }.freeze + + # The hook output Hash as the CLI reads it: String keys in wire + # spelling, at the top level and one level down, inside + # hookSpecificOutput. Values are never rewritten: updatedInput and the + # tool outputs are the tool's own payloads, and a PermissionRequest + # decision goes out as the caller wrote it. + def self.normalize(output) + normalized = rename(output, TOP_LEVEL) + specific = normalized['hookSpecificOutput'] + normalized['hookSpecificOutput'] = rename(specific, HOOK_SPECIFIC) if specific.is_a?(Hash) + normalized + end + + # Every key ends up as one String, so a Symbol and a String spelling the + # same field cannot both reach JSON.generate (json 3.x raises on that; + # 2.x emits the key twice). When a Hash carries both spellings of one + # field the wire spelling wins, whichever comes first; between two keys + # in the same spelling the later one does. + def self.rename(hash, table) + renamed = {} + wire_spelled = {} + hash.each do |key, value| + name = key.to_s + wire = table.fetch(name, name) + if wire == name + wire_spelled[wire] = true + elsif wire_spelled.key?(wire) + next + end + renamed[wire] = value + end + renamed + end + private_class_method :rename + end end diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index f73aa8fc..b44d597c 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks should return a hash. Only top-level keys are auto-converted; use CLI-style camelCase for nested keys. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::SyncHookJSONOutput` / `AsyncHookJSONOutput`, holding a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. ## Structured output diff --git a/sig/claude_agent_sdk/types/hooks.rbs b/sig/claude_agent_sdk/types/hooks.rbs index 3c0e842d..19374e20 100644 --- a/sig/claude_agent_sdk/types/hooks.rbs +++ b/sig/claude_agent_sdk/types/hooks.rbs @@ -47,8 +47,9 @@ module ClaudeAgentSDK | CwdChangedHookSpecificOutput | FileChangedHookSpecificOutput - # What a hook callback returns: a Hash (snake_case or camelCase keys), a - # typed output, or nil (treated as {}). + # What a hook callback returns: a Hash (Symbol or String keys, snake_case + # or camelCase, also inside hook_specific_output), a typed output, or nil + # (treated as {}). type hook_output = Hash[Symbol | String, untyped] | SyncHookJSONOutput | AsyncHookJSONOutput | nil # A hook callback (HookMatcher#hooks entries). tool_use_id is nil for @@ -520,7 +521,9 @@ module ClaudeAgentSDK attr_accessor reason: String? - # A typed output, or the equivalent Hash (camelCase wire keys, sent as-is). + # A typed output, or the equivalent Hash (snake_case or camelCase keys). + # #to_h returns a Hash as written; the SDK puts its keys in wire spelling + # when it sends the output. attr_accessor hook_specific_output: (hook_specific_output | Hash[Symbol | String, untyped])? def to_h: () -> Hash[Symbol, untyped] diff --git a/skills/references/options.md b/skills/references/options.md index f73aa8fc..b44d597c 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks should return a hash. Only top-level keys are auto-converted; use CLI-style camelCase for nested keys. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::SyncHookJSONOutput` / `AsyncHookJSONOutput`, holding a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. ## Structured output diff --git a/spec/integration/real_cli_integration_spec.rb b/spec/integration/real_cli_integration_spec.rb index 67262839..1d153db2 100644 --- a/spec/integration/real_cli_integration_spec.rb +++ b/spec/integration/real_cli_integration_spec.rb @@ -72,6 +72,46 @@ def run_one_shot_query(prompt:, options:) expect(hook_invocations.any? { |invocation| invocation[:tool_use_id] }).to eq(true) end + # A hook may answer with a Hash in Ruby spelling. The CLI reads only + # hookEventName / permissionDecision, so while the SDK forwarded the nested + # keys as written the CLI ignored such an answer and ran the command; the + # marker file is what a dropped deny leaves behind. The unit specs pin the + # frame the SDK writes; only the real CLI can show that it acts on it. + it 'denies a Bash call when a PreToolUse hook answers with a snake_case Hash' do + asked = [] + deny = lambda do |input, _tool_use_id, _context| + asked << input.tool_name + { + hook_specific_output: { + hook_event_name: 'PreToolUse', + permission_decision: 'deny', + permission_decision_reason: 'blocked by the snake_case hook' + } + } + end + + Dir.mktmpdir('cas-hook-deny') do |dir| + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + cwd: dir, model: 'haiku', setting_sources: [], max_turns: 4, max_budget_usd: 0.05, + allowed_tools: ['Bash'], tools: ['Bash'], + hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [deny])] } + ) + messages = [] + ClaudeAgentSDK.query( + prompt: 'Run exactly this one bash command and then stop, without retrying if it is blocked: touch marker.txt', + options: options + ) { |message| messages << message } + + tool_results = messages.grep(ClaudeAgentSDK::UserMessage) + .flat_map { |message| Array(message.content).grep(ClaudeAgentSDK::ToolResultBlock) } + expect(asked).to include('Bash') + expect(tool_results).not_to be_empty + expect(tool_results.first.is_error).to be true + expect(File.exist?(File.join(dir, 'marker.txt'))).to be false + expect(messages.grep(ClaudeAgentSDK::ResultMessage).last.permission_denials).not_to be_empty + end + end + it 'invokes SDK MCP tools for one-shot query() calls through Claude CLI' do executions = [] diff --git a/spec/unit/hook_output_normalization_spec.rb b/spec/unit/hook_output_normalization_spec.rb new file mode 100644 index 00000000..acf55437 --- /dev/null +++ b/spec/unit/hook_output_normalization_spec.rb @@ -0,0 +1,464 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# A hook callback may return a Hash instead of a typed output; the shipped +# signature says "a Hash (snake_case or camelCase keys)". The SDK used to +# rename five top-level keys, only when they were Symbols, and forward a +# nested hook_specific_output Hash untouched. A PreToolUse deny written in +# Ruby spelling therefore reached the CLI under keys it does not read, the +# object was ignored, and the tool ran. +# +# Every example asserts on the control_response frame the SDK writes for a +# hook_callback request, never on the private converter. +RSpec.describe 'hook output normalization' do + # The hook_callback request CLI 2.1.286 writes for a PreToolUse hook + # (recorded from a real session; only the home directory is shortened), as + # the transport hands it to Query: parsed with Symbol keys. The reply is + # built from the callback's return value alone, so the examples for other + # events reuse this envelope with their own event name. + def hook_callback_request(event) + request = JSON.parse(<<~FRAME, symbolize_names: true) + {"type":"control_request","request_id":"a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928","request":{"subtype":"hook_callback","callback_id":"hook_0","input":{"session_id":"097e32d1-723a-42b4-a59b-f816b0d9ac84","transcript_path":"/home/dev/.claude/projects/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84.jsonl","cwd":"/work/app","scratchpad_dir":"/private/tmp/claude-501/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84/scratchpad","prompt_id":"efe95091-ba7c-4f07-8d76-e376ef8f0c92","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf build","description":"Remove the build directory"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"}} + FRAME + request[:request][:input][:hook_event_name] = event + request + end + + # Answers the request with +output+ (what the hook callback returns) and + # gives back the one line the SDK wrote to the CLI. + def reply_line(output, event: 'PreToolUse') + writes = [] + transport = instance_double(ClaudeAgentSDK::Transport) + allow(transport).to receive(:write) { |data| writes << data } + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true) + query.instance_variable_set(:@hook_callbacks, { 'hook_0' => ->(_input, _tool_use_id, _context) { output } }) + + Async { query.send(:handle_control_request, hook_callback_request(event)) }.wait + + expect(writes.size).to eq(1) + writes.first + end + + # The hook output as the CLI reads it: the `response` object of a + # successful control_response. + def reply(output, **) + frame = JSON.parse(reply_line(output, **)) + expect(frame['response']).to include('subtype' => 'success', 'request_id' => 'a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928') + frame['response'].fetch('response') + end + + # What JSON makes of a Ruby value (Symbol keys and values become Strings). + def on_the_wire(value) + JSON.parse(JSON.generate(value)) + end + + describe 'a PreToolUse deny' do + typed = ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( + permission_decision: 'deny', permission_decision_reason: 'rm -rf is not allowed here' + ) + ) + + it 'reaches the CLI in wire spelling when the output is typed' do + expect(JSON.parse(reply_line(typed))).to eq( + 'type' => 'control_response', + 'response' => { + 'subtype' => 'success', + 'request_id' => 'a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928', + 'requestId' => 'a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928', + 'response' => { + 'continue' => true, + 'hookSpecificOutput' => { + 'hookEventName' => 'PreToolUse', + 'permissionDecision' => 'deny', + 'permissionDecisionReason' => 'rm -rf is not allowed here' + } + } + } + ) + end + + { + 'snake_case Symbol keys, nested ones included' => { + continue_: true, + hook_specific_output: { + hook_event_name: 'PreToolUse', permission_decision: 'deny', + permission_decision_reason: 'rm -rf is not allowed here' + } + }, + 'String keys at the top level and camelCase inside' => { + 'continue' => true, + 'hook_specific_output' => { + hookEventName: 'PreToolUse', permissionDecision: 'deny', + permissionDecisionReason: 'rm -rf is not allowed here' + } + }, + 'a typed output that holds a snake_case Hash' => ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: { + hook_event_name: 'PreToolUse', permission_decision: 'deny', + permission_decision_reason: 'rm -rf is not allowed here' + } + ), + 'snake_case String keys throughout' => { + 'continue_' => true, + 'hook_specific_output' => { + 'hook_event_name' => 'PreToolUse', 'permission_decision' => 'deny', + 'permission_decision_reason' => 'rm -rf is not allowed here' + } + }, + 'camelCase Symbol keys (the documented form)' => { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', permissionDecision: 'deny', + permissionDecisionReason: 'rm -rf is not allowed here' + } + }, + 'a Hash that holds a typed hook_specific_output' => { + continue_: true, + hook_specific_output: ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( + permission_decision: 'deny', permission_decision_reason: 'rm -rf is not allowed here' + ) + } + }.each do |form, output| + it "writes the same control_response for #{form}" do + expect(JSON.parse(reply_line(output))).to eq(JSON.parse(reply_line(typed))) + end + end + end + + # Every field of every typed hook output class: attribute => [wire key, + # value]. The wire keys are spelled out here, not derived from the SDK, so + # a renamed key on either side fails the round trip below. + specific_outputs = { + 'SetupHookSpecificOutput' => { + hook_event_name: %w[hookEventName Setup], + additional_context: ['additionalContext', 'Dependencies are installed.'] + }, + 'PreToolUseHookSpecificOutput' => { + hook_event_name: %w[hookEventName PreToolUse], + permission_decision: %w[permissionDecision ask], + permission_decision_reason: ['permissionDecisionReason', 'Touches the lockfile.'], + updated_input: ['updatedInput', { command: 'bundle install --frozen', run_in_background: false }], + additional_context: ['additionalContext', 'Gemfile.lock is under review.'] + }, + 'PostToolUseHookSpecificOutput' => { + hook_event_name: %w[hookEventName PostToolUse], + additional_context: ['additionalContext', 'Secrets were redacted.'], + updated_tool_output: ['updatedToolOutput', { stdout: '[redacted]', stderr: '', exit_code: 0 }], + updated_mcp_tool_output: ['updatedMCPToolOutput', + { content: [{ type: 'text', text: '[redacted]' }], is_error: false }] + }, + 'PostToolUseFailureHookSpecificOutput' => { + hook_event_name: %w[hookEventName PostToolUseFailure], + additional_context: ['additionalContext', 'The registry is down; do not retry.'] + }, + 'UserPromptSubmitHookSpecificOutput' => { + hook_event_name: %w[hookEventName UserPromptSubmit], + additional_context: ['additionalContext', 'The user is on the staging cluster.'] + }, + 'NotificationHookSpecificOutput' => { + hook_event_name: %w[hookEventName Notification], + additional_context: ['additionalContext', 'Forwarded to the on-call channel.'] + }, + 'SubagentStartHookSpecificOutput' => { + hook_event_name: %w[hookEventName SubagentStart], + additional_context: ['additionalContext', 'Only read from the replica.'] + }, + 'PermissionRequestHookSpecificOutput' => { + hook_event_name: %w[hookEventName PermissionRequest], + decision: ['decision', { behavior: 'allow', updatedInput: { command: 'ls -la' } }] + }, + 'SessionStartHookSpecificOutput' => { + hook_event_name: %w[hookEventName SessionStart], + additional_context: ['additionalContext', 'Branch: main, clean tree.'] + }, + 'PermissionDeniedHookSpecificOutput' => { + hook_event_name: %w[hookEventName PermissionDenied], + retry: ['retry', true] + }, + 'CwdChangedHookSpecificOutput' => { + hook_event_name: %w[hookEventName CwdChanged], + watch_paths: ['watchPaths', %w[/work/app/config /work/app/.env]] + }, + 'FileChangedHookSpecificOutput' => { + hook_event_name: %w[hookEventName FileChanged], + watch_paths: ['watchPaths', %w[/work/app/config/routes.rb]] + } + }.freeze + + top_level_outputs = { + 'SyncHookJSONOutput' => { + continue: ['continue', false], + suppress_output: ['suppressOutput', true], + stop_reason: ['stopReason', 'The budget is exhausted.'], + decision: %w[decision block], + system_message: ['systemMessage', 'Stopped by the deploy freeze.'], + reason: ['reason', 'The test suite is failing.'], + hook_specific_output: ['hookSpecificOutput', + { hookEventName: 'PostToolUse', additionalContext: 'Lint failed.' }] + }, + 'AsyncHookJSONOutput' => { + async: ['async', true], + async_timeout: ['asyncTimeout', 5000] + } + }.freeze + + # The spellings a Hash may use for those fields. + spellings = { + 'snake_case Symbol keys' => ->(fields) { fields.transform_values(&:last) }, + 'snake_case String keys' => ->(fields) { fields.to_h { |name, (_wire, value)| [name.to_s, value] } }, + 'camelCase Symbol keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire.to_sym, value] } }, + 'camelCase String keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire, value] } } + }.freeze + attributes_of = spellings.fetch('snake_case Symbol keys') + wire_form_of = spellings.fetch('camelCase String keys') + + describe 'every field of every typed hook output class' do + output_classes = ClaudeAgentSDK.constants.grep(/Hook(?:SpecificOutput|JSONOutput)\z/).map(&:to_s) + rows = specific_outputs.merge(top_level_outputs) + + it 'has a row for every class' do + expect(rows.keys).to match_array(output_classes) + end + + it 'has a field for every attribute' do + expect(rows.transform_values { |fields| fields.keys.map(&:to_s).sort }) + .to eq(rows.to_h { |class_name, _fields| [class_name, ClaudeAgentSDK.const_get(class_name).attribute_names] }) + end + + specific_outputs.each do |class_name, fields| + describe "ClaudeAgentSDK::#{class_name}" do + let(:klass) { ClaudeAgentSDK.const_get(class_name) } + let(:event) { fields.fetch(:hook_event_name).last } + let(:expected) { { 'continue' => true, 'hookSpecificOutput' => on_the_wire(wire_form_of.call(fields)) } } + + # The forms the enclosing output may take around one nested value. + def enclosing(nested) + { + 'typed output' => ClaudeAgentSDK::SyncHookJSONOutput.new(hook_specific_output: nested), + 'snake_case Symbol keys' => { continue_: true, hook_specific_output: nested }, + 'snake_case String keys' => { 'continue_' => true, 'hook_specific_output' => nested }, + 'camelCase Symbol keys' => { continue: true, hookSpecificOutput: nested }, + 'camelCase String keys' => { 'continue' => true, 'hookSpecificOutput' => nested } + } + end + + def replies_to(forms, event) + forms.transform_values { |output| reply(output, event: event) } + end + + it 'is written under its wire keys when typed' do + forms = enclosing(klass.new(attributes_of.call(fields))) + + expect(replies_to(forms, event)).to eq(forms.transform_values { expected }) + end + + spellings.each do |spelling, hash_in| + it "is written the same way from a Hash with #{spelling}" do + forms = enclosing(hash_in.call(fields)) + + expect(replies_to(forms, event)).to eq(forms.transform_values { expected }) + end + end + end + end + + top_level_outputs.each do |class_name, fields| + describe "ClaudeAgentSDK::#{class_name}" do + let(:expected) { on_the_wire(wire_form_of.call(fields)) } + + it 'is written under its wire keys when typed' do + expect(reply(ClaudeAgentSDK.const_get(class_name).new(attributes_of.call(fields)))).to eq(expected) + end + + spellings.each do |spelling, hash_in| + it "is written the same way from a Hash with #{spelling}" do + expect(reply(hash_in.call(fields))).to eq(expected) + end + end + end + end + end + + # The vocabulary is a hand-written table next to the typed classes. Walk + # every class so the two cannot drift: a new attribute, or a renamed wire + # key in a #to_h, fails here until the table follows. + describe 'ClaudeAgentSDK::HookOutputKeys' do + { 'TOP_LEVEL' => [top_level_outputs, %w[async_ continue_]], + 'HOOK_SPECIFIC' => [specific_outputs, []] }.each do |table_name, (rows, ruby_safe_spellings)| + describe table_name do + let(:table) { ClaudeAgentSDK::HookOutputKeys.const_get(table_name) } + + it 'gives every attribute of every typed output class the key its #to_h emits' do + emitted = rows.to_h do |class_name, fields| + [class_name, ClaudeAgentSDK.const_get(class_name).new(attributes_of.call(fields)).to_h.keys.map(&:to_s).sort] + end + mapped = rows.to_h do |class_name, _fields| + names = ClaudeAgentSDK.const_get(class_name).attribute_names + [class_name, names.map { |name| table.fetch(name, name) }.sort] + end + + expect(mapped).to eq(emitted) + end + + it 'lists nothing but those attributes and the Ruby-safe spellings of keywords' do + attributes = rows.keys.flat_map { |class_name| ClaudeAgentSDK.const_get(class_name).attribute_names } + + expect(table.keys - attributes).to match_array(ruby_safe_spellings) + end + end + end + end + + describe 'a key outside the vocabulary' do + it 'is sent as written, at the top level and inside hook_specific_output' do + output = { + hook_specific_output: { + hook_event_name: 'PreToolUse', + permission_decision: 'allow', + sessionTitle: 'Nightly build', + future_field: { inner_key: true }, + 'x-vendor' => 1 + }, + futureField: { some_key: 1 }, + 'future_field' => 'kept' + } + + expect(reply(output)).to eq( + 'hookSpecificOutput' => { + 'hookEventName' => 'PreToolUse', + 'permissionDecision' => 'allow', + 'sessionTitle' => 'Nightly build', + 'future_field' => { 'inner_key' => true }, + 'x-vendor' => 1 + }, + 'futureField' => { 'some_key' => 1 }, + 'future_field' => 'kept' + ) + end + + it 'includes a name the SDK only knows at the other level' do + output = { + permission_decision: 'deny', + hook_specific_output: { hook_event_name: 'PreToolUse', suppress_output: true, stop_reason: 'x' } + } + + expect(reply(output)).to eq( + 'permission_decision' => 'deny', + 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'suppress_output' => true, 'stop_reason' => 'x' } + ) + end + end + + describe 'the value of a field' do + # Tool input and tool output are the tool's own payloads: a key in there + # that happens to be spelled like a hook field is not one. + tool_payload = { + file_path: '/work/app/config.rb', old_string: 'a', new_string: 'b', + hook_event_name: 'kept', 'permission_decision' => 'kept', suppress_output: true, + nested: { additional_context: 'kept', watch_paths: ['kept'] } + }.freeze + + { + 'updated_input' => ['PreToolUse', :updated_input, 'updatedInput'], + 'updatedInput' => ['PreToolUse', :updatedInput, 'updatedInput'], + 'updated_tool_output' => %w[PostToolUse updated_tool_output updatedToolOutput], + 'updatedToolOutput' => %w[PostToolUse updatedToolOutput updatedToolOutput], + 'updated_mcp_tool_output' => ['PostToolUse', :updated_mcp_tool_output, 'updatedMCPToolOutput'], + 'updatedMCPToolOutput' => ['PostToolUse', :updatedMCPToolOutput, 'updatedMCPToolOutput'] + }.each do |spelling, (event, key, wire_key)| + it "is not rewritten under #{spelling}" do + output = { hook_specific_output: { hook_event_name: event, key => tool_payload } } + + expect(reply(output, event: event)).to eq( + 'hookSpecificOutput' => { 'hookEventName' => event, wire_key => on_the_wire(tool_payload) } + ) + end + end + + it 'is not rewritten for the decision of a PermissionRequest output' do + decision = { behavior: 'allow', updated_input: { command: 'ls' }, 'updatedPermissions' => [{ rule_content: 'x' }] } + output = { hook_specific_output: { hook_event_name: 'PermissionRequest', decision: decision } } + + expect(reply(output, event: 'PermissionRequest')).to eq( + 'hookSpecificOutput' => { 'hookEventName' => 'PermissionRequest', 'decision' => on_the_wire(decision) } + ) + end + end + + describe 'a Hash that spells one field both ways' do + allow_then_deny = { hook_event_name: 'PreToolUse', permission_decision: 'allow', permissionDecision: 'deny' } + deny_then_allow = { hookEventName: 'PreToolUse', permissionDecision: 'deny', permission_decision: 'allow' } + denied = { 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'permissionDecision' => 'deny' } } + + it 'sends the camelCase spelling inside hook_specific_output, whichever comes first' do + outputs = [allow_then_deny, deny_then_allow, allow_then_deny.transform_keys(&:to_s)] + + expect(outputs.map { |nested| reply({ hook_specific_output: nested }) }).to eq([denied] * 3) + end + + it 'sends the camelCase spelling at the top level, whichever comes first' do + allowed = { hookEventName: 'PreToolUse', permissionDecision: 'allow' } + wins = { hookEventName: 'PreToolUse', permissionDecision: 'deny' } + outputs = [ + { hook_specific_output: allowed, hookSpecificOutput: wins }, + { hookSpecificOutput: wins, hook_specific_output: allowed }, + { 'hook_specific_output' => allowed, 'hookSpecificOutput' => wins } + ] + + expect(outputs.map { |output| reply(output) }).to eq([denied] * 3) + end + + it 'sends continue rather than continue_, and async rather than async_' do + outputs = [{ continue_: true, continue: false }, { continue: false, continue_: true }, + { async_: false, 'async' => true }, { 'async' => true, async_: false }] + + expect(outputs.map { |output| reply(output) }) + .to eq([{ 'continue' => false }, { 'continue' => false }, { 'async' => true }, { 'async' => true }]) + end + + it 'writes the field once' do + line = reply_line({ hook_specific_output: allow_then_deny, hookSpecificOutput: deny_then_allow }) + + expect(line.scan('"hookSpecificOutput"').size).to eq(1) + expect(line.scan('"permissionDecision"').size).to eq(1) + expect(line).not_to include('hook_specific_output', 'permission_decision') + end + + it 'takes the later one when a Symbol and a String spell the same key' do + nested = { hookEventName: 'PreToolUse', permissionDecision: 'allow', 'permissionDecision' => 'deny' } + line = reply_line({ hookSpecificOutput: nested }) + + expect(JSON.parse(line).dig('response', 'response')).to eq(denied) + expect(line.scan('"permissionDecision"').size).to eq(1) + end + end + + describe 'the Ruby-safe spellings of keywords' do + it 'still stand for continue and async, as a Symbol or a String' do + outputs = [{ continue_: false }, { 'continue_' => false }, { async_: true }, { 'async_' => true }] + + expect(outputs.map { |output| reply(output) }) + .to eq([{ 'continue' => false }, { 'continue' => false }, { 'async' => true }, { 'async' => true }]) + end + end + + describe 'a return value that is not a Hash' do + it 'still answers {} for nil, true and a String' do + expect([nil, true, 'deny'].map { |output| reply(output) }).to eq([{}, {}, {}]) + end + + it 'is normalized like a Hash when it responds to #to_h' do + output = Struct.new(:continue_, :hook_specific_output) + .new(false, { hook_event_name: 'PreToolUse', permission_decision: 'deny' }) + + expect(reply(output)).to eq( + 'continue' => false, + 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'permissionDecision' => 'deny' } + ) + end + end +end From 33a14c4ee34971bda8c6729adbcf772fc3f156d1 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:44:35 +0800 Subject: [PATCH 069/194] Send permission updates in a shape the CLI accepts PermissionUpdate#to_h always wrote ruleContent, as null for a rule that covers a whole tool, and left out a nil destination. The CLI's schema takes a String or no key for ruleContent and requires a destination, and it validates the updatedPermissions array of a can_use_tool reply as one unit: a single entry that does not fit drops every update in the reply, with only a line in the CLI debug log. The tool call is still allowed, so nothing looks wrong, but the documented loop PermissionResultAllow.new(updated_permissions: context.suggestions) never persisted "always allow" for whole-tool rules (the suggestions the CLI itself builds for WebSearch and for MCP tools carry no ruleContent), and the callback was asked again on every call. to_h now omits ruleContent when the rule has no content, and sends an update that has a type but no destination to 'session': it lasts for this run only and writes no settings file (the default the Python SDK's open PR #1330 proposes). An update without a type is unchanged. Nothing is validated, warned about or dropped. User-visible: an update the CLI used to discard now takes effect, and an update built without a destination is applied for the session. Exploration finding: E-P3 / U-P3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- lib/claude_agent_sdk/types/permissions.rb | 27 ++-- sig/claude_agent_sdk/types/permissions.rbs | 6 +- spec/unit/permission_update_wire_spec.rb | 141 +++++++++++++++++++++ 3 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 spec/unit/permission_update_wire_spec.rb diff --git a/lib/claude_agent_sdk/types/permissions.rb b/lib/claude_agent_sdk/types/permissions.rb index c1420a00..50d35bf0 100644 --- a/lib/claude_agent_sdk/types/permissions.rb +++ b/lib/claude_agent_sdk/types/permissions.rb @@ -34,20 +34,21 @@ def rules=(value) @rules = value&.map { |rule| rule.is_a?(Hash) ? PermissionRuleValue.new(rule) : rule } end + # The wire form. The CLI validates the updatedPermissions of a + # can_use_tool reply as one unit and drops the whole array when a single + # entry does not fit its schema, so two things it rejects are never + # written: a rule without content has no ruleContent key (the schema + # wants a String or no key, not null), and an update that has a type but + # no destination goes to 'session' — the narrowest one: it lasts for this + # run and writes no settings file (the default Python PR #1330 proposes). def to_h result = { type: @type } - result[:destination] = @destination if @destination + destination = @destination || ('session' unless @type.nil?) + result[:destination] = destination if destination case @type when 'addRules', 'replaceRules', 'removeRules' - if @rules - result[:rules] = @rules.map do |rule| - { - toolName: rule.tool_name, - ruleContent: rule.rule_content - } - end - end + result[:rules] = @rules.map { |rule| rule_to_h(rule) } if @rules result[:behavior] = @behavior if @behavior when 'setMode' result[:mode] = @mode if @mode @@ -57,6 +58,14 @@ def to_h result end + + private + + def rule_to_h(rule) + wire_rule = { toolName: rule.tool_name } + wire_rule[:ruleContent] = rule.rule_content unless rule.rule_content.nil? + wire_rule + end end # Tool permission context delivered to `can_use_tool` callbacks. diff --git a/sig/claude_agent_sdk/types/permissions.rbs b/sig/claude_agent_sdk/types/permissions.rbs index af97344e..dcef2ed0 100644 --- a/sig/claude_agent_sdk/types/permissions.rbs +++ b/sig/claude_agent_sdk/types/permissions.rbs @@ -41,7 +41,8 @@ module ClaudeAgentSDK attr_accessor directories: Array[String]? - # One of PERMISSION_UPDATE_DESTINATIONS. + # One of PERMISSION_UPDATE_DESTINATIONS. When nil, #to_h sends "session" + # (unless type is nil too). attr_accessor destination: String? attr_reader rules: Array[PermissionRuleValue]? @@ -49,7 +50,8 @@ module ClaudeAgentSDK # Hash entries (camelCase or snake_case keys) become PermissionRuleValue. def rules=: (Array[PermissionRuleValue | Hash[Symbol | String, untyped]]? value) -> Array[PermissionRuleValue | Hash[Symbol | String, untyped]]? - # The wire form (camelCase keys). + # The wire form (camelCase keys). A rule without content has no + # ruleContent key. def to_h: () -> Hash[Symbol, untyped] end diff --git a/spec/unit/permission_update_wire_spec.rb b/spec/unit/permission_update_wire_spec.rb new file mode 100644 index 00000000..2bbdbba6 --- /dev/null +++ b/spec/unit/permission_update_wire_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# The CLI validates the updatedPermissions array of a can_use_tool reply as +# one unit. CLI 2.1.286: a rule is { toolName: string, ruleContent?: string } +# (absent, never null) and every update needs a destination. One entry that +# does not fit and the whole array is dropped, with nothing but a line in the +# CLI's debug log — the tool call is still allowed, the "always allow" never +# sticks, and the callback is asked again on every call. +RSpec.describe ClaudeAgentSDK::PermissionUpdate do + # The suggestion the CLI builds for a tool it allows as a whole (WebSearch, + # every MCP tool): a rule without ruleContent. + whole_tool_suggestion = { + type: 'addRules', + rules: [{ toolName: 'WebSearch' }], + behavior: 'allow', + destination: 'localSettings' + }.freeze + + describe '#to_h' do + it 'leaves ruleContent out of a rule that has no content' do + update = described_class.new( + type: 'addRules', behavior: 'allow', destination: 'session', + rules: [ClaudeAgentSDK::PermissionRuleValue.new(tool_name: 'WebSearch')] + ) + + expect(update.to_h).to eq( + type: 'addRules', destination: 'session', rules: [{ toolName: 'WebSearch' }], behavior: 'allow' + ) + end + + it 'keeps ruleContent for a rule that has content, an empty String included' do + update = described_class.new( + type: 'replaceRules', behavior: 'deny', destination: 'projectSettings', + rules: [{ tool_name: 'Bash', rule_content: 'git push:*' }, { tool_name: 'Read', rule_content: '' }, + { tool_name: 'mcp__docs__search' }] + ) + + expect(update.to_h[:rules]).to eq( + [{ toolName: 'Bash', ruleContent: 'git push:*' }, { toolName: 'Read', ruleContent: '' }, + { toolName: 'mcp__docs__search' }] + ) + end + + it 'round-trips the suggestion the CLI sends for a whole-tool rule' do + hydrated = described_class.from_hash(whole_tool_suggestion) + + expect(hydrated.rules.first.rule_content).to be_nil + expect(hydrated.to_h).to eq(whole_tool_suggestion) + expect(JSON.generate(hydrated.to_h)).not_to include('ruleContent', 'null') + end + + { + 'addRules' => { behavior: 'allow', rules: [{ tool_name: 'Bash', rule_content: 'ls' }] }, + 'replaceRules' => { behavior: 'ask', rules: [{ tool_name: 'Write' }] }, + 'removeRules' => { behavior: 'deny', rules: [{ tool_name: 'WebFetch' }] }, + 'setMode' => { mode: 'acceptEdits' }, + 'addDirectories' => { directories: ['/work/shared'] }, + 'removeDirectories' => { directories: ['/work/shared'] } + }.each do |type, fields| + it "sends #{type} without a destination to the session" do + update = described_class.new(type: type, **fields) + + expect(update.destination).to be_nil + expect(update.to_h[:destination]).to eq('session') + end + end + + it 'keeps the destination the update names' do + sent = ClaudeAgentSDK::PERMISSION_UPDATE_DESTINATIONS.map do |destination| + described_class.new(type: 'setMode', mode: 'plan', destination: destination).to_h[:destination] + end + + expect(sent).to eq(ClaudeAgentSDK::PERMISSION_UPDATE_DESTINATIONS) + end + + it 'leaves an update without a type as it was' do + expect(described_class.new.to_h).to eq(type: nil) + expect(described_class.new(destination: 'userSettings').to_h).to eq(type: nil, destination: 'userSettings') + end + end + + # The documented loop: hand context.suggestions back as updated_permissions. + describe 'echoed back through can_use_tool' do + # A can_use_tool request as CLI 2.1.286 writes it (recorded; the temporary + # directory is shortened), as the transport hands it to Query. + def can_use_tool_request(suggestions) + request = JSON.parse(<<~FRAME, symbolize_names: true) + {"type":"control_request","request_id":"59125456-7f10-41d7-8f57-31700867e194","request":{"subtype":"can_use_tool","tool_name":"Bash","display_name":"Bash","input":{"command":"touch lane-e-probe.txt","description":"Create empty file lane-e-probe.txt"},"description":"Create empty file lane-e-probe.txt","permission_suggestions":[{"type":"addRules","rules":[{"toolName":"Bash","ruleContent":"touch lane-e-probe.txt"}],"behavior":"allow","destination":"localSettings"},{"type":"addDirectories","directories":["/private/var/folders/3y/T/lane-e-20261002"],"destination":"session"},{"type":"setMode","mode":"acceptEdits","destination":"session"}],"blocked_path":"/private/var/folders/3y/T/lane-e-20261002/lane-e-probe.txt","tool_use_id":"toolu_01NzfL6jnh8N6VM4uxq7ZjMX"}} + FRAME + request[:request][:permission_suggestions] = suggestions if suggestions + request + end + + # Answers the request by allowing the call with every suggestion, and + # gives back the one line the SDK wrote to the CLI. + def reply_line(suggestions = nil) + writes = [] + transport = instance_double(ClaudeAgentSDK::Transport) + allow(transport).to receive(:write) { |data| writes << data } + always_allow = lambda do |_tool_name, _input, context| + ClaudeAgentSDK::PermissionResultAllow.new(updated_permissions: context.suggestions) + end + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true, can_use_tool: always_allow) + + Async { query.send(:handle_control_request, can_use_tool_request(suggestions)) }.wait + + expect(writes.size).to eq(1) + writes.first + end + + def updated_permissions(line) + JSON.parse(line, symbolize_names: true).dig(:response, :response, :updatedPermissions) + end + + it 'returns the suggestions of a recorded request unchanged' do + sent = can_use_tool_request(nil).dig(:request, :permission_suggestions) + + expect(sent.map { |suggestion| suggestion[:type] }).to eq(%w[addRules addDirectories setMode]) + expect(updated_permissions(reply_line)).to eq(sent) + end + + it 'returns a whole-tool rule without a null ruleContent' do + line = reply_line([whole_tool_suggestion]) + + expect(updated_permissions(line)).to eq([whole_tool_suggestion]) + expect(line).not_to include('ruleContent', 'null') + end + + it 'writes no null into an array that mixes rules with and without content' do + suggestions = can_use_tool_request(nil).dig(:request, :permission_suggestions) + [whole_tool_suggestion] + line = reply_line(suggestions) + + expect(updated_permissions(line)).to eq(suggestions) + expect(line).not_to include('null') + end + end +end From a149cd3e53a5aa2bff711276732f4d3ff0242a60 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:48:09 +0800 Subject: [PATCH 070/194] Accept agents given as Hashes ClaudeAgentOptions#agents is signed to take an AgentDefinition or a Hash for each agent, but Query#initialize_protocol called readers on every value, so ClaudeAgentOptions.new(agents: { reviewer: { description: '...', prompt: '...' } }) was accepted at construction (and by a type checker) and then raised "NoMethodError: undefined method 'description' for an instance of Hash" out of connect. A Hash value is now converted with AgentDefinition.new before it is serialized, so it means what the typed definition with the same attributes means: Symbol or String keys, snake_case or camelCase. It is a strict type, so a misspelled key raises the usual "unknown attribute" ArgumentError naming AgentDefinition, before anything is written to the CLI. The signature is unchanged. Exploration finding: C-P5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- lib/claude_agent_sdk/query.rb | 4 + spec/unit/agents_hash_option_spec.rb | 157 +++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 spec/unit/agents_hash_option_spec.rb diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 3dbdb914..c60b5cc6 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -287,6 +287,10 @@ def initialize_protocol # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCom agents_dict = nil if @agents agents_dict = @agents.transform_values do |agent_def| + # A Hash stands for the AgentDefinition with the same attributes + # (the options signature allows either). Built through .new: the + # user wrote it, so a misspelled key raises as on the typed class. + agent_def = AgentDefinition.new(agent_def) if agent_def.is_a?(Hash) { description: agent_def.description, prompt: agent_def.prompt, diff --git a/spec/unit/agents_hash_option_spec.rb b/spec/unit/agents_hash_option_spec.rb new file mode 100644 index 00000000..0d585393 --- /dev/null +++ b/spec/unit/agents_hash_option_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# ClaudeAgentOptions#agents is signed +# Hash[String | Symbol, AgentDefinition | Hash[Symbol | String, untyped]] +# but the initialize request was built by calling readers on every value, so +# an agent given as a Hash raised NoMethodError out of connect. +# +# End to end through the real control protocol (no stubbing of Query): a fake +# Transport records each control request as the CLI's JSON parser sees it and +# answers it the way the CLI does. +RSpec.describe 'agents given as Hashes' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, requests:) + super() + @requests = requests + @queue = Thread::Queue.new + end + + def connect = @ready = true + def ready? = @ready + def end_input; end + def close = @queue.push(:eof) + + def write(data) + message = JSON.parse(data, symbolize_names: true) + return unless message[:type] == 'control_request' + + @requests << JSON.parse(data).fetch('request') + response = { subtype: 'success', request_id: message[:request_id], response: {} } + @queue.push({ type: 'control_response', response: response }) + end + + def read_messages + loop do + message = @queue.pop + break if message == :eof + + yield message + end + end + end + end + + let(:requests) { [] } + + def client_with(agents) + ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(agents: agents), + transport_class: transport_class, transport_args: { requests: requests } + ) + end + + # The `agents` object of the initialize request a session with these + # agents sends. + def agents_on_the_wire(agents) + requests.clear + Sync do + client = client_with(agents) + client.connect + ensure + client&.disconnect + end + requests.find { |request| request['subtype'] == 'initialize' }.fetch('agents') + end + + # What connecting with these agents raises, caught inside the reactor. + def connect_error(agents) + Sync do + client = client_with(agents) + client.connect + nil + rescue StandardError => e + e + ensure + client&.disconnect + end + end + + # Every attribute of AgentDefinition, with an inline MCP server config + # next to a server name. + attributes = { + description: 'Reviews pull requests', + prompt: 'You review code. Be specific.', + tools: %w[Read Grep], + disallowed_tools: %w[Bash], + model: 'sonnet', + skills: %w[review], + memory: 'project', + mcp_servers: ['docs', { 'tracker' => { type: 'http', url: 'https://example.com/mcp' } }], + initial_prompt: 'Start with the diff.', + max_turns: 4, + background: true, + effort: 'high', + permission_mode: 'plan' + }.freeze + + wire_agent = { + 'description' => 'Reviews pull requests', + 'prompt' => 'You review code. Be specific.', + 'tools' => %w[Read Grep], + 'disallowedTools' => %w[Bash], + 'model' => 'sonnet', + 'skills' => %w[review], + 'memory' => 'project', + 'mcpServers' => ['docs', { 'tracker' => { 'type' => 'http', 'url' => 'https://example.com/mcp' } }], + 'initialPrompt' => 'Start with the diff.', + 'maxTurns' => 4, + 'background' => true, + 'effort' => 'high', + 'permissionMode' => 'plan' + }.freeze + + it 'covers every attribute of AgentDefinition' do + expect(attributes.keys.map(&:to_s)).to match_array(ClaudeAgentSDK::AgentDefinition.attribute_names) + end + + it 'sends a typed AgentDefinition under its wire keys' do + typed = ClaudeAgentSDK::AgentDefinition.new(attributes) + + expect(agents_on_the_wire('reviewer' => typed)).to eq('reviewer' => wire_agent) + end + + { + 'Symbol keys' => attributes, + 'String keys' => attributes.transform_keys(&:to_s), + 'camelCase String keys' => wire_agent + }.each do |spelling, hash| + it "sends a Hash with #{spelling} like the AgentDefinition it stands for" do + expect(agents_on_the_wire('reviewer' => hash)).to eq('reviewer' => wire_agent) + end + end + + it 'takes Hash and typed agents side by side, under Symbol or String names' do + agents = { + reviewer: { description: 'Reviews pull requests', prompt: 'You review code.' }, + 'writer' => ClaudeAgentSDK::AgentDefinition.new(description: 'Writes docs', prompt: 'You write docs.') + } + + expect(agents_on_the_wire(agents)).to eq( + 'reviewer' => { 'description' => 'Reviews pull requests', 'prompt' => 'You review code.' }, + 'writer' => { 'description' => 'Writes docs', 'prompt' => 'You write docs.' } + ) + end + + it 'raises the strict-attribute error for a misspelled key, before anything is written' do + error = connect_error('reviewer' => { description: 'Reviews pull requests', promt: 'You review code.' }) + + expect(error).to be_a(ArgumentError) + expect(error.message).to start_with('ClaudeAgentSDK::AgentDefinition: unknown attribute :promt (known: ') + expect(requests).to be_empty + end +end From d04a3b1a6c2022005a279e11599e4e42ba5de0ea Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:58:05 +0800 Subject: [PATCH 071/194] Read git's worktree list as UTF-8 whatever the locale Under a non-UTF-8 locale (LANG=C / LC_ALL=C) the output of `git worktree list --porcelain` was read as US-ASCII. One worktree with a non-ASCII path made String#strip raise on its line, and detect_worktrees then fell back to the directory alone: the sessions of every other worktree were missing from list_sessions(directory:) and not found by get_session_info / get_session_messages or the mutations through the repository. The pipe is read as bytes and tagged UTF-8 before the paths are parsed (and NFC-normalized, as before). Exploration finding: U-P19 (worktree paths under LC_ALL=C) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 27 ++++++++++++++------- spec/unit/session_worktree_listing_spec.rb | 28 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index bbfe5dd7..ed22f276 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1603,6 +1603,7 @@ def dedup_rank(session) def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded git subprocess: drained pipes, deadline kill stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain') stdin.close + stdout.binmode # bytes: no transcoding to Encoding.default_internal; tagged in worktree_paths # Drain stdout/stderr concurrently — without this, a repo with enough # worktrees to overrun the 64 KB pipe buffer causes git to block on @@ -1632,13 +1633,7 @@ def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/Cyclomatic return [path] unless wait_thr.value.success? - # NFC, like every path the SDK derives a project dir name from - # (canonicalize_path; Python normalizes here too): git prints a path as - # the filesystem stores it, and a decomposed name sanitizes to a - # different project dir than the one the CLI created. - paths = stdout_buf.lines.filter_map do |line| - line.strip.delete_prefix('worktree ').unicode_normalize(:nfc) if line.start_with?('worktree ') - end + paths = worktree_paths(stdout_buf) paths.empty? ? [path] : paths rescue StandardError [path] @@ -1648,6 +1643,22 @@ def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/Cyclomatic [stdout, stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier end + # The paths in the output of `git worktree list --porcelain`. + # + # The output was read as bytes and is UTF-8 here, whatever the locale: + # under LANG=C the pipe yielded US-ASCII Strings, the first non-ASCII + # worktree path made String#strip raise, and detect_worktrees' rescue + # then dropped EVERY worktree. Each path is NFC-normalized, like every + # path the SDK derives a project dir name from (canonicalize_path; Python + # normalizes here too): git prints a path as the filesystem stores it, and + # a decomposed name sanitizes to a different project dir than the one the + # CLI created. + def worktree_paths(porcelain) + porcelain.force_encoding(Encoding::UTF_8).lines.filter_map do |line| + line.strip.delete_prefix('worktree ').unicode_normalize(:nfc) if line.start_with?('worktree ') + end + end + def find_session_file(session_id, directory) projects_dir = File.join(config_dir, 'projects') return nil unless File.directory?(projects_dir) @@ -1976,7 +1987,7 @@ def filter_visible_messages(chain) private_class_method :project_dir_records_cwd?, :get_session_info_for_directory, :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, - :find_session_file, :stat_candidate, :resolve_subagents_dir, + :worktree_paths, :find_session_file, :stat_candidate, :resolve_subagents_dir, :collect_agent_files, :parse_jsonl_entries, :utf8_transcript_text, :build_conversation_chain, :walk_to_leaf, :walk_to_root, :pick_leaf, :visible_ancestor?, :off_main_conversation?, :visible_message?, diff --git a/spec/unit/session_worktree_listing_spec.rb b/spec/unit/session_worktree_listing_spec.rb index 0d75c9e4..56efbba2 100644 --- a/spec/unit/session_worktree_listing_spec.rb +++ b/spec/unit/session_worktree_listing_spec.rb @@ -140,4 +140,32 @@ def with_git_printing(listing) expect(summaries(root)).to contain_exactly('asked in the repository root', 'asked in the accented worktree') end end + + # LANG=C / LC_ALL=C (minimal Docker images, cron): a pipe read without an + # explicit encoding yields US-ASCII Strings, and git prints the UTF-8 bytes + # of the path. + it 'finds the sessions of a worktree with a non-ASCII path under a non-UTF-8 locale' do + worktree = File.join(cwd, 'プロジェクト-wt') + record_session(root, 'asked in the repository root') + record_session(worktree, 'asked in the Japanese worktree') + listing = "worktree #{root}\nHEAD 1111111\nbranch refs/heads/main\n\nworktree #{worktree}\nHEAD 2222222\ndetached\n" + saved = Encoding.default_external + + listed = with_git_printing(listing) do + silence_warnings { Encoding.default_external = Encoding::US_ASCII } + summaries(root) + ensure + silence_warnings { Encoding.default_external = saved } + end + + expect(listed).to contain_exactly('asked in the repository root', 'asked in the Japanese worktree') + end + + def silence_warnings + verbose = $VERBOSE + $VERBOSE = nil # assigning Encoding.default_external warns under -w + yield + ensure + $VERBOSE = verbose + end end From 0da5c14609a6b3c620b7633ab825149b6cba5765 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:49:53 +0800 Subject: [PATCH 072/194] Build the command when list options were set back to nil allowed_tools, disallowed_tools, add_dirs and extra_args are filled with their defaults by the constructor, and their readers are signed nilable ("nil only when set to nil afterwards"). Clearing one the natural way, options.dup_with(allowed_tools: nil) # or options.extra_args = nil kept the nil, and CommandBuilder then called a method on it: connect failed with "NoMethodError: undefined method 'empty?' for nil" (or 'each', or 'include?' with skills set), naming no option. CommandBuilder now reads nil as the constructor's default at each of its four use sites. The readers still return nil and nothing is written back to the options: only the command builder's view changes. Exploration finding: C-P6 (the command builder's share) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- lib/claude_agent_sdk/command_builder.rb | 13 ++-- spec/unit/command_builder_nil_options_spec.rb | 61 +++++++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 spec/unit/command_builder_nil_options_spec.rb diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index d0734c08..f76dc560 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -129,8 +129,12 @@ def append_allowed_tools(cmd, allowed_tools) # Each listed name is validated before being formatted into a rule (see # #validate_skill_name). Both SDKs reject non-list, non-'all' shapes # loudly; this raises ArgumentError where Python raises TypeError. + # + # allowed_tools, like disallowed_tools, add_dirs and extra_args below, is + # nil when it was set to nil after construction (the readers are signed + # nilable); each use site reads nil as the constructor's default. def skills_defaults - allowed_tools = @options.allowed_tools.dup + allowed_tools = (@options.allowed_tools || []).dup setting_sources = @options.setting_sources&.dup skills = @options.skills return [allowed_tools, setting_sources] if skills.nil? @@ -210,7 +214,8 @@ def validate_skill_name(name) # rubocop:disable Metrics/AbcSize, Metrics/Cycloma end def append_disallowed_tools(cmd) - cmd.push('--disallowedTools', @options.disallowed_tools.join(',')) unless @options.disallowed_tools.empty? + disallowed_tools = @options.disallowed_tools || [] + cmd.push('--disallowedTools', disallowed_tools.join(',')) unless disallowed_tools.empty? end def append_max_turns(cmd) @@ -439,7 +444,7 @@ def append_output_format(cmd) end def append_additional_dirs(cmd) - @options.add_dirs.each { |dir| cmd.push('--add-dir', dir.to_s) } + (@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) } end def append_mcp_servers(cmd) @@ -503,7 +508,7 @@ def append_setting_sources(cmd, setting_sources) end def append_extra_args(cmd) - @options.extra_args.each do |flag, value| + (@options.extra_args || {}).each do |flag, value| unless EXTRA_ARG_FLAG_REGEXP.match?(flag) raise ArgumentError, "Invalid extra_args flag name: #{flag.inspect} (expected lowercase kebab-case)" end diff --git a/spec/unit/command_builder_nil_options_spec.rb b/spec/unit/command_builder_nil_options_spec.rb new file mode 100644 index 00000000..8390291b --- /dev/null +++ b/spec/unit/command_builder_nil_options_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# allowed_tools, disallowed_tools, add_dirs and extra_args are filled with +# their defaults by the constructor, and their signatures are nilable: "nil +# only when set to nil afterwards". `options.dup_with(allowed_tools: nil)` or +# `options.extra_args = nil` — the natural way to clear one — kept the nil, +# and the builder then called a method on it. +RSpec.describe ClaudeAgentSDK::CommandBuilder do + def argv(options) + described_class.new('/usr/bin/claude', options).build + end + + populated = { + allowed_tools: ['Read'], + disallowed_tools: ['Bash'], + add_dirs: ['/work/shared'], + extra_args: { 'debug-to-stderr' => nil } + }.freeze + + # What each option contributes to the command when it is set. + contributions = { + allowed_tools: ['--allowedTools', 'Read'], + disallowed_tools: ['--disallowedTools', 'Bash'], + add_dirs: ['--add-dir', '/work/shared'], + extra_args: ['--debug-to-stderr'] + }.freeze + + { + '#dup_with' => ->(options, name) { options.dup_with(name => nil) }, + 'its writer' => ->(options, name) { options.tap { |opts| opts.public_send(:"#{name}=", nil) } } + }.each do |way, clear| + populated.each_key do |name| + it "builds the command without #{name} once it is set to nil through #{way}" do + options = clear.call(ClaudeAgentSDK::ClaudeAgentOptions.new(**populated), name) + expected = argv(ClaudeAgentSDK::ClaudeAgentOptions.new(**populated.except(name))) + + expect(options.public_send(name)).to be_nil + expect(argv(options)).to eq(expected) + expect(expected).not_to include(*contributions.fetch(name)) + expect(expected).to include(*contributions.except(name).values.flatten) + end + end + end + + it 'builds the default command when all four are nil, and leaves them nil' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + populated.each_key { |name| options.public_send(:"#{name}=", nil) } + + expect(argv(options)).to eq(argv(ClaudeAgentSDK::ClaudeAgentOptions.new)) + expect(populated.keys.map { |name| options.public_send(name) }).to all(be_nil) + end + + it 'still adds the Skill rule that skills implies when allowed_tools is nil' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(skills: %w[pdf]).dup_with(allowed_tools: nil) + cmd = argv(options) + + expect(cmd[cmd.index('--allowedTools') + 1]).to eq('Skill(pdf)') + end +end From e98d7cf859e0733c962e18b4c7451525dc3dc61e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:52:26 +0800 Subject: [PATCH 073/194] Write one sandbox key when settings already carry a sandbox section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox: option is folded into the --settings value so it can override a sandbox section the settings already have ("sandbox: false" against settings that enable it). Settings read from JSON — a JSON String, a settings file, a Hash from JSON.parse — spell that section with a String key, and the option was stored under a Symbol key next to it, so the generated JSON carried "sandbox" twice. json 2.x emits both keys, and the override worked only because the CLI's parser keeps the last duplicate. json 3.x (what a fresh bundle resolves) raises JSON::GeneratorError "detected duplicate key", and since the command is built outside connect's error handling the caller got that raw error instead of a ClaudeSDKError: the documented override crashed connect. Any existing sandbox entry, under either key type, is now dropped from the settings before the option's value is assigned. The caller's Hash is not modified, and settings without a sandbox: option are sent as before. Exploration finding: D-P4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- lib/claude_agent_sdk/command_builder.rb | 4 + .../command_builder_settings_sandbox_spec.rb | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 spec/unit/command_builder_settings_sandbox_spec.rb diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index f76dc560..c0882c6c 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -323,6 +323,10 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo end if !settings_is_path && !@options.sandbox.nil? + # The option replaces any sandbox section the settings carry. Settings + # read from JSON spell that key as a String; left next to the Symbol + # key below it would be written twice (json 3.x raises on that). + settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' } settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox end diff --git a/spec/unit/command_builder_settings_sandbox_spec.rb b/spec/unit/command_builder_settings_sandbox_spec.rb new file mode 100644 index 00000000..28a09041 --- /dev/null +++ b/spec/unit/command_builder_settings_sandbox_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' + +# The `sandbox:` option is folded into the --settings value so that it can +# override a sandbox section the settings already carry (`sandbox: false` +# against settings that enable it). Settings read from JSON (a JSON String, a +# settings file, a Hash from JSON.parse) spell that section with a String +# key; the option was stored under a Symbol key next to it, so the generated +# JSON had the key twice. json 2.x emits both, and the override only works as +# long as the CLI's parser keeps the last one; json 3.x raises +# JSON::GeneratorError out of connect. The examples count the key in the +# argument text, so they fail on either json major. +RSpec.describe ClaudeAgentSDK::CommandBuilder do + settings_document = { + 'permissions' => { 'deny' => ['WebFetch'] }, + 'sandbox' => { 'enabled' => true, 'network' => { 'allowedDomains' => ['example.com'] } } + }.freeze + + # The forms `settings:` may take, each yielding one that carries the + # document above. + settings_sources = { + 'a JSON String' => ->(&use) { use.call(JSON.generate(settings_document)) }, + 'a String-keyed Hash' => ->(&use) { use.call(JSON.parse(JSON.generate(settings_document))) }, + 'a Symbol-keyed Hash' => ->(&use) { use.call(JSON.parse(JSON.generate(settings_document), symbolize_names: true)) }, + 'a Hash with the section under both key types' => lambda { |&use| + use.call(JSON.parse(JSON.generate(settings_document)).merge(sandbox: { enabled: true })) + }, + 'the path of a settings file' => lambda { |&use| + Dir.mktmpdir('settings-sandbox') do |dir| + path = File.join(dir, 'settings.json') + File.write(path, JSON.generate(settings_document)) + use.call(path) + end + } + }.freeze + + # A value of the `sandbox:` option, and the section the CLI must receive. + sandbox_options = { + 'false' => [false, false], + 'a Hash' => [{ enabled: false }, { 'enabled' => false }], + 'a SandboxSettings' => [ + ClaudeAgentSDK::SandboxSettings.new(enabled: true, excluded_commands: ['git']), + { 'enabled' => true, 'excludedCommands' => ['git'] } + ] + }.freeze + + # The --settings argument built for these options. + def settings_argument(**options) + cmd = described_class.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + cmd[cmd.index('--settings') + 1] + end + + settings_sources.each do |source, with_settings| + sandbox_options.each do |option, (sandbox, section)| + it "writes the sandbox key once for settings given as #{source} and sandbox: #{option}" do + with_settings.call do |settings| + argument = settings_argument(settings: settings, sandbox: sandbox) + + expect(argument.scan('"sandbox"').size).to eq(1) + expect(JSON.parse(argument)).to eq('permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => section) + end + end + end + end + + it 'leaves the settings Hash the caller passed untouched' do + settings = JSON.parse(JSON.generate(settings_document)) + + settings_argument(settings: settings, sandbox: false) + + expect(settings).to eq(settings_document) + end + + it 'keeps the sandbox section of the settings when the sandbox option is not set' do + argument = settings_argument(settings: JSON.parse(JSON.generate(settings_document))) + + expect(JSON.parse(argument)).to eq(settings_document) + end +end From ef430e2993702183067161d0ccfd12d2ec53f7fd Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:54:53 +0800 Subject: [PATCH 074/194] Merge hooks registered under both key types of one event ClaudeAgentOptions#hooks is keyed by event name, as a String or as a Symbol. The conversion to the control protocol's format stringified each key and assigned its matcher list, so when one event appeared under both key types, hooks: { 'PreToolUse' => [bash_guard], PreToolUse: [write_guard] } the second list silently replaced the first: bash_guard was never registered with the CLI and never ran, with no error anywhere. The matcher lists of the two keys are now joined, in the order they were written. An empty or nil list under one key still registers nothing. Exploration finding: C-P3 (the merge half) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- lib/claude_agent_sdk.rb | 7 +- spec/unit/hooks_event_key_merge_spec.rb | 154 ++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 spec/unit/hooks_event_key_merge_spec.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 1eed0e3a..1722a3e2 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -79,7 +79,10 @@ def self.extract_sdk_mcp_servers(mcp_servers) end # Internal: normalize hook lists for the control protocol. An absent or - # disabled event must not become an empty registration in initialize. + # disabled event must not become an empty registration in initialize. An + # event written as a String under one key and as a Symbol under another + # ('PreToolUse' and :PreToolUse) is one event: its matcher lists are + # joined in the order they were written, never replaced. # @api private def self.convert_hooks_to_internal_format(hooks) return nil unless hooks @@ -94,7 +97,7 @@ def self.convert_hooks_to_internal_format(hooks) config[:timeout] = matcher.timeout if matcher.timeout entries << config end - internal_hooks[event.to_s] = entries unless entries.empty? + (internal_hooks[event.to_s] ||= []).concat(entries) unless entries.empty? end internal_hooks.empty? ? nil : internal_hooks end diff --git a/spec/unit/hooks_event_key_merge_spec.rb b/spec/unit/hooks_event_key_merge_spec.rb new file mode 100644 index 00000000..5227e984 --- /dev/null +++ b/spec/unit/hooks_event_key_merge_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# ClaudeAgentOptions#hooks is keyed by event name, written as a String or as +# a Symbol. When one event appeared under both key types (two parts of an +# app each adding their own guard, say) the second list silently replaced the +# first, so one of the hooks was never registered and never ran. +# +# End to end through the real control protocol: a fake Transport answers the +# initialize request the way the CLI does, delivers the frames an example +# hands it, and reports every frame the SDK writes. +RSpec.describe 'hooks registered under a String and a Symbol key for one event' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, inbound:, written:) + super() + @inbound = inbound + @written = written + end + + def connect = @ready = true + def ready? = @ready + def end_input; end + def close = @inbound.push(:eof) + + def write(data) + frame = JSON.parse(data, symbolize_names: true) + if frame.dig(:request, :subtype) == 'initialize' + response = { subtype: 'success', request_id: frame[:request_id], response: {} } + @inbound.push({ type: 'control_response', response: response }) + end + @written.push(frame) + end + + def read_messages + loop do + message = @inbound.pop + break if message == :eof + + yield message + end + end + end + end + + let(:inbound) { Thread::Queue.new } + let(:written) { Thread::Queue.new } + let(:called) { [] } + + # A matcher whose one hook records that it ran. + def matcher(name, tool, **) + hook = lambda do |_input, _tool_use_id, _context| + called << name + {} + end + ClaudeAgentSDK::HookMatcher.new(matcher: tool, hooks: [hook], **) + end + + # Connects a session with these hooks and runs the block inside it. + def session(hooks) + Sync do + client = ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(hooks: hooks), + transport_class: transport_class, transport_args: { inbound: inbound, written: written } + ) + client.connect + yield + ensure + client&.disconnect + end + end + + # The next frame the SDK wrote (nil if none arrives: a bounded wait, so a + # regression fails the example instead of hanging the suite). + def next_written + written.pop(timeout: 5) + end + + # The hooks object of the initialize request. + def registered_hooks + next_written.dig(:request, :hooks) + end + + # The hook_callback request CLI 2.1.286 writes for a PreToolUse hook + # (recorded; only the home directory is shortened), for one callback id. + def hook_callback(callback_id) + request = JSON.parse(<<~FRAME, symbolize_names: true) + {"type":"control_request","request_id":"a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928","request":{"subtype":"hook_callback","callback_id":"hook_0","input":{"session_id":"097e32d1-723a-42b4-a59b-f816b0d9ac84","transcript_path":"/home/dev/.claude/projects/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84.jsonl","cwd":"/work/app","scratchpad_dir":"/private/tmp/claude-501/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84/scratchpad","prompt_id":"efe95091-ba7c-4f07-8d76-e376ef8f0c92","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf build","description":"Remove the build directory"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"}} + FRAME + request[:request_id] = "request-for-#{callback_id}" + request[:request][:callback_id] = callback_id + request + end + + it 'registers the matchers of both keys, in the order they were written' do + hooks = { 'PreToolUse' => [matcher('bash_guard', 'Bash')], PreToolUse: [matcher('write_guard', 'Write|Edit')] } + + session(hooks) do + expect(registered_hooks).to eq( + PreToolUse: [ + { matcher: 'Bash', hookCallbackIds: ['hook_0'] }, + { matcher: 'Write|Edit', hookCallbackIds: ['hook_1'] } + ] + ) + end + end + + it 'runs the hooks of both keys' do + hooks = { PreToolUse: [matcher('write_guard', 'Write|Edit')], 'PreToolUse' => [matcher('bash_guard', 'Bash')] } + + replies = session(hooks) do + next_written # the initialize request + %w[hook_0 hook_1].map do |callback_id| + inbound.push(hook_callback(callback_id)) + next_written + end + end + + expect(called).to eq(%w[write_guard bash_guard]) + expect(replies.map { |reply| reply[:response].slice(:subtype, :request_id) }).to eq( + [{ subtype: 'success', request_id: 'request-for-hook_0' }, + { subtype: 'success', request_id: 'request-for-hook_1' }] + ) + end + + it 'keeps each matcher its own timeout and leaves other events alone' do + hooks = { + 'PreToolUse' => [matcher('bash_guard', 'Bash', timeout: 30)], + 'PostToolUse' => [matcher('audit', nil)], + PreToolUse: [matcher('write_guard', 'Write|Edit')] + } + + session(hooks) do + expect(registered_hooks).to eq( + PreToolUse: [ + { matcher: 'Bash', hookCallbackIds: ['hook_0'], timeout: 30 }, + { matcher: 'Write|Edit', hookCallbackIds: ['hook_1'] } + ], + PostToolUse: [{ matcher: nil, hookCallbackIds: ['hook_2'] }] + ) + end + end + + { 'an empty list' => [], 'nil' => nil }.each do |label, nothing| + it "still ignores #{label} under the other key" do + session('PreToolUse' => [matcher('bash_guard', 'Bash')], PreToolUse: nothing) do + expect(registered_hooks).to eq(PreToolUse: [{ matcher: 'Bash', hookCallbackIds: ['hook_0'] }]) + end + end + end +end From c0b3a5ebc2760c9a9661a40e1349e56e907b9e0e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:58:25 +0800 Subject: [PATCH 075/194] Say which typed output holds a hook-specific one; pin frozen returns Follow-up to the hook output normalization. The new "Hook output" text (docs and both skill trees) said both typed outputs hold a *HookSpecificOutput; only SyncHookJSONOutput does. Adds one example to the normalization spec: the Hash a callback returns is never modified, so a frozen constant can be returned on every call. No library change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- docs/hooks-and-permissions.md | 2 +- .../claude-agent-ruby/references/options.md | 2 +- skills/references/options.md | 2 +- spec/unit/hook_output_normalization_spec.rb | 16 ++++++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index fc754fa4..69eb1d79 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -81,7 +81,7 @@ See [examples/hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ru ### Hook output -A callback returns a typed output (`SyncHookJSONOutput` or `AsyncHookJSONOutput`, holding a `*HookSpecificOutput`), a Hash, or `nil` (the same as `{}`). A Hash stands for the typed output with the same fields. Its keys may be Symbols or Strings, and each field may be spelled as the typed class's attribute (`permission_decision`) or as the CLI reads it (`permissionDecision`), at the top level and inside `hook_specific_output`. The deny in the example above can also be written as: +A callback returns a typed output (`AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`), a Hash, or `nil` (the same as `{}`). A Hash stands for the typed output with the same fields. Its keys may be Symbols or Strings, and each field may be spelled as the typed class's attribute (`permission_decision`) or as the CLI reads it (`permissionDecision`), at the top level and inside `hook_specific_output`. The deny in the example above can also be written as: ```ruby { diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index b44d597c..099cf85b 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::SyncHookJSONOutput` / `AsyncHookJSONOutput`, holding a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. ## Structured output diff --git a/skills/references/options.md b/skills/references/options.md index b44d597c..099cf85b 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::SyncHookJSONOutput` / `AsyncHookJSONOutput`, holding a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. ## Structured output diff --git a/spec/unit/hook_output_normalization_spec.rb b/spec/unit/hook_output_normalization_spec.rb index acf55437..c96baa63 100644 --- a/spec/unit/hook_output_normalization_spec.rb +++ b/spec/unit/hook_output_normalization_spec.rb @@ -446,6 +446,22 @@ def replies_to(forms, event) end end + describe 'the Hash a callback returned' do + it 'is left untouched, so a frozen constant can be returned on every call' do + output = { + continue_: true, + hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' }.freeze + }.freeze + denied = { 'continue' => true, + 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'permissionDecision' => 'deny' } } + + expect([reply(output), reply(output)]).to eq([denied, denied]) + expect(output).to eq( + continue_: true, hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' } + ) + end + end + describe 'a return value that is not a Hash' do it 'still answers {} for nil, true and a String' do expect([nil, true, 'deny'].map { |output| reply(output) }).to eq([{}, {}, {}]) From f3be4459a014b636330816e2c8b90bedbda6f517 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:34:36 +0800 Subject: [PATCH 076/194] Narrow the hook output docs to what the conversion leaves alone The new "Hook output" text said values are sent as written. That is not true of every value: a top-level value that responds to #to_h is still converted, as it was before this branch. That is how a typed hook_specific_output inside a Hash gets serialized, and it also turns { system_message: nil } into {"systemMessage":{}}. What holds, and what the specs pin, is narrower: the keys inside updated_input, the tool outputs and a PermissionRequest decision are not renamed. The docs and both skill trees now say exactly that. Two more corrections in the same paragraphs. The skill's note about 1.1.0 and earlier said only top-level Symbol keys were converted, but continue_ and async_ were accepted as Strings too. And "the camelCase spelling is sent" becomes "the CLI's spelling": continue over continue_ is not a matter of case. Docs only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- docs/hooks-and-permissions.md | 4 ++-- .../skills/claude-agent-ruby/references/options.md | 2 +- skills/references/options.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index 69eb1d79..0221a0c5 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -95,8 +95,8 @@ A callback returns a typed output (`AsyncHookJSONOutput`, or `SyncHookJSONOutput - `continue_` and `async_` stand for `continue` and `async`. - A key the typed classes do not have is sent as written, so a CLI field the SDK does not model has to be spelled the way the CLI spells it. -- Values are sent as written: the keys inside `updated_input`, `updated_tool_output`, `updated_mcp_tool_output` and a `PermissionRequest` `decision` are never renamed. -- If one Hash spells the same field both ways, the camelCase one is sent. +- The keys inside `updated_input`, `updated_tool_output`, `updated_mcp_tool_output` and a `PermissionRequest` `decision` are not renamed: those are the tool's own payloads (for `decision`, the CLI's), so spell them as the tool or the CLI does. +- If one Hash spells the same field both ways, the CLI's spelling is the one sent (`permissionDecision` over `permission_decision`, `continue` over `continue_`). ### Hook cancellation diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index 099cf85b..e15444b1 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase). The keys inside `updated_input`, the tool outputs (`updated_tool_output`, `updated_mcp_tool_output`) and a `PermissionRequest` `decision` are not renamed. In 1.1.0 and earlier only top-level keys were converted, and only when written as Symbols (except `continue_` / `async_`, which also worked as Strings): use camelCase for nested keys and for String keys there. ## Structured output diff --git a/skills/references/options.md b/skills/references/options.md index 099cf85b..e15444b1 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase); values such as `updated_input` are never rewritten. In 1.1.0 and earlier only top-level Symbol keys were converted: use camelCase for nested keys there. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase). The keys inside `updated_input`, the tool outputs (`updated_tool_output`, `updated_mcp_tool_output`) and a `PermissionRequest` `decision` are not renamed. In 1.1.0 and earlier only top-level keys were converted, and only when written as Symbols (except `continue_` / `async_`, which also worked as Strings): use camelCase for nested keys and for String keys there. ## Structured output From f0edb33beae9483a35806ddecff91361fbbbdfce Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:38:58 +0800 Subject: [PATCH 077/194] Return hook outputs the CLI reads in the two hook examples advanced_hooks_example.rb and lifecycle_hooks_example.rb returned a *HookSpecificOutput's #to_h as the whole hook output. That puts hookEventName, permissionDecision, additionalContext, retry and watchPaths at the top level, where the CLI does not read them: - the PreToolUse hook's "deny" for rm -rf did not deny, its "allow" did not allow, and the hook_with_context demonstration did the same; - the SessionStart and Setup context and the PermissionDenied retry answer were ignored; - the CwdChanged and FileChanged hooks fared worse: a top-level Array value (watchPaths) makes the SDK's conversion raise TypeError, so those two hooks were answered with an error. Each one now returns its typed output inside a SyncHookJSONOutput, as docs/hooks-and-permissions.md teaches, so the fields travel under hookSpecificOutput. What each example demonstrates is unchanged, and the new form works with released versions too. No library change: the SDK does not wrap such outputs itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6hyMhp2HKVE76Dw2NYrMw --- examples/advanced_hooks_example.rb | 19 ++++++++----- examples/lifecycle_hooks_example.rb | 41 +++++++++++++++++++---------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/examples/advanced_hooks_example.rb b/examples/advanced_hooks_example.rb index 87dff227..85b2e53e 100644 --- a/examples/advanced_hooks_example.rb +++ b/examples/advanced_hooks_example.rb @@ -32,12 +32,15 @@ # Check for dangerous patterns if original_command.match?(/rm\s+-rf|sudo\s+rm/) - # Create typed deny output + # Create typed deny output. The CLI reads the decision from + # hookSpecificOutput, so the hook returns it inside a + # SyncHookJSONOutput: output.to_h on its own would put the fields at + # the top level, where the CLI ignores them and the command runs. output = ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( permission_decision: 'deny', permission_decision_reason: 'Destructive commands are not allowed' ) - return output.to_h + return ClaudeAgentSDK::SyncHookJSONOutput.new(hook_specific_output: output) end # Modify command to be safer (example: add echo prefix for demo) @@ -46,7 +49,7 @@ output = ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( permission_decision: 'allow' ) - return output.to_h + return ClaudeAgentSDK::SyncHookJSONOutput.new(hook_specific_output: output) end end @@ -182,10 +185,12 @@ puts "Hook context signal available: #{!context.signal.nil?}" - # Return allow decision - ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( - permission_decision: 'allow' - ).to_h + # Return allow decision (inside a SyncHookJSONOutput, as above) + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( + permission_decision: 'allow' + ) + ) end puts "Hook with context signal defined (for demonstration)" diff --git a/examples/lifecycle_hooks_example.rb b/examples/lifecycle_hooks_example.rb index 5e92bada..e609d97d 100755 --- a/examples/lifecycle_hooks_example.rb +++ b/examples/lifecycle_hooks_example.rb @@ -17,10 +17,15 @@ # SessionStart: fires at session startup, resume, clear, or compact session_start = lambda do |input, _id, _ctx| puts "[SessionStart] source=#{input.source}, model=#{input.model}" - # Return additional context to inject into the conversation - ClaudeAgentSDK::SessionStartHookSpecificOutput.new( - additional_context: "Session started at #{Time.now}" - ).to_h + # Return additional context to inject into the conversation. The CLI + # reads event-specific fields from hookSpecificOutput, so the typed + # output goes inside a SyncHookJSONOutput: its own #to_h alone would + # leave the fields at the top level, where the CLI ignores them. + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::SessionStartHookSpecificOutput.new( + additional_context: "Session started at #{Time.now}" + ) + ) end # SessionEnd: fires when the session ends @@ -58,16 +63,20 @@ # Setup: fires on init or maintenance setup = lambda do |input, _id, _ctx| puts "[Setup] trigger=#{input.trigger}" # "init" or "maintenance" - ClaudeAgentSDK::SetupHookSpecificOutput.new( - additional_context: "Environment: #{RUBY_PLATFORM}" - ).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::SetupHookSpecificOutput.new( + additional_context: "Environment: #{RUBY_PLATFORM}" + ) + ) end # PermissionDenied: fires when a tool permission is denied permission_denied = lambda do |input, _id, _ctx| puts "[PermissionDenied] tool=#{input.tool_name}, reason=#{input.reason}" # Optionally retry - ClaudeAgentSDK::PermissionDeniedHookSpecificOutput.new(retry: false).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::PermissionDeniedHookSpecificOutput.new(retry: false) + ) end # ConfigChange: fires when settings files change @@ -80,17 +89,21 @@ cwd_changed = lambda do |input, _id, _ctx| puts "[CwdChanged] #{input.old_cwd} -> #{input.new_cwd}" # Optionally return watch paths - ClaudeAgentSDK::CwdChangedHookSpecificOutput.new( - watch_paths: [input.new_cwd] - ).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::CwdChangedHookSpecificOutput.new( + watch_paths: [input.new_cwd] + ) + ) end # FileChanged: fires when watched files change file_changed = lambda do |input, _id, _ctx| puts "[FileChanged] #{input.event}: #{input.file_path}" - ClaudeAgentSDK::FileChangedHookSpecificOutput.new( - watch_paths: [File.dirname(input.file_path)] - ).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::FileChangedHookSpecificOutput.new( + watch_paths: [File.dirname(input.file_path)] + ) + ) end # InstructionsLoaded: fires when CLAUDE.md or memory files are loaded From d4a3cd65150c3cc9427b253ebcb7df73215c3e62 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:58:16 +0800 Subject: [PATCH 078/194] Pin four behaviors of the disk session readers A mutation run over sessions.rb left these changes green in the whole suite; each now fails an example: - a transcript with no title, summary or prompt is listed (the summary presence check removed); - get_session_messages returns isMeta entries, or a teammate's (teamName) entries; - summary takes a summary entry over a last-prompt entry. The examples use transcripts in the shape the CLI writes: a session opened and closed without a prompt, a slash command with its injected skill body and a teammate's message in the chain, and a tail holding both a summary and a last-prompt entry. The fifth survivor of that run, a project path over 200 characters never matching its project dir, is covered by session_long_path_fallback_spec.rb. Specs only. Exploration finding: F-P7 (mutants D02, D04, D05, D09, D10) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- spec/unit/session_reader_assertions_spec.rb | 83 +++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 spec/unit/session_reader_assertions_spec.rb diff --git a/spec/unit/session_reader_assertions_spec.rb b/spec/unit/session_reader_assertions_spec.rb new file mode 100644 index 00000000..21cd5240 --- /dev/null +++ b/spec/unit/session_reader_assertions_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Behavior of the disk readers that no example pinned: each of these stayed +# green with the line that implements it removed. +RSpec.describe 'disk session readers' do + include_context 'with a Claude config dir' + + let(:session_id) { 'd4c3b2a1-0f9e-4d8c-8b7a-6f5e4d3c2b1a' } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def transcript + @transcript ||= CLITranscript.new(session_id: session_id, cwd: cwd) + end + + def listed + transcript.write(transcript_path(session_id)) + ClaudeAgentSDK.list_sessions(directory: cwd) + end + + # A session opened and closed without a prompt: the CLI wrote its opening + # metadata lines and a hook attachment, nothing to show the session under. + it 'do not list a transcript with no title, summary or prompt' do + transcript.mode + transcript.permission_mode + transcript.attachment(:hook, parent: nil, hook: 'SessionStart') + + expect(listed).to eq([]) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd)).to be_nil + end + + it 'list a transcript again once it has a prompt' do + transcript.mode + transcript.attachment(:hook, parent: nil, hook: 'SessionStart') + transcript.prompt(:prompt, 'what does a.rb define?', parent: :hook) + + expect(listed.map(&:summary)).to eq(['what does a.rb define?']) + end + + # prompt ─ skill_body (isMeta) ─ answer ─ note (a teammate's message) ─ reply + it 'leave meta entries and teammate entries out of the messages of a conversation' do + transcript.prompt(:prompt, '<command-name>/simplify</command-name>') + transcript.meta(:skill_body, [transcript.text('# Simplify: review the changed code')], parent: :prompt) + transcript.assistant(:answer, transcript.text('Simplified.'), parent: :skill_body) + transcript.prompt(:note, 'Reviewer: looks fine.', parent: :answer, teamName: 'crew', agentName: 'reviewer') + transcript.assistant(:reply, transcript.text('Thanks.'), parent: :note, message: 'msg_02') + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt answer reply]) + end + + describe 'summary' do + # An older CLI wrote {"type":"summary"} entries; a current one writes + # last-prompt at exit. A transcript resumed across versions has both. + def summary_entry(text) + transcript.raw({ 'type' => 'summary', 'summary' => text, 'leafUuid' => transcript.id(:answer) }) + end + + before do + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + end + + it 'is the last prompt when the tail holds a last-prompt and a summary entry' do + summary_entry('Exploring a.rb') + transcript.last_prompt('and b.rb?', leaf: :answer) + + expect(listed.map(&:summary)).to eq(['and b.rb?']) + end + + it 'is the summary entry when there is no last-prompt entry' do + summary_entry('Exploring a.rb') + + expect(listed.map(&:summary)).to eq(['Exploring a.rb']) + end + end +end From 2e6ed17e8b9d040749c612c88b268187c576353a Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:58:47 +0800 Subject: [PATCH 079/194] Test the transport against a real child process: a fake CLI Every spec entered SubprocessCLITransport#connect with Open3.popen3 stubbed, and the real-child examples planted a process in @process. So several fixed behaviors had no assertion, and reverting any of them passed the suite: the grace period after stdin EOF before SIGTERM, the KILL escalation in #close, the bound on the recent-stderr buffer, the error classes for a missing executable and a missing cwd, the refusal to write to an exited process, and CLAUDECODE being unset in the child (the fix for running the SDK from inside Claude Code). spec/support/fake_claude.rb is a stand-in for the `claude` executable: a plain-Ruby child process that speaks the stream-JSON protocol, answers `-v` and `initialize`, logs what it received, and takes its scenario from environment variables (a conversation, a slow exit after EOF, a process that ignores EOF and TERM, stderr lines followed by a failure, an immediate exit). FakeClaude.install writes the executable and returns its absolute path for `cli_path:`. Nothing is stubbed: popen3, the child's environment, chdir, the stderr thread, the stdin close and the exit ladder are the real ones. subprocess_cli_transport_fake_cli_spec.rb uses it for one example per behavior above, plus one query() run end to end (hook, SDK MCP handshake and tool call, stdin closed at idle). The example for the grace period uses a child that exits shortly after EOF: a child that ignores TERM cannot tell a grace period from none. Each example runs under its own bound, so a child the SDK never stops waiting for fails the example. The TERM-ignoring one takes about seven seconds, the real 5s + 2s ladder. Specs only; no library change. Exploration findings: F-P5, F-S1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/support/fake_claude.rb | 333 ++++++++++++++++++ .../subprocess_cli_transport_fake_cli_spec.rb | 261 ++++++++++++++ 2 files changed, 594 insertions(+) create mode 100644 spec/support/fake_claude.rb create mode 100644 spec/unit/subprocess_cli_transport_fake_cli_spec.rb diff --git a/spec/support/fake_claude.rb b/spec/support/fake_claude.rb new file mode 100644 index 00000000..261e07de --- /dev/null +++ b/spec/support/fake_claude.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +require 'json' + +# A stand-in for the `claude` executable: a real child process that speaks +# the CLI's stream-JSON protocol on stdin/stdout. With it the SDK's transport +# is tested through its real #connect — Open3.popen3, the environment handed +# to the child, chdir, the stderr thread, the stdin close and the TERM/KILL +# ladder are all the real thing, and nothing is stubbed. +# +# cli = FakeClaude.install(dir) # writes an executable, returns its ABSOLUTE path +# options = ClaudeAgentSDK::ClaudeAgentOptions.new( +# cli_path: cli, cwd: dir, env: FakeClaude.env(log: File.join(dir, 'log.jsonl'), scenario: 'slow_exit') +# ) +# ... +# FakeClaude.events(File.join(dir, 'log.jsonl')) # what the child saw and did, in order +# +# This file is two things. Required by spec_helper it only defines the module; +# run as a program (which is what the installed executable does) it is the +# child. The child is plain Ruby started with --disable-gems and loads +# nothing but json, so it starts in milliseconds. +# +# Scenarios (FAKE_CLAUDE_SCENARIO, see .env): +# conversation (default) answers control requests and user messages; exits 0 at stdin EOF +# slow_exit the same, but after EOF it takes a moment to finish its work before exiting 0 +# wedged ignores stdin EOF and SIGTERM; only SIGKILL ends it +# stderr_then_fail writes FAKE_CLAUDE_STDERR_LINES lines to stderr, waits for EOF, exits 1 +# exit_at_once exits 0 without reading anything +# +# For the control channel alone, in process, see ScriptedCLI. +module FakeClaude + # What `-v` prints: a version the SDK accepts without a warning. + CLI_VERSION = '2.1.286' + + SCENARIOS = %w[conversation slow_exit wedged stderr_then_fail exit_at_once].freeze + + # Writes an executable named `claude` into +dir+ and returns its absolute + # path, for `cli_path:`. A shell wrapper rather than a shebang line, for two + # reasons: the interpreter path can be long or contain spaces, and + # `bundle exec` leaves RUBYOPT=-rbundler/setup in the environment, which + # would make every start of the child load Bundler. + def self.install(dir) + require 'rbconfig' + require 'shellwords' + + path = File.join(File.realpath(dir), 'claude') + File.write(path, <<~SH) + #!/bin/sh + unset RUBYOPT RUBYLIB + exec #{Shellwords.escape(RbConfig.ruby)} --disable-gems #{Shellwords.escape(File.expand_path(__FILE__))} "$@" + SH + File.chmod(0o755, path) + path + end + + # The entries for ClaudeAgentOptions#env that select what the child does. + # +log+ is a file the child appends one JSON event per line to; +report_env+ + # names environment variables whose values the child records at start + # (names of all variables are recorded anyway, values of none). + def self.env(scenario: 'conversation', log: nil, report_env: [], **settings) + raise ArgumentError, "unknown scenario #{scenario.inspect}" unless SCENARIOS.include?(scenario.to_s) + + env = { 'FAKE_CLAUDE_SCENARIO' => scenario.to_s } + env['FAKE_CLAUDE_LOG'] = log.to_s if log + env['FAKE_CLAUDE_REPORT_ENV'] = report_env.join(',') unless report_env.empty? + settings.each { |name, value| env["FAKE_CLAUDE_#{name.to_s.upcase}"] = value.to_s } + env + end + + # The child's log, parsed: [{ 'event' => 'start', ... }, ...]. Complete + # once the child has exited, which is when specs read it. + def self.events(log) + return [] unless File.exist?(log) + + File.readlines(log, chomp: true).reject(&:empty?).map { |line| JSON.parse(line) } + end + + # The pids of the children that logged a start to +log+ and may still be + # running: what a spec kills when its own bound expires. + def self.pids(log) + events(log).filter_map { |event| event['pid'] if event['event'] == 'start' } + end + + # The child process. + class Child + SESSION_ID = '11111111-1111-4111-8111-111111111111' + MODEL = 'claude-haiku-4-5-20251001' + + def initialize(argv, env) + @argv = argv + @env = env + @scenario = env.fetch('FAKE_CLAUDE_SCENARIO', 'conversation') + @log = env['FAKE_CLAUDE_LOG'] && File.open(env['FAKE_CLAUDE_LOG'], File::WRONLY | File::APPEND | File::CREAT) + @backlog = [] + @hooks = {} + @ids = 0 + end + + def run + if @argv.include?('-v') || @argv.include?('--version') + puts "#{CLI_VERSION} (Claude Code)" + return 0 + end + + $stdout.sync = true + $stderr.sync = true + log('start', pid: Process.pid, argv: @argv, cwd: Dir.pwd, env_names: @env.keys.sort, env: reported_env) + status = play + log('exit', status: status) + status + end + + private + + def play + case @scenario + when 'exit_at_once' then 0 + when 'slow_exit' then slow_exit + when 'wedged' then wedged + when 'stderr_then_fail' then stderr_then_fail + else + converse + 0 + end + end + + # After stdin EOF the real CLI still has work to do (it flushes the + # session file) before it exits on its own. A SIGTERM that arrives during + # that work is recorded, and ends the process the way an interrupted CLI + # ends. + def slow_exit + trap('TERM') do + log('signal', name: 'TERM') + exit!(143) + end + converse + sleep Float(@env.fetch('FAKE_CLAUDE_FLUSH_SECONDS', '0.2')) + log('flushed') + 0 + end + + def wedged + trap('TERM') { log('signal', name: 'TERM') } + converse + sleep + end + + def stderr_then_fail + Integer(@env.fetch('FAKE_CLAUDE_STDERR_LINES', '25')).times { |i| warn "fake claude: stderr line #{i + 1}" } + converse + 1 + end + + # The protocol loop: answers every control request, plays one turn per + # user message, returns at stdin EOF. + def converse + while (frame = next_frame) + case frame['type'] + when 'control_request' then answer_control_request(frame) + when 'user' then play_turn + end + end + log('eof') + end + + def answer_control_request(frame) + return emit_control_response(frame, {}) unless frame.dig('request', 'subtype') == 'initialize' + + @hooks = frame.dig('request', 'hooks') || {} + # In the order CLI 2.1.286 does it: each SDK MCP server is initialized + # before the SDK's own `initialize` is answered, and asked for its + # tools after. + sdk_servers.each { |name| ask_sdk_server(name, method: 'initialize', params: mcp_client_info, id: 0) } + emit_control_response(frame, initialize_response) + sdk_servers.each do |name| + ask_sdk_server(name, method: 'notifications/initialized') + ask_sdk_server(name, method: 'tools/list', id: 1) + end + end + + # One turn: the frames CLI 2.1.286 emits for a prompt answered with text, + # plus a PreToolUse hook callback per registered hook and, when + # FAKE_CLAUDE_TOOL_CALL names one, an SDK MCP tool call. + def play_turn + emit_session_state('running') + emit(init_frame) + Array(@hooks['PreToolUse']).flat_map { |matcher| matcher['hookCallbackIds'] }.each do |callback_id| + ask(subtype: 'hook_callback', callback_id: callback_id, tool_use_id: 'toolu_01FakeClaudeBash', + input: pre_tool_use_input) + end + if (call = @env['FAKE_CLAUDE_TOOL_CALL']) + call = JSON.parse(call) + ask_sdk_server(call.fetch('server'), method: 'tools/call', id: 2, + params: { name: call.fetch('name'), arguments: call.fetch('arguments') }) + end + text = @env.fetch('FAKE_CLAUDE_REPLY', 'OK') + emit(assistant_frame(text)) + emit(result_frame(text)) + emit_session_state('idle') + end + + # Sends a control request and waits for its response. Frames that arrive + # in between (the user prompt is written right after `initialize`) are + # kept, in order, for #next_frame. + def ask(request) + id = next_id + emit(type: 'control_request', request_id: id, request: request) + loop do + frame = read_frame + unless frame + log('eof') + exit!(0) + end + return frame if frame['type'] == 'control_response' && frame.dig('response', 'request_id') == id + + @backlog << frame + end + end + + def ask_sdk_server(name, message) + ask(subtype: 'mcp_message', server_name: name, message: message.merge(jsonrpc: '2.0')) + end + + def next_frame + @backlog.empty? ? read_frame : @backlog.shift + end + + def read_frame + line = $stdin.gets or return nil + frame = JSON.parse(line) + log('stdin', frame: frame) + frame + end + + def emit(frame) + $stdout.write("#{JSON.generate(frame)}\n") + end + + def emit_control_response(request_frame, payload) + emit(type: 'control_response', + response: { subtype: 'success', request_id: request_frame['request_id'], response: payload }) + end + + # Only for an SDK that asked for them, as the real CLI does. + def emit_session_state(state) + return unless @env['CLAUDE_CODE_SDK_READS_SESSION_STATE'] == '1' + + emit(type: 'system', subtype: 'session_state_changed', state: state, sdk_host_only: true, uuid: next_id, + session_id: SESSION_ID) + end + + # One line per event, written with a single syswrite: complete on disk + # the moment it returns, and safe inside a signal handler. + def log(event, details = {}) + @log&.syswrite("#{JSON.generate({ event: event }.merge(details))}\n") + end + + def reported_env + names = %w[CLAUDE_AGENT_SDK_VERSION CLAUDE_CODE_SDK_READS_SESSION_STATE PWD] + + @env.fetch('FAKE_CLAUDE_REPORT_ENV', '').split(',') + names.to_h { |name| [name, @env[name]] } + end + + def next_id + @ids += 1 + format('00000000-0000-4000-8000-%012d', @ids) + end + + # The names of the `type: sdk` servers in --mcp-config. + def sdk_servers + index = @argv.index('--mcp-config') or return [] + servers = JSON.parse(@argv[index + 1]).fetch('mcpServers', {}) + servers.select { |_, config| config['type'] == 'sdk' }.keys + rescue JSON::ParserError + [] + end + + def mcp_client_info + { protocolVersion: '2025-11-25', capabilities: {}, + clientInfo: { name: 'claude-code', title: 'Claude Code', description: "Anthropic's agentic coding tool", + websiteUrl: 'https://claude.com/claude-code', version: CLI_VERSION } } + end + + def initialize_response + { commands: [{ name: 'design', description: 'Grant or revoke Claude agent access to your Design projects', + argumentHint: 'consent | revoke', builtin: true }], + agents: [{ name: 'claude', description: "Catch-all for any task that doesn't fit a more specific agent." }], + output_style: 'default', available_output_styles: %w[default Proactive Concise Explanatory Learning], + models: [{ value: 'default', resolvedModel: 'claude-opus-5-5', displayName: 'Default (recommended)', + description: 'Use the default model', supportsEffort: true, + supportedEffortLevels: %w[low medium high xhigh max], supportsAdaptiveThinking: true, + supportsFastMode: true, supportsAutoMode: true }], + account: { tokenSource: 'none', apiProvider: 'firstParty' }, pid: Process.pid, + current_permission_mode: 'auto', session_state: 'idle' } + end + + def pre_tool_use_input + { session_id: SESSION_ID, transcript_path: File.join(Dir.pwd, "#{SESSION_ID}.jsonl"), cwd: Dir.pwd, + scratchpad_dir: File.join(Dir.pwd, 'scratchpad'), prompt_id: next_id, permission_mode: 'default', + hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'ls', description: 'List files' }, + tool_use_id: 'toolu_01FakeClaudeBash' } + end + + def init_frame + { type: 'system', subtype: 'init', cwd: Dir.pwd, session_id: SESSION_ID, tools: %w[Bash Read], + mcp_servers: sdk_servers.map { |name| { name: name, status: 'connected' } }, model: MODEL, + permissionMode: 'default', slash_commands: %w[compact context], apiKeySource: 'none', + claude_code_version: CLI_VERSION, output_style: 'default', agents: %w[claude general-purpose], skills: [], + plugins: [], capabilities: %w[interrupt_receipt_v1 msg_lifecycle_v1], uuid: next_id, fast_mode_state: 'off' } + end + + def assistant_frame(text) + { type: 'assistant', + message: { model: MODEL, id: 'msg_01FakeClaude', type: 'message', role: 'assistant', + content: [{ type: 'text', text: text }], stop_reason: nil, stop_sequence: nil, + usage: { input_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, + output_tokens: 4, service_tier: 'standard' } }, + parent_tool_use_id: nil, session_id: SESSION_ID, uuid: next_id } + end + + def result_frame(text) + { type: 'result', subtype: 'success', is_error: false, duration_ms: 1973, duration_api_ms: 1937, num_turns: 1, + result: text, stop_reason: 'end_turn', session_id: SESSION_ID, total_cost_usd: 0.01533, + usage: { input_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 38, + service_tier: 'standard' }, + modelUsage: { MODEL => { inputTokens: 10, outputTokens: 38, cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, costUSD: 0.01533, contextWindow: 200_000 } }, + permission_denials: [], terminal_reason: 'completed', uuid: next_id } + end + end +end + +exit(FakeClaude::Child.new(ARGV, ENV.to_h).run) if $PROGRAM_NAME == __FILE__ diff --git a/spec/unit/subprocess_cli_transport_fake_cli_spec.rb b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb new file mode 100644 index 00000000..67827bcb --- /dev/null +++ b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# SubprocessCLITransport against a real child process (FakeClaude, +# spec/support/fake_claude.rb): what #connect hands the child, what it raises +# when the spawn fails, and how #close and the read loop treat a child that +# finishes, lingers or fails. The other transport specs enter #connect with +# Open3.popen3 stubbed, or plant a process in @process; here nothing is +# stubbed, and what the child saw is read from the child's own log. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport do + around do |example| + Dir.mktmpdir('fake-claude') do |dir| + @dir = File.realpath(dir) + @cli = FakeClaude.install(@dir) + @log = File.join(@dir, 'fake-claude.jsonl') + example.run + end + end + + def transport_for(scenario: 'conversation', cli_path: @cli, cwd: @dir, env: {}, **options) + described_class.new( + ClaudeAgentSDK::ClaudeAgentOptions.new( + cli_path: cli_path, cwd: cwd, env: FakeClaude.env(scenario: scenario, log: @log).merge(env), **options + ) + ) + end + + # Runs the block on a thread of its own and fails the example if it is + # still running after +seconds+. The regressions these examples guard + # against leave the SDK waiting for a child forever, and that has to be a + # failure here, not a suite that never ends. Generous on purpose: nothing + # healthy comes near it. + def bounded(seconds = 60) + worker = Thread.new do + Thread.current.report_on_exception = false # the error is re-raised below, by #value + yield + end + return worker.value if worker.join(seconds) + + # The child is what the SDK is stuck on: take it away, then report. + FakeClaude.pids(@log).each { |pid| kill_fake_claude(pid) } + worker.kill unless worker.join(10) + raise "still running after #{seconds}s (the fake CLI was killed to end the example)" + end + + # KILLs +pid+ only while it still is the fake CLI: a pid outlives its + # process as a number that the system hands out again. + def kill_fake_claude(pid) + command, = Open3.capture2('ps', '-o', 'command=', '-p', pid.to_s) + Process.kill('KILL', pid) if command.include?('fake_claude.rb') + rescue Errno::ESRCH + nil + end + + # The first exchange of every session, and proof that the child is up and + # reading: the `initialize` request the SDK sends, and the child's answer. + def handshake(transport) + request = { type: 'control_request', request_id: 'req_1_0a1b2c3d', + request: { subtype: 'initialize', hooks: nil, agents: nil } } + transport.write("#{JSON.generate(request)}\n") + answer = transport.read_messages { |frame| break frame } + expect(answer).to include(type: 'control_response') + expect(answer.dig(:response, :request_id)).to eq('req_1_0a1b2c3d') + end + + def events + FakeClaude.events(@log) + end + + describe '#connect' do + it 'starts the child in cwd with the inherited environment, except CLAUDECODE' do + previous = %w[CLAUDECODE FAKE_CLAUDE_INHERITED].to_h { |name| [name, ENV.fetch(name, nil)] } + ENV['CLAUDECODE'] = '1' # what a shell inside Claude Code has; the CLI refuses to nest under it + ENV['FAKE_CLAUDE_INHERITED'] = 'yes' + transport = transport_for(env: { 'FAKE_CLAUDE_FROM_OPTIONS' => 'yes' }) + + bounded do + transport.connect + handshake(transport) + transport.close + end + + start = events.first + expect(start).to include('event' => 'start', 'cwd' => @dir) + # Inherited and option-supplied variables both arrive, so the missing + # one below was removed, not lost with everything else. + expect(start.fetch('env_names')).to include('FAKE_CLAUDE_INHERITED', 'FAKE_CLAUDE_FROM_OPTIONS') + expect(start.fetch('env_names')).not_to include('CLAUDECODE') + expect(start.dig('env', 'CLAUDE_AGENT_SDK_VERSION')).to eq(ClaudeAgentSDK::VERSION) + expect(start.dig('env', 'PWD')).to eq(@dir) + ensure + transport&.close + previous&.each { |name, value| value ? ENV[name] = value : ENV.delete(name) } + end + + it 'raises CLINotFoundError when the executable does not exist' do + missing = File.join(@dir, 'no-such-claude') + transport = transport_for(cli_path: missing) + + expect { bounded { transport.connect } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError, /#{Regexp.escape(missing)}/) + expect(transport).not_to be_ready + ensure + transport&.close + end + + it 'raises a CLIConnectionError naming the directory when cwd does not exist' do + gone = File.join(@dir, 'gone') + transport = transport_for(cwd: gone) + + expect { bounded { transport.connect } }.to raise_error(ClaudeAgentSDK::CLIConnectionError) do |error| + # The executable is there: this must not be reported as a missing CLI. + expect(error).not_to be_a(ClaudeAgentSDK::CLINotFoundError) + expect(error.message).to include(gone) + end + expect(events).to be_empty # the child was never started + ensure + transport&.close + end + end + + describe '#close' do + # The grace period after stdin EOF exists for this child: the CLI flushes + # its session file before it exits, and a SIGTERM in that window loses + # the last message. A child that ignores TERM cannot tell a grace period + # from none; one that needs a moment can. + it 'lets a child that needs a moment after stdin EOF finish on its own, unsignalled' do + transport = transport_for(scenario: 'slow_exit') + + bounded do + transport.connect + handshake(transport) + transport.close + end + + expect(events.map { |event| event['event'] }).to eq(%w[start stdin eof flushed exit]) + expect(events.last).to include('status' => 0) + ensure + transport&.close + end + + it 'kills a child that ignores stdin EOF and SIGTERM' do + transport = transport_for(scenario: 'wedged') + + # Real time: the ladder waits 5s for the exit and 2s after TERM before + # KILL. If the escalation regresses, #close waits for a child that + # never dies and the bound ends the example. + bounded do + transport.connect + handshake(transport) + transport.close + end + + # It saw EOF, was asked with TERM, and never logged an exit of its own... + expect(events.map { |event| event['event'] }).to eq(%w[start stdin eof signal]) + expect(events.last).to include('name' => 'TERM') + # ...and it is gone, reaped, all the same. + expect { Process.kill(0, events.first.fetch('pid')) }.to raise_error(Errno::ESRCH) + ensure + transport&.close + end + end + + describe '#read_messages and #write around a child that has exited' do + it 'keeps the last 20 stderr lines and puts the last 10 in the ProcessError of a failed child' do + lines = Thread::Queue.new + transport = transport_for(scenario: 'stderr_then_fail', env: { 'FAKE_CLAUDE_STDERR_LINES' => '25' }, + stderr: ->(line) { lines << line }) + written = Array.new(25) { |i| "fake claude: stderr line #{i + 1}" } + + error = bounded do + transport.connect + # All 25 have gone through the stderr thread before the child is + # allowed to exit, so what the error carries below is not a race + # with that thread. + expect(Array.new(25) { lines.pop(timeout: 30) }).to eq(written) + transport.end_input + begin + transport.read_messages { |_frame| nil } + nil + rescue ClaudeAgentSDK::ProcessError => e + e + end + end + + expect(error).to be_a(ClaudeAgentSDK::ProcessError) + expect(error.exit_code).to eq(1) + expect(error.stderr).to eq(written.last(10).join("\n")) + expect(transport.instance_variable_get(:@recent_stderr)).to eq(written.last(20)) + ensure + transport&.close + end + + it 'refuses to write to a child that has exited' do + transport = transport_for(scenario: 'exit_at_once') + + bounded do + transport.connect + transport.read_messages { |_frame| nil } # returns at stdout EOF, once the child is reaped + expect { transport.write("{}\n") } + .to raise_error(ClaudeAgentSDK::CLIConnectionError, 'Cannot write to terminated process') + end + ensure + transport&.close + end + end + + # The whole stack on the fake: ClaudeAgentSDK.query spawns it, probes its + # version, initializes, serves the SDK MCP handshake, a hook and a tool + # call, and closes stdin once the child reports idle. + describe 'a query() run end to end' do + it 'serves hooks and an SDK MCP server over the control channel, then lets the child exit on its own' do + hook_inputs = [] + hook = lambda do |input, _tool_use_id, _context| + hook_inputs << input + {} + end + tool_calls = [] + add = ClaudeAgentSDK.create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args| + tool_calls << args + (args[:a] + args[:b]).to_s + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + cli_path: @cli, cwd: @dir, + env: FakeClaude.env(log: @log, tool_call: JSON.generate(server: 'calc', name: 'add', arguments: { a: 2, b: 3 })), + hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [hook])] }, + mcp_servers: { 'calc' => ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: [add]) } + ) + + messages = [] + bounded { ClaudeAgentSDK.query(prompt: 'hi', options: options) { |message| messages << message } } + + # What the caller sees. + expect(messages.map(&:class)).to eq([ClaudeAgentSDK::InitMessage, ClaudeAgentSDK::AssistantMessage, + ClaudeAgentSDK::ResultMessage]) + expect(messages.last.result).to eq('OK') + expect(hook_inputs.map(&:class)).to eq([ClaudeAgentSDK::PreToolUseHookInput]) + expect(hook_inputs.first.tool_input).to eq(command: 'ls', description: 'List files') + expect(tool_calls).to eq([{ a: 2, b: 3 }]) + + # What the child received on its stdin. + received = events.select { |event| event['event'] == 'stdin' }.map { |event| event.fetch('frame') } + expect(received.first.dig('request', 'subtype')).to eq('initialize') + expect(received.first.dig('request', 'hooks', 'PreToolUse')).to eq( + [{ 'matcher' => 'Bash', 'hookCallbackIds' => ['hook_0'] }] + ) + expect(received.find { |frame| frame['type'] == 'user' }.dig('message', 'content')).to eq('hi') + mcp_results = received.filter_map { |frame| frame.dig('response', 'response', 'mcp_response', 'result') } + expect(mcp_results[0]).to include('protocolVersion' => '2024-11-05', 'capabilities' => { 'tools' => {} }) + expect(mcp_results[2].fetch('tools').map { |tool| tool['name'] }).to eq(['add']) + expect(mcp_results[3].fetch('content')).to eq([{ 'type' => 'text', 'text' => '5' }]) + + # stdin was closed once the child reported idle, and it exited by + # itself: an EOF, then a clean exit, no signal. + expect(events.last(2).map { |event| event['event'] }).to eq(%w[eof exit]) + expect(events.last).to include('status' => 0) + end + end +end From 6ac34e19525cdbb2fe6c9ac6734f36887dd22888 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:58:49 +0800 Subject: [PATCH 080/194] Say what a failed first install can really leave behind docs/cli-installer.md said a first install that fails "leaves no binary and no VERSION behind". The second half is wrong for one case. VERSION is recorded before the binary is renamed into place (that order is what protects a working install during an upgrade, and it does not change), so when the step that fails is the last one, the rename, the directory is left with .install.lock and a complete VERSION and no binary. The paragraph now says so: a failed first install never publishes a binary; it can leave the directory, the lock file and, after a failed rename, a VERSION that already records the version. That state is harmless: installed? re-hashes the binary rather than trusting VERSION, so lock-free discovery finds no binary and moves on, and the next install redoes it. Three examples in the durability spec pin the wording: a first install whose rename fails (VERSION recorded, no binary, installed_path nil), the next install redoing it, and an earlier failure leaving only the lock file. No production change. Review finding: install-r1 B1 (follow-up to D-P15) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- docs/cli-installer.md | 2 +- spec/unit/cli_installer_durability_spec.rb | 72 +++++++++++++++++++--- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/docs/cli-installer.md b/docs/cli-installer.md index 171dfa09..cd97e030 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -32,7 +32,7 @@ ClaudeAgentSDK::CLIInstaller.installed_path Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`. -**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. The binary and `VERSION` are also flushed to disk (`fsync`) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails leaves no binary and no `VERSION` behind — at most the install directory and its `.install.lock`. +**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. The binary and `VERSION` are also flushed to disk (`fsync`) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails never publishes a binary. It can leave the install directory and its `.install.lock` behind and — when the step that failed is the last one, the rename — a `VERSION` that already records the version. Nothing trusts that file on its own: discovery finds no binary there and moves on, and the next `install` redoes it cleanly. ## When the pin moves diff --git a/spec/unit/cli_installer_durability_spec.rb b/spec/unit/cli_installer_durability_spec.rb index 03072099..aa3a869f 100644 --- a/spec/unit/cli_installer_durability_spec.rb +++ b/spec/unit/cli_installer_durability_spec.rb @@ -20,9 +20,9 @@ let(:metadata) { ClaudeAgentSDK::CLIInstaller::Metadata } let(:dir) { @dir } let(:binary_path) { File.join(dir, 'claude') } - # [:fsync, name, size at that moment], [:fsync_failed, name] and - # [:rename, from, to], in order; the random part of a temp name is replaced - # by <hex>. + # [:fsync, name, size at that moment], [:fsync_failed, name], + # [:rename, from, to] and [:rename_failed, from, to], in order; the random + # part of a temp name is replaced by <hex>. let(:events) { [] } around do |example| @@ -39,15 +39,24 @@ def label(path) end # Records every fsync and every rename, then lets the real one run. - # +failing+ maps a label to the error its fsync raises instead. + # +failing+ maps a label to the error raised instead: by the fsync of that + # file, or by the rename onto that name. def spy_on_syncs_and_renames(failing: {}) spy_on_fsync(failing) + spy_on_rename(failing) + end + + def spy_on_rename(failing) log = events labeller = method(:label) allow(File).to receive(:rename).and_wrap_original do |original, from, to| - original.call(from, to) - log << [:rename, labeller.call(from), labeller.call(to)] - 0 + names = [labeller.call(from), labeller.call(to)] + if failing.key?(names.last) + log << [:rename_failed, *names] + raise failing[names.last] + end + + original.call(from, to).tap { log << [:rename, *names] } end end @@ -135,6 +144,55 @@ def stub_release(version:, body:) expect(events.last).to eq([:rename, 'claude.download.<hex>', 'claude']) end + # What a FIRST install that fails can leave behind, as docs/cli-installer.md + # states it. VERSION is recorded before the binary is renamed into place + # (that order is what protects a working install during an upgrade), so a + # failure at that last step leaves a VERSION with nothing beside it. + context 'when a first install fails' do + let(:body) { 'not-really-280MB-of-claude' } + + before { stub_release(version: '2.1.220', body: body) } + + it 'publishes no binary but leaves a recorded VERSION when the last step, the rename, fails' do + spy_on_syncs_and_renames(failing: { 'claude' => Errno::EIO }) + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events.last(2)).to eq([[:rename, 'VERSION.<hex>.tmp', 'VERSION'], + [:rename_failed, 'claude.download.<hex>', 'claude']]) + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION') + expect(metadata.read(dir)) + .to eq(version: '2.1.220', checksum: Digest::SHA256.hexdigest(body), platform: 'linux-x64') + # Lock-free discovery finds no binary there and moves on. + expect(described_class.installed_path(dir: dir)).to be_nil + end + + it 'is redone cleanly by the next install, which does not trust the leftover VERSION' do + spy_on_syncs_and_renames(failing: { 'claude' => Errno::EIO }) + expect { described_class.install(version: '2.1.220', dir: dir) }.to raise_error(ClaudeAgentSDK::CLIInstallError) + events.clear + spy_on_syncs_and_renames + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + + expect(events.map(&:first)).to eq(%i[fsync fsync rename rename fsync]) + expect(File.binread(binary_path)).to eq(body) + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION', 'claude') + end + + it 'leaves only the directory and its lock file when an earlier step fails' do + spy_on_syncs_and_renames(failing: { 'claude.download.<hex>' => Errno::EIO }) + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events).to eq([[:fsync_failed, 'claude.download.<hex>']]) + expect(Dir.children(dir)).to contain_exactly('.install.lock') + expect(described_class.installed_path(dir: dir)).to be_nil + end + end + context 'with a working install in place' do before do stub_release(version: '2.1.220', body: 'the installed build') From df68b770ac32e480cdec7271a43c792675d85d06 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:01:22 +0800 Subject: [PATCH 081/194] Tell a missing tool :content key from a present non-Array value A handler result with a PRESENT but falsy :content got the wrong diagnostic, and the two tools/call paths disagreed about it: { content: false } direct call_tool: "must return a hash with :content key" routed (mcp gem): "must return :content as an Array of content blocks (got FalseClass)" { content: nil } both paths: "must return a hash with :content key" The direct path tested the value for truthiness and the routed path for nil, so each called a key that is there "missing". Both paths now ask about the key first (result.key?(:content) || result.key?('content'), after the Hash check), and only then check that the value is an Array. A result that is not a Hash, or a Hash without the key under either spelling, still gets "must return a hash with :content key"; a present false or nil gets the Array diagnostic with its class, like any other non-Array value. Legal results are unchanged, including a Hash that carries the Array under one spelling and nil under the other. Review finding: install-r1 B2 (follow-up to D-P9) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC --- lib/claude_agent_sdk/sdk_mcp_server.rb | 21 ++++++++++------ spec/unit/sdk_mcp_tool_result_spec.rb | 34 +++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/lib/claude_agent_sdk/sdk_mcp_server.rb b/lib/claude_agent_sdk/sdk_mcp_server.rb index 32eba563..cac87fb9 100644 --- a/lib/claude_agent_sdk/sdk_mcp_server.rb +++ b/lib/claude_agent_sdk/sdk_mcp_server.rb @@ -307,9 +307,14 @@ def call_tool(name, arguments) ClaudeAgentSDK.normalize_tool_result(tool.handler.call(arguments)) end - # Guard before flexible_fetch: it raises on non-Hash inputs. - content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil - return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content + # Guard before flexible_fetch: it raises on non-Hash inputs. This first + # diagnostic is about the KEY: a present false or nil is a wrong value + # and gets the Array diagnostic below, like any other non-Array. + unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content')) + return error_tool_result("Tool '#{name}' must return a hash with :content key") + end + + content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') # A String or a single block here is not a result an MCP client accepts. unless content.is_a?(Array) return error_tool_result("Tool '#{name}' must return :content as an Array of content blocks " \ @@ -510,12 +515,14 @@ def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgumen # Guard BEFORE flexible_fetch: on a non-Hash it raises # TypeError/NoMethodError, surfacing garbage instead of the - # friendly message. - raise "Tool '#{@tool_def.name}' must return a hash with :content key" unless result.is_a?(Hash) + # friendly message. This first diagnostic is about the KEY: a + # present false or nil is a wrong value and gets the Array + # diagnostic below, like any other non-Array. + unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content')) + raise "Tool '#{@tool_def.name}' must return a hash with :content key" + end content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') - raise "Tool '#{@tool_def.name}' must return a hash with :content key" if content.nil? - # A String or a single block Hash would go out as it is, and the # CLI rejects that frame against its schema: the model is told # the SERVER returned a malformed result and never sees the text. diff --git a/spec/unit/sdk_mcp_tool_result_spec.rb b/spec/unit/sdk_mcp_tool_result_spec.rb index 598254b9..ee3fde84 100644 --- a/spec/unit/sdk_mcp_tool_result_spec.rb +++ b/spec/unit/sdk_mcp_tool_result_spec.rb @@ -34,7 +34,12 @@ def expect_content_error(result, got) ['a String', { content: 'Order A-7 has shipped' }, 'String'], ['a single block Hash', { content: { type: 'text', text: 'Order A-7 has shipped' } }, 'Hash'], ['a String under a String key', { 'content' => 'Order A-7 has shipped' }, 'String'], - ['an Integer', { content: 7 }, 'Integer'] + ['an Integer', { content: 7 }, 'Integer'], + # Present, but falsy: the key is there, so this is a wrong value, not a + # missing key. + ['false', { content: false }, 'FalseClass'], + ['a present nil', { content: nil }, 'NilClass'], + ['a present nil under a String key', { 'content' => nil }, 'NilClass'] ].each do |label, handler_result, got| it "answers a session's tools/call with an in-band error when :content is #{label}" do expect_content_error(call_through_session(server_returning(handler_result)), got) @@ -51,6 +56,33 @@ def expect_content_error(result, got) expect_content_error(result, 'String') end + # The other diagnostic is about the KEY, and only about the key: a result + # that is not a Hash, or a Hash with no :content under either spelling. + [ + ['nil', nil], + ['an Array of blocks without the Hash around it', [{ type: 'text', text: 'Order A-7 has shipped' }]], + ['a Hash without the key', { text: 'Order A-7 has shipped' }], + ['a Hash whose only key is :contents', { contents: [{ type: 'text', text: 'Order A-7 has shipped' }] }] + ].each do |label, handler_result| + it "says the :content key is missing, on both paths, when the handler returns #{label}" do + server = server_returning(handler_result) + missing = [{ type: 'text', text: "Tool 'order_status' must return a hash with :content key" }] + + session = call_through_session(server) + expect(session[:isError]).to be true + expect(session[:content]).to eq(missing) + + expect(server.call_tool('order_status', { id: 'A-7' })).to eq(content: missing, isError: true) + end + end + + it 'reads the Array under either spelling when the other one is nil' do + server = server_returning({ content: nil, 'content' => [{ type: 'text', text: 'shipped' }] }) + + expect(call_through_session(server)).to include(isError: false, content: [{ type: 'text', text: 'shipped' }]) + expect(server.call_tool('order_status', { id: 'A-7' })).not_to have_key(:isError) + end + # The legal shapes are untouched. [ ['a String', 'Order A-7 has shipped', [{ type: 'text', text: 'Order A-7 has shipped' }]], From a5ffba87ba871982824488c66e333610656b0740 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:05:29 +0800 Subject: [PATCH 082/194] Call the optional store methods with their own argument in a spec The example showing that a delegating wrapper reads as implementing every optional method called all four with a session key. list_sessions and list_session_summaries take a project key String; under the RBS runtime checker (rake rbs:test) the call failed its signature before it reached the stub. Each method now gets the argument it is declared with. Spec only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- spec/unit/session_store_optional_methods_spec.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/spec/unit/session_store_optional_methods_spec.rb b/spec/unit/session_store_optional_methods_spec.rb index 01619a2e..d913f1ca 100644 --- a/spec/unit/session_store_optional_methods_spec.rb +++ b/spec/unit/session_store_optional_methods_spec.rb @@ -62,9 +62,11 @@ def seed(store) let(:store) { seed(SimpleDelegator.new(minimal_store_class.new)) } it 'looks as if it implemented every optional method' do - %i[list_sessions list_session_summaries delete list_subkeys].each do |method| + project_key = key['project_key'] + { list_sessions: project_key, list_session_summaries: project_key, delete: key, + list_subkeys: key }.each do |method, argument| expect(ClaudeAgentSDK::SessionStore.implements?(store, method)).to be(true) - expect { store.public_send(method, key) }.to raise_error(NotImplementedError) + expect { store.public_send(method, argument) }.to raise_error(NotImplementedError) end end From 1a9691230dedf8f87df22e330452bec478e788a3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:06:52 +0800 Subject: [PATCH 083/194] Pin the control-request deadline for a caller on another reactor Test only. Since the waiter is picked by reactor ownership, a caller on another thread's reactor waits on a ThreadWaiter while its deadline is still the cooperative timeout that every caller with an Async task gets. No example made that deadline fire on a ThreadWaiter with a session actually running; the nearest one (query_spec.rb, "raises ControlRequestTimeoutError on the reactor when no response arrives") only reaches that path because its Query is never started. The new example holds the CLI's response back for good and expects ControlRequestTimeoutError, a live read loop and no waiter left behind. It passes before the ownership change as well: it pins the deadline, not the choice of waiter. Exploration finding: A-P3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- .../query_control_waiter_ownership_spec.rb | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/spec/unit/query_control_waiter_ownership_spec.rb b/spec/unit/query_control_waiter_ownership_spec.rb index 84a2003a..8667c0eb 100644 --- a/spec/unit/query_control_waiter_ownership_spec.rb +++ b/spec/unit/query_control_waiter_ownership_spec.rb @@ -204,4 +204,24 @@ def read_task sender&.join end end + + # The deadline is still chosen by "has an Async task": for such a caller it + # is the cooperative timeout, now cancelling a wait on the ThreadWaiter. + it 'still times out a caller on another reactor whose response never arrives' do + transport.hold_interrupt_response! # the response is never delivered + + with_session_on_its_own_reactor do + # Only from here on: the handshake above keeps its generous cap. + allow(query).to receive(:control_request_timeout_seconds).and_return(0.2) + answer = interrupt_from_another_reactor.value + + aggregate_failures do + expect(answer).to be_a(ClaudeAgentSDK::ControlRequestTimeoutError) + expect(answer.message).to match(/interrupt/) + expect(read_task).to be_alive + expect(query.instance_variable_get(:@pending_control_responses)).to be_empty + expect(query.instance_variable_get(:@pending_control_results)).to be_empty + end + end + end end From 68b1a2e4d5fd159f686635bd6c743e1f93670388 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:10:12 +0800 Subject: [PATCH 084/194] Take the scheduler out of the real-child grace examples Two groups in subprocess_cli_transport_spec.rb start a real child and assert which signal ended it, with graces of 50 ms and 200 ms. An example that expects TERM to do the job failed whenever the child was not scheduled inside that window: the SDK escalated to KILL and the example reported `expected: 15, got: 9`. Holding the child off the CPU for half a second reproduces it every time. The examples that expect KILL had the mirror image: the same short grace also bounds the wait for the kernel to reap the killed child, and a late reap left it registered. A grace is now short only where the wait has to lapse: - a child expected to die on TERM gets a generous grace (30 s). It is gone in milliseconds, so the examples are as fast as before, and a slow machine can no longer turn the TERM into a KILL; - a child that ignores TERM keeps the short grace, and Process.kill returns from the KILL only once the child has been reaped (the signals are still sent for real), so the wait after the KILL is not a race either. Both examples now also assert that TERM was sent before KILL; - the fallback-termination examples wait for the worker and for the child before they read the registry. Specs only; no library change. Exploration finding: F-P8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/unit/subprocess_cli_transport_spec.rb | 45 +++++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/spec/unit/subprocess_cli_transport_spec.rb b/spec/unit/subprocess_cli_transport_spec.rb index 3d9cd74e..c63a9a6d 100644 --- a/spec/unit/subprocess_cli_transport_spec.rb +++ b/spec/unit/subprocess_cli_transport_spec.rb @@ -11,6 +11,23 @@ # (and other specs) from observing leaked test doubles. after { described_class.active_processes.clear } + # SIGKILL cannot be refused, but how soon the kernel has reaped the child + # afterwards is the scheduler's business. Makes Process.kill return from a + # KILL of +waiter+'s process only once that process has been reaped, so an + # example can keep a short grace for the wait that has to lapse (its child + # ignores TERM) without racing the wait that follows the KILL. The signals + # are still sent for real. Returns the [signal, pid] pairs, in order. + def reap_before_kill_returns(waiter) + sent = [] + allow(Process).to receive(:kill).and_wrap_original do |original, signal, pid| + sent << [signal, pid] + result = original.call(signal, pid) + waiter.join(30) if signal == 'KILL' && pid == waiter.pid + result + end + sent + end + describe '#build_command' do it 'passes a string system_prompt via --system-prompt' do options = ClaudeAgentSDK::ClaudeAgentOptions.new( @@ -1642,6 +1659,10 @@ def expect_woken_with_connection_error(writer, caught) let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: '/usr/bin/claude') } let(:transport) { described_class.new('hi', options) } + # Short graces, for the waits that have to lapse: a child that never + # exits on its own, or ignores TERM. An example whose child is expected + # to act INSIDE a grace (die on TERM, exit by itself) gives that grace a + # generous value instead, so a slow machine cannot change the outcome. before do stub_const("#{described_class}::EOF_EXIT_GRACE_SECONDS", 0.2) stub_const("#{described_class}::EOF_TERM_GRACE_SECONDS", 0.2) @@ -1673,7 +1694,7 @@ def read_to_end(transport) rescue StandardError => e e end - expect(@runner.join(10)).not_to be_nil, 'read_messages never returned after stdout EOF' + expect(@runner.join(60)).not_to be_nil, 'read_messages never returned after stdout EOF' @runner.value end @@ -1692,8 +1713,10 @@ def reap(stdout, stderr, waiter) it 'escalates TERM then KILL for a child that ignores TERM, surfacing a ProcessError like the signal path' do stdout, stderr, waiter = spawn_child('sleep', prelude: 'trap("TERM") {}; ') + sent = reap_before_kill_returns(waiter) error = read_to_end(transport) + expect(sent.first(2)).to eq([['TERM', waiter.pid], ['KILL', waiter.pid]]) expect(error).to be_a(ClaudeAgentSDK::ProcessError) expect(error.exit_code).to eq(-9) expect(error.message).to include('did not exit within 0.2s of closing stdout') @@ -1704,6 +1727,9 @@ def reap(stdout, stderr, waiter) end it 'TERMs a child that keeps running after stdout EOF and reports the signal' do + # A healthy child dies on TERM in milliseconds; the generous grace only + # keeps a slow machine from escalating to KILL. + stub_const("#{described_class}::EOF_TERM_GRACE_SECONDS", 30) stdout, stderr, waiter = spawn_child('sleep') error = read_to_end(transport) @@ -1715,6 +1741,7 @@ def reap(stdout, stderr, waiter) end it 'waits without blocking the reactor when the read loop runs in a task' do + stub_const("#{described_class}::EOF_TERM_GRACE_SECONDS", 30) # as above: TERM is expected to work stdout, stderr, waiter = spawn_child('sleep') ticks = 0 @@ -1766,7 +1793,7 @@ def reap(stdout, stderr, waiter) it 'does not signal a child that exits on its own within the grace period' do # A generous grace here: interpreter exit + reap must fit inside it even # on a slow CI runner, or the example would signal a healthy child. - stub_const("#{described_class}::EOF_EXIT_GRACE_SECONDS", 5) + stub_const("#{described_class}::EOF_EXIT_GRACE_SECONDS", 30) expect(Process).not_to receive(:kill) stdout, stderr, waiter = spawn_child('exit 0') @@ -1959,9 +1986,17 @@ def bare_transport expect(stdout.gets).to eq("ready\n") described_class.register_active_process(waiter) - worker = bare_transport.force_terminate_in_background(waiter, grace_seconds: 0.05) - expect(worker.join(2)).not_to be_nil - expect(waiter.join(1)).not_to be_nil + # The worker waits grace_seconds twice: for TERM to work, then for the + # KILLed child to be reaped. A child that dies on TERM gets a generous + # grace (it is gone in milliseconds, so this stays fast). One that + # ignores TERM keeps the short grace, which has to lapse anyway, and + # the KILL only returns once the child is reaped. + sent = reap_before_kill_returns(waiter) if ignore_term + worker = bare_transport.force_terminate_in_background(waiter, grace_seconds: ignore_term ? 0.05 : 30) + # Worker finished and child reaped, before the registry is read. + expect(worker.join(60)).not_to be_nil + expect(sent).to eq([['TERM', waiter.pid], ['KILL', waiter.pid]]) if ignore_term + expect(waiter.join(60)).not_to be_nil expect(waiter.value.termsig).to eq(Signal.list.fetch(ignore_term ? 'KILL' : 'TERM')) expect(described_class.active_processes).not_to include(waiter) ensure From 071e03d6607b57497f3d6e5d3e6296f3c6d073c0 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:25:28 +0800 Subject: [PATCH 085/194] Synchronize query_run_end_spec on barriers, not on sleeps The spec fed frames to a queue-backed read loop and then slept 10 ms on the assumption that the loop had processed them by then, and its ceiling examples used a 50 ms ceiling and slept past it. On a loaded machine both assumptions failed (lane F saw three different examples fail that way), and a read loop that is merely slow is enough: with 30 ms per frame, the example for sdk_host_only frames stops its consumer before it consumed anything. feed now enqueues a barrier behind the frames. The fake transport takes it off the queue only after the block for the frame before it has returned, so the read loop has finished every side effect of every frame, including the ones that suspend it half-way (arming and stopping the ceiling sleeper both yield), and the tasks the frames woke then get a few turns on the reactor. No clock decides an ordering any more. The ceiling examples leave the default ten-minute ceiling in place and call end_run_at_ceiling(generation) themselves, which is what the sleeper does when its sleep is over: they test which sleeper is armed when, and what one that wakes is allowed to do. One example still sleeps through a real 300 ms ceiling. The "not yet ended" check that used to race a 50 ms timer is its own example now, with no timer to race. Both versions kill the same eleven run-end mutants, on async 2.36, 2.10 and 2.46. The file now runs in about 0.35 s. Specs only; no library change. The forced-order regression for Query#end_run's statement order is not here: it ships with that fix. Exploration finding: F-P3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/unit/query_run_end_spec.rb | 282 ++++++++++++++++++++++---------- 1 file changed, 195 insertions(+), 87 deletions(-) diff --git a/spec/unit/query_run_end_spec.rb b/spec/unit/query_run_end_spec.rb index ad53db0e..167ea16f 100644 --- a/spec/unit/query_run_end_spec.rb +++ b/spec/unit/query_run_end_spec.rb @@ -7,20 +7,30 @@ # #1190/#1279): at the CLI's "idle" session state after a result, bounded # between turns by CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, and at the first # result with no tracked task in flight from a CLI that sends no state. +# +# Nothing here waits on a clock to "let the read loop catch up": frames are +# followed by a barrier (#with_query), and the ceiling is fired by hand +# (#ceiling_passes) in every example but one. RSpec.describe ClaudeAgentSDK::Query do + # The transport double. Frames come from +queue+; a Thread::Queue among + # them is a barrier, which is answered instead of delivered (#with_query). + # +written+ receives every line the SDK writes. def queue_fed_transport(queue) ended = [] + written = Thread::Queue.new transport = mock_transport allow(transport).to receive(:end_input) { ended << true } + allow(transport).to receive(:write) { |line| written << line } allow(transport).to receive(:read_messages) do |&blk| loop do msg = queue.dequeue break if msg == :eof + next msg.push(:reached) if msg.is_a?(Thread::Queue) blk.call(msg) end end - [transport, ended] + [transport, ended, written] end def hooks_config @@ -57,19 +67,38 @@ def user_message(content = 'hi') { type: 'user', message: { role: 'user', content: content }, session_id: '' } end - # Runs +body+ with a started query whose read loop is fed from a queue. - # `feed` enqueues frames and yields so the read loop processes them. + # Runs +body+ with a started query whose read loop is fed from a queue, and + # yields the query, `feed`, the end_input calls so far, the task and the + # queue of written lines. + # + # `feed.call(*frames)` returns once the read loop is done with the frames + # and what they woke has run. It enqueues a barrier behind them, which the + # transport double only takes off the queue after the block for the frame + # before it has returned — so every side effect of every frame is in place, + # including the ones that suspend the read loop half-way (arming and + # stopping the ceiling sleeper both yield). Being told that a frame was + # dequeued would not be enough. Then every task that is ready gets a few + # turns on the reactor: a task the frames woke (the stdin-closing waiter) + # needs two to reach end_input, as it yields once if it has a ceiling + # sleeper to stop; five is that with room to spare, and still no clock. + # + # `feed.call` with no frames is the same barrier with nothing in front of + # it: it lets what the example itself made ready (a gate it opened, a + # ceiling it fired) run. def with_query(**kwargs) queue = Async::Queue.new - transport, ended = queue_fed_transport(queue) + transport, ended, written = queue_fed_transport(queue) query = build_query(transport, **kwargs) Async do |task| query.start feed = lambda do |*frames| - frames.each { |frame| queue.enqueue(frame) } - task.sleep 0.01 + barrier = Thread::Queue.new + [*frames, barrier].each { |frame| queue.enqueue(frame) } + raise 'the read loop never got through the frames it was fed' unless barrier.pop(timeout: 10) + + 5.times { task.yield } end - yield query, feed, ended, task + yield query, feed, ended, task, written ensure query.close release_parked_tasks(task) @@ -90,6 +119,12 @@ def release_parked_tasks(task) raise end + # Waits for a task that is expected to finish now. The bound is what a + # regression runs into; nothing healthy comes near it. + def finish(task, awaited) + task.with_timeout(10) { awaited.wait } + end + describe 'stdin stays open until idle' do it 'keeps stdin open past a result when a task settled just before it, and closes at idle (#1190)' do with_query do |query, feed, ended, task| @@ -101,7 +136,7 @@ def release_parked_tasks(task) expect(ended).to be_empty feed.call(state('idle')) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -127,7 +162,7 @@ def release_parked_tasks(task) expect(ended).to be_empty feed.call(state('idle', sdk_host_only: false)) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -136,7 +171,7 @@ def release_parked_tasks(task) with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(result) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -148,7 +183,7 @@ def release_parked_tasks(task) expect(ended).to be_empty # idle before any result does not end the run feed.call(result) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -160,7 +195,7 @@ def release_parked_tasks(task) expect(ended).to be_empty feed.call(task_done('bg-1'), state('running'), main_assistant, result, state('idle')) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -172,7 +207,7 @@ def release_parked_tasks(task) expect(ended).to be_empty feed.call(state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -185,15 +220,17 @@ def release_parked_tasks(task) y << user_message('two') end - with_query do |query, feed, ended, task| + with_query do |query, feed, ended, task, written| streamer = task.async { query.stream_input(prompts) } + expect(written.pop(timeout: 10)).to include('one') feed.call(state('running'), result, state('idle')) # the first message's run ends gate.enqueue(:go) - task.sleep 0.01 - expect(ended).to be_empty # the second message's run has not even started + expect(written.pop(timeout: 10)).to include('two') + feed.call # the stream is exhausted: the streamer now waits for message two's run + expect(ended).to be_empty # which has not even started feed.call(state('running'), result, state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -211,7 +248,7 @@ def release_parked_tasks(task) expect(ended).to be_empty feed.call(state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -229,11 +266,11 @@ def release_parked_tasks(task) feed.call(state('running'), result, state('idle')) # ended while the stream is still open feed.call(state(wake)) # a finished background task woke the CLI gate.enqueue(:go) - task.sleep 0.01 + feed.call # the stream is exhausted: the streamer waits for the reopened run expect(ended).to be_empty feed.call(state('running'), main_assistant, result, state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -241,136 +278,207 @@ def release_parked_tasks(task) end describe 'run-end ceiling' do - let(:ceiling) { { run_end_ceiling_ms: 50 } } + # The ceiling is a sleeper task that calls end_run_at_ceiling(generation) + # when its sleep is over. These examples leave the default ceiling of ten + # minutes in place, so the real sleeper never wakes, and make that call + # themselves: what is under test is which sleeper is armed when, and what + # one that wakes is allowed to do — not how long the sleep takes. The one + # example that sleeps through a real ceiling is the first. + def ceiling_armed?(query) + !query.instance_variable_get(:@run_end_ceiling_task).nil? + end + + # The generation the armed sleeper holds (it stands down when it wakes + # with a stale one). + def ceiling_generation(query) + query.instance_variable_get(:@run_end_ceiling_generation) + end + + # What the sleeper armed at +generation+ does when its sleep is over; + # +feed+ then lets what that woke run. + def ceiling_passes(query, generation, feed) + query.send(:end_run_at_ceiling, generation) + feed.call + end it 'ends the run when no idle arrives within the ceiling after a result' do - with_query(**ceiling) do |query, feed, ended, task| + with_query(run_end_ceiling_ms: 300) do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('running'), result) - expect(ended).to be_empty - task.with_timeout(2) { waiter.wait } + # No idle follows: only the ceiling, a real one here, ends this run. + finish(task, waiter) expect(ended).not_to be_empty end end + it 'does not end the run at a result while the CLI still reports running' do + with_query do |query, feed, ended, task| + task.async { query.wait_for_result_and_end_input } + feed.call(state('running'), result) + + expect(ended).to be_empty + expect(ceiling_armed?(query)).to be(true) # the clock for the wait between turns + end + end + it 'is stopped by a main-thread turn and re-armed by its result' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result, main_assistant) - task.sleep 0.15 + feed.call(state('running'), result) + stopped = ceiling_generation(query) + feed.call(main_assistant) + expect(ceiling_armed?(query)).to be(false) + + ceiling_passes(query, stopped, feed) # the stopped sleeper was already waking up expect(ended).to be_empty feed.call(result) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'is not stopped by subagent messages' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result, main_assistant.merge(parent_tool_use_id: 'toolu_1')) - task.with_timeout(2) { waiter.wait } + feed.call(state('running'), result) + armed = ceiling_generation(query) + feed.call(main_assistant.merge(parent_tool_use_id: 'toolu_1')) + expect(ceiling_armed?(query)).to be(true) + expect(ceiling_generation(query)).to eq(armed) # the same sleeper, not a new one + + ceiling_passes(query, armed, feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'is stopped by requires_action and re-armed by the running that follows' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result, state('requires_action')) - task.sleep 0.15 + feed.call(state('running'), result) + stopped = ceiling_generation(query) + feed.call(state('requires_action')) + expect(ceiling_armed?(query)).to be(false) + + ceiling_passes(query, stopped, feed) expect(ended).to be_empty feed.call(state('running')) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'is not armed mid-turn' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| task.async { query.wait_for_result_and_end_input } feed.call(state('running'), result, main_assistant, task_started('bg-1'), task_done('bg-1'), state('running')) - task.sleep 0.15 + + expect(ceiling_armed?(query)).to be(false) expect(ended).to be_empty end end it 'is not armed by a result that arrives while the SDK is answering a request' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('requires_action'), result) - task.sleep 0.15 + expect(ceiling_armed?(query)).to be(false) expect(ended).to be_empty feed.call(state('running')) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'leaves a tracked agent alone and starts over once it settles' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('running'), task_started('bg-1'), result) - task.sleep 0.15 + + ceiling_passes(query, ceiling_generation(query), feed) # with bg-1 still running expect(ended).to be_empty + expect(ceiling_armed?(query)).to be(false) feed.call(task_done('bg-1')) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'does not close stdin under a control request the SDK is still answering' do - log = [] - queue = Async::Queue.new - transport = mock_transport - allow(transport).to receive(:write) { |data| log << [:write, data] } - allow(transport).to receive(:end_input) { log << [:end_input] } - allow(transport).to receive(:read_messages) do |&blk| - loop { blk.call(queue.dequeue) } - end - slow_hook = lambda do |_input, _tool_use_id, _context| - sleep 0.25 # on its worker thread, several ceilings long + entered = Thread::Queue.new + release = Thread::Queue.new + parked_hook = lambda do |_input, _tool_use_id, _context| + entered << true + release.pop # on its worker thread, for as long as the example wants {} end - query = build_query(transport, run_end_ceiling_ms: 50) - query.instance_variable_set(:@hook_callbacks, { 'hook_0' => slow_hook }) + hook_request = { type: 'control_request', request_id: 'req_1', + request: { subtype: 'hook_callback', callback_id: 'hook_0', tool_use_id: nil, + input: { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {}, + session_id: 's', cwd: '/tmp' } } } - Async do |task| - query.start + with_query do |query, feed, ended, task, written| + query.instance_variable_set(:@hook_callbacks, { 'hook_0' => parked_hook }) waiter = task.async { query.wait_for_result_and_end_input } - [state('running'), result, - { type: 'control_request', request_id: 'req_1', - request: { subtype: 'hook_callback', callback_id: 'hook_0', tool_use_id: nil, - input: { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {}, - session_id: 's', cwd: '/tmp' } } }].each { |frame| queue.enqueue(frame) } - task.with_timeout(3) { waiter.wait } + feed.call(state('running'), result, hook_request) + expect(entered.pop(timeout: 10)).to be(true) + + ceiling_passes(query, ceiling_generation(query), feed) # while the hook is still running + expect(ended).to be_empty + expect(ceiling_armed?(query)).to be(true) # the clock starts over instead + + release << true + expect(written.pop(timeout: 10)).to include('req_1') # the reply went out, + expect(ended).to be_empty # on a stdin that was still open + feed.call + + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) + expect(ended).not_to be_empty ensure - query.close - end.wait + release << true # never leave the worker thread parked + end + end + + it 'arms no ceiling when the ceiling is 0, and waits for idle' do + with_query(run_end_ceiling_ms: 0) do |query, feed, ended, task| + waiter = task.async { query.wait_for_result_and_end_input } + feed.call(state('running'), result) + expect(ceiling_armed?(query)).to be(false) + expect(ended).to be_empty - reply = log.index { |entry| entry[0] == :write && entry[1].include?('req_1') } - expect(reply).not_to be_nil - expect(log.index([:end_input])).to be > reply + feed.call(state('idle')) + finish(task, waiter) + expect(ended).not_to be_empty + end end - [0, 10**12].each do |ms| - it "waits for idle with a ceiling of #{ms}" do - with_query(run_end_ceiling_ms: ms) do |query, feed, ended, task| - waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result) - task.sleep 0.1 - expect(ended).to be_empty + # 10**12 ms is past what a timer can take; the ceiling is capped + # (MAX_RUN_END_CEILING_MS), so arming it must neither raise nor fire. + it 'waits for idle with a ceiling of 10**12' do + with_query(run_end_ceiling_ms: 10**12) do |query, feed, ended, task| + waiter = task.async { query.wait_for_result_and_end_input } + feed.call(state('running'), result) + expect(ceiling_armed?(query)).to be(true) + expect(ended).to be_empty - feed.call(state('idle')) - task.with_timeout(2) { waiter.wait } - expect(ended).not_to be_empty - end + feed.call(state('idle')) + finish(task, waiter) + expect(ended).not_to be_empty end end @@ -381,30 +489,30 @@ def release_parked_tasks(task) gate.dequeue end - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| streamer = task.async { query.stream_input(prompts) } feed.call(state('running'), result) - task.sleep 0.1 # the ceiling ends the run while the stream is still open + ceiling_passes(query, ceiling_generation(query), feed) # ends the run while the stream is still open feed.call(main_assistant) gate.enqueue(:go) - task.sleep 0.01 + feed.call # the stream is exhausted: the streamer waits for the reopened run expect(ended).to be_empty feed.call(result, state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end it 'arms no ceiling once stdin is closed' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('running'), result, state('idle')) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended.length).to eq(1) feed.call(state('running'), main_assistant, result) - expect(query.instance_variable_get(:@run_end_ceiling_task)).to be_nil + expect(ceiling_armed?(query)).to be(false) end end @@ -422,7 +530,7 @@ def release_parked_tasks(task) [state('running'), result, :eof].each { |frame| queue.enqueue(frame) } waiter.wait end.wait - end.join(5) + end.join(30) expect(finished).to be_truthy expect(ended).not_to be_empty From 21549eaf06bdb0390691527af2a6f3785680a708 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:28:24 +0800 Subject: [PATCH 086/194] Pin two run-end decisions that no example asserted Two branches of the run-end logic could be removed without a failure: - stream_input resets "a result arrived" when it writes a message: that message owes a result of its own. Without the reset, an idle of the run that just ended, arriving after the next message went out, ended that message's run too and closed stdin under it. - "running" re-arms the run-end ceiling only after a result. "running" before any result is a turn starting, and the ceiling only bounds the wait between turns. query_run_end_spec.rb gets one example for each, on the frame barrier: a late idle after message two was written must leave stdin open, and no ceiling may be armed after "running" with no result yet. Specs only; no library change. Exploration finding: F-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/unit/query_run_end_spec.rb | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/spec/unit/query_run_end_spec.rb b/spec/unit/query_run_end_spec.rb index 167ea16f..815c26e5 100644 --- a/spec/unit/query_run_end_spec.rb +++ b/spec/unit/query_run_end_spec.rb @@ -235,6 +235,32 @@ def finish(task, awaited) end end + # The idle of the run that just ended can still be on its way when the + # next message goes out. It belongs to the earlier run: the new message + # owes a result of its own before an idle can end its run. + it 'does not let a late idle of the previous run end the run of the next message' do + gate = Async::Queue.new + prompts = Enumerator.new do |y| + y << user_message('one') + gate.dequeue + y << user_message('two') + end + + with_query do |query, feed, ended, task, written| + streamer = task.async { query.stream_input(prompts) } + expect(written.pop(timeout: 10)).to include('one') + feed.call(state('running'), result, state('idle')) # the first message's run ends + gate.enqueue(:go) + expect(written.pop(timeout: 10)).to include('two') + feed.call(state('idle')) # a late duplicate, before message two produced anything + expect(ended).to be_empty + + feed.call(state('running'), result, state('idle')) + finish(task, streamer) + expect(ended).not_to be_empty + end + end + # Frame sequence observed from CLI 2.1.285 for three messages written # before any result: it merges queued messages into fewer turns (two # results here) and reports no idle until all queued input is served. @@ -375,6 +401,18 @@ def ceiling_passes(query, generation, feed) end end + # "running" before any result is a turn starting, not the wait between + # turns that the ceiling bounds. + it 'is not armed by running before the first result' do + with_query do |query, feed, ended, task| + task.async { query.wait_for_result_and_end_input } + feed.call(state('running')) + + expect(ceiling_armed?(query)).to be(false) + expect(ended).to be_empty + end + end + it 'is not armed mid-turn' do with_query do |query, feed, ended, task| task.async { query.wait_for_result_and_end_input } From c5365ee1f85d5eba1345e73d45457b56dfb20ffb Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:28:24 +0800 Subject: [PATCH 087/194] Pin that strict attributes are inherited by subclasses Type.strict_attributes is documented as inherited: a subclass of a type the user builds (HookMatcher, a permission result, an option value) rejects a key it does not know, and still accepts a key that names a reader the subclass defines. Only the SDK's own classes were tested, so dropping either half passed the suite. strict_attributes_inheritance_spec.rb checks both on a subclass of HookMatcher, each with the opposite case beside it. Specs only; no library change. Exploration finding: F-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- .../strict_attributes_inheritance_spec.rb | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 spec/unit/strict_attributes_inheritance_spec.rb diff --git a/spec/unit/strict_attributes_inheritance_spec.rb b/spec/unit/strict_attributes_inheritance_spec.rb new file mode 100644 index 00000000..d3475e41 --- /dev/null +++ b/spec/unit/strict_attributes_inheritance_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Strict attributes are documented as inherited (Type.strict_attributes): a +# subclass of a type the user builds — HookMatcher here — rejects a key it +# does not know, as the type itself does, and still accepts one that names a +# reader the subclass defines. strict_attributes_spec.rb covers the SDK's +# own classes; nothing covered a subclass. +RSpec.describe ClaudeAgentSDK::Type do + describe 'strict attributes on a subclass of a strict type' do + it 'raises for an unknown key, as the parent type does' do + subclass = Class.new(ClaudeAgentSDK::HookMatcher) + + expect { subclass.new(matchr: 'Bash') }.to raise_error(ArgumentError, /unknown attribute :matchr/) + expect(subclass.new(matcher: 'Bash').matcher).to eq('Bash') + end + + it 'accepts a key that names a reader the subclass defines' do + subclass = Class.new(ClaudeAgentSDK::HookMatcher) do + def note + 'defined by the subclass' + end + end + + expect { subclass.new(matcher: 'Bash', note: 'x') }.not_to raise_error + expect { subclass.new(notes: 'x') }.to raise_error(ArgumentError, /unknown attribute :notes/) + end + end +end From 22b1546c5b0d5de5eb0a0c228eddd2bb293ad7f2 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:33:17 +0800 Subject: [PATCH 088/194] Add a keyless real-CLI smoke; show the keyed run as skipped "Integration (real CLI)" had never run a spec: the repository has no ANTHROPIC_API_KEY secret, every run took the skip path inside a job that then reported success, and the CLI pins moved on that green check. integration.yml now has two jobs on the same triggers: - keyless-smoke needs no secret. It installs the pinned CLI, puts it on PATH and runs spec/integration/keyless_smoke_spec.rb with RUN_KEYLESS_SMOKE=1: with a fresh HOME and CLAUDE_CONFIG_DIR, no API key and CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, Client#connect must complete the initialize handshake (commands, models and agents listed, token source "none"), and a one-shot query must yield InitMessage, an AssistantMessage whose error is authentication_failed and an error ResultMessage, then raise ResultError, all under a bound. Without credentials the CLI answers the prompt itself, so no model is reached and nothing is billed. A last step fails the job if the spec ran no examples. - real-cli is unchanged inside, but the key check moved to a gate job and real-cli runs only when the gate found the key, so a run without it shows real-cli as skipped instead of passed. The workflow also triggers on changes to the transport, Query and the message parser, and its header describes both jobs. The smoke file filters its examples out without RUN_KEYLESS_SMOKE, so the default suite never runs it. CONTRIBUTING.md says what CI runs. Exploration findings: O-P2, O-S1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- .github/workflows/integration.yml | 99 ++++++++++++++++++++------ CONTRIBUTING.md | 2 +- spec/integration/keyless_smoke_spec.rb | 81 +++++++++++++++++++++ 3 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 spec/integration/keyless_smoke_spec.rb diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7088c2f8..17cb6490 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,16 +1,24 @@ -# Real-CLI integration run: installs the CLI pinned by -# CLIInstaller::PINNED_CLI_VERSION and runs spec/integration against it with -# live, budget-capped API calls (each example sets max_budget_usd). +# Real-CLI checks. Each job installs the CLI pinned by +# CLIInstaller::PINNED_CLI_VERSION (rake claude_agent_sdk:install_cli) and +# drives it from the SDK: # -# Triggers: weekly, on demand, and on PRs that touch the installer (which is -# where cli-pin-bump.yml moves the pin), so a CLI bump is checked against the -# real binary before it merges. +# - keyless-smoke needs no secret and runs on every trigger: the initialize +# handshake, and a prompt the CLI cannot send for lack of credentials, +# which must come back as a typed ResultError rather than a hang +# (spec/integration/keyless_smoke_spec.rb). It checks the installer, the +# transport, the control protocol, the message parser and the error path +# against the real binary. It reaches no model. +# - real-cli runs spec/integration with live, budget-capped API calls (each +# example sets max_budget_usd). It needs an ANTHROPIC_API_KEY repository +# secret: the gate job checks for it, and without one real-cli shows as +# skipped, not as passed. Fork and Dependabot PRs never receive secrets, +# so for them it is always skipped. # -# Requires an ANTHROPIC_API_KEY repository secret. Without it the job skips -# with a notice instead of failing. Fork and Dependabot PRs never receive -# secrets, so they always take the skip path; so do pin-bump PRs opened with -# the default GITHUB_TOKEN, which don't trigger workflows at all (see -# cli-pin-bump.yml; dispatch this workflow on that branch instead). +# Triggers: weekly, on demand, and on PRs that touch the installer (which is +# where cli-pin-bump.yml moves the pin), the transport, Query or the message +# parser, so a CLI bump or a wire-protocol change meets the real binary +# before it merges. A pin-bump PR opened with the default GITHUB_TOKEN gets +# its runs only once someone approves them (see cli-pin-bump.yml). name: Integration (real CLI) on: @@ -23,6 +31,9 @@ on: branches: [main] paths: - lib/claude_agent_sdk/cli_installer.rb + - lib/claude_agent_sdk/subprocess_cli_transport.rb + - lib/claude_agent_sdk/query.rb + - lib/claude_agent_sdk/message_parser.rb - spec/integration/** - .github/workflows/integration.yml @@ -34,31 +45,76 @@ concurrency: cancel-in-progress: true jobs: - real-cli: - name: real CLI (pinned version, Ruby 3.4) + keyless-smoke: + name: keyless smoke (pinned CLI, no credentials, Ruby 3.4) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + # As in real-cli below: the vendored binary, put on PATH. + - name: Install pinned Claude Code CLI + run: | + bundle exec rake claude_agent_sdk:install_cli + echo "$GITHUB_WORKSPACE/vendor/claude" >> "$GITHUB_PATH" + + - name: Show CLI version + run: claude --version + + - name: Run the keyless smoke + env: + RUN_KEYLESS_SMOKE: '1' + run: bundle exec rspec spec/integration/keyless_smoke_spec.rb --format json --out "$RUNNER_TEMP/keyless-smoke.json" + + # Without RUN_KEYLESS_SMOKE the spec filters its examples out, and rspec + # passes a run that executed none. Make sure this one ran them. + - name: Check that the smoke ran + run: >- + ruby -rjson -e 'summary = JSON.parse(File.read(ARGV[0])).fetch("summary"); + abort("the keyless smoke did not run its examples: #{summary}") unless + summary["example_count"] >= 2 && summary["pending_count"].zero? && summary["failure_count"].zero?' + "$RUNNER_TEMP/keyless-smoke.json" + + # secrets.* can't appear in an `if:`, so this job checks for the key and + # real-cli keys off its output. + gate: + name: ANTHROPIC_API_KEY available + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.check.outputs.run }} steps: - # secrets.* can't appear in an `if:`, so a step checks for the key and - # the rest of the job keys off its output. - name: Check for ANTHROPIC_API_KEY - id: gate + id: check env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | if [ -z "$ANTHROPIC_API_KEY" ]; then - echo "::notice title=Integration run skipped::The ANTHROPIC_API_KEY secret is not available (not configured, or a fork/Dependabot PR). Add it as a repository secret to enable this run." + echo "::notice title=Keyed integration run skipped::The ANTHROPIC_API_KEY secret is not available (not configured, or a fork/Dependabot PR), so the real-cli job is skipped. Add it as a repository secret to enable that run; keyless-smoke runs either way." echo "run=false" >> "$GITHUB_OUTPUT" else echo "run=true" >> "$GITHUB_OUTPUT" fi + real-cli: + name: real CLI (pinned version, Ruby 3.4) + needs: gate + if: needs.gate.outputs.run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: - uses: actions/checkout@v4 - if: steps.gate.outputs.run == 'true' - name: Set up Ruby 3.4 - if: steps.gate.outputs.run == 'true' uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' @@ -69,17 +125,14 @@ jobs: # the CLI up on PATH and skip without it, so put it there and fail # loudly here if it doesn't run. - name: Install pinned Claude Code CLI - if: steps.gate.outputs.run == 'true' run: | bundle exec rake claude_agent_sdk:install_cli echo "$GITHUB_WORKSPACE/vendor/claude" >> "$GITHUB_PATH" - name: Show CLI version - if: steps.gate.outputs.run == 'true' run: claude --version - name: Run integration specs - if: steps.gate.outputs.run == 'true' env: RUN_INTEGRATION: '1' ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fecc23c1..7ba7dab2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ bundle exec rake claude_agent_sdk:install_cli # optional: the pinned CLI, into PATH="$PWD/vendor/claude:$PATH" RUN_INTEGRATION=1 ANTHROPIC_API_KEY=... bundle exec rspec spec/integration ``` -Run them when you change anything on the CLI wire protocol (the transport, `Query`, the control protocol, `CommandBuilder`). CI also runs them weekly, and on PRs that touch `cli_installer.rb`, against the pinned CLI version. +Run them when you change anything on the CLI wire protocol (the transport, `Query`, the control protocol, `CommandBuilder`). CI also runs them weekly, and on PRs that touch the installer, the transport, `Query` or the message parser, against the pinned CLI version, when the repository has an `ANTHROPIC_API_KEY` secret; without it that job shows as skipped. On the same triggers CI runs a keyless smoke that needs no secret (`spec/integration/keyless_smoke_spec.rb`): it installs the pinned CLI, completes the `initialize` handshake and checks that a prompt without credentials fails with a `ResultError`. **TLA+ models** (`formal/tla/`) model-check the CLI installer's publish/lock ordering and the outbound control-request protocol. They need Java 11+; `run.sh` fetches the pinned TLA+ tools itself. Run `formal/tla/run.sh` when you change `CLIInstaller.install` / `#publish` or `Query#send_control_request` / `#await_control_response`, and update the model if the design changed. `formal/tla/README.md` explains what each model covers. diff --git a/spec/integration/keyless_smoke_spec.rb b/spec/integration/keyless_smoke_spec.rb new file mode 100644 index 00000000..a7e899fe --- /dev/null +++ b/spec/integration/keyless_smoke_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# A real-CLI smoke that needs no secret, so CI can run it on every PR that +# moves the pinned CLI (integration.yml, job keyless-smoke): the installed +# binary, the real transport, the control protocol, the message parser and +# the error path, against a CLI that has no credentials at all. It reaches +# no model: without credentials the CLI answers a prompt itself. +# +# It runs only with RUN_KEYLESS_SMOKE=1, and from then on it skips nothing: +# a missing CLI is a failure. The CLI is found the way the SDK finds it +# (CLAUDE_CLI_PATH, the vendored install, PATH): +# +# bundle exec rake claude_agent_sdk:install_cli +# RUN_KEYLESS_SMOKE=1 bundle exec rspec spec/integration/keyless_smoke_spec.rb +RSpec.describe 'The real CLI with no credentials', if: ENV['RUN_KEYLESS_SMOKE'] == '1' do + around do |example| + Dir.mktmpdir('keyless-smoke') do |home| + @home = File.realpath(home) + example.run + end + end + + # Nothing the CLI could authenticate with: a fresh HOME and config dir, no + # settings files, and none of the variables that carry a key or a token or + # pick a cloud provider (a developer machine may export any of them). + def keyless_options(**options) + env = { 'HOME' => @home, 'CLAUDE_CONFIG_DIR' => File.join(@home, '.claude'), + 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } + %w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN + CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY].each { |name| env[name] = nil } + ClaudeAgentSDK::ClaudeAgentOptions.new(cwd: @home, setting_sources: [], env: env, **options) + end + + # Runs the block on a thread of its own: whatever the CLI does without + # credentials, it must not leave the SDK waiting. Generous — a healthy run + # takes a few seconds. + def bounded(seconds = 120) + worker = Thread.new do + Thread.current.report_on_exception = false # re-raised below, by #value + yield + end + return worker.value if worker.join(seconds) + + ClaudeAgentSDK::SubprocessCLITransport.kill_active_processes # the CLI is what it waits on + worker.kill unless worker.join(30) + raise "still running after #{seconds}s (the CLI was terminated to end the example)" + end + + it 'completes the initialize handshake' do + info = bounded do + ClaudeAgentSDK::Client.open(options: keyless_options, &:server_info) + end + + expect(info.fetch(:commands)).to include(a_hash_including(:name, :description)) + expect(info.fetch(:models)).to include(a_hash_including(:value)) + expect(info.fetch(:agents)).to include(a_hash_including(:name)) + expect(info.dig(:account, :tokenSource)).to eq('none') + end + + it 'turns a prompt it cannot send into a typed failure' do + messages = [] + error = bounded do + ClaudeAgentSDK.query(prompt: 'Say hi', options: keyless_options(max_turns: 1)) { |message| messages << message } + nil + rescue ClaudeAgentSDK::ResultError => e + e + end + + expect(messages.map(&:class)).to eq([ClaudeAgentSDK::InitMessage, ClaudeAgentSDK::AssistantMessage, + ClaudeAgentSDK::ResultMessage]) + assistant, result = messages.last(2) + expect(assistant.error).to eq('authentication_failed') + expect(result.is_error).to be(true) + expect(result.total_cost_usd).to eq(0) # the CLI answered itself; no model was called + expect(error).to be_a(ClaudeAgentSDK::ResultError) + expect(error.exit_code).to eq(1) + end +end From 4d650d213f09ca6ddf687df876df8d50acd6cc99 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:34:04 +0800 Subject: [PATCH 089/194] Tear the session down even when an observer wait is interrupted Client#disconnect notified on_close, and a failing Client#connect notified on_error, before the begin/ensure that guards their teardown. An exception raised into the caller's fiber while it waited for that observer therefore skipped the teardown: the transport stayed open, the read loop kept running, the CLI subprocess was left behind, and so was the materialized resume dir with its credentials copy. Two kinds of interruption reached that path: - Since "Let a caller's deadline through while an observer runs", a caller's task.with_timeout or Timeout.timeout deadline propagates out of the observer hop instead of being swallowed, so with the default :thread scheduling it now skipped the teardown (before that commit the deadline was lost and the teardown ran). - Exceptions that are not StandardErrors always propagated: Async::Stop when the caller's task is stopped, and FiberBoundary's cooperative timeouts (an inline hook's timeout) under :inline scheduling. Those skipped the teardown before as well. The notification now sits inside a begin whose ensure is the unchanged teardown chain, in both places. on_close is still sent first and still only when connected, the query handler still closes before the transport, the materialized dir is still decided after the subprocess exited, and a cleanup failure still never replaces the original connect error. An interrupted disconnect or connect now completes the teardown, then lets the interruption propagate. With :inline scheduling a StandardError deadline is still contained by the observer it lands in (a known residue); the teardown runs either way. Exploration finding: A-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk.rb | 75 +++-- .../client_teardown_under_deadline_spec.rb | 306 ++++++++++++++++++ 2 files changed, 351 insertions(+), 30 deletions(-) create mode 100644 spec/unit/client_teardown_under_deadline_spec.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index f0943915..0af7326d 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -1075,17 +1075,22 @@ def connect(prompt = nil) # (The enumerator branch streams in the background and cannot raise # out of connect.) No on_close follows for pre-handshake failures # (disconnect gates it on @connected): the session never opened. - notify_error(e) if e.is_a?(StandardError) && !@connected - # Tear down the partial connect, but never let a cleanup failure (e.g. a - # custom transport whose #close raises) mask the original connect error. - # Rescue Exception (not StandardError) so reactor cancellation - # (Async::Stop < Exception) after materialize_resume set @materialized - # still runs disconnect -> @materialized.cleanup, never leaking the temp - # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy. begin - disconnect - rescue StandardError => cleanup_error - warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}" + notify_error(e) if e.is_a?(StandardError) && !@connected + ensure + # Tear down the partial connect, but never let a cleanup failure (e.g. a + # custom transport whose #close raises) mask the original connect error. + # Rescue Exception (not StandardError) so reactor cancellation + # (Async::Stop < Exception) after materialize_resume set @materialized + # still runs disconnect -> @materialized.cleanup, never leaking the temp + # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy. + # In an ensure: an exception raised into this fiber while it waits + # for the on_error observer (a caller's deadline) must not skip it. + begin + disconnect + rescue StandardError => cleanup_error + warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}" + end end raise end @@ -1346,11 +1351,11 @@ def get_server_info # holds, in every scheduling mode, for a streaming-input enumerator that # calls disconnect: it is iterated on the reactor inside a task the close # stops, so it unwinds with Async::Stop once the teardown has completed. + # + # Interrupted while an observer's on_close runs — the caller's deadline + # expires, or its task is stopped — disconnect still completes the + # teardown, then lets the interruption propagate. def disconnect - if @connected - ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - end # Tear down whatever exists — robust to a partial/failed connect, where # @connected is still false but a transport and/or materialized temp dir # were already created. #close on the query handler also closes the @@ -1367,26 +1372,36 @@ def disconnect # and the materialized-dir decision at the bottom needs to ask it. query_handler = @query_handler begin - @query_handler&.close + # Notified inside the begin: an exception raised into this fiber while + # it waits for an observer (a caller's deadline) propagates, and must + # not skip the teardown below. + if @connected + ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, + scheduling: @callback_scheduling, wrapper: @callback_wrapper) + end ensure - @query_handler = nil begin - @transport&.close + @query_handler&.close ensure - @transport = nil - @connected = false - # Remove the materialized resume temp dir AFTER the subprocess - # exited — unless the mirror dropped batches: the store copy is then - # incomplete and the temp dir holds the only copy of the dropped - # turns, so it is preserved (scrubbed of credentials) with a warning - # instead of deleted. - if @materialized - if query_handler&.mirror_batches_dropped? - @materialized.preserve_transcripts - else - @materialized.cleanup + @query_handler = nil + begin + @transport&.close + ensure + @transport = nil + @connected = false + # Remove the materialized resume temp dir AFTER the subprocess + # exited — unless the mirror dropped batches: the store copy is then + # incomplete and the temp dir holds the only copy of the dropped + # turns, so it is preserved (scrubbed of credentials) with a warning + # instead of deleted. + if @materialized + if query_handler&.mirror_batches_dropped? + @materialized.preserve_transcripts + else + @materialized.cleanup + end + @materialized = nil end - @materialized = nil end end end diff --git a/spec/unit/client_teardown_under_deadline_spec.rb b/spec/unit/client_teardown_under_deadline_spec.rb new file mode 100644 index 00000000..1cdfd2f9 --- /dev/null +++ b/spec/unit/client_teardown_under_deadline_spec.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'fileutils' +require 'securerandom' +require 'tmpdir' + +# Client#disconnect notifies on_close, and a failing Client#connect notifies +# on_error, BEFORE they tear the session down. Whatever is raised into the +# caller's fiber while it waits for that observer must not skip the teardown: +# the transport is closed, the read loop ends and the materialized resume dir +# (it holds a credentials copy) is removed, and only then does the exception +# reach the caller. +# +# What reaches the caller differs by scheduling. With the default :thread +# scheduling a task.with_timeout expiry propagates. With :inline the observer +# runs on the caller's fiber, so that same expiry lands inside the observer and +# is contained with the observer's own failures (a known residue). What does +# get through under :inline is a cancellation that is not a StandardError: +# FiberBoundary's cooperative timeouts (an inline hook's timeout) and +# Async::Stop. +# +# The observers block on a queue until the example releases them, so the +# deadlines can only expire while they wait. +RSpec.describe ClaudeAgentSDK::Client, 'teardown when the caller is interrupted during an observer' do + # In-memory stand-in for the CLI process. It answers `initialize` the way + # CLI 2.1.286 answers control requests (payload trimmed), or rejects it + # with the error frame shape the CLI uses for a refused request. + let(:fake_cli_class) do + Class.new do + attr_reader :config_dir + + def initialize(options, reject_initialize: false) + @config_dir = options.env['CLAUDE_CONFIG_DIR'] # the materialized resume dir + @reject_initialize = reject_initialize + @stdout = Thread::Queue.new + @closed = false + @read_loop_ended = false + end + + def connect; end + + def ready? + !@closed + end + + def end_input; end + + def closed? + @closed + end + + def read_loop_ended? + @read_loop_ended + end + + def inspect + "#<fake CLI closed=#{@closed} read_loop_ended=#{@read_loop_ended}>" + end + + def close + @closed = true + @stdout << :eof + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + return unless frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + + @stdout << { type: 'control_response', response: initialize_response(frame[:request_id]) } + end + + def read_messages + while (frame = @stdout.pop) != :eof + yield frame + end + ensure + @read_loop_ended = true + end + + private + + def initialize_response(request_id) + if @reject_initialize + { subtype: 'error', request_id: request_id, error: 'Invalid initialize request' } + else + { subtype: 'success', request_id: request_id, + response: { commands: [], agents: [], output_style: 'default', models: [], pid: 4242, + session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] } + end + end + end + end + + let(:cwd) { Dir.mktmpdir('client-teardown-cwd-') } + let(:user_config_dir) { Dir.mktmpdir('client-teardown-config-') } # keeps the host's Keychain out of it + let(:session_id) { SecureRandom.uuid } + let(:store) do + ClaudeAgentSDK::InMemorySessionStore.new.tap do |store| + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id }, + [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'message' => { 'content' => 'hi' } }]) + end + end + let(:release) { Thread::Queue.new } + let(:entered) { Thread::Queue.new } + let(:handed) { [] } + + after do + release.close + FileUtils.remove_entry(cwd) if File.directory?(cwd) + FileUtils.remove_entry(user_config_dir) if File.directory?(user_config_dir) + end + + # Client materializes a store-backed resume only for SubprocessCLITransport + # and its subclasses, so the class it is given is one; its .new hands back + # the in-memory stand-in. No subprocess is spawned. + def transport_class(created) + fake = fake_cli_class + Class.new(ClaudeAgentSDK::SubprocessCLITransport) do + define_singleton_method(:new) { |options, **kwargs| fake.new(options, **kwargs).tap { |cli| created << cli } } + end + end + + # An observer that stays in +hook+ until the example releases it. on_error + # waits only for the handshake rejection: handed anything else (the + # caller's deadline, had it expired before the hop) it returns at once. + def blocking_observer(hook) + queues = { entered: entered, release: release, handed: handed } + Class.new do + include ClaudeAgentSDK::Observer + + define_method(hook) do |*args| + queues[:handed] << (args.first || :called) + next if hook == :on_error && !args.first.message.include?('Invalid initialize request') + + queues[:entered] << true + queues[:release].pop + end + end.new + end + + def client_for(hook, scheduling:, reject_initialize: false) + created = [] + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + session_store: store, resume: session_id, cwd: cwd, env: { 'CLAUDE_CONFIG_DIR' => user_config_dir }, + observers: [blocking_observer(hook)], callback_scheduling: scheduling + ) + client = described_class.new(options: options, transport_class: transport_class(created), + transport_args: { reject_initialize: reject_initialize }) + [client, created] + end + + # What the caller got from the call: :returned, or the exception it raised. + def outcome_of + yield + :returned + rescue Exception => e # rubocop:disable Lint/RescueException -- the deadline may be a bare Exception + e + end + + def expect_torn_down(client, cli) + aggregate_failures do + expect(cli).to be_closed + expect(cli).to be_read_loop_ended + expect(File.exist?(cli.config_dir)).to be(false) # the materialized resume dir is gone + expect { client.query('again') }.to raise_error(ClaudeAgentSDK::CLIConnectionError, /Not connected/) + end + end + + # The cooperative-timeout class FiberBoundary uses for inline callbacks. + def inline_cancellation + Class.new(ClaudeAgentSDK::FiberBoundary::InlineCancellation) + end + + describe 'Client#disconnect while on_close runs' do + def connected_client(scheduling) + client, created = client_for(:on_close, scheduling: scheduling) + client.connect + [client, created.fetch(0)] + end + + it 'tears the session down, then raises the expired deadline (:thread)' do + Sync do |task| + client, cli = connected_client(:thread) + outcome = outcome_of { task.with_timeout(0.05) { client.disconnect } } + + expect(outcome).to be_a(Async::TimeoutError) + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + + it 'tears the session down, then raises an inline cooperative cancellation (:inline)' do + Sync do |task| + client, cli = connected_client(:inline) + cancellation = inline_cancellation + outcome = outcome_of { task.with_timeout(0.05, cancellation) { client.disconnect } } + + expect(outcome).to be_a(cancellation) + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + + it 'tears the session down when the caller task is stopped (:thread)' do + Sync do |task| + client, cli = connected_client(:thread) + caller_task = task.async { client.disconnect } + entered.pop # on_close is running + caller_task.stop + caller_task.wait + + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + + it 'tears the session down while the observer contains a deadline it ran into (:inline, residue)' do + Sync do |task| + client, cli = connected_client(:inline) + outcome = outcome_of { task.with_timeout(0.05) { client.disconnect } } + + expect(outcome).to eq(:returned) # the expiry landed inside the observer, which contains it + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + end + + describe 'a failing Client#connect while on_error runs' do + # Skips, rather than passes, when the deadline went off before the hop. + def expect_on_error_reached + reached = handed.first.is_a?(StandardError) && handed.first.message.include?('Invalid initialize request') + skip 'the deadline expired before the on_error hop; this run did not reach it' unless reached + end + + it 'tears the partial session down, then raises the expired deadline (:thread)' do + Sync do |task| + client, created = client_for(:on_error, scheduling: :thread, reject_initialize: true) + outcome = outcome_of { task.with_timeout(0.2) { client.connect } } + expect_on_error_reached + + expect(outcome).to be_a(Async::TimeoutError) + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + + it 'tears the partial session down, then raises an inline cooperative cancellation (:inline)' do + Sync do |task| + client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) + cancellation = inline_cancellation + outcome = outcome_of { task.with_timeout(0.2, cancellation) { client.connect } } + expect_on_error_reached + + expect(outcome).to be_a(cancellation) + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + + it 'tears the partial session down when the caller task is stopped (:thread)' do + Sync do |task| + client, created = client_for(:on_error, scheduling: :thread, reject_initialize: true) + caller_task = task.async { client.connect } + entered.pop # on_error is running with the handshake rejection + caller_task.stop + caller_task.wait + + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + + it 'tears the partial session down and raises the connect error while the observer contains a deadline ' \ + '(:inline, residue)' do + Sync do |task| + client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) + outcome = outcome_of { task.with_timeout(0.2) { client.connect } } + expect_on_error_reached + + expect(outcome).to be_a(StandardError).and(have_attributes(message: 'Invalid initialize request')) + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + end +end From 284c883840e4881fc663457ef04b9b06ef5a9bd0 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:34:42 +0800 Subject: [PATCH 090/194] Describe the test suite as it is in spec/README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec/README.md documented a mock_transport(messages: [...]) signature (the helper takes no arguments), an "end-to-end workflow simulation" that does not exist, and listed 5 of the 50 unit spec files. It now describes what is there: the directories and how each is gated, the helpers in spec/support (including ScriptedCLI and the new FakeClaude), how to run the keyless real-CLI smoke, and the conventions the suite relies on — the at-exit registry check, synchronizing on events rather than sleeps, bounding every wait a regression could turn into a hang, and fixtures that look like real frames. Docs only. Exploration finding: F-S6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- spec/README.md | 198 ++++++++++++------------------------------------- 1 file changed, 48 insertions(+), 150 deletions(-) diff --git a/spec/README.md b/spec/README.md index a68a8f7b..89874211 100644 --- a/spec/README.md +++ b/spec/README.md @@ -4,54 +4,49 @@ This directory contains the test suite for the Claude Agent SDK for Ruby. ## Running Tests -### Run all tests ```bash -bundle exec rspec +bundle exec rspec # the default suite (spec/, without spec/rails) +bundle exec rspec spec/unit/message_parser_spec.rb # one file +bundle exec rspec spec/unit/types_spec.rb:42 # one example +bundle exec rspec --seed 81 # a given order (the suite runs in random order) +COVERAGE=1 bundle exec rspec # with a simplecov report in coverage/ +PROFILE=1 bundle exec rspec # and the 10 slowest examples ``` -### Run with detailed output -```bash -bundle exec rspec --format documentation -``` +The other bundles (`gemfiles/floor.gemfile`, `gemfiles/latest.gemfile`), the Rails specs, `rake rbs:test` and RuboCop are described in [CONTRIBUTING.md](../CONTRIBUTING.md). -### Run specific test file -```bash -bundle exec rspec spec/unit/message_parser_spec.rb -``` +## Layout -### Run specific test by line number -```bash -bundle exec rspec spec/unit/types_spec.rb:42 -``` +### `spec/unit/` -### Run with profiling (show slowest 10 tests) -```bash -PROFILE=1 bundle exec rspec -``` +One file per area of `lib/`, e.g. `query_spec.rb`, `subprocess_cli_transport_spec.rb`, `sdk_mcp_server_spec.rb`, `sessions_spec.rb`, `types_spec.rb`. Most of them drive one object with doubles. A few go further on purpose: -## Test Structure +- **Through the control protocol, in process**: `query_hook_input_spec.rb` (the typed input of every hook event), `query_sdk_mcp_messages_spec.rb` (the JSON-RPC exchange with an SDK MCP server) and `query_permission_result_spec.rb` (a `can_use_tool` callback that returns something else than a `PermissionResult`) open a real `Client` on `ScriptedCLI` and assert on the frames the SDK writes back. +- **Against a real child process**: `subprocess_cli_transport_fake_cli_spec.rb` runs `SubprocessCLITransport` and one `query()` against `FakeClaude`, with nothing stubbed. +- **In child processes**: `callback_process_exit_spec.rb` runs every cell of `CallbackExitHarness` in its own Ruby process, because those cells end the process with `exit` or a signal. -### Unit Tests (`spec/unit/`) +### `spec/integration/` -Unit tests verify individual components in isolation: +Both files spawn the real `claude` binary and are skipped by default. -- **errors_spec.rb** - Tests for error classes and their behavior -- **types_spec.rb** - Tests for all type classes (Messages, ContentBlocks, Options, etc.) -- **message_parser_spec.rb** - Tests for JSON message parsing and validation -- **sdk_mcp_server_spec.rb** - Tests for SDK MCP server functionality and tool execution -- **transport_spec.rb** - Tests for the Transport abstract base class +- **`real_cli_integration_spec.rb`** makes live, budget-capped API calls. It is tagged `:integration` and runs only with `RUN_INTEGRATION=1` (`RUN_REAL_INTEGRATION` is accepted as a legacy alias); it then skips itself when `claude` is not on `PATH` or `ANTHROPIC_API_KEY` is unset: -### Integration Tests (`spec/integration/`) + ```bash + RUN_INTEGRATION=1 ANTHROPIC_API_KEY=... bundle exec rspec spec/integration/real_cli_integration_spec.rb + ``` -- **real_cli_integration_spec.rb** - Real Claude CLI end-to-end tests (opt-in only, live API calls) +- **`keyless_smoke_spec.rb`** needs no credentials and reaches no model: with a fresh `HOME` and `CLAUDE_CONFIG_DIR` it completes the `initialize` handshake and checks that a prompt the CLI cannot send comes back as a `ResultError`. It runs only with `RUN_KEYLESS_SMOKE=1` and, once enabled, skips nothing (a missing CLI fails). It finds the CLI the way the SDK does (`CLAUDE_CLI_PATH`, the vendored install, `PATH`): -**Note:** Integration tests are tagged with `:integration` and are skipped by default. `RUN_INTEGRATION=1` is the single gate (`RUN_REAL_INTEGRATION` is accepted as a legacy alias); the suite self-skips green when the `claude` CLI is not on PATH or `ANTHROPIC_API_KEY` is not set: + ```bash + bundle exec rake claude_agent_sdk:install_cli # the pinned CLI, into vendor/claude + RUN_KEYLESS_SMOKE=1 bundle exec rspec spec/integration/keyless_smoke_spec.rb + ``` -```bash -RUN_INTEGRATION=1 ANTHROPIC_API_KEY=... bundle exec rspec spec/integration/real_cli_integration_spec.rb -``` + When you run it from a shell that is itself inside Claude Code, start it under `env -i` with only `HOME`, `PATH` and `LANG`: that shell exports `CLAUDE_CODE_*` variables the CLI child would inherit. + +The workflow `.github/workflows/integration.yml` runs the keyless smoke on every trigger, and the live suite only when the repository has an `ANTHROPIC_API_KEY` secret. -### Rails Integration Tests (`spec/rails/`) +### `spec/rails/` Railtie, `claude_agent_sdk:install_cli` rake task, install generator and `Railtie.callback_wrapper`. They load railties/ActiveSupport, which patch core classes process-wide, so the root `.rspec` excludes `spec/rails` from the default run; they run in their own process against a Rails bundle, with `spec/rails/.rspec` replacing the root options: @@ -60,116 +55,33 @@ BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/. BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rspec --options spec/rails/.rspec # Rails 7.1 floor ``` -### Test Helpers (`spec/support/`) - -- **test_helpers.rb** - Shared test fixtures and helper methods used across test files - -## Test Configuration - -Test configuration is managed in `spec_helper.rb`: - -- **Random order** - Tests run in random order to detect order dependencies -- **Persistence** - Test status is saved to `.rspec_status` for `--only-failures` and `--next-failure` -- **No monkey patching** - RSpec's clean syntax without global method pollution -- **Integration filter** - Integration tests skipped by default (enable with `RUN_INTEGRATION=1`) - -## Test Coverage - -The test suite covers: - -### Error Handling -- All error class instantiation and attributes -- Error inheritance hierarchy -- Error message formatting - -### Type System -- Content blocks (TextBlock, ThinkingBlock, ToolUseBlock, ToolResultBlock) -- Messages (UserMessage, AssistantMessage, SystemMessage, ResultMessage, StreamEvent) -- ClaudeAgentOptions with all configuration fields -- Permission types (PermissionResultAllow, PermissionResultDeny, PermissionUpdate) -- Hook matchers - -### Message Parser -- Parsing all message types from JSON -- Content block parsing -- Validation and error handling for malformed messages -- Missing required field detection - -### SDK MCP Server -- Tool creation with schemas -- Server configuration -- Tool execution and error handling -- JSON schema generation from Ruby types - -### Transport -- Abstract base class interface -- NotImplementedError for unimplemented methods +### `spec/examples/` -### Integration -- Component interaction verification -- SDK MCP server integration -- Hook configuration and execution -- Permission callback handling -- End-to-end workflow simulation +The SessionStore reference adapters under `examples/session_stores/`. The S3 one runs against an in-process fake; the Redis and Postgres ones are live-only and filter themselves out unless their client gem is installed (the optional `examples` Bundler group) and a server is reachable (see the comment at the top of each file). -**Total:** Run `bundle exec rspec` to see the current example count. +## Test Helpers (`spec/support/`) -## Writing New Tests +`spec_helper.rb` requires every file here. -### Test Fixtures +- **`test_helpers.rb`** (included in every example group): `sample_user_message`, `sample_assistant_message`, `sample_assistant_message_with_tool_use`, `sample_result_message` and `sample_system_message` return small message Hashes with Symbol keys, the key style the transport yields (they are trimmed, not copies of real frames); `mock_transport` takes no arguments and returns a double whose `connect`, `close`, `write`, `ready?` and `end_input` are stubbed. An example that needs `read_messages` stubs it on that double itself (`query_run_end_spec.rb` feeds it from a queue). +- **`scripted_cli.rb`** — `ScriptedCLI`, the CLI's end of the control protocol in process. `ScriptedCLI.session(options) { |cli, client| ... }` opens a real `Client` on it; `cli.request(...)` sends a control request (`hook_callback`, `can_use_tool`, `mcp_message`) and returns the control response the SDK wrote; `initialize` is answered the way CLI 2.1.286 answers it. +- **`fake_claude.rb`** — `FakeClaude`, a stand-in for the `claude` executable: a plain-Ruby child process that speaks the stream-JSON protocol. `FakeClaude.install(dir)` writes the executable and returns its absolute path for `cli_path:`; `FakeClaude.env(scenario:, log:)` selects what the child does (a conversation, a slow exit after stdin EOF, a process that ignores EOF and SIGTERM, stderr lines followed by a failure, an immediate exit); `FakeClaude.events(log)` reads back what the child received and did. +- **`callback_exit_harness.rb`** — `CallbackExitHarness`, the child-process side of `callback_process_exit_spec.rb`. -Use the fixtures provided in `spec/support/test_helpers.rb`: +## Conventions -```ruby -include TestHelpers - -# Use sample messages -message = sample_user_message -result = sample_result_message -``` - -### Mock Transports - -Create mock transports for testing without CLI: - -```ruby -mock = mock_transport( - messages: [sample_user_message, sample_result_message] -) -``` - -### Testing SDK Tools - -```ruby -tool = ClaudeAgentSDK.create_tool('test_tool', 'Description', { arg: :string }) do |args| - { content: [{ type: 'text', text: "Result: #{args[:arg]}" }] } -end - -server = ClaudeAgentSDK.create_sdk_mcp_server( - name: 'test_server', - tools: [tool] -) - -result = server[:instance].call_tool('test_tool', { arg: 'value' }) -``` - -## Continuous Integration - -In CI environments, tests run with: - -- Documentation formatter for better output visibility -- Random seed for reproducibility -- Strict failure reporting - -The test suite should always pass with 0 failures before merging. +- `expect` syntax only (`disable_monkey_patching!`), random order, and status persisted to `.rspec_status` for `--only-failures` / `--next-failure`. +- An example must not leave anything in `SubprocessCLITransport`'s at-exit process registry: a check in `spec_helper.rb` fails the example that does, and empties the registry for the next one. +- Synchronize on events, never on the clock: a queue, a barrier, the child's own output. `sleep` to "let things settle" makes an example pass or fail with the machine's load. +- Bound every wait that a regression could turn into a hang (`task.with_timeout`, `Thread#join(seconds)`, `Queue#pop(timeout:)`), and stop or kill whatever is left waiting, so a hang fails the example instead of the run. CI jobs also have a time limit. +- Fixtures should look like what the real CLI sends: same keys, same nesting. Several bugs hid behind hand-simplified frames. +- Examples that pass values outside the RBS signatures on purpose carry `rbs_incompatible: '<reason>'` metadata, which `rake rbs:test` skips (see CONTRIBUTING.md). ## Troubleshooting -### Tests hanging or timing out +### A test hangs -If tests hang, it may be due to: -- Process not terminating properly in transport tests -- Async operations not completing +Run it alone with `--format documentation` to see which example it is. A hang usually means something waits without a bound: a process that does not exit, a task left parked on a condition, a queue that is never fed. ### LoadError or require failures @@ -179,27 +91,13 @@ Ensure dependencies are installed: bundle install ``` -### Integration tests failing - -Integration tests require Claude Code CLI to be installed: +If `bundle exec` cannot find gems that `bundle install` just installed, see the note on version managers in CONTRIBUTING.md. -```bash -npm install -g @anthropic-ai/claude-code -``` +### Integration tests are skipped -Check installation: +`real_cli_integration_spec.rb` needs `RUN_INTEGRATION=1`, `ANTHROPIC_API_KEY` and `claude` on `PATH`; `keyless_smoke_spec.rb` needs `RUN_KEYLESS_SMOKE=1` and a CLI (see above). Check the CLI with: ```bash which claude claude -v ``` - -## Contributing - -When adding new features: - -1. Write unit tests for new classes/methods -2. Add integration tests for feature workflows -3. Update this README if adding new test categories -4. Ensure all tests pass: `bundle exec rspec` -5. Aim for comprehensive coverage of error cases and edge conditions From c5f2aab2b8a730ae1f56635211119c6943315840 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:38:47 +0800 Subject: [PATCH 091/194] Preserve only inside the resume dir the SDK created When the transcript mirror dropped batches, preserve_transcripts deletes every entry of the materialized config dir except projects/. It reached that directory by path: if the path had been replaced before teardown, by a symlink or another directory at the same place (anything that knows CLAUDE_CONFIG_DIR can do that), File.directory? and Dir.children followed it and the loop deleted the target's entries instead. A probe with the root symlinked to an unrelated directory lost unrelated-dir/innocent/keep.txt; the four-name scrub before this branch never touched it. materialize_resume_session now records the directory's identity (File.lstat device and inode) right after mkdtemp and hands it to MaterializedResume, which takes it itself when it is built without one. preserve_transcripts checks that the path still leads to that directory (a real directory, not a symlink, same device and inode) before it lists the entries and again before each deletion. If the check fails it deletes nothing and the preservation warning says so. A symlink inside the directory is still unlinked, never followed. A user can notice: when the directory was replaced or removed, the warning adds "... is no longer the directory the SDK created (it was replaced or removed), so the SDK deleted nothing in it". docs/sessions.md says so too. Exploration finding: B-P8 (codex review round 1, B1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- docs/sessions.md | 4 +- lib/claude_agent_sdk/session_resume.rb | 76 ++++++++++++++--- spec/unit/materialized_resume_spec.rb | 110 +++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 11 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index 902f0f70..6a6f2ef9 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -294,7 +294,9 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > everything else in the temp dir — the seeded credential and settings copies > and whatever the CLI wrote beside them, such as its `backups/` copy of > `.claude.json` — and warns with the preserved path so you can import them -> into the store. +> into the store. If the path no longer leads to the directory the SDK created +> (it was replaced, for instance by a symlink), the SDK deletes nothing there +> and the warning says so. ### Implementing an adapter diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index a7d51456..120e6296 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -23,9 +23,13 @@ module ClaudeAgentSDK class MaterializedResume attr_reader :config_dir, :resume_session_id - def initialize(config_dir:, resume_session_id:) + # +root_identity+ is SessionResume.directory_identity of +config_dir+ as + # the SDK created it; materialize_resume_session takes it right after + # mkdtemp. Without it, it is taken here. + def initialize(config_dir:, resume_session_id:, root_identity: nil) @config_dir = config_dir @resume_session_id = resume_session_id + @root_identity = root_identity || SessionResume.directory_identity(config_dir) end # Best-effort removal of the temp config dir (never raises). @@ -45,17 +49,55 @@ def cleanup # the CLI writes its own: at startup it saves the seeded .claude.json — # which can hold MCP header secrets — as backups/.claude.json.backup.<ts>. # A list of names to delete missed that one and would miss the next. + # + # Deleting "everything else" is only safe inside the directory the SDK + # created. Anything that knows CLAUDE_CONFIG_DIR can replace it before + # teardown — with a symlink, or another directory at the same path — and + # following that would delete files the SDK never wrote. So the root is + # checked before the listing and again before each deletion, and nothing + # is deleted once it fails; a symlink inside is unlinked, never followed. def preserve_transcripts - entries = File.directory?(@config_dir) ? Dir.children(@config_dir) : [] - entries.each do |name| - FileUtils.rm_rf(File.join(@config_dir, name)) unless name == 'projects' - end - warn 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \ - "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \ - 'deleting it — import it into your session store, then remove the directory.' + notice = scrub_all_but_projects + warn [preservation_warning, notice].compact.join(' ') rescue StandardError => e warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}" end + + private + + # Deletes every entry of the root but projects/. Returns nil, or what the + # preservation warning has to add. + def scrub_all_but_projects + return replaced_root_notice unless root_unchanged? + + Dir.children(@config_dir).each do |name| + next if name == 'projects' + return replaced_root_notice unless root_unchanged? + + FileUtils.rm_rf(File.join(@config_dir, name)) + end + nil + end + + # True while config_dir is still the directory the SDK created: a real + # directory (lstat: a symlink does not count) with the device and inode + # recorded when it was made. + def root_unchanged? + return false if @root_identity.nil? + + SessionResume.directory_identity(@config_dir) == @root_identity + end + + def preservation_warning + 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \ + "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \ + 'deleting it — import it into your session store, then remove the directory.' + end + + def replaced_root_notice + "#{@config_dir} is no longer the directory the SDK created (it was replaced or removed), so the SDK " \ + 'deleted nothing in it — check what it holds before importing from it.' + end end # Materialize a SessionStore-backed resume into a temp CLAUDE_CONFIG_DIR. @@ -135,7 +177,7 @@ def build_mirror_batcher(store:, env:, on_error:, eager: false, callback_wrapper # session id is not a valid UUID) — the caller then falls through to the # normal spawn path. Raises SessionStoreError (#cause: the adapter's own # exception or the timeout) if a store call fails or times out. - def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order + def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- materialization sequence kept in order store = options.session_store return nil if store.nil? return nil if options.resume.nil? && !options.continue_conversation @@ -167,6 +209,9 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat session_id, lines = resolved tmp_base = Dir.mktmpdir('claude-resume-') + # Taken now: a teardown that deletes inside the directory checks that + # the path still leads to this one (MaterializedResume#preserve_transcripts). + root_identity = directory_identity(tmp_base) begin project_dir = File.join(tmp_base, 'projects', project_key) FileUtils.mkdir_p(project_dir) @@ -188,7 +233,7 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat raise end - MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id) + MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id, root_identity: root_identity) end # -- Helpers -- @@ -726,6 +771,17 @@ def resolve_dir(dir) File.expand_path(dir) end + # [device, inode] of +path+ itself when it is a real directory — lstat, so + # a symlink is not followed and does not count — else nil. Taken when the + # temp config dir is created; MaterializedResume compares it before it + # deletes anything inside. + def directory_identity(path) + stat = File.lstat(path) + stat.directory? ? [stat.dev, stat.ino] : nil + rescue SystemCallError, TypeError + nil + end + # Best-effort recursive removal with retries on transient lock errors # (Windows AV/indexer). Never raises. The temp dir holds an access token, so # the final sweep matters for not leaking secrets. diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index 958eebc0..b09f4001 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -4,6 +4,7 @@ require 'json' require 'tmpdir' require 'fileutils' +require 'stringio' RSpec.describe ClaudeAgentSDK::MaterializedResume do # When the mirror dropped batches the materialized config dir is kept, since @@ -64,6 +65,15 @@ def preserve described_class.new(config_dir: config_dir, resume_session_id: sid).preserve_transcripts end + def stderr_of + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + def files_under(dir) Dir.glob('**/*', File::FNM_DOTMATCH, base: dir).select { |relative| File.file?(File.join(dir, relative)) } end @@ -88,5 +98,105 @@ def files_under(dir) expect { preserve }.to output(/transcript mirror dropped batches/).to_stderr end + + # Anything that knows CLAUDE_CONFIG_DIR can replace the temp dir before + # teardown. "Delete every entry but projects/" must then delete nothing, + # rather than the entries of whatever the path leads to now. + # + # The original directory is always renamed aside, never deleted, before + # its replacement appears: a freshly created directory may otherwise reuse + # its inode number and look like the same directory. + context 'when the directory is no longer the one the SDK created' do + let(:unrelated) { Dir.mktmpdir } + let(:aside) { "#{config_dir}-aside" } + let(:refusal) { /no longer the directory the SDK created.*deleted nothing in it/ } + + before do + { 'projects/-elsewhere/x.jsonl' => "{}\n", 'innocent/keep.txt' => 'synthetic', '.claude.json' => '{}' } + .each do |relative, content| + path = File.join(unrelated, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + end + + after do + [unrelated, aside].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + it 'deletes nothing through a root that has become a symlink' do + materialized = described_class.new(config_dir: config_dir, resume_session_id: sid) + File.rename(config_dir, aside) + File.symlink(unrelated, config_dir) + before_scrub = files_under(unrelated).sort + + warning = stderr_of { materialized.preserve_transcripts } + + expect(files_under(unrelated).sort).to eq(before_scrub) + expect(File.symlink?(config_dir)).to be(true) + expect(warning).to match(refusal) + end + + it 'deletes nothing in a different directory at the same path' do + materialized = described_class.new(config_dir: config_dir, resume_session_id: sid) + File.rename(config_dir, aside) + FileUtils.cp_r(unrelated, config_dir) + before_scrub = files_under(config_dir).sort + + warning = stderr_of { materialized.preserve_transcripts } + + expect(files_under(config_dir).sort).to eq(before_scrub) + expect(warning).to match(refusal) + end + + it 'refuses a symlink it is handed as the directory' do + link = File.join(outside, 'replaced-config') + File.symlink(unrelated, link) + before_scrub = files_under(unrelated).sort + + warning = stderr_of { described_class.new(config_dir: link, resume_session_id: sid).preserve_transcripts } + + expect(files_under(unrelated).sort).to eq(before_scrub) + expect(warning).to match(refusal) + end + + # What counts is the directory materialize_resume_session created, not + # whatever is at the path by the time the MaterializedResume is built. + it 'compares with the directory as created, even if it was replaced during materialization' do + created = [] + allow(Dir).to receive(:mktmpdir).and_wrap_original do |original, *args, &block| + original.call(*args, &block).tap { |dir| created << dir if File.basename(dir).start_with?('claude-resume-') } + end + replace_temp_dir = lambda do + File.rename(created.last, "#{created.last}-aside") + FileUtils.cp_r(unrelated, created.last) + end + store = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + define_method(:list_subkeys) do |_key| + replace_temp_dir.call # runs after the transcript and the seed files are written + [] + end + end.new + project_dir = File.join(outside, 'project') + FileUtils.mkdir_p(project_dir) + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(project_dir), 'session_id' => sid }, + [{ 'type' => 'user', 'uuid' => 'u1', 'sessionId' => sid, 'message' => { 'role' => 'user', 'content' => 'hi' } }]) + caller_config_dir = File.join(outside, 'caller-config') # empty: nothing seeded from the developer's own + FileUtils.mkdir_p(caller_config_dir) + + materialized = ClaudeAgentSDK::SessionResume.materialize_resume_session( + ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: sid, cwd: project_dir, + env: { 'CLAUDE_CONFIG_DIR' => caller_config_dir }) + ) + before_scrub = files_under(materialized.config_dir).sort + + warning = stderr_of { materialized.preserve_transcripts } + + expect(files_under(materialized.config_dir).sort).to eq(before_scrub) + expect(warning).to match(refusal) + ensure + created&.each { |dir| [dir, "#{dir}-aside"].each { |path| FileUtils.rm_rf(path) } } + end + end end end From 3433f8426b07025ab86e77d730d5399e03b9596d Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:41:00 +0800 Subject: [PATCH 092/194] Report what preserve_transcripts could not remove preserve_transcripts deleted each entry with FileUtils.rm_rf, which swallows errors. An entry it could not remove therefore survived silently: with an owner-owned read-only (0500) backups/ directory, which can hold the seeded .claude.json with MCP header secrets, backups/ stayed in the preserved directory and the warning read as if the scrub had worked. Each entry is now removed with FileUtils.remove_entry, which raises instead of skipping and lstat's every entry, so a symlink is still unlinked and never followed. When that fails, the directories in the entry's subtree that this user owns are made accessible (0700), without following or changing symlinks, and the removal is tried once more. Afterwards preserve_transcripts lists the directory again: if anything besides projects/ is left, the preservation warning says that scrubbing failed and names what is left, with the first error. A user can notice: a read-only directory left by the CLI is now removed, and a warning that would have read as success now ends with "Scrubbing failed: could not remove <names> (<error>) - delete them yourself; they can hold copies of your credentials and settings." docs/sessions.md says so. Exploration finding: B-P8 (codex review round 1, B2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- docs/sessions.md | 3 +- lib/claude_agent_sdk/session_resume.rb | 54 ++++++++++++++++++++++++-- spec/unit/materialized_resume_spec.rb | 43 ++++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index 6a6f2ef9..60a601c2 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -296,7 +296,8 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > `.claude.json` — and warns with the preserved path so you can import them > into the store. If the path no longer leads to the directory the SDK created > (it was replaced, for instance by a symlink), the SDK deletes nothing there -> and the warning says so. +> and the warning says so; if an entry cannot be removed, the warning says the +> scrub failed and names what is left. ### Implementing an adapter diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 120e6296..070b8f4d 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -65,18 +65,30 @@ def preserve_transcripts private - # Deletes every entry of the root but projects/. Returns nil, or what the - # preservation warning has to add. + # Deletes every entry of the root but projects/, then checks that nothing + # else is left. Returns nil, or what the preservation warning has to add. def scrub_all_but_projects return replaced_root_notice unless root_unchanged? + errors = {} Dir.children(@config_dir).each do |name| next if name == 'projects' return replaced_root_notice unless root_unchanged? - FileUtils.rm_rf(File.join(@config_dir, name)) + error = SessionResume.remove_entry_without_following(File.join(@config_dir, name)) + errors[name] = error if error end - nil + return replaced_root_notice unless root_unchanged? + + left = Dir.children(@config_dir).reject { |name| name == 'projects' }.sort + scrub_failed_notice(left, errors) unless left.empty? + end + + def scrub_failed_notice(left, errors) + reason = errors.values_at(*left).compact.first&.message + them, they = left.size == 1 ? %w[it it] : %w[them they] + "Scrubbing failed: could not remove #{left.join(', ')}#{" (#{reason})" if reason} — delete #{them} " \ + "yourself; #{they} can hold copies of your credentials and settings." end # True while config_dir is still the directory the SDK created: a real @@ -782,6 +794,40 @@ def directory_identity(path) nil end + # Remove +path+ and everything under it without following symlinks: + # FileUtils.remove_entry lstat's every entry, so a symlink is unlinked, + # not traversed, and it raises rather than skipping what it cannot + # remove. When it fails, the directories in the subtree that this user + # owns are made accessible (0700) — the CLI, or anything else running as + # this user, may have left one read-only — and it is tried once more. + # Returns nil, or the SystemCallError of that second attempt. + def remove_entry_without_following(path) + FileUtils.remove_entry(path) + nil + rescue SystemCallError + make_owned_directories_accessible(path) + begin + FileUtils.remove_entry(path) + nil + rescue SystemCallError => e + e + end + end + + # chmod 0700 each directory at or under +path+ that this user owns. lstat + # throughout, so a symlink is neither followed nor changed (File.chmod + # itself would follow it); each chmod comes before that directory's + # listing, so an unlistable one is reached too. Best effort. + def make_owned_directories_accessible(path) + stat = File.lstat(path) + return unless stat.directory? && stat.owned? + + File.chmod(0o700, path) + Dir.children(path).each { |name| make_owned_directories_accessible(File.join(path, name)) } + rescue SystemCallError + nil + end + # Best-effort recursive removal with retries on transient lock errors # (Windows AV/indexer). Never raises. The temp dir holds an access token, so # the final sweep matters for not leaking secrets. diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index b09f4001..b12735a4 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -99,6 +99,49 @@ def files_under(dir) expect { preserve }.to output(/transcript mirror dropped batches/).to_stderr end + # An entry that cannot be removed must not pass for scrubbed: backups/ can + # hold the seeded .claude.json, MCP header secrets included. + context 'when an entry resists deletion' do + let(:backups) { File.join(config_dir, 'backups') } + + it 'makes a read-only directory of its own writable and removes it' do + File.chmod(0o500, backups) # listable, not writable: its file cannot be unlinked + + warning = stderr_of { preserve } + + expect(Dir.children(config_dir)).to eq(['projects']) + expect(warning).to match(/Preserving the session transcript/) + expect(warning).not_to match(/Scrubbing failed/) + ensure + File.chmod(0o700, backups) if File.directory?(backups) + end + + it 'neither follows nor changes a symlink while it makes a directory writable' do + File.symlink(outside, File.join(backups, 'elsewhere')) + File.chmod(0o555, outside) + File.chmod(0o500, backups) + + stderr_of { preserve } + + expect(Dir.children(config_dir)).to eq(['projects']) + expect(format('%o', File.stat(outside).mode & 0o777)).to eq('555') + expect(File.read(File.join(outside, 'not-ours.txt'))).to eq('keep me') + ensure + File.chmod(0o700, backups) if File.directory?(backups) + File.chmod(0o700, outside) + end + + it 'says scrubbing failed, and names what is left, when it still cannot remove it' do + allow(FileUtils).to receive(:remove_entry).and_call_original + allow(FileUtils).to receive(:remove_entry).with(backups).and_raise(Errno::EACCES, backups) + + warning = stderr_of { preserve } + + expect(Dir.children(config_dir).sort).to eq(%w[backups projects]) + expect(warning).to match(/Preserving the session transcript.* Scrubbing failed: could not remove backups \(Permission denied/) + end + end + # Anything that knows CLAUDE_CONFIG_DIR can replace the temp dir before # teardown. "Delete every entry but projects/" must then delete nothing, # rather than the entries of whatever the path leads to now. From 3c2f764b5e7d662802c42351b65c0c6f9425a78b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:41:28 +0800 Subject: [PATCH 093/194] Pin the root check before each deletion in preserve_transcripts preserve_transcripts checks that its path still leads to the directory the SDK created before it lists the entries, and again before each deletion. Nothing pinned the second check: with it removed, a directory replaced in the middle of the scrub got the rest of the listed names deleted from the replacement, and every spec stayed green. The new example replaces the directory with a symlink right after the first deletion, gives the replacement an entry for every listed name, and expects all of them to survive and the warning to say that nothing was deleted in it. Specs only. Exploration finding: B-P8 (codex review round 1, B1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- spec/unit/materialized_resume_spec.rb | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index b12735a4..1bdce4d1 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -192,6 +192,34 @@ def files_under(dir) expect(warning).to match(refusal) end + it 'stops deleting as soon as the directory is replaced in the middle of the scrub' do + # Give the replacement an entry for every name the scrub has listed, + # whatever order it walks them in: each one deleted after the swap + # would be a file the SDK never wrote. + (Dir.children(config_dir) - ['projects']).each do |name| + path = File.join(unrelated, name) + File.write(path, 'synthetic') unless File.exist?(path) + end + materialized = described_class.new(config_dir: config_dir, resume_session_id: sid) + swapped = false + allow(ClaudeAgentSDK::SessionResume).to receive(:remove_entry_without_following).and_wrap_original do |original, path| + result = original.call(path) + unless swapped # right after the first deletion + swapped = true + File.rename(config_dir, aside) + File.symlink(unrelated, config_dir) + end + result + end + before_scrub = files_under(unrelated).sort + + warning = stderr_of { materialized.preserve_transcripts } + + expect(swapped).to be(true) + expect(files_under(unrelated).sort).to eq(before_scrub) + expect(warning).to match(refusal) + end + it 'refuses a symlink it is handed as the directory' do link = File.join(outside, 'replaced-config') File.symlink(unrelated, link) From bb3d478bf426b1b04b53d7dba51d85276173cc07 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:42:23 +0800 Subject: [PATCH 094/194] Pin that the configured interval reaches Kernel#sleep on retry The backoff example set MIRROR_APPEND_BACKOFF_S to [0, 0]: it proved the Async::Task#sleep deprecation warning was gone, but it stayed green with sleep_backoff turned into sleep(0), i.e. a retry loop with no backoff at all against an overloaded store. A second example configures [0.25, 0.5], lets the store fail twice, and intercepts Kernel#sleep on the batcher (so nothing actually waits): the two intervals must arrive, in order. It fails for sleep(0) ("expected: [0.25, 0.5] got: [0, 0]"), for the Async::Task#sleep code this branch replaced, and when the backoff call is removed. Specs only. Exploration finding: U-P17 (codex review round 1, B3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- .../transcript_mirror_batcher_backoff_spec.rb | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/spec/unit/transcript_mirror_batcher_backoff_spec.rb b/spec/unit/transcript_mirror_batcher_backoff_spec.rb index 967e188d..5f5a4588 100644 --- a/spec/unit/transcript_mirror_batcher_backoff_spec.rb +++ b/spec/unit/transcript_mirror_batcher_backoff_spec.rb @@ -46,5 +46,32 @@ def load(_key) = nil expect(attempts).to eq(2) # the backoff ran: one failure, one retry end + + # With [0, 0] intervals the example above cannot tell a backoff from no + # backoff at all. Here the configured intervals have to reach + # Kernel#sleep — intercepted on the batcher, so nothing actually waits. + it 'sleeps the configured interval before each retry' do + stub_const("#{described_class}::MIRROR_APPEND_BACKOFF_S", [0.25, 0.5].freeze) + attempts = 0 + store = Class.new(ClaudeAgentSDK::SessionStore) do + define_method(:append) do |_key, _entries| + attempts += 1 + raise IOError, 'connection reset' if attempts < 3 + end + + def load(_key) = nil + end.new + batcher = described_class.new(store: store, projects_dir: projects, on_error: ->(_key, _message) {}) + sleeps = [] + allow(batcher).to receive(:sleep) { |seconds| sleeps << seconds } + + Async do + batcher.enqueue(file_path, [{ type: 'user', uuid: 'a' }]) + batcher.flush + end.wait + + expect(attempts).to eq(3) + expect(sleeps).to eq([0.25, 0.5]) + end end end From 4c56a64947deeeda57a236a289c0e728906fc362 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:47:42 +0800 Subject: [PATCH 095/194] Unlink a dangling root symlink in the preserve spec's teardown Two of the new preserve_transcripts examples replace the temp dir with a symlink to another scratch directory, and the inner after hook removes that directory first. The outer after hook then asked File.directory?, which is false for a symlink whose target is gone, so every run left two dangling claude-resume-* symlinks in $TMPDIR (they pointed at deleted scratch dirs and held nothing). The hook now also removes the path when it is a symlink. Three runs of the file add no entries to $TMPDIR. Specs only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- spec/unit/materialized_resume_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index 1bdce4d1..2f3023a2 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -57,8 +57,10 @@ File.symlink(outside, File.join(config_dir, 'linked-elsewhere')) end + # config_dir may have been replaced by a symlink whose target is gone by + # now: File.directory? is false for that, so test for the link itself. after do - [config_dir, outside].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + [config_dir, outside].each { |dir| FileUtils.remove_entry(dir) if File.symlink?(dir) || File.directory?(dir) } end def preserve From 51205d29d31df0b7ea65dcd87b79f8e22b9f3f5d Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:49:19 +0800 Subject: [PATCH 096/194] Verify long-path identity per transcript, from parsed lines only Round 1 review of 3686e18. The long-path prefix fallback accepted a candidate directory when one transcript in it recorded the requested path as its cwd, and could be satisfied by the wrong evidence: - the head window of a transcript whose first line is over 64 KiB cuts that line, and the "top-level" scan then fell back to a raw match, so a cwd nested in a tool input (naming B) passed as A's transcript being B's; - one matching transcript validated the whole directory: in an old-hash directory holding one session of A and one of B, list_sessions(directory: B) listed A's session, get_session_messages read it and the mutations reached it. Identity now comes only from a parsed top-level cwd of a COMPLETE line (recorded_cwd); a cut line establishes nothing. And it is checked per transcript: in a directory the fallback found, the listing, the session file lookup behind the readers, get_session_info and the mutations keep only transcripts whose own verified cwd is the path (own_transcript?); an unknown cwd is excluded. The worktree lookups go through the same code. Exploration finding: U-P8 (round 1, B1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/session_mutations.rb | 26 ++++-- lib/claude_agent_sdk/sessions.rb | 95 +++++++++++++++----- spec/unit/session_long_path_fallback_spec.rb | 65 +++++++++++++- 3 files changed, 155 insertions(+), 31 deletions(-) diff --git a/lib/claude_agent_sdk/session_mutations.rb b/lib/claude_agent_sdk/session_mutations.rb index b6e310b8..2540e0a6 100644 --- a/lib/claude_agent_sdk/session_mutations.rb +++ b/lib/claude_agent_sdk/session_mutations.rb @@ -312,7 +312,7 @@ def find_in_directory(file_name, directory) # it before the session was even looked for — while the readers, which # canonicalize, still found the session through the same directory. path = Sessions.canonicalize_path(directory) - result = try_project_dir(file_name, Sessions.find_project_dir(path)) + result = try_project_dir(file_name, Sessions.find_project_dir(path), path) return result if result worktree_paths = begin @@ -323,7 +323,7 @@ def find_in_directory(file_name, directory) worktree_paths.each do |wt_path| next if wt_path == path - result = try_project_dir(file_name, Sessions.find_project_dir(wt_path)) + result = try_project_dir(file_name, Sessions.find_project_dir(wt_path), wt_path) return result if result end nil @@ -333,11 +333,17 @@ def find_in_directory(file_name, directory) # in one project dir must not stop the search when the real transcript # lives under another (worktree) project dir. Mirrors the read path # (Sessions.stat_candidate) and the append path (try_append). - def try_project_dir(file_name, project_dir) + # With +path+ (a directory-scoped lookup), the candidate must also be one + # of that path's own transcripts (Sessions.own_transcript?: a directory + # the long-path fallback found can hold other paths' sessions). + def try_project_dir(file_name, project_dir, path = nil) return nil unless project_dir candidate = File.join(project_dir, file_name) - File.size(candidate).positive? ? [candidate, project_dir] : nil + return nil unless File.size(candidate).positive? + return nil if path && !Sessions.own_transcript?(project_dir, candidate, path) + + [candidate, project_dir] rescue SystemCallError nil end @@ -579,7 +585,7 @@ def append_to_session_in_directory(session_id, data, file_name, directory) # Try the exact/prefix-matched project directory first. project_dir = Sessions.find_project_dir(path) - return if project_dir && try_append(File.join(project_dir, file_name), data) + return if project_dir && own_append(project_dir, file_name, path, data) # Worktree fallback begin @@ -592,7 +598,7 @@ def append_to_session_in_directory(session_id, data, file_name, directory) next false if wt_path == path wt_project_dir = Sessions.find_project_dir(wt_path) - wt_project_dir && try_append(File.join(wt_project_dir, file_name), data) + wt_project_dir && own_append(wt_project_dir, file_name, wt_path, data) end return if found @@ -614,6 +620,12 @@ def append_to_session_global(session_id, data, file_name) raise Errno::ENOENT, "Session #{session_id} not found in any project directory" end + # try_append, for a transcript of +path+'s own (Sessions.own_transcript?). + def own_append(project_dir, file_name, path, data) + candidate = File.join(project_dir, file_name) + Sessions.own_transcript?(project_dir, candidate, path) && try_append(candidate, data) + end + # Try appending to a path. # # Opens with WRONLY | APPEND (no CREAT) so the open fails with @@ -667,7 +679,7 @@ def unicode_category(char) :find_in_directory, :try_project_dir, :find_in_all_projects, :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid, :append_to_session, :append_to_session_in_directory, - :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category, + :append_to_session_global, :own_append, :try_append, :sanitize_unicode, :unicode_category, :iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries, :ensure_store_session_exists end diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index ed22f276..21053dee 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -268,11 +268,13 @@ def find_project_dir(path) # the same prefix and another suffix may be this path's — or that of # ANY path sharing the prefix (a sibling in a deep per-tenant tree). # The name cannot tell them apart; a transcript inside can: accept a - # candidate only if one records the path as its cwd, and only when - # exactly one candidate does. Taking the first prefix match listed, - # read and renamed another project's sessions for a directory that had - # none of its own. A directory whose transcripts record no cwd is not - # used: no guess from the name alone. + # candidate only if one records the path as its cwd (recorded_cwd), and + # only when exactly one candidate does. Taking the first prefix match + # listed, read and renamed another project's sessions for a directory + # that had none of its own. A directory whose transcripts record no cwd + # is not used: no guess from the name alone. The directory returned may + # still hold sessions of other paths sharing the prefix: callers keep + # only the path's own transcripts (own_transcript?). prefix = sanitized[0, MAX_SANITIZED_LENGTH + 1] # includes the trailing '-' verified = Dir.children(projects_dir).select do |child| candidate = File.join(projects_dir, child) @@ -281,22 +283,60 @@ def find_project_dir(path) verified.length == 1 ? File.join(projects_dir, verified.first) : nil end - # Whether a session transcript in +project_dir+ was recorded for +path+: - # its first non-blank top-level cwd (the one the listing reports) is the - # path, compared in NFC like every path the SDK derives a name from. + # Whether a session transcript in +project_dir+ was recorded for +path+. def project_dir_records_cwd?(project_dir, path) Dir.children(project_dir).any? do |name| - next false unless name.end_with?('.jsonl') && valid_session_id?(name.delete_suffix('.jsonl')) - - head = File.open(File.join(project_dir, name), 'rb') { |file| file.read(LITE_READ_BUF_SIZE) || '' } - extract_top_level_string_field(head, 'cwd', skip_blank: true)&.unicode_normalize(:nfc) == path - rescue SystemCallError - false # unreadable, or removed between the listing and the read + name.end_with?('.jsonl') && valid_session_id?(name.delete_suffix('.jsonl')) && + recorded_cwd(File.join(project_dir, name)) == path end rescue SystemCallError false end + # Whether +file_path+, a transcript in +project_dir+ — the directory + # find_project_dir returned for +path+ — is one of +path+'s sessions. Every + # transcript of the directory named after the path is. In a directory the + # long-path prefix fallback found, only one whose own recorded cwd is the + # path: that directory can hold the sessions of every path sharing the + # prefix, and a transcript whose cwd cannot be verified is not counted. + def own_transcript?(project_dir, file_path, path) + File.basename(project_dir) == sanitize_path(path) || recorded_cwd(file_path) == path + end + + # The directory a session transcript was recorded in: the first non-blank + # top-level cwd of a COMPLETE line in its first LITE_READ_BUF_SIZE bytes, + # NFC-normalized; nil when there is none (or the file cannot be read). A + # line the window cuts establishes nothing: its top-level shape cannot be + # checked, and a raw "cwd" match on it may sit inside a tool input. + def recorded_cwd(file_path) + File.open(file_path, 'rb') do |file| + head = file.read(LITE_READ_BUF_SIZE) || '' + each_parsed_entry(head, file.eof?) do |entry| + cwd = entry['cwd'] + return cwd.unicode_normalize(:nfc) if cwd.is_a?(String) && presence(cwd) + end + end + nil + rescue SystemCallError + nil + end + + # Yield each Hash entry parsed from a COMPLETE line of +text+, a window + # read from the start of a transcript: every line that ends in a newline, + # and the last one too when +to_eof+ (the window reaches the end of the + # file). A line that does not parse is skipped. + def each_parsed_entry(text, to_eof) + complete = to_eof ? text.bytesize : (text.byterindex("\n") || -1) + 1 + text.byteslice(0, complete).each_line do |line| + entry = begin + JSON.parse(line) + rescue JSON::ParserError + next + end + yield entry if entry.is_a?(Hash) + end + end + # Extract a JSON string field value from raw text without full JSON parse def extract_json_string_field(text, key, last: false) search_patterns = ["\"#{key}\":\"", "\"#{key}\": \""] @@ -762,6 +802,9 @@ def read_sessions_from_dir(project_dir, project_path = nil) return [] unless File.directory?(project_dir) sessions = [] + # Listing a directory found by the long-path prefix fallback: only the + # transcripts recorded for +project_path+ (own_transcript?). + verify = project_path && File.basename(project_dir) != sanitize_path(project_path) # base:, not a pattern built from the directory: a config dir path with # glob characters in it (`/Volumes/Data [SSD]/…`, `/srv/{tenant}/…`) # is a path, and as part of the pattern it matched nothing. @@ -770,6 +813,8 @@ def read_sessions_from_dir(project_dir, project_path = nil) next unless stem.match?(UUID_RE) file_path = File.join(project_dir, name) + next if verify && recorded_cwd(file_path) != project_path + session = read_session_lite(file_path, project_path) sessions << session if session end @@ -1505,7 +1550,7 @@ def get_session_info_for_directory(file_name, directory) # os.path.realpath never raises here. canonical = canonicalize_path(directory) project_dir = find_project_dir(canonical) - if project_dir + if project_dir && own_transcript?(project_dir, File.join(project_dir, file_name), canonical) info = read_session_lite(File.join(project_dir, file_name), canonical) return info if info end @@ -1516,7 +1561,7 @@ def get_session_info_for_directory(file_name, directory) next if wt_path == canonical wt_project_dir = find_project_dir(wt_path) - next unless wt_project_dir + next unless wt_project_dir && own_transcript?(wt_project_dir, File.join(wt_project_dir, file_name), wt_path) info = read_session_lite(File.join(wt_project_dir, file_name), wt_path) return info if info @@ -1667,13 +1712,13 @@ def find_session_file(session_id, directory) if directory path = canonicalize_path(directory) - found = stat_candidate(find_project_dir(path), file_name) + found = stat_candidate(find_project_dir(path), file_name, path) return found if found detect_worktrees(path).each do |wt_path| next if wt_path == path # already tried above - found = stat_candidate(find_project_dir(wt_path), file_name) + found = stat_candidate(find_project_dir(wt_path), file_name, wt_path) return found if found end @@ -1699,11 +1744,16 @@ def find_session_file(session_id, directory) # exists AND is non-empty — a 0-byte stub in one project dir must not # stop the search when the real transcript lives under another # worktree's project dir (same hazard SessionMutations.try_append guards). - def stat_candidate(project_dir, file_name) + # With +path+ (a directory-scoped lookup), the candidate must also be one + # of that path's own transcripts (own_transcript?). + def stat_candidate(project_dir, file_name, path = nil) return nil if project_dir.nil? candidate = File.join(project_dir, file_name) - File.size(candidate).positive? ? candidate : nil + return nil unless File.size(candidate).positive? + return nil if path && !own_transcript?(project_dir, candidate, path) + + candidate rescue SystemCallError nil end @@ -1984,7 +2034,7 @@ def filter_visible_messages(chain) end end - private_class_method :project_dir_records_cwd?, :get_session_info_for_directory, + private_class_method :project_dir_records_cwd?, :recorded_cwd, :each_parsed_entry, :get_session_info_for_directory, :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, :worktree_paths, :find_session_file, :stat_candidate, :resolve_subagents_dir, @@ -2008,6 +2058,7 @@ def filter_visible_messages(chain) # config_dir, sanitize_path, find_project_dir, detect_worktrees, # valid_session_id? (mutation boundary checks), listing_sort_key # (--continue candidate order), read_head_tail, title_and_first_prompt - # and display_title (the fork title) + # and display_title (the fork title), own_transcript? (the mutations' + # lookups) end end diff --git a/spec/unit/session_long_path_fallback_spec.rb b/spec/unit/session_long_path_fallback_spec.rb index a6a085f6..b430b131 100644 --- a/spec/unit/session_long_path_fallback_spec.rb +++ b/spec/unit/session_long_path_fallback_spec.rb @@ -18,8 +18,8 @@ before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } - def session_recorded_in(directory) - transcript = CLITranscript.new(session_id: session_id, cwd: directory) + def session_recorded_in(directory, id = session_id) + transcript = CLITranscript.new(session_id: id, cwd: directory) transcript.queue_operations('the private question of project one') transcript.prompt(:prompt, 'the private question of project one') transcript.assistant(:answer, transcript.text('Answered.'), parent: :prompt) @@ -91,4 +91,65 @@ def old_cli_project_dir(directory, hash = 'bunhash1') expect(ClaudeAgentSDK.list_sessions(directory: project_a)).to eq([]) end end + + # Identity comes only from a parsed top-level cwd of a complete line, and + # it is checked per transcript: a directory found by the prefix fallback + # may hold sessions of several paths sharing the prefix. + context 'when the transcripts in an old-hash directory do not all belong to the path' do + let(:other_session_id) { '0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d' } + + # A's session opens with a tool call over 64 KiB whose input names B's + # directory as its cwd; A's own top-level cwd follows the input on the + # same line, past the window. + it 'does not take a cwd nested in a tool input on a line the window cuts' do + transcript = CLITranscript.new(session_id: session_id, cwd: project_a) + transcript.assistant(:call, transcript.tool_use('toolu_1', 'Bash', + { 'cwd' => project_b, 'command' => "cat #{'x' * 70_000}" }), + parent: nil) + transcript.prompt(:prompt, 'the private question of project one', parent: :call) + file = transcript.write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + before = File.binread(file) + + expect(ClaudeAgentSDK.list_sessions(directory: project_b)).to eq([]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_b)).to eq([]) + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Mine now', directory: project_b) } + .to raise_error(Errno::ENOENT) + expect(File.binread(file)).to eq(before) + end + + context 'with one session of each path in it' do + let!(:file_a) do + session_recorded_in(project_a).write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + end + + before do + session_recorded_in(project_b, other_session_id) + .write(File.join(old_cli_project_dir(project_a), "#{other_session_id}.jsonl")) + end + + it "lists each path's own session only" do + expect(ClaudeAgentSDK.list_sessions(directory: project_b).map(&:session_id)).to eq([other_session_id]) + expect(ClaudeAgentSDK.list_sessions(directory: project_a).map(&:session_id)).to eq([session_id]) + end + + it "reads and renames only the path's own session" do + before = File.binread(file_a) + + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_b)).to eq([]) + expect(ClaudeAgentSDK.get_session_messages(session_id: other_session_id, directory: project_b).length) + .to eq(2) + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Mine now', directory: project_b) } + .to raise_error(Errno::ENOENT) + expect(File.binread(file_a)).to eq(before) + end + + it 'keeps to the same rule when the path is reached as a worktree' do + main = File.join(cwd, 'main').tap { |dir| FileUtils.mkdir_p(dir) } + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([main, project_b]) + + expect(ClaudeAgentSDK.list_sessions(directory: main).map(&:session_id)).to eq([other_session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: main)).to eq([]) + end + end + end end From a08470a154a56106185caa9b9a65ba4098b74496 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:50:59 +0800 Subject: [PATCH 097/194] Keep the chain's own tool result; re-attach at most one per call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 review of 743cac0. The re-attachment of parallel tool results took any off-chain user child of a chain assistant whose tool_result named a tool_use on the chain, deduplicating by entry uuid only. After a rewind to a tool call — the call stays on the chain, its old result and the answer that followed are an abandoned branch, the call was answered again on the kept branch — the old result came back next to the new one, on the disk, store and subagent readers alike: two results for one tool_use, one of them from a branch that was dropped. The chain's own result for a tool_use_id now wins: an off-chain result for a call the chain already answers is not re-attached, and at most one off-chain result per call is added (the first in file order; a second copy of the same entry in a store is the same case). Distinct parallel calls keep their file order. Exploration finding: U-P1 (round 1, B2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 33 +++++---- .../session_parallel_tool_results_spec.rb | 70 +++++++++++++++++++ 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 21053dee..4673c4ad 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -1928,8 +1928,11 @@ def visible_message?(entry) # # The tool_use_id match is what keeps other user siblings out: a prompt # abandoned by a rewind is a second child of a chain entry too, and - # starts a branch that was dropped. +skip_flagged+ additionally rejects - # sidechain / meta / team entries (main transcripts; a subagent + # starts a branch that was dropped — and so is the old result of a call + # the conversation was rewound to and answered again: the chain's own + # result for a tool_use_id wins, and at most one off-chain result per id + # is ever added (the first in file order). +skip_flagged+ additionally + # rejects sidechain / meta / team entries (main transcripts; a subagent # transcript is sidechain throughout). def reattach_parallel_tool_results(chain, entries, skip_flagged:) off_chain = off_chain_tool_results(chain, entries, skip_flagged) @@ -1954,27 +1957,31 @@ def off_chain_tool_results(chain, entries, skip_flagged) # rubocop:disable Metri on_chain = Set.new assistants = Set.new tool_use_ids = Set.new + answered = Set.new # tool_use ids the chain's own results answer chain.each do |entry| on_chain << entry['uuid'] - next unless entry['type'] == 'assistant' - - assistants << entry['uuid'] - tool_use_ids.merge(content_block_values(entry, 'tool_use', 'id')) + case entry['type'] + when 'assistant' + assistants << entry['uuid'] + tool_use_ids.merge(content_block_values(entry, 'tool_use', 'id')) + when 'user' then answered.merge(content_block_values(entry, 'tool_result', 'tool_use_id')) + end end - return {} if tool_use_ids.empty? + return {} if (tool_use_ids - answered).empty? - # A store may hold an entry twice (a retried mirror batch overlaps the - # write it retries); the first copy counts, as each uuid counts once on - # the chain. - seen = Set.new found = {} entries.each_with_index do |entry, position| next unless entry['type'] == 'user' && assistants.include?(entry['parentUuid']) next if on_chain.include?(entry['uuid']) next if skip_flagged && off_main_conversation?(entry) - next unless content_block_values(entry, 'tool_result', 'tool_use_id').any? { |id| tool_use_ids.include?(id) } - next unless seen.add?(entry['uuid']) + ids = content_block_values(entry, 'tool_result', 'tool_use_id') + next if ids.empty? || !ids.all? { |id| tool_use_ids.include?(id) && !answered.include?(id) } + + # Claimed: a later result for the same call is not added — nor a + # second copy of this entry, which a store can hold (a retried mirror + # batch overlaps the write it retries). + answered.merge(ids) (found[entry['parentUuid']] ||= []) << [position, entry] end found diff --git a/spec/unit/session_parallel_tool_results_spec.rb b/spec/unit/session_parallel_tool_results_spec.rb index e94978a6..b99cdb97 100644 --- a/spec/unit/session_parallel_tool_results_spec.rb +++ b/spec/unit/session_parallel_tool_results_spec.rb @@ -44,6 +44,16 @@ def two_tool_batch(transcript) transcript end + def rewound_tool_call(transcript) + transcript.prompt(:prompt, 'Read a.rb') + transcript.assistant(:use, transcript.tool_use('toolu_one'), parent: :prompt) + transcript.tool_result(:result_old, 'toolu_one', 'old contents', parent: :use) + transcript.assistant(:abandoned, transcript.text('Abandoned.'), parent: :result_old, message: 'msg_02') + transcript.tool_result(:result_new, 'toolu_one', 'new contents', parent: :use) + transcript.assistant(:current, transcript.text('Current.'), parent: :result_new, message: 'msg_03') + transcript + end + def main_transcript transcript = CLITranscript.new(session_id: session_id, cwd: cwd) transcript.queue_operations('Read a.rb and b.rb') @@ -163,6 +173,56 @@ def tool_result_ids(messages) expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a summary kept answer]) end + # A rewind to the tool call: the call stays, its old result and the + # answer that followed it are the abandoned branch, and the call was + # answered again on the branch the conversation went on with. + # + # use ─ result_old ─ abandoned + # └─ result_new ─ current + it 'does not pull in the old result of a tool call the conversation answered again' do + transcript = main_transcript do |t| + rewound_tool_call(t) + t.assistant(:answer, t.text('Done.'), parent: :current, message: 'msg_04') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use result_new current answer]) + end + + it 'does not pull in the old result of a tool call the conversation answered again, from a store' do + transcript = main_transcript do |t| + rewound_tool_call(t) + t.assistant(:answer, t.text('Done.'), parent: :current, message: 'msg_04') + end + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id }, + transcript.store_entries) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + + expect(transcript.labels(messages)).to eq(%w[prompt use result_new current answer]) + end + + # Two results for one call that the conversation did not take up (the + # tool was run twice): one of them comes back, the first written. + it 'pulls in at most one result per tool call' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb and b.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.assistant(:use_b, t.tool_use('toolu_b'), parent: :use_a) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.tool_result(:result_a_again, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) + t.assistant(:answer, t.text('Both files read.'), parent: :result_b, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a use_b result_a result_b answer]) + end + # Not a shape the CLI writes (a result is parented on its own tool_use # entry); it pins the check itself. A user child that carries a # tool_result for a call the conversation does not contain stays out. @@ -222,6 +282,16 @@ def parent_session expect(transcript.labels(messages)).to eq(batch_order) end + it 'does not pull in the old result of a tool call the subagent answered again' do + parent_session.write(transcript_path(session_id)) + transcript = rewound_tool_call(CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id)) + transcript.write(subagent_transcript_path(session_id, agent_id)) + + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use result_new current]) + end + it 'returns every result of a parallel batch from a subagent transcript in a session store' do transcript = subagent_transcript key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } From 6e128c85b40fa467105d3900b11b1745f034dcb3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:52:29 +0800 Subject: [PATCH 098/194] Wrap only the list_subkeys call in optional_call during resume Round 1 review of 4ca8434. Store-backed resume ran the whole subagent materialization inside SessionStores.optional_call(:list_subkeys): the optional list_subkeys call, and the loop that calls the REQUIRED #load for every subkey it returned. A NotImplementedError from that load was therefore read as "list_subkeys is not implemented": the resume returned a usable-looking session with the subagent silently missing. Only the direct list_subkeys call is inside optional_call now; the loop and its loads are outside. A NotImplementedError from #load surfaces from the resume, and the materialized directory is removed as on any other resume failure. Exploration finding: B-P11 (round 1, B3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/session_resume.rb | 19 ++++++++------ lib/claude_agent_sdk/session_store.rb | 5 ++-- .../session_store_optional_methods_spec.rb | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 766632f5..22ee5b37 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -160,12 +160,7 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat # so it can authenticate. Missing files are fine (API-key auth, etc.). copy_auth_files(tmp_base, options.env) - # list_subkeys is optional: without it (or when it raises - # NotImplementedError, see optional_call) only the main transcript - # is materialized. - SessionStores.optional_call(store, :list_subkeys) do - materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) - end + materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) rescue Exception # rubocop:disable Lint/RescueException # Any failure after mkdtemp leaves tmp_base (which may already hold a # .credentials.json copy) on disk with no path for the caller to clean @@ -606,9 +601,17 @@ def capture_with_timeout(argv, timeout_s) # Load and write all subagent transcripts/metadata under session_id. 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 session_dir = File.join(project_dir, session_id) - subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do - store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + # list_subkeys is optional: without it (or when it raises + # NotImplementedError, see optional_call) only the main transcript is + # materialized. Only this one call sits inside optional_call: the loop + # below calls the REQUIRED #load, and a NotImplementedError from that + # must surface as a failed resume, not read as "no list_subkeys". + listed, subkeys = SessionStores.optional_call(store, :list_subkeys) do + with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do + store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + end end + return unless listed Array(subkeys).each do |subpath| # Subpaths come from an external store and become filesystem path diff --git a/lib/claude_agent_sdk/session_store.rb b/lib/claude_agent_sdk/session_store.rb index d8907be4..9e165d89 100644 --- a/lib/claude_agent_sdk/session_store.rb +++ b/lib/claude_agent_sdk/session_store.rb @@ -334,8 +334,9 @@ def store_callback_scheduling(store) # included, and reached the caller raw where the documented behavior is # the fallback of a store without the method. # - # Only for an optional method: a NotImplementedError from #append or - # #load is an adapter bug and must surface. + # Only for an optional method, and only around the call itself: a + # NotImplementedError from #append or #load is an adapter bug and must + # surface, so nothing that calls those belongs in the block. def optional_call(store, method) return [false, nil] unless SessionStore.implements?(store, method) diff --git a/spec/unit/session_store_optional_methods_spec.rb b/spec/unit/session_store_optional_methods_spec.rb index d913f1ca..796f758c 100644 --- a/spec/unit/session_store_optional_methods_spec.rb +++ b/spec/unit/session_store_optional_methods_spec.rb @@ -162,6 +162,32 @@ def opting_out_of(method) end end + # list_subkeys works; the REQUIRED load fails for the subagent it lists. + # That is not "list_subkeys is not implemented": the resume must fail, and + # leave nothing behind, rather than hand over a session without its + # subagent. + it 'lets a NotImplementedError from load surface while a resume materializes subagents' do + store = seed(Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def load(key) + raise NotImplementedError, 'load is not available for subpaths' if key['subpath'] + + super + end + end.new) + tmpdir = File.join(cwd, 'tmp').tap { |dir| FileUtils.mkdir_p(dir) } + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: session_id, cwd: cwd, + env: { 'CLAUDE_CONFIG_DIR' => config_dir }) + saved = ENV.fetch('TMPDIR', nil) + ENV['TMPDIR'] = tmpdir + begin + expect { ClaudeAgentSDK::SessionResume.materialize_resume_session(options) } + .to raise_error(NotImplementedError, 'load is not available for subpaths') + expect(Dir.children(tmpdir)).to eq([]) # the materialized directory was removed + ensure + saved.nil? ? ENV.delete('TMPDIR') : ENV['TMPDIR'] = saved + end + end + it 'still lets a NotImplementedError from a required method through' do store = Class.new(ClaudeAgentSDK::InMemorySessionStore) do def load(_key) = raise(NotImplementedError, 'load is not available') From 00a07f28406c8e503d78fbf6da90a4b548378aa0 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:56:02 +0800 Subject: [PATCH 099/194] Take created_at from parsed complete lines, never a raw match Round 1 review of e46f5c5. created_at went through the top-level field scanner, which falls back to a raw byte match for a line the 64 KiB head window cuts (its top-level shape cannot be checked there). A file-history-snapshot line longer than the window, with its nested snapshot.timestamp near the start, therefore still supplied created_at: the disk listing reported the snapshot's time where the store fold reports the first entry's own. created_at now comes only from top-level timestamps of lines that were parsed whole: the complete lines of the head and, when those hold none and the file goes on, the lines past them, read to their end in fixed-size chunks up to the first 1 MiB (the bounded reader the first-prompt scan uses; both now share it). It is the first timestamp that parses, as in the fold. Reading on, rather than reporting nil for a cut line, matters on real data: an SDK prompt over 64 KiB makes the first line (the enqueue record, whose timestamp is genuine) longer than the window. Over the 6,350 transcripts of one machine the value changes for 2 sessions, both from nil to a timestamp; reporting nil instead would have dropped it for 139. Exploration finding: B-P5 (round 1, B4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 6 +- lib/claude_agent_sdk/sessions.rb | 88 +++++++++++++------- spec/unit/session_info_disk_vs_store_spec.rb | 50 +++++++++-- 3 files changed, 106 insertions(+), 38 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index dad45f7f..1593c168 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -27,12 +27,12 @@ Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_ Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt; `created_at` is the first top-level `timestamp`. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths (`get_session_info` takes it from the store's `list_sessions`, or else `list_session_summaries`, row for the session — one extra adapter call; a store that implements neither reports the timestamp of the session's last entry instead, `0` when no entry has one). -The disk path does not read whole transcripts to list them: it looks at the first and the last 64 KiB of each file, and for `first_prompt` at up to the first 1 MiB when those first 64 KiB hold no prompt (a large hook attachment, or a long prompt, often comes first). The store paths fold every entry. The two report the same values unless the entry that decides a field lies outside what the disk path reads: +The disk path does not read whole transcripts to list them: it looks at the first and the last 64 KiB of each file, and for `first_prompt` and `created_at` at up to the first 1 MiB when those first 64 KiB hold none (a large hook attachment, or a long prompt, often comes first). The store paths fold every entry. The two report the same values unless the entry that decides a field lies outside what the disk path reads: - a custom title or tag appended while the session was still running stops being seen once more than 64 KiB of transcript follows it (`custom_title` falls back to the AI title or `nil`, `summary` to the next source, `tag` to `nil`) until the CLI resumes the session, which re-appends them at the end; - a last-prompt entry that is only at the start of the file is not used for `summary`; -- `cwd` and `created_at` come from the first 64 KiB: a first entry larger than that leaves `cwd` at the project path and `created_at` at `nil`; -- a first prompt whose transcript line ends beyond the first 1 MiB is not found: `first_prompt` is the name of a slash command seen before it, or `nil` — and a session with neither that nor a title or last-prompt entry in its last 64 KiB is not listed from disk at all. +- `cwd` comes from the first 64 KiB: a first entry larger than that leaves it at the project path; +- a first prompt whose transcript line ends beyond the first 1 MiB is not found: `first_prompt` is the name of a slash command seen before it, or `nil` — and a session with neither that nor a title or last-prompt entry in its last 64 KiB is not listed from disk at all. Likewise `created_at` is `nil` when no line ending within the first 1 MiB carries a timestamp of its own. When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (`directory:` listings: the directory's own copy, then the worktrees in the order `git worktree list` reports them, main worktree first). diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 4673c4ad..9463610d 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -89,8 +89,9 @@ module Sessions # rubocop:disable Metrics/ModuleLength -- session listing/readin LITE_READ_BUF_SIZE = 65_536 MAX_SANITIZED_LENGTH = 200 - # How far into a transcript the disk listing looks for the first prompt - # when the head window holds none (see first_prompt_from_file). + # How far into a transcript the disk listing looks for the first prompt, + # and for the first timestamp, when the head window holds none (see + # first_prompt_from_file, created_at_from_file). FIRST_PROMPT_SCAN_LIMIT = 1_048_576 UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i @@ -586,15 +587,25 @@ def first_prompt_from_file(file_path, head, size) prompt || command_fallback || '' end - # Continue the first-prompt scan from the end of the last complete line - # of +head+ up to byte +limit+ of the file. Read in fixed-size chunks, - # never line by line: one transcript line can be gigabytes, and the - # limit has to hold before the bytes are in memory. What is left open at - # the end is the last line of a file without a final newline — or a line - # cut by the limit, which does not parse and is skipped like any other - # bad line. An IO failure here leaves the answer the head gave: this - # read is an extra, and must not hide a session. + # Continue the first-prompt scan past +head+ (see each_line_past_head). An + # IO failure there leaves the answer the head gave. def first_prompt_past_head(file_path, head, limit, command_fallback) + each_line_past_head(file_path, head, limit) do |line| + prompt, command_fallback = first_prompt_in(line, command_fallback) + return [prompt, command_fallback] if prompt + end + [nil, command_fallback] + end + + # Yield the lines of a transcript that follow the last complete line of + # +head+, up to byte +limit+ of the file. Read in fixed-size chunks, + # never line by line: one transcript line can be gigabytes, and the + # limit has to hold before the bytes are in memory. What is yielded last + # is the final line of a file without a closing newline — or a line cut + # by the limit, which does not parse and is skipped by its consumer like + # any other bad line. An IO failure ends the read quietly: this read is + # an extra, and must not hide a session. + def each_line_past_head(file_path, head, limit, &) offset = (head.byterindex("\n") || -1) + 1 open_line = String.new(encoding: Encoding::BINARY) File.open(file_path, 'rb') do |file| @@ -603,15 +614,12 @@ def first_prompt_past_head(file_path, head, limit, command_fallback) offset += chunk.bytesize open_line << chunk newline = open_line.rindex("\n") - next unless newline - - prompt, command_fallback = first_prompt_in(open_line.slice!(0, newline + 1), command_fallback) - return [prompt, command_fallback] if prompt + open_line.slice!(0, newline + 1).each_line(&) if newline end - first_prompt_in(open_line, command_fallback) + yield open_line unless open_line.empty? end rescue SystemCallError - [nil, command_fallback] + nil end # Text blocks of a genuine user entry, or nil when the line should be @@ -733,6 +741,39 @@ def display_title(custom_title, ai_title) presence(custom_title) || presence(ai_title) end + # created_at (epoch ms) for the disk listing: the first top-level + # timestamp that parses — what the store fold takes. More reliable than + # stat().birthtime, which is unsupported on some filesystems. Every line + # is looked at, not only the first: the first record may be a + # metadata-only entry (e.g. permission-mode) with no timestamp field, and + # the first user/assistant record that follows carries one (Python #907). + # + # Parsed top-level fields of COMPLETE lines only, never a raw match: a + # file-history-snapshot entry, common near the start of an interactive + # session, has no timestamp of its own but nests one + # (snapshot.timestamp), and on a line the head window cuts a raw match + # cannot be told from such a nested one. When the complete head lines + # hold no timestamp and the file goes on, the lines past them are read + # to their end instead (bounded like the first-prompt scan): the cut line + # is usually the one that carries it — an SDK prompt of more than 64 KiB + # makes the very first line that long. + def created_at_from_file(file_path, head, size) + to_eof = size <= head.bytesize + each_parsed_entry(head, to_eof) do |entry| + created_at = parse_iso_timestamp_ms(entry['timestamp']) + return created_at if created_at + end + return nil if to_eof + + each_line_past_head(file_path, head, [size, FIRST_PROMPT_SCAN_LIMIT].min) do |line| + each_parsed_entry(line, true) do |entry| + created_at = parse_iso_timestamp_ms(entry['timestamp']) + return created_at if created_at + end + end + nil + end + def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize -- one optional field per SDKSessionInfo attribute custom_title, first_prompt = title_and_first_prompt(file_path, head, tail, stat.size) # lastPrompt tail entry shows what the user was most recently doing. @@ -747,17 +788,7 @@ def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disa tag_line = tail.lines.reverse.find { |ln| ln.start_with?('{"type":"tag"') } tag_value = presence(tag_line ? extract_json_string_field(tag_line, 'tag', last: true) : nil) - # created_at from the first ISO timestamp found in the head (epoch ms). - # More reliable than stat().birthtime which is unsupported on some - # filesystems. Scans the whole head rather than only the first line - # because the first record may be a metadata-only entry (e.g. - # permission-mode) with no timestamp field; the first user/assistant - # record that follows does carry one (Python #907). TOP-LEVEL only, as - # the store fold reads it: a file-history-snapshot entry, common near - # the start of an interactive session, has no timestamp of its own but - # nests one (snapshot.timestamp), and the raw scan reported that one. - first_timestamp = extract_top_level_string_field(head, 'timestamp') - created_at = parse_iso_timestamp_ms(first_timestamp) if first_timestamp + created_at = created_at_from_file(file_path, head, stat.size) SDKSessionInfo.new( session_id: File.basename(file_path, '.jsonl'), @@ -2050,7 +2081,8 @@ def filter_visible_messages(chain) :pick_leaf, :visible_ancestor?, :off_main_conversation?, :visible_message?, :reattach_parallel_tool_results, :off_chain_tool_results, :content_block_values, - :filter_visible_messages, :build_session_info, :user_entry_texts, + :filter_visible_messages, :build_session_info, :created_at_from_file, :user_entry_texts, + :each_line_past_head, :first_prompt_in, :first_prompt_from_file, :first_prompt_past_head, :valid_agent_id?, :sidechain_head?, :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot, diff --git a/spec/unit/session_info_disk_vs_store_spec.rb b/spec/unit/session_info_disk_vs_store_spec.rb index 5abe44e5..533380f1 100644 --- a/spec/unit/session_info_disk_vs_store_spec.rb +++ b/spec/unit/session_info_disk_vs_store_spec.rb @@ -80,19 +80,18 @@ def read_both end end - # An SDK prompt that inlines a document. The prompt is found by the scan - # past the head; the entry's own timestamp follows its message on the - # same line, beyond the head window. - it 'agrees on the prompt of a 300 KB first line, and has no created_at for it on disk' do + # An SDK prompt that inlines a document. The prompt, and the entry's own + # timestamp after it on the same line, are found by reading that line to + # its end past the head window. + it 'agrees on the prompt and the created_at of a 300 KB first line' do disk, stored = read_both do |t| t.prompt(:prompt, "#{'Q' * 300_000} end") t.assistant(:answer, t.text('Read.'), parent: :prompt) end expect(disk.first_prompt).to eq("#{'Q' * 200}…") - expect([disk.first_prompt, disk.summary, disk.cwd]).to eq([stored.first_prompt, stored.summary, stored.cwd]) - expect(disk.created_at).to be_nil - expect(stored.created_at).not_to be_nil + expect(disk.created_at).not_to be_nil + expect(fields.to_h { |f| [f, disk.public_send(f)] }).to eq(fields.to_h { |f| [f, stored.public_send(f)] }) end end @@ -110,6 +109,43 @@ def read_both expect(Time.at(disk.created_at / 1000).utc.strftime('%F %T')).to eq('2026-09-08 05:19:30') end + # The snapshot line is larger than the head window, which therefore cuts + # it: nothing on it can be checked there. Its nested timestamp (written + # before the large backup map) must not be taken for the session's; the + # line is read to its end, and the first entry with a timestamp of its + # own follows it. + it 'does not take created_at from the timestamp nested in a snapshot line the window cuts' do + disk, stored = read_both do |t| + t.mode + t.raw({ 'type' => 'file-history-snapshot', 'messageId' => t.id(:snapshot), + 'snapshot' => { 'messageId' => t.id(:snapshot), 'timestamp' => '2001-01-01T00:00:00.000Z', + 'trackedFileBackups' => { 'lib/big.rb' => { 'backupFileName' => 'x' * 70_000 } } }, + 'isSnapshotUpdate' => false }) + t.prompt(:prompt, 'after the snapshot') + t.assistant(:answer, t.text('Done.'), parent: :prompt) + end + + expect(disk.first_prompt).to eq('after the snapshot') + expect(Time.at(disk.created_at / 1000).utc.year).to eq(2026) # the prompt's, not the snapshot's 2001 + expect(disk.created_at).to eq(stored.created_at) + end + + it 'has no created_at when no line within the first 1 MiB carries a timestamp of its own' do + disk, stored = read_both do |t| + t.mode + t.raw({ 'type' => 'file-history-snapshot', 'messageId' => t.id(:snapshot), + 'snapshot' => { 'messageId' => t.id(:snapshot), 'timestamp' => '2001-01-01T00:00:00.000Z', + 'trackedFileBackups' => { 'lib/big.rb' => { 'backupFileName' => 'x' * 1_100_000 } } }, + 'isSnapshotUpdate' => false }) + t.prompt(:prompt, 'after the snapshot') + t.last_prompt('after the snapshot', leaf: :prompt) + end + + expect(disk.summary).to eq('after the snapshot') + expect(disk.created_at).to be_nil + expect(Time.at(stored.created_at / 1000).utc.year).to eq(2026) + end + # What only a full read would find. The entries below have more than 64 KiB # of transcript both before and after them, or sit past the first-prompt # scan; the CLI re-appends its own metadata when it resumes a session, an From b8ecabd24689276e258e81165edb23081e3bda0b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:57:13 +0800 Subject: [PATCH 100/194] Follow a dangling symlink when canonicalizing a missing path Round 1 review of 7b63834. canonicalize_path resolved a missing path by taking the nearest existing ancestor (File.exist?) and appending the rest as written. File.exist? is false for a symlink whose target is gone, so such a link was treated as a missing plain component: a session recorded through a symlinked project directory (keyed by the link's target) was no longer found through the link once the target had been removed, and the mutations raised Errno::ENOENT. Python's os.path.realpath resolves the link to its former target. A missing path is now resolved component by component: a component that is a symlink is followed (lstat/readlink) whether or not its target exists, any other one is kept as written, as is everything after the first missing component. Following stops after 40 links, so links that point at each other end the walk instead of looping. Exploration finding: B-P6 (round 1, B5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 63 +++++++++++++++------ spec/unit/session_deleted_directory_spec.rb | 46 +++++++++++++++ 2 files changed, 93 insertions(+), 16 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 9463610d..d727db84 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -165,28 +165,58 @@ def sanitize_path(name) # CLI's project-directory naming. # # A path that cannot be resolved as a whole (the directory was removed, or - # does not exist yet) is resolved as far as it exists: symlinks in the - # nearest existing ancestor are followed and the missing rest is appended - # as written — what Python's os.path.realpath does, where Ruby's - # File.realpath raises. The CLI keyed the project by the real path while + # does not exist yet) is resolved as far as it exists, the way Python's + # os.path.realpath does where Ruby's File.realpath raises + # (resolve_missing_path). The CLI keyed the project by the real path while # the directory existed, so a removed /tmp/proj on macOS must still # canonicalize to /private/tmp/proj for its sessions to be found; a plain # expand_path (the earlier fallback) resolved no symlink at all. def canonicalize_path(dir) File.realpath(dir).unicode_normalize(:nfc) rescue SystemCallError - existing = File.expand_path(dir) - missing = [] - until File.exist?(existing) || existing == File.dirname(existing) - missing.unshift(File.basename(existing)) - existing = File.dirname(existing) - end - resolved = begin - File.realpath(existing) - rescue SystemCallError - existing + resolve_missing_path(File.expand_path(dir)).unicode_normalize(:nfc) + end + + # How many symlinks resolve_missing_path follows before it keeps a link as + # written: the guard against links that point at each other. + MAX_SYMLINK_HOPS = 40 + + # Resolve an absolute +path+ that does not exist as a whole, component by + # component: a component that is a symlink is followed (lstat/readlink) + # whether or not its target exists, any other one — existing or missing — + # is kept as written. So is everything after the first missing + # component, and a link past MAX_SYMLINK_HOPS. Following a link whose + # target is gone is the point: a session recorded through a symlinked + # project directory is keyed by the target, and must still be found + # through the link after the target was removed. + def resolve_missing_path(path) + root = path[%r{\A(?:[A-Za-z]:)?/+}] || File::SEPARATOR + resolved = root + pending = path.delete_prefix(root).split(File::SEPARATOR).reject(&:empty?) + hops = 0 + until pending.empty? + name = pending.shift + next if name == '.' + + candidate = name == '..' ? File.dirname(resolved) : File.join(resolved, name) + target = name == '..' || hops >= MAX_SYMLINK_HOPS ? nil : symlink_target(candidate) + if target.nil? + resolved = candidate + next + end + + hops += 1 + resolved = root if target.start_with?(File::SEPARATOR) + pending.unshift(*target.split(File::SEPARATOR).reject(&:empty?)) end - File.join(resolved, *missing).unicode_normalize(:nfc) + resolved + end + + # The target of +path+ when it is a symlink (dangling or not), else nil. + def symlink_target(path) + File.symlink?(path) ? File.readlink(path) : nil + rescue SystemCallError + nil end # Derive the SessionStore +project_key+ for a directory (default: cwd). @@ -2072,7 +2102,8 @@ def filter_visible_messages(chain) end end - private_class_method :project_dir_records_cwd?, :recorded_cwd, :each_parsed_entry, :get_session_info_for_directory, + private_class_method :resolve_missing_path, :symlink_target, :project_dir_records_cwd?, :recorded_cwd, + :each_parsed_entry, :get_session_info_for_directory, :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, :worktree_paths, :find_session_file, :stat_candidate, :resolve_subagents_dir, diff --git a/spec/unit/session_deleted_directory_spec.rb b/spec/unit/session_deleted_directory_spec.rb index 4cbcae01..d5169478 100644 --- a/spec/unit/session_deleted_directory_spec.rb +++ b/spec/unit/session_deleted_directory_spec.rb @@ -92,6 +92,52 @@ def record_session(directory) end end + # The session was recorded through a symlinked project directory, so the CLI + # keyed it by the link's target. The target is gone; the link is still + # there, pointing at nothing. + context 'when the directory is a symlink whose target was removed' do + let(:target) { File.join(cwd, 'checkouts', 'project') } + let(:link) { File.join(cwd, 'current') } + + before do + FileUtils.mkdir_p(target) + File.symlink(target, link) + record_session(link) + FileUtils.rm_rf(target) + end + + it 'is found by the readers through the link' do + expect(File.symlink?(link) && !File.exist?(link)).to be(true) + expect(ClaudeAgentSDK.list_sessions(directory: link).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: link).length).to eq(2) + end + + it 'can be renamed through the link' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Old checkout', directory: link) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: link).custom_title) + .to eq('Old checkout') + end + + it 'has the project key of the former target, also through a relative link' do + relative = File.join(cwd, 'relative') + File.symlink(File.join('checkouts', 'project'), relative) + + expect(ClaudeAgentSDK.project_key_for_directory(link)).to eq(ClaudeAgentSDK::Sessions.sanitize_path(target)) + expect(ClaudeAgentSDK.project_key_for_directory(File.join(relative, 'packages', 'app'))) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(target, 'packages', 'app'))) + end + end + + it 'resolves a missing path under symlinks that point at each other without looping' do + one = File.join(cwd, 'one') + two = File.join(cwd, 'two') + File.symlink(two, one) + File.symlink(one, two) + + expect(ClaudeAgentSDK.list_sessions(directory: File.join(one, 'project'))).to eq([]) + end + it 'reports a directory that never held the session as a missing session' do typo = File.join(cwd, 'no-such-project') From 60aec84ea6973644e49a2eb76cc7200b3f7e8656 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:58:07 +0800 Subject: [PATCH 101/194] Let the bundle install on Ruby 4.0; run the test matrix on it `bundle install` failed on Ruby 4.0 for anyone using the main Gemfile, contributors and CI alike: Ruby 4.0 ships Bundler 4, and the gemspec declared the development dependency `bundler ~> 2.0`, which the Gemfile pulls in through `gemspec`: Because the current Bundler version (4.0.10) does not satisfy bundler ~> 2.0 and Gemfile depends on bundler ~> 2.0, version solving has failed. The development dependency is now `bundler >= 2.0`. Nothing changes for users of the gem: development dependencies are not installed with it, and the runtime dependencies are untouched. On Ruby 3.x an existing Gemfile.lock changes in that one line. ci.yml: Ruby 4.0 joins the `test` matrix (suite and RuboCop on Linux), next to the `deps (latest, Ruby 4.0)` leg. The macOS leg stays on Ruby 3.4, and its comment now says so instead of "the newest Ruby". CONTRIBUTING.md lists the Rubies CI runs. `gem build` still succeeds. It prints one more warning than before: RubyGems calls a `>=` requirement without an upper bound "open-ended". Exploration finding: O-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN --- .github/workflows/ci.yml | 9 ++------- CONTRIBUTING.md | 2 +- claude-agent-sdk.gemspec | 2 +- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db0b011a..a2e8fe91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: # Main suite + lint on every supported Ruby (Linux). The Ruby 3.4 Linux leg # also collects coverage (simplecov, COVERAGE=1): the total goes to the job # summary and the HTML report is uploaded as an artifact. No threshold gate. - # One macOS leg runs the suite on the newest Ruby: the transport depends on + # One macOS leg runs the suite on Ruby 3.4: the transport depends on # signals, pipes and TERM/KILL escalation, whose behavior differs per OS. test: name: test (${{ matrix.os }}, Ruby ${{ matrix.ruby-version }}) @@ -25,7 +25,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - ruby-version: ['3.2', '3.3', '3.4'] + ruby-version: ['3.2', '3.3', '3.4', '4.0'] include: - os: ubuntu-latest ruby-version: '3.4' @@ -105,11 +105,6 @@ jobs: # on Ruby 3.4 and on Ruby 4.0; `floor` pins the oldest supported runtime # dependencies, on Ruby 3.2 and 3.4. rspec only — lint runs on the main leg. # See gemfiles/*.gemfile. - # - # The `latest` row is the only Ruby 4.0 leg: the main Gemfile does not - # resolve there (Ruby 4.0 ships Bundler 4; the gemspec's development - # dependency is `bundler ~> 2.0`), so 4.0 cannot join the `test` matrix - # until that constraint is relaxed. deps: name: deps (${{ matrix.gemfile }}, Ruby ${{ matrix.ruby-version }}) runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ba7dab2..bf04b920 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ Run them when you change anything on the CLI wire protocol (the transport, `Quer **TLA+ models** (`formal/tla/`) model-check the CLI installer's publish/lock ordering and the outbound control-request protocol. They need Java 11+; `run.sh` fetches the pinned TLA+ tools itself. Run `formal/tla/run.sh` when you change `CLIInstaller.install` / `#publish` or `Query#send_control_request` / `#await_control_response`, and update the model if the design changed. `formal/tla/README.md` explains what each model covers. -CI runs the unit suite and RuboCop on Ruby 3.2, 3.3 and 3.4 (Linux), the unit suite on macOS, the dependency floor and latest legs (the latest one on Ruby 3.4 and on Ruby 4.0), and the Rails specs on Rails 7.1 and 8. The Ruby 3.4 Linux leg also runs `rake rbs:validate`, and a separate job runs `rake rbs:test`. See [`.github/workflows/`](.github/workflows/). +CI runs the unit suite and RuboCop on Ruby 3.2, 3.3, 3.4 and 4.0 (Linux), the unit suite on macOS, the dependency floor and latest legs (the latest one on Ruby 3.4 and on Ruby 4.0), and the Rails specs on Rails 7.1 and 8. The Ruby 3.4 Linux leg also runs `rake rbs:validate`, and a separate job runs `rake rbs:test`. See [`.github/workflows/`](.github/workflows/). ## Pull requests diff --git a/claude-agent-sdk.gemspec b/claude-agent-sdk.gemspec index 229afbb2..536b082a 100644 --- a/claude-agent-sdk.gemspec +++ b/claude-agent-sdk.gemspec @@ -74,7 +74,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'mcp', '>= 0.22', '< 2' # Development dependencies - spec.add_development_dependency 'bundler', '~> 2.0' + spec.add_development_dependency 'bundler', '>= 2.0' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.0' # Pinned to a single minor so local and CI resolve the same RuboCop (Gemfile.lock From 43abc0deeca325737dd319c5d8299a9cfca3a22b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:59:13 +0800 Subject: [PATCH 102/194] Tag paths as UTF-8 before normalizing them under a C locale Under LANG=C / LC_ALL=C Ruby tags what it gets from the system with the locale's encoding: an ENV value with non-ASCII bytes and (on macOS) Dir.pwd come back BINARY, File.realpath US-ASCII. String#unicode_normalize raises on a BINARY String ("Unicode Normalization not appropriate for ASCII-8BIT"), so there: - a non-ASCII CLAUDE_CONFIG_DIR made Sessions.config_dir and SessionStores.projects_dir raise, i.e. every disk session API and the transcript mirror's key mapping; - `directory: Dir.pwd` made list_sessions, project_key_for_directory and the other directory-scoped APIs raise for any path, ASCII included (canonicalize_path keeps the encoding of the String it was given). Sessions.nfc_path tags the bytes UTF-8 (scrubbing what is not valid) before normalizing; config_dir, projects_dir and canonicalize_path go through it. The regression runs the SDK in a child process started with LC_ALL=C, since a process's locale is fixed at start. The two examples that failed under LC_ALL=C on main (session_store_spec.rb:455, sessions_spec.rb:1691) pass there now. Round 1 follow-up (C locale). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/session_store.rb | 6 +- lib/claude_agent_sdk/sessions.rb | 25 ++++++-- spec/unit/session_c_locale_paths_spec.rb | 77 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 spec/unit/session_c_locale_paths_spec.rb diff --git a/lib/claude_agent_sdk/session_store.rb b/lib/claude_agent_sdk/session_store.rb index 9e165d89..198d0d7a 100644 --- a/lib/claude_agent_sdk/session_store.rb +++ b/lib/claude_agent_sdk/session_store.rb @@ -454,10 +454,12 @@ def projects_dir(env_override = nil) # NFC like Python's _get_projects_dir(env_override) — a decomposed # Unicode override would otherwise mismatch the NFC paths used for # the mirror's projects-dir prefix comparison and drop every frame. - return File.join(override.unicode_normalize(:nfc), 'projects') if override + # Sessions.nfc_path: under LANG=C the ENV value arrives tagged BINARY, + # which unicode_normalize alone raises on. + return File.join(Sessions.nfc_path(override), 'projects') if override home = Sessions.home_dir(env_override) - home && File.join(home, '.claude', 'projects').unicode_normalize(:nfc) + home && Sessions.nfc_path(File.join(home, '.claude', 'projects')) end end end diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index d727db84..424e90ac 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -172,9 +172,23 @@ def sanitize_path(name) # canonicalize to /private/tmp/proj for its sessions to be found; a plain # expand_path (the earlier fallback) resolved no symlink at all. def canonicalize_path(dir) - File.realpath(dir).unicode_normalize(:nfc) + nfc_path(File.realpath(dir)) rescue SystemCallError - resolve_missing_path(File.expand_path(dir)).unicode_normalize(:nfc) + nfc_path(resolve_missing_path(File.expand_path(dir))) + end + + # +path+ as an NFC-normalized UTF-8 String. Paths are UTF-8 whatever the + # locale says, but under LANG=C Ruby hands out what it gets from the + # system — ENV values, Dir.pwd, File.realpath — tagged BINARY or US-ASCII. + # String#unicode_normalize raises on the first ("Unicode Normalization + # not appropriate for ASCII-8BIT": `directory: Dir.pwd` and a non-ASCII + # CLAUDE_CONFIG_DIR failed every disk session API there) and leaves the + # second as it is, non-ASCII bytes included. So the bytes are tagged + # UTF-8 first, and scrubbed when they are not valid UTF-8. + def nfc_path(path) + utf8 = path.encoding == Encoding::UTF_8 ? path : path.dup.force_encoding(Encoding::UTF_8) + utf8 = utf8.scrub unless utf8.valid_encoding? + utf8.unicode_normalize(:nfc) end # How many symlinks resolve_missing_path follows before it keeps a link as @@ -242,7 +256,7 @@ def project_key_for_directory(directory = nil) # raised a bare ArgumentError from deep inside every disk session API. def config_dir dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil) - return dir.unicode_normalize(:nfc) if dir && !dir.empty? + return nfc_path(dir) if dir && !dir.empty? home = home_dir unless home @@ -252,7 +266,7 @@ def config_dir 'Set CLAUDE_CONFIG_DIR to the directory holding your Claude Code data (normally ~/.claude).' end - File.join(home, '.claude').unicode_normalize(:nfc) + nfc_path(File.join(home, '.claude')) end # A usable home directory, or nil when there is none. The ONE definition @@ -2127,7 +2141,8 @@ def filter_visible_messages(chain) # These remain accessible for SessionMutations / SessionResume: # config_dir, sanitize_path, find_project_dir, detect_worktrees, # valid_session_id? (mutation boundary checks), listing_sort_key - # (--continue candidate order), read_head_tail, title_and_first_prompt + # (--continue candidate order), nfc_path (SessionStores.projects_dir), + # read_head_tail, title_and_first_prompt # and display_title (the fork title), own_transcript? (the mutations' # lookups) end diff --git a/spec/unit/session_c_locale_paths_spec.rb b/spec/unit/session_c_locale_paths_spec.rb new file mode 100644 index 00000000..a046397e --- /dev/null +++ b/spec/unit/session_c_locale_paths_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'open3' +require 'rbconfig' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Under LANG=C / LC_ALL=C (minimal Docker images, cron, systemd) Ruby hands +# out the paths it gets from the system — ENV values, Dir.pwd, File.realpath — +# tagged BINARY or US-ASCII. They are UTF-8 all the same. The locale of a +# process is fixed when it starts, so this runs the SDK in a child process. +RSpec.describe 'session paths under a non-UTF-8 locale' do + include_context 'with a Claude config dir' + + let(:session_id) { '7e6f5d4c-3b2a-4190-8f7e-6d5c4b3a2910' } + let(:accented) { "caf#{[0xE9].pack('U')}" } # "café", NFC + + # What the SDK reports in a child started with LC_ALL=C, as hex (the pipe + # back is read as bytes) plus the encoding of each value. + let(:probe) do + <<~'RUBY' + require 'claude_agent_sdk' + require 'json' + report = lambda do |value| + value.is_a?(String) ? [value.unpack1('H*'), value.encoding.name, value.valid_encoding?] : value + rescue StandardError => e + "#{e.class}: #{e.message}" + end + attempt = lambda do |&block| + report.call(block.call) + rescue StandardError => e + "#{e.class}: #{e.message}" + end + puts JSON.generate( + 'external' => Encoding.default_external.name, + 'config_dir' => attempt.call { ClaudeAgentSDK::Sessions.config_dir }, + 'projects_dir' => attempt.call { ClaudeAgentSDK::SessionStores.projects_dir }, + 'key_for_pwd' => attempt.call { ClaudeAgentSDK.project_key_for_directory(Dir.pwd) }, + 'key_for_default' => attempt.call { ClaudeAgentSDK.project_key_for_directory }, + 'listed_for_pwd' => attempt.call { ClaudeAgentSDK.list_sessions(directory: Dir.pwd).map(&:session_id) } + ) + RUBY + end + + def hex(string) + [string.unpack1('H*'), 'UTF-8', true] + end + + def run_under_c_locale(chdir:, config:) + env = { 'LC_ALL' => 'C', 'LANG' => 'C', 'LC_CTYPE' => 'C', 'CLAUDE_CONFIG_DIR' => config } + output, errors, status = Open3.capture3(env, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), + '-e', probe, chdir: chdir) + raise "probe failed: #{errors}" unless status.success? + + JSON.parse(output) + end + + it 'reads a non-ASCII CLAUDE_CONFIG_DIR and lists the sessions of Dir.pwd' do + config = File.join(cwd, "#{accented}-config") + project = File.join(cwd, "#{accented}-project").tap { |dir| FileUtils.mkdir_p(dir) } + transcript = CLITranscript.new(session_id: session_id, cwd: project) + transcript.prompt(:prompt, 'hi') + transcript.write(File.join(config, 'projects', ClaudeAgentSDK::Sessions.sanitize_path(project), + "#{session_id}.jsonl")) + + seen = run_under_c_locale(chdir: project, config: config) + + expect(seen['external']).to eq('US-ASCII') + expect(seen['config_dir']).to eq(hex(config)) + expect(seen['projects_dir']).to eq(hex(File.join(config, 'projects'))) + expect(seen['key_for_pwd']).to eq(hex(ClaudeAgentSDK::Sessions.sanitize_path(project))) + expect(seen['key_for_default']).to eq(seen['key_for_pwd']) + expect(seen['listed_for_pwd']).to eq([session_id]) + end +end From f1652c1ebe808799a497a193ab536f19b9d5bbd1 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:59:27 +0800 Subject: [PATCH 103/194] Say that delete_session is for sessions whose CLI has exited docs/sessions.md said session mutations are safe to call while the session is open in a CLI process. That holds for rename_session, tag_session and fork_session. A running CLI does not notice that its transcript was deleted: its next write recreates the file with only the entries written from then on, and the session is listed again holding just the later part of the conversation. The note is limited to rename / tag / fork, and "Deleting a Session" says to delete only sessions whose CLI process has exited. Docs only. Exploration finding: B-P14 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- docs/sessions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/sessions.md b/docs/sessions.md index 1593c168..78809754 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -119,6 +119,8 @@ ClaudeAgentSDK.delete_session( ) ``` +Delete only sessions whose CLI process has exited. A CLI that is still running the session does not notice the deletion: its next write recreates the file with only the entries it writes from then on, so the session comes back in listings holding just the later part of the conversation. + ## Forking a Session ```ruby @@ -138,7 +140,7 @@ ClaudeAgentSDK.fork_session( Without `title:` the fork is named after its source, `<title> (fork)`: the title the listing shows for the source (its custom title, else its AI title), else its first prompt, else `Forked session`. The disk and the store path use the same rule. -> Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session` writes and closes a private staging file before atomically publishing it with a hard link, so partial forks are not discoverable and existing sessions are never overwritten. The project filesystem must support hard links; publication failures leave the source and any existing destination untouched. +> `rename_session` and `tag_session` use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They, and `fork_session`, are safe to call while the session is open in a CLI process; `delete_session` is not (see [Deleting a Session](#deleting-a-session)). `fork_session` writes and closes a private staging file before atomically publishing it with a hard link, so partial forks are not discoverable and existing sessions are never overwritten. The project filesystem must support hard links; publication failures leave the source and any existing destination untouched. ## Resuming at a Specific Message From 23b92f0884c0127506d512230bfc828057ef4a94 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 03:06:40 +0800 Subject: [PATCH 104/194] Pin the required-load resume failure through query() as well The regression for a required #load failing while a store-backed resume materializes subagents went through SessionResume.materialize_resume_session. It now also goes through ClaudeAgentSDK.query with resume: and session_store:, which materializes before it starts a CLI: the NotImplementedError surfaces from query(), no CLI is looked for, and no materialized directory is left behind. Spec only. Exploration finding: B-P11 (round 1, B3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- .../session_store_optional_methods_spec.rb | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/spec/unit/session_store_optional_methods_spec.rb b/spec/unit/session_store_optional_methods_spec.rb index 796f758c..f99f38a5 100644 --- a/spec/unit/session_store_optional_methods_spec.rb +++ b/spec/unit/session_store_optional_methods_spec.rb @@ -166,26 +166,45 @@ def opting_out_of(method) # That is not "list_subkeys is not implemented": the resume must fail, and # leave nothing behind, rather than hand over a session without its # subagent. - it 'lets a NotImplementedError from load surface while a resume materializes subagents' do - store = seed(Class.new(ClaudeAgentSDK::InMemorySessionStore) do - def load(key) - raise NotImplementedError, 'load is not available for subpaths' if key['subpath'] + context 'when the required load fails for a subagent that list_subkeys names' do + let(:store) do + seed(Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def load(key) + raise NotImplementedError, 'load is not available for subpaths' if key['subpath'] - super - end - end.new) - tmpdir = File.join(cwd, 'tmp').tap { |dir| FileUtils.mkdir_p(dir) } - options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: session_id, cwd: cwd, - env: { 'CLAUDE_CONFIG_DIR' => config_dir }) - saved = ENV.fetch('TMPDIR', nil) - ENV['TMPDIR'] = tmpdir - begin - expect { ClaudeAgentSDK::SessionResume.materialize_resume_session(options) } - .to raise_error(NotImplementedError, 'load is not available for subpaths') - expect(Dir.children(tmpdir)).to eq([]) # the materialized directory was removed + super + end + end.new) + end + # The resume materializes into Dir.tmpdir: a private one shows what is left. + let(:tmpdir) { File.join(cwd, 'tmp').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:options) do + ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: session_id, cwd: cwd, + cli_path: File.join(cwd, 'no-such-claude'), + env: { 'CLAUDE_CONFIG_DIR' => config_dir }) + end + + around do |example| + saved = ENV.fetch('TMPDIR', nil) + ENV['TMPDIR'] = tmpdir + example.run ensure saved.nil? ? ENV.delete('TMPDIR') : ENV['TMPDIR'] = saved end + + it 'lets the NotImplementedError surface from the resume materialization and removes its directory' do + expect { ClaudeAgentSDK::SessionResume.materialize_resume_session(options) } + .to raise_error(NotImplementedError, 'load is not available for subpaths') + expect(Dir.children(tmpdir)).to eq([]) + end + + # The public path: query() materializes the resume before it starts a CLI + # (there is none at cli_path, and it is never looked for). + it 'lets it surface from query() with resume: and session_store:' do + expect { ClaudeAgentSDK.query(prompt: 'continue', options: options) { |_message| nil } } + .to raise_error(NotImplementedError, 'load is not available for subpaths') + expect(Dir.children(tmpdir)).to eq([]) + end end it 'still lets a NotImplementedError from a required method through' do From 5162cd03b1578a8fb2f3371f71431a7076303ba8 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 03:11:23 +0800 Subject: [PATCH 105/194] Remember the end of the stream instead of re-queueing its marker A Client can outlive its reactor: once the CLI has exited, the read loop is over, the Async block that connected finishes, and the application may still call receive_messages / receive_response from plain Ruby. There the first call drained the queue, and the next one hung (async >= 2.29) or raised NoMethodError (async 2.10): the end marker was put back only from inside an Async task, because async before 2.29 cannot enqueue outside one. Query#receive_messages now records that it consumed the end marker and returns at once when nothing else is queued. That holds in any context and on every async version, so the marker is no longer put back at all. Messages still queued, and the one stream error queued before the end, are delivered exactly as before; so is anything reported after the end (a mirror error from the final flush). The specs call both receive methods three times in a row after the reactor has finished, after a clean end and after a crash. Exploration finding: A-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- lib/claude_agent_sdk/query.rb | 21 +++--- .../client_receive_after_stream_end_spec.rb | 65 ++++++++++++++----- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 656825ca..d1fa62cd 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -195,6 +195,8 @@ def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sd # Message stream @message_queue = Async::Queue.new + # Set once #receive_messages has consumed the read loop's end sentinel. + @stream_ended = false # Ends when the run is over, so the stdin-closing waiter can wake; see # #read_messages and @inflight_tasks below (Python #1088, #1190/#1279). # Work the CLI takes up after the run ended swaps in a fresh one @@ -1788,16 +1790,19 @@ def receive_messages(&block) # reception — ResultMessage dropped, the query reported as complete, and # on_error never fired. `while` propagates it like any other error. while true # rubocop:disable Style/InfiniteLoop + # End of stream is sticky. The read loop enqueues ONE sentinel and is + # gone, so once a receive has consumed it, every later one must end + # at once rather than wait on a queue nothing writes to any more + # (Python closes the send side of its stream, so later iterations + # end at once there too). The end is remembered, not put back on the + # queue: a receive can be made after the reactor has finished, and + # async < 2.29 cannot enqueue outside a task. Anything still queued, + # such as a mirror error reported after the end, is delivered first. + break if @stream_ended && @message_queue.empty? + message = @message_queue.dequeue if message[:type] == 'end' - # End of stream is sticky. The read loop enqueues ONE sentinel and - # is gone, so the sentinel goes back for the next caller: consumed - # here, every later receive would park on a queue nothing writes - # to any more. (Python closes the send side of its stream instead, - # so later iterations end at once there too.) Only from inside a - # task: async < 2.29 raises for an enqueue outside one, so a - # receive from outside a reactor consumes the sentinel as before. - @message_queue.enqueue(message) if Async::Task.current? + @stream_ended = true break end raise message[:error] if message[:type] == 'error' diff --git a/spec/unit/client_receive_after_stream_end_spec.rb b/spec/unit/client_receive_after_stream_end_spec.rb index 683d12c1..b09593d6 100644 --- a/spec/unit/client_receive_after_stream_end_spec.rb +++ b/spec/unit/client_receive_after_stream_end_spec.rb @@ -2,13 +2,14 @@ require 'spec_helper' require 'async' +require 'timeout' # Once the CLI's stdout has ended — the process exited, cleanly or not — the # read loop is gone and nothing is ever written to the message queue again. -# The read loop leaves ONE end-of-stream marker behind, so the marker has to -# stay there for every later Client#receive_response / #receive_messages: -# consumed by the first reader, the next one waits on an empty queue that has -# no producer, for as long as the process lives. +# The read loop leaves ONE end-of-stream marker behind, so the end has to be +# remembered for every later Client#receive_response / #receive_messages: +# forgotten once the first reader consumed the marker, the next one waits on +# an empty queue that has no producer, for as long as the process lives. # # Each call below runs under a bounded wait, so a call that parks fails the # example instead of hanging the suite. A call that ends does so without @@ -153,19 +154,49 @@ def receive(task, client, method) end end - # The marker goes back only from inside a task: async < 2.29 raises for an - # Async::Queue#enqueue outside one. A receive called after the run and its - # reactor are over keeps draining what is queued, as it always did. - it 'still drains the finished stream when the receive is called outside a reactor' do - client = described_class.new(transport_class: transport_class, - transport_args: { on_user_message: cli_exits_after_the_turn }) - Async do - client.connect - client.query('What is 2 + 2?') - end.wait # the CLI exited: the read loop ended, and the reactor with it + # A Client can outlive its reactor: once the CLI has exited, the read loop + # is over, the `Async { }` that connected finishes, and the application may + # still call receive_* from plain Ruby. The end of the stream has to be + # remembered there too, where nothing can be put back on the queue (async + # < 2.29 cannot enqueue outside a task). + describe 'from plain Ruby, after the reactor has finished' do + def finished_session(on_user_message) + client = described_class.new(transport_class: transport_class, + transport_args: { on_user_message: on_user_message }) + Async do + client.connect + client.query('What is 2 + 2?') + end.wait # the CLI exited: the read loop ended, and the reactor with it + client + end - seen = [] - client.receive_messages { |message| seen << message.class } - expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + # As #receive above; there is no task here, so the bound is a plain Timeout. + def receive_without_a_reactor(client, method) + seen = [] + Timeout.timeout(5) { client.public_send(method) { |message| seen << message.class } } + seen + rescue Timeout::Error + :parked + end + + %i[receive_response receive_messages].each do |method| + it "ends ##{method} at once on every call after a clean end of stream" do + client = finished_session(cli_exits_after_the_turn) + calls = Array.new(3) { receive_without_a_reactor(client, method) } + + expect(calls).to eq([[ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], [], []]) + end + + it "raises the stream error from ##{method} once, then ends at once on every later call" do + client = finished_session(cli_crashes_mid_turn) + seen_before_the_crash = [] + expect do + Timeout.timeout(5) { client.public_send(method) { |message| seen_before_the_crash << message.class } } + end.to raise_error(ClaudeAgentSDK::ProcessError, /exit code 1/) + expect(seen_before_the_crash).to eq([ClaudeAgentSDK::AssistantMessage]) + + expect(Array.new(2) { receive_without_a_reactor(client, method) }).to eq([[], []]) + end + end end end From 9ccdb730e95319ff9c943b47bb15f7c7390dbee5 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 03:13:02 +0800 Subject: [PATCH 106/194] Arm the connect deadline only around the on_error notification Test only. The connect examples of the teardown-under-deadline spec put a 0.2 s task.with_timeout around the whole Client#connect and skipped when the deadline expired before connect reached on_error. connect suspends before that point (resume materialization, the handshake), so on a slow run the examples went pending, the teardown was no longer checked, and the suite stayed green. The real task.with_timeout is now armed on the connecting fiber for exactly the time Client spends notifying on_error, so nothing before it can use the deadline up, however slow. Reaching on_error with the handshake rejection is a hard expectation instead of a skip. Checked with the fake handshake delayed by 0.3 s: with the teardown fix reverted the connect examples fail (3 of 4, on async 2.10, 2.36 and 2.46; the fourth pins the :inline residue and passes either way), with the fix they pass, and none goes pending. Exploration finding: A-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ --- .../client_teardown_under_deadline_spec.rb | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/spec/unit/client_teardown_under_deadline_spec.rb b/spec/unit/client_teardown_under_deadline_spec.rb index 1cdfd2f9..830a7000 100644 --- a/spec/unit/client_teardown_under_deadline_spec.rb +++ b/spec/unit/client_teardown_under_deadline_spec.rb @@ -22,7 +22,10 @@ # Async::Stop. # # The observers block on a queue until the example releases them, so the -# deadlines can only expire while they wait. +# deadlines can only expire while they wait. disconnect reaches its observer +# without suspending. connect suspends before it reaches on_error (resume +# materialization, the handshake), so there the real task.with_timeout is +# armed only around the observer notification: no slow setup can use it up. RSpec.describe ClaudeAgentSDK::Client, 'teardown when the caller is interrupted during an observer' do # In-memory stand-in for the CLI process. It answers `initialize` the way # CLI 2.1.286 answers control requests (payload trimmed), or rejects it @@ -124,8 +127,8 @@ def transport_class(created) end # An observer that stays in +hook+ until the example releases it. on_error - # waits only for the handshake rejection: handed anything else (the - # caller's deadline, had it expired before the hop) it returns at once. + # waits only for the handshake rejection: handed anything else it returns + # at once, and the example's gate (#expect_on_error_reached) fails. def blocking_observer(hook) queues = { entered: entered, release: release, handed: handed } Class.new do @@ -238,18 +241,26 @@ def connected_client(scheduling) end describe 'a failing Client#connect while on_error runs' do - # Skips, rather than passes, when the deadline went off before the hop. + # Arms a real task.with_timeout on the connecting fiber for exactly the + # time Client spends notifying on_error. + def arm_deadline_around_on_error(client, seconds, *exception_class) + allow(client).to receive(:notify_error).and_wrap_original do |notify, error| + Async::Task.current.with_timeout(seconds, *exception_class) { notify.call(error) } + end + end + + # The gate of every example here: on_error ran, with the handshake rejection. def expect_on_error_reached - reached = handed.first.is_a?(StandardError) && handed.first.message.include?('Invalid initialize request') - skip 'the deadline expired before the on_error hop; this run did not reach it' unless reached + expect(handed).to match([have_attributes(class: StandardError, message: 'Invalid initialize request')]) end it 'tears the partial session down, then raises the expired deadline (:thread)' do - Sync do |task| + Sync do client, created = client_for(:on_error, scheduling: :thread, reject_initialize: true) - outcome = outcome_of { task.with_timeout(0.2) { client.connect } } - expect_on_error_reached + arm_deadline_around_on_error(client, 0.05) + outcome = outcome_of { client.connect } + expect_on_error_reached expect(outcome).to be_a(Async::TimeoutError) expect_torn_down(client, created.fetch(0)) ensure @@ -259,12 +270,13 @@ def expect_on_error_reached end it 'tears the partial session down, then raises an inline cooperative cancellation (:inline)' do - Sync do |task| + Sync do client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) cancellation = inline_cancellation - outcome = outcome_of { task.with_timeout(0.2, cancellation) { client.connect } } - expect_on_error_reached + arm_deadline_around_on_error(client, 0.05, cancellation) + outcome = outcome_of { client.connect } + expect_on_error_reached expect(outcome).to be_a(cancellation) expect_torn_down(client, created.fetch(0)) ensure @@ -281,6 +293,7 @@ def expect_on_error_reached caller_task.stop caller_task.wait + expect_on_error_reached expect_torn_down(client, created.fetch(0)) ensure release.close @@ -290,11 +303,12 @@ def expect_on_error_reached it 'tears the partial session down and raises the connect error while the observer contains a deadline ' \ '(:inline, residue)' do - Sync do |task| + Sync do client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) - outcome = outcome_of { task.with_timeout(0.2) { client.connect } } - expect_on_error_reached + arm_deadline_around_on_error(client, 0.05) + outcome = outcome_of { client.connect } + expect_on_error_reached expect(outcome).to be_a(StandardError).and(have_attributes(message: 'Invalid initialize request')) expect_torn_down(client, created.fetch(0)) ensure From 603214121d0d8632342881104e7d551caf2eb1e6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:10:47 +0800 Subject: [PATCH 107/194] Move a preserved resume dir out of reach before scrubbing it preserve_transcripts checked that its path still led to the directory the SDK created and then deleted by path. Two races were left: - the root could become a symlink after that check and before FileUtils.remove_entry(root/backups) ran, as an intermediate path component, so a file outside was deleted while the warning said nothing had been; - the EACCES retry lstat'ed a child and then chmodded it by path, so a child swapped for a symlink in between got the directory it pointed at chmodded (0555 -> 0700). The CLI's tools can write into the temp config dir, sandboxed ones too, so the SDK's own cleanup must not become a way to reach outside it. A path that was checked is no longer resolved again: 1. The directory is moved, in one rename, into a fresh private (0700) directory created next to it. A rename moves a symlink itself, never its target. A read-only directory cannot be moved, so it is made writable first, through a no-follow handle that has to be the directory recorded at creation (device and inode). 2. What was moved is validated: a real directory with the recorded device and inode. Otherwise, or if it could not be moved, nothing is deleted and the warning says scrubbing was skipped and why. 3. SessionResume::Scrubber deletes the entries. It renames each one into a private trash directory before it looks at it, so a swap after the look has nothing to redirect; a symlink is unlinked, a directory is emptied the same way one level at a time. Permissions are repaired only with fchmod on a handle opened O_NOFOLLOW, after the handle proved to be the directory lstat had just seen and one this user owns, and the path is compared again afterwards. If it changed, the scrub stops and the warning says so. A user can notice: the preserved transcript is no longer at the temp dir's path but under claude-preserved-resume-*/<temp dir name>/projects, next to where it was; the warning names that path and the directory to remove afterwards, and config_dir returns it. When the scrub is cut short by a cancellation, a warning still says where the transcript is. docs/sessions.md describes the move. The specs force each interleaving by wrapping the call that comes next (no thread, no clock): the root swapped right before the move, a symlink planted at the old path, an entry swapped right before it is taken, backups/ swapped between the lstat and the chmod and right after the chmod. Directories outside keep their files and their mode. Exploration finding: B-P8 (codex review round 2, B1 and B4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- docs/sessions.md | 18 +- lib/claude_agent_sdk/session_resume.rb | 260 +++++++++---- spec/unit/materialized_resume_spec.rb | 493 +++++++++++++++++++------ spec/unit/session_resume_spec.rb | 16 +- 4 files changed, 590 insertions(+), 197 deletions(-) diff --git a/docs/sessions.md b/docs/sessions.md index 60a601c2..34fea133 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -290,14 +290,16 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > (terminal append failures — timeouts immediately, other failures after up to > three attempts — surfaced as `MirrorErrorMessage`): > the store copy is then incomplete and the temp dir holds the only copy of the -> dropped turns, so the SDK keeps the transcripts (`projects/`), deletes -> everything else in the temp dir — the seeded credential and settings copies -> and whatever the CLI wrote beside them, such as its `backups/` copy of -> `.claude.json` — and warns with the preserved path so you can import them -> into the store. If the path no longer leads to the directory the SDK created -> (it was replaced, for instance by a symlink), the SDK deletes nothing there -> and the warning says so; if an entry cannot be removed, the warning says the -> scrub failed and names what is left. +> dropped turns, so the SDK moves it into a fresh private directory next to it +> (`claude-preserved-resume-*`), keeps the transcripts (`projects/`) there, +> deletes everything else — the seeded credential and settings copies and +> whatever the CLI wrote beside them, such as its `backups/` copy of +> `.claude.json` — and warns with the new path so you can import them into the +> store. If what it moved is not the directory the SDK created (the path had +> been replaced, for instance by a symlink), or it cannot be moved, the SDK +> deletes nothing and the warning says the scrub was skipped and why; if an +> entry cannot be removed, the warning says the scrub failed and names what is +> left. ### Implementing an adapter diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 070b8f4d..fb5fd7ed 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -23,6 +23,13 @@ module ClaudeAgentSDK class MaterializedResume attr_reader :config_dir, :resume_session_id + MIRROR_DROPPED = 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete.' + SKIPPED = 'Scrubbing was skipped and nothing was deleted:' + # Prefix of the private directory a preserved temp dir is moved into. Not + # "claude-resume-": that one is for directories the SDK may still delete. + STAGING_PREFIX = 'claude-preserved-resume-' + private_constant :MIRROR_DROPPED, :SKIPPED, :STAGING_PREFIX + # +root_identity+ is SessionResume.directory_identity of +config_dir+ as # the SDK created it; materialize_resume_session takes it right after # mkdtemp. Without it, it is taken here. @@ -50,65 +57,111 @@ def cleanup # which can hold MCP header secrets — as backups/.claude.json.backup.<ts>. # A list of names to delete missed that one and would miss the next. # - # Deleting "everything else" is only safe inside the directory the SDK - # created. Anything that knows CLAUDE_CONFIG_DIR can replace it before - # teardown — with a symlink, or another directory at the same path — and - # following that would delete files the SDK never wrote. So the root is - # checked before the listing and again before each deletion, and nothing - # is deleted once it fails; a symlink inside is unlinked, never followed. + # Deleting "everything else" must never reach outside the directory the + # SDK created, and the CLI's tools can write into that directory — + # sandboxed ones too — up to and during this call: they can turn the + # directory, or anything in it, into a symlink between two steps taken + # here. So a path that was checked is never resolved again: + # + # 1. The directory is moved, in one rename, into a fresh private directory + # next to it. A rename moves a symlink itself, never its target, and it + # takes the directory away from the path its writers know. (A read-only + # directory cannot be moved; it is made writable first, through a + # handle that has to be the directory the SDK created.) + # 2. What was moved is then examined: it has to be a real directory with + # the device and inode recorded when the SDK created it. If it is not, + # or it could not be moved, nothing is deleted and the warning says why. + # 3. Its entries are deleted by SessionResume::Scrubber, which takes each + # one out of the tree before it looks at it. + # + # config_dir is the new location afterwards, the one the warning names. def preserve_transcripts - notice = scrub_all_but_projects - warn [preservation_warning, notice].compact.join(' ') + announced = false + warn "#{MIRROR_DROPPED} #{move_aside_and_scrub}" + announced = true rescue StandardError => e warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}" + announced = true + ensure + # Cut short by something that is not a StandardError (a cancellation, a + # signal): the directory may have been moved, so say where it is. + unless announced + warn "#{MIRROR_DROPPED} Scrubbing was interrupted; the session transcript is under " \ + "#{File.join(@config_dir, 'projects')}." + end end private - # Deletes every entry of the root but projects/, then checks that nothing - # else is left. Returns nil, or what the preservation warning has to add. - def scrub_all_but_projects - return replaced_root_notice unless root_unchanged? - - errors = {} - Dir.children(@config_dir).each do |name| - next if name == 'projects' - return replaced_root_notice unless root_unchanged? + # Returns the rest of the preservation warning. + def move_aside_and_scrub + original = @config_dir + staging, failure = move_aside(original) + if failure + return "#{SKIPPED} #{original} could not be moved aside (#{failure.message}). If it is still there, the " \ + "session transcript is under #{File.join(original, 'projects')}, next to copies of your " \ + 'credentials and settings that were not removed.' + end - error = SessionResume.remove_entry_without_following(File.join(@config_dir, name)) - errors[name] = error if error + @config_dir = File.join(staging, File.basename(original)) + unless @root_identity && SessionResume.directory_identity(@config_dir) == @root_identity + return "#{SKIPPED} what was at #{original} is not the directory the SDK created (it had been replaced). " \ + "It is now at #{@config_dir}; check what it holds before importing anything from it." end - return replaced_root_notice unless root_unchanged? - left = Dir.children(@config_dir).reject { |name| name == 'projects' }.sort - scrub_failed_notice(left, errors) unless left.empty? + preserved = "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \ + "deleting it — import it into your session store, then remove #{staging}." + [preserved, scrub_all_but_projects(staging)].compact.join(' ') end - def scrub_failed_notice(left, errors) - reason = errors.values_at(*left).compact.first&.message - them, they = left.size == 1 ? %w[it it] : %w[them they] - "Scrubbing failed: could not remove #{left.join(', ')}#{" (#{reason})" if reason} — delete #{them} " \ - "yourself; #{they} can hold copies of your credentials and settings." + # Step 1. Returns [private directory the root now lives in, nil], or + # [nil, error] when it stayed where it was. + def move_aside(original) + staging = nil + make_root_movable(original) + staging = Dir.mktmpdir(STAGING_PREFIX, File.dirname(original)) + File.rename(original, File.join(staging, File.basename(original))) + [staging, nil] + rescue SystemCallError, SessionResume::Scrubber::Changed => e + Dir.rmdir(staging) if staging + [nil, e] end - # True while config_dir is still the directory the SDK created: a real - # directory (lstat: a symlink does not count) with the device and inode - # recorded when it was made. - def root_unchanged? - return false if @root_identity.nil? + # Moving a directory to another parent takes write permission on the + # directory itself. Skipping the scrub for a read-only one would leave the + # credential copies behind, so it is made accessible first — through a + # handle that has to be the directory the SDK created. + def make_root_movable(root) + stat = File.lstat(root) + return if !stat.directory? || SessionResume::Scrubber.owner_rwx?(stat) - SessionResume.directory_identity(@config_dir) == @root_identity + SessionResume::Scrubber.make_accessible(root, @root_identity) end - def preservation_warning - 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \ - "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \ - 'deleting it — import it into your session store, then remove the directory.' - end + # Step 3. Deletes every entry of the moved root but projects/, then checks + # that nothing else is left. Returns nil, or what the warning has to add. + def scrub_all_but_projects(staging) + trash = Dir.mktmpdir('scrub-', staging) + scrubber = SessionResume::Scrubber.new(trash) + errors = {} + Dir.children(@config_dir).each do |name| + next if name == 'projects' + + scrubber.remove(@config_dir, name) + rescue SessionResume::Scrubber::Changed => e + errors[name] = e + break # something is rearranging the directory under the scrub + rescue SystemCallError => e + errors[name] = e + end + Dir.rmdir(trash) if errors.empty? - def replaced_root_notice - "#{@config_dir} is no longer the directory the SDK created (it was replaced or removed), so the SDK " \ - 'deleted nothing in it — check what it holds before importing from it.' + left = (Dir.children(@config_dir) - ['projects'] + errors.keys).uniq.sort + return if left.empty? + + reason = errors.values_at(*left).compact.first&.message + "Scrubbing failed: could not remove #{left.join(', ')}#{" (#{reason})" if reason}. What is left is under " \ + "#{staging} and can hold copies of your credentials and settings." end end @@ -794,38 +847,105 @@ def directory_identity(path) nil end - # Remove +path+ and everything under it without following symlinks: - # FileUtils.remove_entry lstat's every entry, so a symlink is unlinked, - # not traversed, and it raises rather than skipping what it cannot - # remove. When it fails, the directories in the subtree that this user - # owns are made accessible (0700) — the CLI, or anything else running as - # this user, may have left one read-only — and it is tried once more. - # Returns nil, or the SystemCallError of that second attempt. - def remove_entry_without_following(path) - FileUtils.remove_entry(path) - nil - rescue SystemCallError - make_owned_directories_accessible(path) - begin - FileUtils.remove_entry(path) - nil - rescue SystemCallError => e - e + # Deletes entries of a directory tree that something else may still be + # writing into, without ever following a symlink — not even one that takes + # an entry's place while this runs. + # + # Looking at an entry and then acting on it by path leaves a gap: what + # lstat called a directory can be a symlink by the time it is listed, + # chmodded or descended into, and a path through it then leads outside the + # tree. So an entry is taken out first — renamed into +trash+, a private + # directory nothing else knows; a rename moves a symlink itself, never its + # target. Only then is the entry examined, and unlinked or, for a + # directory, emptied the same way one level at a time. Every path used is + # therefore an entry of the directory handed to #remove (which the caller + # vouches for), trash/<taken> or trash/<taken>/<child>. + # + # A directory can refuse: moving one to another parent takes write + # permission on the directory itself, emptying it takes read, write and + # search. It is then made accessible (see .make_accessible); nothing else + # is ever chmodded. + class Scrubber + # An entry was no longer what an earlier look at it had found. + class Changed < StandardError; end + + OWNER_RWX = 0o700 + + # Passes over a directory that keeps receiving entries, before rmdir + # is left to report it. + EMPTYING_PASSES = 3 + + def self.owner_rwx?(stat) + stat.mode.allbits?(OWNER_RWX) end - end - # chmod 0700 each directory at or under +path+ that this user owns. lstat - # throughout, so a symlink is neither followed nor changed (File.chmod - # itself would follow it); each chmod comes before that directory's - # listing, so an unlistable one is reached too. Best effort. - def make_owned_directories_accessible(path) - stat = File.lstat(path) - return unless stat.directory? && stat.owned? + # chmod 0700 the directory at +path+ without following a symlink: the + # mode is set through a handle (fchmod), and only once that handle + # proved to be a directory this user owns whose [device, inode] is + # +identity+. The path is looked at again afterwards. Raises Changed + # when either look finds something else, and SystemCallError when the + # directory cannot be opened (its owner cannot read it) or chmodded. + def self.make_accessible(path, identity) + File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |handle| + stat = handle.stat + unless stat.directory? && [stat.dev, stat.ino] == identity + raise Changed, "#{path} is not the directory it was a moment ago" + end + raise Errno::EPERM, path unless stat.owned? + + handle.chmod(OWNER_RWX) + end + return if SessionResume.directory_identity(path) == identity - File.chmod(0o700, path) - Dir.children(path).each { |name| make_owned_directories_accessible(File.join(path, name)) } - rescue SystemCallError - nil + raise Changed, "#{path} was replaced while it was being made writable" + rescue Errno::ELOOP, Errno::EMLINK + # What O_NOFOLLOW answers for a symlink (EMLINK on some BSDs). + raise Changed, "#{path} became a symlink" + end + + def initialize(trash) + @trash = trash + @taken = 0 + end + + # Remove the entry +name+ of +dir+ and everything under it. Raises + # SystemCallError when something cannot be removed, and Changed when an + # entry was swapped while it was being made accessible. + def remove(dir, name) + path = take(dir, name) + stat = File.lstat(path) + return File.unlink(path) unless stat.directory? + + self.class.make_accessible(path, [stat.dev, stat.ino]) unless self.class.owner_rwx?(stat) + EMPTYING_PASSES.times do + children = Dir.children(path) + break if children.empty? + + children.each { |child| remove(path, child) } + end + Dir.rmdir(path) + end + + private + + # Move dir/name into the trash, whatever it is, and return its new path. + def take(dir, name) + source = File.join(dir, name) + target = File.join(@trash, (@taken += 1).to_s) + begin + File.rename(source, target) + rescue Errno::EACCES, Errno::EPERM + # A directory that is not writable cannot be moved to another + # parent. It is looked at immediately before it is repaired; + # whatever else refuses (the parent, a file) is left as it is. + stat = File.lstat(source) + raise if !stat.directory? || self.class.owner_rwx?(stat) + + self.class.make_accessible(source, [stat.dev, stat.ino]) + File.rename(source, target) + end + target + end end # Best-effort recursive removal with retries on transient lock errors diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index 2f3023a2..2205f329 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -9,11 +9,18 @@ RSpec.describe ClaudeAgentSDK::MaterializedResume do # When the mirror dropped batches the materialized config dir is kept, since # it holds the only copy of the dropped turns. It must then hold nothing but - # those transcripts. + # those transcripts, and getting there must not delete or change anything + # outside it, whatever is done to the directory in the meantime: the CLI's + # tools can write into it, sandboxed ones included. describe '#preserve_transcripts' do - let(:config_dir) { Dir.mktmpdir('claude-resume-') } - let(:outside) { Dir.mktmpdir } + # One scratch directory per example holds the temp config dir, the private + # directory the SDK moves it into (created next to it) and the directories + # nothing may touch. Nothing lands in the real $TMPDIR. + let(:scratch) { Dir.mktmpdir('preserve-spec-') } + let(:config_dir) { File.join(scratch, 'claude-resume-under-test') } + let(:outside) { File.join(scratch, 'outside') } let(:sid) { '11111111-1111-4111-8111-111111111111' } + let(:materialized) { described_class.new(config_dir: config_dir, resume_session_id: sid) } # What the SDK seeds from the caller's .claude.json: it can carry MCP # header secrets. @@ -47,24 +54,35 @@ end before do - secrets.merge(transcripts).each do |relative, content| - path = File.join(config_dir, relative) - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, content) - end + Dir.mkdir(config_dir, 0o700) + write_tree(config_dir, secrets.merge(transcripts)) Dir.mkdir(File.join(config_dir, 'sessions')) # the CLI leaves this one empty - File.write(File.join(outside, 'not-ours.txt'), 'keep me') + write_tree(outside, 'not-ours.txt' => 'keep me') File.symlink(outside, File.join(config_dir, 'linked-elsewhere')) + materialized # built now, like the SDK does: it records which directory this is end - # config_dir may have been replaced by a symlink whose target is gone by - # now: File.directory? is false for that, so test for the link itself. after do - [config_dir, outside].each { |dir| FileUtils.remove_entry(dir) if File.symlink?(dir) || File.directory?(dir) } + unlock(scratch) + FileUtils.remove_entry(scratch) end - def preserve - described_class.new(config_dir: config_dir, resume_session_id: sid).preserve_transcripts + def write_tree(root, files) + files.each do |relative, content| + path = File.join(root, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + end + + # Give every real directory under +path+ its owner bits back (never + # through a symlink), so the scratch tree can be removed whatever an + # example left read-only. + def unlock(path) + return unless File.lstat(path).directory? + + File.chmod(0o700, path) + Dir.children(path).each { |name| unlock(File.join(path, name)) } end def stderr_of @@ -76,29 +94,90 @@ def stderr_of $stderr = original end - def files_under(dir) - Dir.glob('**/*', File::FNM_DOTMATCH, base: dir).select { |relative| File.file?(File.join(dir, relative)) } + # relative path => content, for every regular file under +dir+. + def tree(dir) + files = Dir.glob('**/*', File::FNM_DOTMATCH, base: dir).select { |relative| File.file?(File.join(dir, relative)) } + files.sort.to_h { |relative| [relative, File.read(File.join(dir, relative))] } + end + + def mode_of(path) + format('%o', File.stat(path).mode & 0o777) + end + + # Runs preserve_transcripts and returns its warning. + def preserve + stderr_of { materialized.preserve_transcripts } + end + + # Where the directory lives once it was moved, and the private directory + # it was moved into. + def preserved + materialized.config_dir end - it 'keeps projects/ and deletes every other entry, known or not' do - expect { preserve }.to output(/transcript mirror dropped batches.*#{Regexp.escape(File.join(config_dir, 'projects'))}/).to_stderr + def staging + File.dirname(preserved) + end - expect(Dir.children(config_dir)).to eq(['projects']) - expect(files_under(config_dir).to_h { |relative| [relative, File.read(File.join(config_dir, relative))] }) - .to eq(transcripts) + let(:skipped) { /transcript mirror dropped batches.*Scrubbing was skipped and nothing was deleted/ } + + it 'moves the directory into a private one next to it, keeps projects/ and deletes every other entry' do + warning = preserve + + expect(File.symlink?(config_dir) || File.exist?(config_dir)).to be(false) + expect(File.basename(staging)).to start_with('claude-preserved-resume-') + expect(Dir.children(scratch)).to contain_exactly(File.basename(staging), 'outside') + expect(mode_of(staging)).to eq('700') + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(preserved)).to eq(transcripts) + expect(warning).to match(/transcript mirror dropped batches.*Preserving the session transcript under /) + expect(warning).to match(/under #{Regexp.escape(File.join(preserved, 'projects'))} .*then remove #{Regexp.escape(staging)}\./) + expect(warning).not_to match(/Scrubbing/) end it 'removes a symlink without following it' do - expect { preserve }.to output.to_stderr + preserve - expect(File.symlink?(File.join(config_dir, 'linked-elsewhere'))).to be(false) - expect(File.read(File.join(outside, 'not-ours.txt'))).to eq('keep me') + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') end it 'still reports the incomplete store copy, without raising, when the directory is already gone' do FileUtils.remove_entry(config_dir) - expect { preserve }.to output(/transcript mirror dropped batches/).to_stderr + expect(preserve).to match(skipped) + expect(Dir.children(scratch)).to eq(['outside']) # and leaves no empty private directory behind + end + + it 'deletes nothing, and leaves no empty directory behind, when the directory cannot be moved' do + allow(File).to receive(:rename).and_raise(Errno::EACCES, config_dir) + + warning = preserve + + expect(tree(config_dir)).to eq(secrets.merge(transcripts)) + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside') + expect(warning).to match(skipped) + expect(warning).to include("#{config_dir} could not be moved aside (Permission denied") + end + + # The transcript was moved: if the scrub is cut short by something that is + # not a StandardError (a cancellation, a signal), the new location must + # still be said. + it 'says where the transcript is when the scrub is interrupted' do + cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException -- what Async::Stop looks like + interrupting = true + allow(File).to receive(:unlink).and_wrap_original do |original, *paths| + raise cancellation if interrupting + + original.call(*paths) + end + + warning = stderr_of { expect { materialized.preserve_transcripts }.to raise_error(cancellation) } + interrupting = false + + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/transcript mirror dropped batches.*#{Regexp.escape(File.join(preserved, 'projects'))}/) end # An entry that cannot be removed must not pass for scrubbed: backups/ can @@ -107,15 +186,14 @@ def files_under(dir) let(:backups) { File.join(config_dir, 'backups') } it 'makes a read-only directory of its own writable and removes it' do - File.chmod(0o500, backups) # listable, not writable: its file cannot be unlinked + File.chmod(0o500, backups) # listable, not writable: it cannot be moved, its file cannot be unlinked - warning = stderr_of { preserve } + warning = preserve - expect(Dir.children(config_dir)).to eq(['projects']) + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) expect(warning).to match(/Preserving the session transcript/) expect(warning).not_to match(/Scrubbing failed/) - ensure - File.chmod(0o700, backups) if File.directory?(backups) end it 'neither follows nor changes a symlink while it makes a directory writable' do @@ -123,24 +201,86 @@ def files_under(dir) File.chmod(0o555, outside) File.chmod(0o500, backups) - stderr_of { preserve } + preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + end + + it 'does not chmod a directory that another user owns' do + File.chmod(0o500, backups) + allow_any_instance_of(File::Stat).to receive(:owned?).and_return(false) + + warning = preserve - expect(Dir.children(config_dir)).to eq(['projects']) - expect(format('%o', File.stat(outside).mode & 0o777)).to eq('555') - expect(File.read(File.join(outside, 'not-ours.txt'))).to eq('keep me') - ensure - File.chmod(0o700, backups) if File.directory?(backups) - File.chmod(0o700, outside) + expect(mode_of(File.join(preserved, 'backups'))).to eq('500') + expect(warning).to match(/Scrubbing failed: could not remove backups \(Operation not permitted/) end - it 'says scrubbing failed, and names what is left, when it still cannot remove it' do - allow(FileUtils).to receive(:remove_entry).and_call_original - allow(FileUtils).to receive(:remove_entry).with(backups).and_raise(Errno::EACCES, backups) + it 'says scrubbing failed, and names what is left, when it cannot take an entry out' do + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + raise Errno::EACCES, from if File.basename(from) == 'backups' + + original.call(from, to) + end - warning = stderr_of { preserve } + warning = preserve - expect(Dir.children(config_dir).sort).to eq(%w[backups projects]) + expect(Dir.children(preserved)).to contain_exactly('backups', 'projects') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) expect(warning).to match(/Preserving the session transcript.* Scrubbing failed: could not remove backups \(Permission denied/) + expect(warning).to match(/What is left is under #{Regexp.escape(staging)}/) + end + + it 'names an entry it had already taken out but could not finish removing' do + taken = nil + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + taken = to if File.basename(from) == 'backups' + original.call(from, to) + end + refusing = true + allow(Dir).to receive(:rmdir).and_wrap_original do |original, path| + raise Errno::EACCES, path if refusing && path == taken + + original.call(path) + end + + warning = preserve + refusing = false + + expect(Dir.children(preserved)).to eq(['projects']) # it is no longer next to the transcript ... + expect(File).to exist(taken) # ... but it is not gone either + expect(warning).to match(/Scrubbing failed: could not remove backups \(Permission denied.*What is left is under #{Regexp.escape(staging)}/) + end + end + + # A read-only root cannot be moved. Skipping the scrub would leave the + # credential copies behind, so the root is repaired first — through a + # handle that has to be the directory the SDK created. + context 'when the directory itself is read-only' do + it 'makes it writable, moves it and scrubs it' do + File.chmod(0o500, config_dir) + + warning = preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(preserved)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript/) + expect(warning).not_to match(/Scrubbing/) + end + + it 'skips the scrub, deleting nothing, when it cannot be made writable' do + skip 'a superuser can open any directory' if Process.euid.zero? + + File.chmod(0o300, config_dir) # its owner cannot even open it + + warning = preserve + + File.chmod(0o700, config_dir) + expect(tree(config_dir)).to eq(secrets.merge(transcripts)) + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside') + expect(warning).to match(skipped) end end @@ -152,96 +292,78 @@ def files_under(dir) # its replacement appears: a freshly created directory may otherwise reuse # its inode number and look like the same directory. context 'when the directory is no longer the one the SDK created' do - let(:unrelated) { Dir.mktmpdir } - let(:aside) { "#{config_dir}-aside" } - let(:refusal) { /no longer the directory the SDK created.*deleted nothing in it/ } + let(:unrelated) { File.join(scratch, 'unrelated') } + let(:aside) { File.join(scratch, 'the-sdk-directory-moved-aside') } before do - { 'projects/-elsewhere/x.jsonl' => "{}\n", 'innocent/keep.txt' => 'synthetic', '.claude.json' => '{}' } - .each do |relative, content| - path = File.join(unrelated, relative) - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, content) - end - end - - after do - [unrelated, aside].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + write_tree(unrelated, 'projects/-elsewhere/x.jsonl' => "{}\n", 'innocent/keep.txt' => 'synthetic', + '.claude.json' => '{}') end - it 'deletes nothing through a root that has become a symlink' do - materialized = described_class.new(config_dir: config_dir, resume_session_id: sid) + it 'deletes nothing when the path has become a symlink: it moves the link, not its target' do File.rename(config_dir, aside) File.symlink(unrelated, config_dir) - before_scrub = files_under(unrelated).sort - - warning = stderr_of { materialized.preserve_transcripts } - - expect(files_under(unrelated).sort).to eq(before_scrub) - expect(File.symlink?(config_dir)).to be(true) - expect(warning).to match(refusal) + File.chmod(0o555, unrelated) + before_scrub = tree(unrelated) + + warning = preserve + + expect(tree(unrelated)).to eq(before_scrub) + expect(mode_of(unrelated)).to eq('555') + expect(tree(aside)).to eq(secrets.merge(transcripts)) + expect(File.symlink?(preserved)).to be(true) + expect(File.readlink(preserved)).to eq(unrelated) + expect(warning).to match(skipped) + expect(warning).to include(preserved) end it 'deletes nothing in a different directory at the same path' do - materialized = described_class.new(config_dir: config_dir, resume_session_id: sid) File.rename(config_dir, aside) FileUtils.cp_r(unrelated, config_dir) - before_scrub = files_under(config_dir).sort + before_scrub = tree(config_dir) - warning = stderr_of { materialized.preserve_transcripts } + warning = preserve - expect(files_under(config_dir).sort).to eq(before_scrub) - expect(warning).to match(refusal) + expect(tree(preserved)).to eq(before_scrub) + expect(warning).to match(skipped) end - it 'stops deleting as soon as the directory is replaced in the middle of the scrub' do - # Give the replacement an entry for every name the scrub has listed, - # whatever order it walks them in: each one deleted after the swap - # would be a file the SDK never wrote. - (Dir.children(config_dir) - ['projects']).each do |name| - path = File.join(unrelated, name) - File.write(path, 'synthetic') unless File.exist?(path) - end - materialized = described_class.new(config_dir: config_dir, resume_session_id: sid) - swapped = false - allow(ClaudeAgentSDK::SessionResume).to receive(:remove_entry_without_following).and_wrap_original do |original, path| - result = original.call(path) - unless swapped # right after the first deletion - swapped = true - File.rename(config_dir, aside) - File.symlink(unrelated, config_dir) - end - result - end - before_scrub = files_under(unrelated).sort + it 'does not make a read-only directory writable when it is not the one the SDK created' do + File.rename(config_dir, aside) + FileUtils.cp_r(unrelated, config_dir) + File.chmod(0o500, config_dir) - warning = stderr_of { materialized.preserve_transcripts } + warning = preserve - expect(swapped).to be(true) - expect(files_under(unrelated).sort).to eq(before_scrub) - expect(warning).to match(refusal) + expect(mode_of(config_dir)).to eq('500') # still where it was, as it was + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside', 'unrelated', + File.basename(aside)) + expect(warning).to match(skipped) end - it 'refuses a symlink it is handed as the directory' do - link = File.join(outside, 'replaced-config') + it 'deletes nothing when it is handed a symlink as the directory' do + link = File.join(scratch, 'replaced-config') File.symlink(unrelated, link) - before_scrub = files_under(unrelated).sort + handed_a_link = described_class.new(config_dir: link, resume_session_id: sid) + before_scrub = tree(unrelated) - warning = stderr_of { described_class.new(config_dir: link, resume_session_id: sid).preserve_transcripts } + warning = stderr_of { handed_a_link.preserve_transcripts } - expect(files_under(unrelated).sort).to eq(before_scrub) - expect(warning).to match(refusal) + expect(tree(unrelated)).to eq(before_scrub) + expect(warning).to match(skipped) end # What counts is the directory materialize_resume_session created, not # whatever is at the path by the time the MaterializedResume is built. it 'compares with the directory as created, even if it was replaced during materialization' do created = [] - allow(Dir).to receive(:mktmpdir).and_wrap_original do |original, *args, &block| - original.call(*args, &block).tap { |dir| created << dir if File.basename(dir).start_with?('claude-resume-') } + allow(Dir).to receive(:mktmpdir).and_wrap_original do |original, *args, **options, &block| + next original.call(*args, **options, &block) unless args == ['claude-resume-'] + + original.call('claude-resume-', scratch).tap { |dir| created << dir } end replace_temp_dir = lambda do - File.rename(created.last, "#{created.last}-aside") + File.rename(created.last, aside) FileUtils.cp_r(unrelated, created.last) end store = Class.new(ClaudeAgentSDK::InMemorySessionStore) do @@ -250,25 +372,170 @@ def files_under(dir) [] end end.new - project_dir = File.join(outside, 'project') - FileUtils.mkdir_p(project_dir) + project_dir = File.join(scratch, 'project') + caller_config_dir = File.join(scratch, 'caller-config') # empty: nothing seeded from the developer's own + [project_dir, caller_config_dir].each { |dir| FileUtils.mkdir_p(dir) } store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(project_dir), 'session_id' => sid }, [{ 'type' => 'user', 'uuid' => 'u1', 'sessionId' => sid, 'message' => { 'role' => 'user', 'content' => 'hi' } }]) - caller_config_dir = File.join(outside, 'caller-config') # empty: nothing seeded from the developer's own - FileUtils.mkdir_p(caller_config_dir) - materialized = ClaudeAgentSDK::SessionResume.materialize_resume_session( + from_the_store = ClaudeAgentSDK::SessionResume.materialize_resume_session( ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: sid, cwd: project_dir, env: { 'CLAUDE_CONFIG_DIR' => caller_config_dir }) ) - before_scrub = files_under(materialized.config_dir).sort + before_scrub = tree(from_the_store.config_dir) + + warning = stderr_of { from_the_store.preserve_transcripts } + + expect(tree(from_the_store.config_dir)).to eq(before_scrub) + expect(warning).to match(skipped) + end + end + + # The same, with the swap timed against the cleanup itself. Each example + # puts it between two steps of preserve_transcripts by wrapping the call + # that comes next; nothing depends on a clock or on another thread. + context 'when something is swapped while the cleanup runs' do + let(:unrelated) { File.join(scratch, 'unrelated') } + let(:aside) { File.join(scratch, 'the-sdk-directory-moved-aside') } + let(:backups) { File.join(config_dir, 'backups') } + + before do + # An entry for every name the scrub lists, whatever order it walks + # them in, and for what is inside backups/: each one deleted through + # a swapped path would be a file the SDK never wrote. + names = Dir.children(config_dir) - %w[projects linked-elsewhere] + write_tree(unrelated, names.to_h { |name| [name == 'backups' ? 'backups/kept.txt' : name, 'synthetic'] } + .merge('.claude.json.backup.1790881328533' => 'synthetic')) + end + + # Dir.children answers in the filesystem's order. Putting backups/ first + # lets an example see that the scrub stopped at it, in what it never got to. + def scrub_backups_first + allow(Dir).to receive(:children).and_wrap_original do |original, path| + names = original.call(path) + next names unless File.basename(path) == 'claude-resume-under-test' + + names.partition { |name| name == 'backups' }.flatten + end + end + + it 'deletes nothing when the directory becomes a symlink right before it is moved' do + swapped = false + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + if from == config_dir && !swapped + swapped = true + original.call(config_dir, aside) + File.symlink(unrelated, config_dir) + end + original.call(from, to) + end + before_scrub = tree(unrelated) + + warning = preserve + + expect(swapped).to be(true) + expect(tree(unrelated)).to eq(before_scrub) + expect(tree(aside)).to eq(secrets.merge(transcripts)) + expect(warning).to match(skipped) + end + + it 'never follows a symlink planted at the old path once the directory was moved' do + planted = false + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + if File.basename(File.dirname(to)).start_with?('scrub-') && !planted # right before the first removal + planted = true + File.symlink(unrelated, config_dir) + end + original.call(from, to) + end + before_scrub = tree(unrelated) - warning = stderr_of { materialized.preserve_transcripts } + warning = preserve - expect(files_under(materialized.config_dir).sort).to eq(before_scrub) - expect(warning).to match(refusal) - ensure - created&.each { |dir| [dir, "#{dir}-aside"].each { |path| FileUtils.rm_rf(path) } } + expect(planted).to be(true) + expect(tree(unrelated)).to eq(before_scrub) + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(preserved)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript under #{Regexp.escape(File.join(preserved, 'projects'))}/) + expect(warning).not_to match(/Scrubbing/) + end + + it 'unlinks, and does not follow, an entry that becomes a symlink right before it is removed' do + swapped = false + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + if File.basename(from) == 'backups' && !swapped + swapped = true + original.call(from, "#{from}-aside") + File.symlink(unrelated, from) + end + original.call(from, to) + end + before_scrub = tree(unrelated) + + warning = preserve + + expect(swapped).to be(true) + expect(tree(unrelated)).to eq(before_scrub) + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + # The real backups/ is still there under its new name, and the warning says so. + expect(Dir.children(preserved)).to contain_exactly('backups-aside', 'projects') + expect(warning).to match(/Scrubbing failed: could not remove backups-aside/) + end + + # A read-only backups/ has to be made writable. Between the look at it + # and the chmod it is swapped for a symlink to a directory elsewhere: + # that directory must keep its mode and its files. + it 'leaves an outside directory alone when backups/ is swapped between the lstat and the chmod' do + File.chmod(0o500, backups) + File.chmod(0o555, outside) + scrub_backups_first + swapped = false + allow(File).to receive(:lstat).and_wrap_original do |original, path| + stat = original.call(path) + if !swapped && File.basename(path) == 'backups' && stat.directory? && stat.mode.nobits?(0o200) + swapped = true + File.rename(path, "#{path}-aside") + File.symlink(outside, path) + end + stat + end + + warning = preserve + + expect(swapped).to be(true) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript.* Scrubbing failed: could not remove .*backups/) + expect(warning).to match(/backups became a symlink/) + # It stopped there: backups/ came first, nothing after it was touched. + expect(Dir.children(preserved)).to include(*(secrets.keys.map { |key| key.split('/').first }.uniq - ['backups'])) + end + + it 'stops, leaving an outside directory alone, when backups/ is swapped right after the chmod' do + File.chmod(0o500, backups) + File.chmod(0o555, outside) + scrub_backups_first + swapped = false + allow(File).to receive(:open).and_wrap_original do |original, *args, **options, &block| + result = original.call(*args, **options, &block) + if !swapped && File.basename(args.first.to_s) == 'backups' + swapped = true + File.rename(args.first, "#{args.first}-aside") + File.symlink(outside, args.first) + end + result + end + + warning = preserve + + expect(swapped).to be(true) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/Scrubbing failed: could not remove .*backups/) + expect(warning).to match(/backups was replaced while it was being made writable/) + expect(Dir.children(preserved)).to include(*(secrets.keys.map { |key| key.split('/').first }.uniq - ['backups'])) end end end diff --git a/spec/unit/session_resume_spec.rb b/spec/unit/session_resume_spec.rb index f30c1228..a4ed5783 100644 --- a/spec/unit/session_resume_spec.rb +++ b/spec/unit/session_resume_spec.rb @@ -1049,10 +1049,10 @@ def mode_of(path) # not delete it. It keeps the transcripts but scrubs the credential copies. describe 'MaterializedResume#preserve_transcripts' do it 'removes credential copies, keeps transcripts, warns, and never rmtrees' do - Dir.mktmpdir do |dir| - transcript = File.join(dir, 'projects', 'pk', 'sid.jsonl') - FileUtils.mkdir_p(File.dirname(transcript)) - File.write(transcript, "{}\n") + Dir.mktmpdir do |scratch| + dir = File.join(scratch, 'claude-resume-spec') + FileUtils.mkdir_p(File.join(dir, 'projects', 'pk')) + File.write(File.join(dir, 'projects', 'pk', 'sid.jsonl'), "{}\n") # settings.json / cowork_settings.json are seeded from the caller's # config dir too, and their env blocks routinely carry API keys — the # scrub must cover them, not just the credential files. @@ -1063,10 +1063,14 @@ def mode_of(path) materialized = ClaudeAgentSDK::MaterializedResume.new(config_dir: dir, resume_session_id: 'sid') expect { materialized.preserve_transcripts }.to output(/[Pp]reserving/).to_stderr + # The directory is kept in a private one created next to it (so still + # under scratch): that is where the transcript is now. + preserved = materialized.config_dir + expect(File.dirname(preserved, 2)).to eq(scratch) ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name| - expect(File).not_to exist(File.join(dir, name)) + expect(File).not_to exist(File.join(preserved, name)) end - expect(File).to exist(transcript) + expect(File).to exist(File.join(preserved, 'projects', 'pk', 'sid.jsonl')) end end end From af35a98ad7300d36f9fac83e0592c65109a6da1f Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:12:22 +0800 Subject: [PATCH 108/194] List each worktree's sessions from a shared fallback directory Round 2 review of 51205d2. A project dir found by the long-path prefix fallback is filtered to the transcripts of the path it was found for. The worktree listing still marked a project dir as read after its first use, so when two worktrees whose paths share the first 200 characters resolve to one old-hash directory, it was read for the first path only: list_sessions(directory:) dropped the sessions of the second worktree, while get_session_messages and the mutations still found them. A directory found by the fallback is now read once per path, and one named after its path once, as before. Session ids are deduplicated over the result as before. Exploration finding: U-P8 (round 2, B1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 31 ++++++++++++++---- spec/unit/session_long_path_fallback_spec.rb | 34 ++++++++++++++++++++ spec/unit/session_worktree_listing_spec.rb | 14 ++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 424e90ac..ce59058a 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -345,7 +345,14 @@ def project_dir_records_cwd?(project_dir, path) # path: that directory can hold the sessions of every path sharing the # prefix, and a transcript whose cwd cannot be verified is not counted. def own_transcript?(project_dir, file_path, path) - File.basename(project_dir) == sanitize_path(path) || recorded_cwd(file_path) == path + !prefix_fallback_dir?(project_dir, path) || recorded_cwd(file_path) == path + end + + # Whether +project_dir+, the directory find_project_dir returned for + # +path+, is one the long-path prefix fallback found rather than the one + # named after the path. + def prefix_fallback_dir?(project_dir, path) + File.basename(project_dir) != sanitize_path(path) end # The directory a session transcript was recorded in: the first non-blank @@ -879,7 +886,7 @@ def read_sessions_from_dir(project_dir, project_path = nil) sessions = [] # Listing a directory found by the long-path prefix fallback: only the # transcripts recorded for +project_path+ (own_transcript?). - verify = project_path && File.basename(project_dir) != sanitize_path(project_path) + verify = project_path && prefix_fallback_dir?(project_dir, project_path) # base:, not a pattern built from the directory: a config dir path with # glob characters in it (`/Volumes/Data [SSD]/…`, `/srv/{tenant}/…`) # is a path, and as part of the pattern it matched nothing. @@ -1659,15 +1666,24 @@ def list_sessions_for_directory(directory, include_worktrees) # `git worktree list` reports worktree ROOTS, so a subdirectory (a # monorepo package) is none of them, and reading only the listed paths # left out exactly the sessions that were asked for (Python: - # "Always include the user's actual directory"). Then every worktree; - # a project dir is read once. + # "Always include the user's actual directory"). Then every worktree. + # + # A project dir named after its path is read once. One found by the + # long-path prefix fallback is read once per path: reading it keeps + # that path's own transcripts only (read_sessions_from_dir), and + # worktrees whose paths share the first 200 characters share the + # directory. Marked as read after the first of them, it never gave the + # sessions of the others. all_sessions = [] seen = {} [path, *worktree_paths].each do |dir| project_dir = find_project_dir(dir) - next if project_dir.nil? || seen[project_dir] + next if project_dir.nil? + + scan = prefix_fallback_dir?(project_dir, dir) ? [project_dir, dir] : project_dir + next if seen[scan] - seen[project_dir] = true + seen[scan] = true all_sessions.concat(read_sessions_from_dir(project_dir, dir)) end @@ -2116,7 +2132,8 @@ def filter_visible_messages(chain) end end - private_class_method :resolve_missing_path, :symlink_target, :project_dir_records_cwd?, :recorded_cwd, + private_class_method :resolve_missing_path, :symlink_target, :project_dir_records_cwd?, :prefix_fallback_dir?, + :recorded_cwd, :each_parsed_entry, :get_session_info_for_directory, :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, diff --git a/spec/unit/session_long_path_fallback_spec.rb b/spec/unit/session_long_path_fallback_spec.rb index b430b131..0600bda7 100644 --- a/spec/unit/session_long_path_fallback_spec.rb +++ b/spec/unit/session_long_path_fallback_spec.rb @@ -150,6 +150,40 @@ def old_cli_project_dir(directory, hash = 'bunhash1') expect(ClaudeAgentSDK.list_sessions(directory: main).map(&:session_id)).to eq([other_session_id]) expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: main)).to eq([]) end + + # Both paths are worktrees of one repository. The old-hash directory is + # the project dir of each of them, so it is read for each: once, it + # gave the sessions of the first path and none of the second. + context 'when both paths are worktrees of one repository' do + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([project_a, project_b]) } + + it 'lists the session of each worktree once, from either of them' do + [project_a, project_b].each do |directory| + listed = ClaudeAgentSDK.list_sessions(directory: directory) + + expect(listed.map { |session| [session.session_id, session.cwd] }) + .to contain_exactly([session_id, project_a], [other_session_id, project_b]) + end + end + + it "still lists only a path's own session with include_worktrees: false" do + expect(ClaudeAgentSDK.list_sessions(directory: project_a, include_worktrees: false).map(&:session_id)) + .to eq([session_id]) + expect(ClaudeAgentSDK.list_sessions(directory: project_b, include_worktrees: false).map(&:session_id)) + .to eq([other_session_id]) + end + + it 'reads the shared directory once for each path' do + shared = old_cli_project_dir(project_a) + allow(ClaudeAgentSDK::Sessions).to receive(:read_sessions_from_dir).and_call_original + + ClaudeAgentSDK.list_sessions(directory: project_a) + + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).with(shared, project_a).once + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).with(shared, project_b).once + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).twice + end + end end end end diff --git a/spec/unit/session_worktree_listing_spec.rb b/spec/unit/session_worktree_listing_spec.rb index 56efbba2..3541fe5e 100644 --- a/spec/unit/session_worktree_listing_spec.rb +++ b/spec/unit/session_worktree_listing_spec.rb @@ -94,6 +94,20 @@ def with_git_printing(listing) expect(summaries(main)).to eq(['copy kept for repo-a']) end + # `repo.one` and `repo-one` have one sanitized name, hence one project dir + # (for the CLI too). It holds the sessions of both and is read once. + it 'reads a project dir that two worktree paths share by name once' do + dotted = File.join(cwd, 'repo.one').tap { |dir| FileUtils.mkdir_p(dir) } + dashed = File.join(cwd, 'repo-one').tap { |dir| FileUtils.mkdir_p(dir) } + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([dotted, dashed]) + record_session(dotted, 'asked in repo.one') + record_session(dashed, 'asked in repo-one') + allow(ClaudeAgentSDK::Sessions).to receive(:read_sessions_from_dir).and_call_original + + expect(summaries(dotted)).to contain_exactly('asked in repo.one', 'asked in repo-one') + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).once + end + # No stub: a real `git worktree list --porcelain` over a real repository. context 'with a real repository' do # git takes GIT_DIR and friends from the environment (they are set while From c06b5e62391f604037b4410e71e45c0010d74bdf Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:25:28 +0800 Subject: [PATCH 109/194] Follow a link before the `..` after it in a missing path Round 2 review of b8ecabd. When File.realpath fails, canonicalize_path walks the path component by component and follows symlinks whose target is gone. It handed that walk File.expand_path(dir), which removes a `..` together with the name in front of it. For `link/../sibling` that name is a symlink, and the parent meant is the one of the link's target: a session recorded through such a path while the target existed is keyed by <parent of the target>/sibling. Once the target was removed, the SDK computed the sibling of the link instead, so the readers returned nothing and the mutations raised Errno::ENOENT. The walk now gets the path absolute but uncollapsed: relative to the working directory, a leading ~ or ~user expanded (as expand_path did; File.realpath expands neither), `.` and `..` left in place. It applies a `..` to what is resolved so far, that is after the links in front of it were followed: the order of the kernel and of Python's os.path.realpath. Checked against os.path.realpath (Python 3.11.13) on random synthetic layouts of directories, files and symlinks, dangling ones included: 27,000 paths over three seeds, 18,310 of them not resolvable by File.realpath. 1,512 differed before this change, none after. Exploration finding: B-P6 (round 2, B5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 35 +++++++- spec/unit/session_deleted_directory_spec.rb | 99 +++++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index ce59058a..91d78f4e 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -170,11 +170,34 @@ def sanitize_path(name) # (resolve_missing_path). The CLI keyed the project by the real path while # the directory existed, so a removed /tmp/proj on macOS must still # canonicalize to /private/tmp/proj for its sessions to be found; a plain - # expand_path (the earlier fallback) resolved no symlink at all. + # expand_path (the earlier fallback) resolved no symlink at all. The path + # goes to that walk absolute but with its `..` components still in it + # (absolute_path_keeping_dots). def canonicalize_path(dir) nfc_path(File.realpath(dir)) rescue SystemCallError - nfc_path(resolve_missing_path(File.expand_path(dir))) + nfc_path(resolve_missing_path(absolute_path_keeping_dots(dir))) + end + + # +dir+ as an absolute path with its `.` and `..` components left where + # they are, for resolve_missing_path. File.expand_path — what that walk + # was given before — removes a `..` together with the name in front of + # it. When that name is a symlink, the parent meant is the one of the + # link's TARGET: a session recorded through `current/../sibling` while + # `current` pointed at checkouts/project belongs to checkouts/sibling. + # Collapsed by name, the path was the sibling of the link — another + # project key as soon as the target was gone and File.realpath raised. + # + # Otherwise as File.expand_path has it: a relative path starts at the + # working directory, and a leading ~ or ~user is that home directory + # (File.realpath expands neither, so `directory: '~/project'` has always + # been resolved through here). + def absolute_path_keeping_dots(dir) + return dir if File.absolute_path?(dir) + + first, rest = dir.split(File::SEPARATOR, 2) + base = first&.start_with?('~') ? File.expand_path(first) : File.join(Dir.pwd, first.to_s) + rest ? File.join(base, rest) : base end # +path+ as an NFC-normalized UTF-8 String. Paths are UTF-8 whatever the @@ -203,6 +226,10 @@ def nfc_path(path) # target is gone is the point: a session recorded through a symlinked # project directory is keyed by the target, and must still be found # through the link after the target was removed. + # + # A `..` drops the last component of what is resolved so far — after the + # links in front of it were followed, never before: the order of the + # kernel and of Python's os.path.realpath. def resolve_missing_path(path) root = path[%r{\A(?:[A-Za-z]:)?/+}] || File::SEPARATOR resolved = root @@ -2132,8 +2159,8 @@ def filter_visible_messages(chain) end end - private_class_method :resolve_missing_path, :symlink_target, :project_dir_records_cwd?, :prefix_fallback_dir?, - :recorded_cwd, + private_class_method :absolute_path_keeping_dots, :resolve_missing_path, :symlink_target, + :project_dir_records_cwd?, :prefix_fallback_dir?, :recorded_cwd, :each_parsed_entry, :get_session_info_for_directory, :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, diff --git a/spec/unit/session_deleted_directory_spec.rb b/spec/unit/session_deleted_directory_spec.rb index d5169478..2e13ef9a 100644 --- a/spec/unit/session_deleted_directory_spec.rb +++ b/spec/unit/session_deleted_directory_spec.rb @@ -129,6 +129,105 @@ def record_session(directory) end end + # The session was recorded through `current/../sibling` while the target of + # `current` existed. A link is followed before the `..` after it is applied, + # so that is the sibling of the TARGET (checkouts/sibling), not of the link + # — and the link, still there, says so after its target is gone. + # + # The expected directory is what Python's os.path.realpath returns for this + # layout once checkouts/project is removed. Obtained by hand, not by the + # suite (Python 3.11.13 on macOS): + # python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' <path> + # printed <cwd>/checkouts/sibling for <cwd>/current/../sibling, + # <cwd>/relative/../sibling, <cwd>/via-target and + # <cwd>/gone/../checkouts/sibling, and for the relative current/../sibling + # (run in <cwd>) and ../../current/../sibling (run in <cwd>/checkouts/sibling). + context 'when the path goes up from a symlink whose target was removed' do + let(:target) { File.join(cwd, 'checkouts', 'project') } + let(:sibling) { File.join(cwd, 'checkouts', 'sibling') } + let(:link) { File.join(cwd, 'current') } + let(:through) { File.join(link, '..', 'sibling') } + let(:sibling_key) { ClaudeAgentSDK::Sessions.sanitize_path(sibling) } + + # The project key of the path while the target existed. Then the target + # goes; checkouts/sibling, where the session was recorded, stays. + let!(:recorded_key) do + FileUtils.mkdir_p(target) + File.symlink(target, link) + record_session(through) + ClaudeAgentSDK.project_key_for_directory(through).tap { FileUtils.rm_rf(target) } + end + + it 'keeps the project key it had while the target existed' do + expect(File.symlink?(link) && !File.exist?(link)).to be(true) + expect(recorded_key).to eq(sibling_key) + expect(ClaudeAgentSDK.project_key_for_directory(through)).to eq(sibling_key) + end + + it 'is found by the readers' do + expect(ClaudeAgentSDK.list_sessions(directory: through).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: through).length).to eq(2) + end + + it 'can be renamed' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Sibling checkout', directory: through) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: through).custom_title) + .to eq('Sibling checkout') + end + + it 'goes up from the target of a relative link, and of a link another link names' do + File.symlink(File.join('checkouts', 'project'), File.join(cwd, 'relative')) + File.symlink(File.join('current', '..', 'sibling'), File.join(cwd, 'via-target')) + + expect(ClaudeAgentSDK.project_key_for_directory(File.join(cwd, 'relative', '..', 'sibling'))).to eq(sibling_key) + expect(ClaudeAgentSDK.project_key_for_directory(File.join(cwd, 'via-target'))).to eq(sibling_key) + end + + it 'goes up from a directory that is simply missing by its name' do + expect(ClaudeAgentSDK.project_key_for_directory(File.join(cwd, 'gone', '..', 'checkouts', 'sibling'))) + .to eq(sibling_key) + end + + it 'resolves the path alike when it is given relative to the working directory' do + Dir.chdir(cwd) do + relative = File.join('current', '..', 'sibling') + + expect(ClaudeAgentSDK.project_key_for_directory(relative)).to eq(sibling_key) + expect(ClaudeAgentSDK.list_sessions(directory: relative).map(&:session_id)).to eq([session_id]) + end + Dir.chdir(sibling) do + expect(ClaudeAgentSDK.project_key_for_directory(File.join('..', '..', 'current', '..', 'sibling'))) + .to eq(sibling_key) + end + end + end + + # File.realpath does not expand a leading ~, so a directory named with one + # is resolved as a missing path whether it exists or not. + context 'when the directory is named with a leading ~' do + let(:home) { File.join(cwd, 'home') } + + around do |example| + previous_home = ENV.fetch('HOME', nil) # rubocop:disable Style/EnvHome -- raw value; nil when unset + ENV['HOME'] = home + example.run + ensure + previous_home.nil? ? ENV.delete('HOME') : (ENV['HOME'] = previous_home) + end + + it 'names the directory under the home directory, removed or not' do + record_session(File.join(home, 'project')) + + expect(ClaudeAgentSDK.list_sessions(directory: '~/project').map(&:session_id)).to eq([session_id]) + FileUtils.rm_rf(File.join(home, 'project')) + expect(ClaudeAgentSDK.list_sessions(directory: '~/project').map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.project_key_for_directory('~/gone/../project')) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(home, 'project'))) + expect(ClaudeAgentSDK.project_key_for_directory('~')).to eq(ClaudeAgentSDK::Sessions.sanitize_path(home)) + end + end + it 'resolves a missing path under symlinks that point at each other without looping' do one = File.join(cwd, 'one') two = File.join(cwd, 'two') From 81966be461429ba5301f2f5bcf8c67d3831aeef6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:38:38 +0800 Subject: [PATCH 110/194] Ship .yardopts in the gem rubydoc.info builds a gem's API docs from the files inside the .gem. Neither of the gemspec's two file lists included .yardopts, so YARD ran there with its defaults: every object tagged `@api private` got a page (Query, FiberBoundary, MessageParser, ... - 302 more objects than the repository build lists) and CHANGELOG.md / UPGRADING-1.0.md were not rendered. The `@api private` tag is this gem's SemVer boundary, so the published reference showed internals as if they were public API. .yardopts is now in both file lists (git ls-files and the git-less fallback glob). gemspec_spec.rb asserts that it ships and that every extra file it names is packaged too. Checked on the built gem: `yard list` in the unpacked package prints 1588 objects with the file (the same as the repository) and 1890 without it. The published docs change with the first release that contains the file. Exploration finding: G-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- claude-agent-sdk.gemspec | 9 +++++++-- spec/unit/gemspec_spec.rb | 25 ++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/claude-agent-sdk.gemspec b/claude-agent-sdk.gemspec index 229afbb2..4f864f79 100644 --- a/claude-agent-sdk.gemspec +++ b/claude-agent-sdk.gemspec @@ -34,8 +34,13 @@ Gem::Specification.new do |spec| # spec/unit/gemspec_spec.rb asserts both select the same set. Not a hard # failure: Bundler evaluates this file for path/git-sourced consumers, and # raising here would break their bundle, not just a release build. + # + # .yardopts ships because rubydoc.info builds the API docs from the gem's + # own files: without it YARD runs with its defaults, which list everything + # tagged `@api private` (the tag is the SemVer boundary) and skip the + # CHANGELOG / UPGRADING pages. tracked = begin - IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md], + IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md .yardopts], chdir: __dir__, err: File::NULL, &:read).split("\x0") rescue SystemCallError [] @@ -45,7 +50,7 @@ Gem::Specification.new do |spec| # .rake: the Railtie's tasks; .tt: the Rails generator's templates; # sig/: the RBS signatures of the public API (+ manifest.yaml). Dir.glob(['lib/**/*.{rb,rake,tt}', 'sig/**/*.{rbs,yaml}', 'docs/**/*.md', 'README.md', 'LICENSE', - 'CHANGELOG.md', 'UPGRADING-1.0.md'], + 'CHANGELOG.md', 'UPGRADING-1.0.md', '.yardopts'], base: __dir__) else tracked diff --git a/spec/unit/gemspec_spec.rb b/spec/unit/gemspec_spec.rb index 2b80f821..49620f6d 100644 --- a/spec/unit/gemspec_spec.rb +++ b/spec/unit/gemspec_spec.rb @@ -8,7 +8,7 @@ let(:gemspec_path) { File.join(root, 'claude-agent-sdk.gemspec') } def git_tracked_files - IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md], + IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md .yardopts], chdir: root, err: File::NULL, &:read).split("\x0") .reject { |path| path.start_with?('docs/history/') } rescue SystemCallError @@ -35,4 +35,27 @@ def git_tracked_files expect(files).to include('sig/claude_agent_sdk.rbs', 'sig/claude_agent_sdk/types/options.rbs') end end + + # rubydoc.info builds a gem's documentation from the packaged files. Without + # .yardopts it runs YARD with its defaults: nothing tagged `@api private` is + # hidden (the tag is this gem's SemVer boundary) and the extra files are not + # rendered. + it 'ships .yardopts, through git and without it' do + from_git = Bundler.load_gemspec_uncached(gemspec_path).files + allow(IO).to receive(:popen).and_raise(Errno::ENOENT, 'git') + fallback = Bundler.load_gemspec_uncached(gemspec_path).files + + [from_git, fallback].each do |files| + expect(files).to include('.yardopts') + end + end + + it 'packages every extra file .yardopts tells YARD to render' do + # Everything after the lone `-` line is an extra file (README, CHANGELOG, ...). + extra_files = File.readlines(File.join(root, '.yardopts'), chomp: true) + .drop_while { |line| line != '-' }.drop(1).reject(&:empty?) + + expect(extra_files).not_to be_empty + expect(Bundler.load_gemspec_uncached(gemspec_path).files).to include(*extra_files) + end end From a8fed2496aa712a8eafe7aefdba48a4d8fd0b152 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:50:13 +0800 Subject: [PATCH 111/194] Document session isolation and the auto-memory switch Claude Code's per-project auto-memory is on in SDK sessions, and nothing in the docs said so. docs/configuration.md gains a "Session Isolation" section (anchor #session-isolation) for servers and multi-tenant hosts: - every session loads <config dir>/projects/<project key>/memory/MEMORY.md into its context, under the heading CLAUDE.md gets; setting_sources: [] drops the user/project/local CLAUDE.md and settings files but not this index; - a session on the claude_code preset also writes memory files, and the CLI allows those writes without any permission setup; - the directory is keyed by the git repository root (subdirectories and worktrees share it) or by the cwd outside a repository; - the switch: env CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 or settings autoMemoryEnabled: false. '0' / 'false' force it on; - a different cwd or CLAUDE_CONFIG_DIR isolates only under conditions the section spells out, and Client#context_usage[:memoryFiles] shows what a session loaded. The read side, the switches and the keying were re-checked with handshakes (no model turn) against CLI 2.1.286 and 2.1.287 in a throwaway config directory; the write side is the exploration's live result on 2.1.286. Documentation only. Exploration finding: O2-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/configuration.md | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 66f988f8..e8adaff7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -287,6 +287,110 @@ and `Client#query`. Matches the Python SDK's `verbatim_prompts`. +## Session Isolation + +Claude Code keeps an **auto-memory** for each project: Markdown notes and an +index file, `<config dir>/projects/<project key>/memory/MEMORY.md`. It is a +CLI feature, it is on by default, and SDK sessions take part in it. If one +process runs sessions for more than one user or tenant, three things follow: + +- **Every session reads the index.** The CLI puts `MEMORY.md` into the context + of every session of that project, under the same heading as `CLAUDE.md` (in + CLI 2.1.287: "IMPORTANT: These instructions OVERRIDE any default behavior + and you MUST follow them exactly as written"). That holds with the SDK's + default (empty) system prompt and with `tools: []`. `setting_sources: []` + does not change it either: that option selects the user, project and local + sources (their settings files and their `CLAUDE.md` files), and the + auto-memory is not one of them. +- **A session on the `claude_code` preset also writes it.** The preset system + prompt includes instructions for keeping memories, so a message such as + "remember that ..." makes the model save a note and update the index with + its file tools. The CLI allows those writes by itself: in testing (CLI + 2.1.286) they succeeded in the default permission mode with no + `can_use_tool` callback, no hook and no allow rule. Do not count on your + permission setup to stop them. With the SDK's default system prompt the + sessions tested only read the index: asked to remember something, they + wrote nothing (an observation, not a guarantee). +- **The memory belongs to the project, not to the session.** The project key + is the root of the git repository that contains the working directory, so + every subdirectory and every git worktree of one repository shares one + memory directory. Outside a repository the key is the working directory + itself. + +So on a server that runs every user's session from one checkout and one config +directory with the `claude_code` preset, what one user asks the agent to +remember can be saved without a permission check and then reach every later +session as an instruction. With the default system prompt the exposure is the +read side: whatever memory already exists for that project and config +directory, a developer's own for instance, is in every session's context. + +### Turning auto-memory off + +Servers and multi-tenant hosts should switch it off. Both forms work with +`query()` and `Client`: + +```ruby +# An environment variable for the CLI process +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } +) + +# Or the CLI setting +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + settings: { autoMemoryEnabled: false } +) +``` + +To apply it to every session, set it as a default (a per-call `env` Hash is +merged into the configured one): + +```ruby +ClaudeAgentSDK.configure do |config| + config.default_options = { env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } } +end +``` + +- The variable must be `'1'`. `'0'` and `'false'` do not mean "the default": + they force auto-memory **on** and override `autoMemoryEnabled: false`. +- `bare: true` turns auto-memory off as well, but bare mode never reads an + OAuth login or the keychain: it authenticates with `ANTHROPIC_API_KEY` (or + an `apiKeyHelper` setting) only. See [Bare Mode](#bare-mode). +- With a [custom transport](client.md#custom-transport), `env` reaches the CLI + only if the transport passes it on. `settings` travels in the `--settings` + argument that `CommandBuilder` builds. + +### What does not isolate sessions + +- **A different `cwd`** separates the memory only when the two directories + are not in the same git repository. +- **A different `CLAUDE_CONFIG_DIR`** separates it only when the two config + directories do not share `projects/` (a `projects/` that is a symlink to + another config directory's is shared). A new config directory also has no + login, so give the CLI `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` + (in the process environment or in `env`). +- **`setting_sources: []`** keeps the user, project and local settings and + `CLAUDE.md` files out of a session. The auto-memory index still loads. + +"One working directory per tenant" is therefore not enough on its own. Use the +switch. + +### Checking what a session loaded + +`Client#context_usage` reports the files in a session's context without a +model call. The auto-memory index is the entry whose `:type` is `"AutoMem"`: + +```ruby +ClaudeAgentSDK::Client.open(options: options) do |client| + client.context_usage.fetch(:memoryFiles, []).each do |file| + puts "#{file[:type]} #{file[:path]} (#{file[:tokens]} tokens)" + end +end +# AutoMem /home/app/.claude/projects/-srv-app/memory/MEMORY.md (27 tokens) +``` + +With auto-memory off the list has no `AutoMem` entry. `query()` has no +equivalent; run the check through a `Client` with the same options. + ## Forwarding Subagent Text By default only `tool_use` / `tool_result` blocks from subagents (spawned via From ceca8088e90c8d4e983edc75c90c93474eb40c80 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:56:54 +0800 Subject: [PATCH 112/194] Bring docs/types.md in line with the message and block classes docs/types.md is the reference for what a message block receives, and it was wrong by omission: - the "all possible messages" union listed 7 classes; the parser returns 11 (ToolProgressMessage, AuthStatusMessage, ToolUseSummaryMessage and PromptSuggestionMessage were missing), so an exhaustive `case` written from the page had no branch for them; - the content-block union lacked ServerToolUseBlock and ServerToolResultBlock; - 16 parser-produced classes were not on the page at all (InitMessage, CompactBoundaryMessage, StatusMessage, APIRetryMessage, the hook and session-state messages, StreamEvent's and RateLimitEvent's fields, ...); - the Configuration Types table still listed SandboxIgnoreViolations, removed in 0.12.0 (referencing it is a NameError); - AssistantMessage lacked message_id / stop_reason / session_id / uuid and the max_output_tokens error value, ResultMessage lacked deferred_tool_use. The page now has both unions as in sig/, a "System and progress messages" table (class, wire type, attributes), the two server-tool blocks, and a note that unknown message types are skipped and unknown system subtypes arrive as a plain SystemMessage. spec/unit/docs_reference_spec.rb keeps it that way, from the code to the page: every Type subclass the message parser can produce must be named in docs/types.md, the two unions must match the classes, and a table row may only start with a constant ClaudeAgentSDK defines. Documentation only. Exploration finding: C-P13, G-P8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/types.md | 98 +++++++++++++++++++++++++++++--- spec/unit/docs_reference_spec.rb | 73 ++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 spec/unit/docs_reference_spec.rb diff --git a/docs/types.md b/docs/types.md index e667a668..777081e9 100644 --- a/docs/types.md +++ b/docs/types.md @@ -82,9 +82,24 @@ currently plain classes, not `Type`s: use their snake_case accessors ```ruby # Union type of all possible messages Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | - StreamEvent | RateLimitEvent | ConversationResetMessage + StreamEvent | RateLimitEvent | ConversationResetMessage | + ToolProgressMessage | AuthStatusMessage | ToolUseSummaryMessage | + PromptSuggestionMessage ``` +This is everything the block of `query()`, `ask`, `Client#receive_messages` and +`Client#receive_response` can receive. `SystemMessage` stands for its typed +subclasses as well (`InitMessage`, `TaskStartedMessage`, ...; see +[System and progress messages](#system-and-progress-messages)): each of them +`is_a?(SystemMessage)`. The other ten classes share nothing but the SDK's +`Type` base class. + +A message type this SDK version does not know is skipped before it reaches +your block, and a `system` message with an unknown `subtype` arrives as a plain +`SystemMessage` (`subtype` and `data` set). Give a `case` over messages an +`else` that ignores the rest: a later gem release can add a class to this +list. + ### UserMessage User input message. @@ -108,11 +123,20 @@ class AssistantMessage attr_accessor :content, # Array<ContentBlock> :model, # String :parent_tool_use_id, # String | nil - :error, # String | nil ('authentication_failed', 'billing_error', 'rate_limit', 'invalid_request', 'server_error', 'unknown') - :usage # Hash | nil - Token usage info from the API response + :error, # String | nil - see ASSISTANT_MESSAGE_ERRORS below + :usage, # Hash | nil - Token usage info from the API response + :message_id, # String | nil - the API message id + :stop_reason, # String | nil + :session_id, # String | nil + :uuid # String | nil - UUID of this message in the transcript end ``` +`error` is passed through from the CLI. The values this SDK version knows are +in `ASSISTANT_MESSAGE_ERRORS`: `authentication_failed`, `billing_error`, +`rate_limit`, `invalid_request`, `server_error`, `max_output_tokens`, +`unknown`. A newer CLI can send others. + ### SystemMessage System message with metadata. Task lifecycle events are typed subclasses. @@ -175,6 +199,35 @@ end See [subagent capabilities](subagents.md) for the contracts behind these fields. +### System and progress messages + +The remaining typed messages. An attribute the CLI did not send reads `nil` +(except `RateLimitEvent#rate_limit_info`, which is then an empty +`RateLimitInfo`). The first twelve are `SystemMessage` subclasses (wire `type` +is `system`), so they also have `subtype` and `data`, the whole frame as a +Symbol-keyed Hash; the last six are message types of their own. + +| Class | Wire type | Attributes | Notes | +|-------|-----------|------------|-------| +| `InitMessage` | `system` / `init` | `uuid`, `session_id`, `model`, `cwd`, `tools`, `mcp_servers`, `agents`, `skills`, `plugins`, `slash_commands`, `permission_mode`, `claude_code_version`, `api_key_source`, `betas`, `output_style`, `fast_mode_state` | Session start, and again after `/clear` | +| `CompactBoundaryMessage` | `system` / `compact_boundary` | `uuid`, `session_id`, `compact_metadata` (a `CompactMetadata`: `pre_tokens`, `post_tokens`, `trigger`, `preserved_segment`, `custom_instructions`) | Context compaction completed | +| `StatusMessage` | `system` / `status` | `uuid`, `session_id`, `status`, `permission_mode` | Compacting status, permission mode changes | +| `APIRetryMessage` | `system` / `api_retry` | `uuid`, `session_id`, `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error` | The CLI is retrying an API request | +| `LocalCommandOutputMessage` | `system` / `local_command_output` | `uuid`, `session_id`, `content` | Output of a local command | +| `HookStartedMessage` | `system` / `hook_started` | `uuid`, `session_id`, `hook_id`, `hook_name`, `hook_event` | Hook lifecycle; `include_hook_events: true` asks the CLI for all of these | +| `HookProgressMessage` | `system` / `hook_progress` | the `HookStartedMessage` attributes, `stdout`, `stderr`, `output` | | +| `HookResponseMessage` | `system` / `hook_response` | the `HookProgressMessage` attributes, `exit_code`, `outcome` (`'success'`, `'error'`, `'cancelled'`) | | +| `SessionStateChangedMessage` | `system` / `session_state_changed` | `uuid`, `session_id`, `state` (`'idle'`, `'running'`, `'requires_action'`) | Reaches your block only with `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` in `env` | +| `FilesPersistedMessage` | `system` / `files_persisted` | `uuid`, `session_id`, `files`, `failed`, `processed_at` | | +| `ElicitationCompleteMessage` | `system` / `elicitation_complete` | `uuid`, `session_id`, `mcp_server_name`, `elicitation_id` | | +| `MirrorErrorMessage` | `system` / `mirror_error` | `uuid`, `session_id`, `error`, `key` | Produced by the SDK, not the CLI: a `session_store` mirror batch was dropped (see [Sessions](sessions.md#mirroring-to-a-sessionstore)) | +| `ToolProgressMessage` | `tool_progress` | `uuid`, `session_id`, `tool_use_id`, `tool_name`, `parent_tool_use_id`, `elapsed_time_seconds`, `task_id` | Progress of a running tool call | +| `ToolUseSummaryMessage` | `tool_use_summary` | `uuid`, `session_id`, `summary`, `preceding_tool_use_ids` | | +| `AuthStatusMessage` | `auth_status` | `uuid`, `session_id`, `is_authenticating`, `output`, `error` | | +| `PromptSuggestionMessage` | `prompt_suggestion` | `uuid`, `session_id`, `suggestion` | | +| `StreamEvent` | `stream_event` | `uuid`, `session_id`, `event` (the raw API stream event, Symbol keys), `parent_tool_use_id` | Partial message chunks; only with `include_partial_messages: true` | +| `RateLimitEvent` | `rate_limit_event` | `uuid`, `session_id`, `rate_limit_info` (a `RateLimitInfo`: `status`, `resets_at`, `rate_limit_type`, `utilization`, `overage_status`, `overage_resets_at`, `overage_disabled_reason`, `raw`), `data` (the whole event) | Rate limit information changed | + ### ResultMessage Final result message with cost and usage information. @@ -199,10 +252,16 @@ class ResultMessage :fast_mode_state, # String | nil ('off', 'cooldown', 'on') :api_error_status, # Integer | nil (HTTP status on api_error subtype) :terminal_reason, # String | nil (see below) - :origin # Hash | nil - origin of the triggering user message (see below) + :origin, # Hash | nil - origin of the triggering user message (see below) + :deferred_tool_use # DeferredToolUse | nil (see below) end ``` +`deferred_tool_use` is set when a `PreToolUse` hook answered a tool call with +`permissionDecision: 'defer'`: a `DeferredToolUse` with the `id`, `name` and +`input` of the call that was put off. The session can be resumed later to run +the deferred call. + `terminal_reason` says why the query loop ended (`"completed"`, `"max_turns"`, `"aborted_streaming"`, ...). `"aborted_streaming"` / `"aborted_tools"` mean the turn was cancelled via `Client#interrupt`. `nil` when the CLI did not report @@ -287,7 +346,8 @@ cached session title). Read the new session id from the next message. ```ruby # Union type of all content blocks -ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock +ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | + ServerToolUseBlock | ServerToolResultBlock | UnknownBlock ``` ### TextBlock @@ -333,6 +393,30 @@ class ToolResultBlock end ``` +### ServerToolUseBlock and ServerToolResultBlock + +`ServerToolUseBlock` is a call to a tool that runs server-side (wire type +`server_tool_use`), and `ServerToolResultBlock` is the advisor tool's result +(wire type `advisor_tool_result`). An +[advisor](configuration.md#advisor-model) consultation shows up as a +`ServerToolUseBlock` named `'advisor'` and a `ServerToolResultBlock`. Any +other block type, a different server-side result included, arrives as an +`UnknownBlock`. + +```ruby +class ServerToolUseBlock + attr_accessor :id, # String + :name, # String ('advisor', ...) + :input # Hash +end + +class ServerToolResultBlock + attr_accessor :tool_use_id, # String + :content, # the result payload, as the CLI sent it + :is_error # Boolean | nil +end +``` + ### UnknownBlock Generic content block for types the SDK doesn't explicitly handle (e.g., `document` for PDFs, `image` for inline images). Preserves the raw data for forward compatibility with newer CLI versions. @@ -370,9 +454,9 @@ end | `TaskUsage` | Typed usage data (`total_tokens`, `tool_uses`, `duration_ms`) with `from_hash` factory | | `SDKSessionInfo` | Session metadata from `list_sessions` | | `SessionMessage` | Single message from `get_session_messages` | -| `SandboxSettings` | Sandbox settings for isolated command execution | +| `SandboxSettings` | Sandbox settings for isolated command execution. `ignore_violations` (which violations to ignore) is a plain Hash | | `SandboxNetworkConfig` | Network configuration for sandbox | -| `SandboxIgnoreViolations` | Configure which sandbox violations to ignore | +| `SandboxFilesystemConfig` | Filesystem configuration for sandbox (`allow_write`, `deny_write`, `deny_read`, `allow_read`, `allow_managed_read_paths_only`) | | `SystemPromptPreset` | System prompt preset configuration (`preset`, `append`, `exclude_dynamic_sections`, `snapshot`) | | `SystemPromptCustom` | Custom system prompt configuration — the object form of a String prompt, so `snapshot` can be set alongside it | | `SystemPromptFile` | System prompt loaded from a file path | diff --git a/spec/unit/docs_reference_spec.rb b/spec/unit/docs_reference_spec.rb new file mode 100644 index 00000000..b09a5247 --- /dev/null +++ b/spec/unit/docs_reference_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# docs/ is part of the SemVer contract (public API = docs/ plus the YARD docs +# without `@api private`), so the reference pages have to name what the code +# defines. The checks run from the code to the page: a class the SDK can hand +# to a consumer must be in the reference. Prose is not scanned for names; the +# only page-to-code check is on table rows that start with a constant name. +RSpec.describe 'docs/types.md' do + let(:root) { File.expand_path('../..', __dir__) } + let(:types_doc) { File.read(File.join(root, 'docs/types.md')) } + + def short_name(klass) + klass.name.split('::').last + end + + def sdk_classes + ClaudeAgentSDK.constants.map { |name| ClaudeAgentSDK.const_get(name) }.grep(Class) + end + + # Every class the message parser can hand to a consumer: the Type subclasses + # message_parser.rb names (its system-subtype table included), plus every + # Type subclass called *Message / *Block, so the list cannot shrink + # unnoticed if the dispatch moves out of that file. + def produced_classes + parser_source = File.read(File.join(root, 'lib/claude_agent_sdk/message_parser.rb')) + sdk_classes.select do |klass| + next false unless klass < ClaudeAgentSDK::Type + + name = short_name(klass) + name.end_with?('Message', 'Block') || parser_source.match?(/\b#{name}\b/) + end + end + + def block_classes + produced_classes.select { |klass| short_name(klass).end_with?('Block') } + end + + def message_classes + produced_classes - block_classes + end + + # The members of a `Name = A | B | ...` union inside a fenced block. + def documented_union(name) + types_doc[/^#{name} = (.*?)^```/m, 1].to_s.scan(/[A-Z]\w+/) + end + + it 'names every message and content-block class the parser can produce' do + missing = produced_classes.map { |klass| short_name(klass) }.reject { |name| types_doc.match?(/\b#{name}\b/) } + + expect(produced_classes.size).to be >= 36 + expect(missing).to be_empty, "docs/types.md does not mention: #{missing.sort.join(', ')}" + end + + it 'lists every top-level message class in the Message union' do + top_level = message_classes.reject { |klass| klass < ClaudeAgentSDK::SystemMessage } + + expect(documented_union('Message')).to match_array(top_level.map { |klass| short_name(klass) }) + end + + it 'lists every content-block class in the ContentBlock union' do + expect(documented_union('ContentBlock')).to match_array(block_classes.map { |klass| short_name(klass) }) + end + + it 'starts its table rows only with classes and constants that exist' do + names = types_doc.scan(/^\| `([A-Z]\w+)` \|/).flatten + unknown = names.reject { |name| ClaudeAgentSDK.const_defined?(name) } + + expect(names).not_to be_empty + expect(unknown).to be_empty, "docs/types.md lists names ClaudeAgentSDK does not define: #{unknown.join(', ')}" + end +end From 21c0a171a42faaf29a54ea1c2c5ff2a61dff5d8c Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:57:07 +0800 Subject: [PATCH 113/194] Document CLIInstallError; sandbox settings travel in --settings docs/errors.md listed ten of the eleven ClaudeSDKError classes: the reference block and the table had no CLIInstallError, the one a deploy or bin/setup script calling CLIInstaller.install has to rescue. Both now have it, with what raises it and that a wrapped failure keeps the original exception in #cause. docs/configuration.md said sandbox settings reach the CLI "via the CLI's --sandbox flag". There is no such flag: CommandBuilder puts them under the `sandbox` key of --settings, merged into the caller's `settings:`. The sentence now says so. docs_reference_spec.rb gains the matching check: every ClaudeSDKError subclass must have a reference entry and a table row in docs/errors.md. Documentation only. Exploration finding: C-P13 (also G-P12, item 4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/configuration.md | 2 +- docs/errors.md | 6 ++++++ spec/unit/docs_reference_spec.rb | 13 ++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index e8adaff7..1e7fcb66 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -197,7 +197,7 @@ Semantics: `nil` (default) leaves CLI defaults untouched; `[]` hides every skill ## Sandbox Settings -Configure [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) restrictions (network policy, filesystem access) via the CLI's `--sandbox` flag. The CLI handles OS-level process isolation using `srt`. +Configure [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) restrictions (network policy, filesystem access) with the `sandbox` option. The SDK sends it to the CLI as the `sandbox` key of the `--settings` argument, merged into your `settings:` when you pass both; there is no separate sandbox flag. The CLI handles OS-level process isolation using `srt`. ```ruby sandbox = ClaudeAgentSDK::SandboxSettings.new( diff --git a/docs/errors.md b/docs/errors.md index 30b50284..d8c04996 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -113,6 +113,11 @@ class CLINotFoundError < CLIConnectionError # @param cli_path [String, nil] Optional path to the CLI that was not found end +# Raised by CLIInstaller.install / .install_pinned (and the +# claude_agent_sdk:install_cli rake task) when the CLI cannot be installed. +# Never raised by query, ask or Client +class CLIInstallError < ClaudeSDKError; end + # Raised by the local-disk session APIs when CLAUDE_CONFIG_DIR is unset and # no usable home directory exists for the default ~/.claude class ConfigDirError < ClaudeSDKError; end @@ -159,6 +164,7 @@ end | `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect), and `ClaudeAgentSDK.ask` when the stream ends without a `ResultMessage` | | `ControlRequestTimeoutError` | Control protocol timeout (configurable via env var) | | `CLINotFoundError` | Claude Code not installed | +| `CLIInstallError` | `CLIInstaller.install` / `install_pinned` (or the `claude_agent_sdk:install_cli` rake task) could not install the CLI: unsupported platform, invalid version, HTTP error, a response or download over its size limit, checksum mismatch, or a filesystem error. A wrapped failure keeps the original exception in `#cause`. This is the error a deploy or `bin/setup` script rescues; a failed install leaves a previously installed binary in place. See [CLI installer](cli-installer.md) | | `ConfigDirError` | A local-disk session API (`list_sessions`, `get_session_*`, `rename_session`, ...) could not locate the Claude config directory: `CLAUDE_CONFIG_DIR` is unset and there is no usable home directory (`HOME` unset with no passwd entry, as under `docker --user` in a minimal image, or an empty/relative `HOME`). Set `CLAUDE_CONFIG_DIR` | | `SessionStoreError` | Resuming from `session_store:` failed: a store call (`#load`, `#list_sessions`, `#list_subkeys`) raised or exceeded `load_timeout_ms` while the SDK materialized the transcript, before the CLI started. The message names the call; `#cause` is the adapter's exception. Before 1.0 this was a bare `RuntimeError` — see [Sessions](sessions.md#mirroring-to-a-sessionstore) | | `ProcessError` | Process failed (includes `exit_code` and `stderr`) — also raised when the CLI is still running 5s after closing stdout and the SDK had to terminate it | diff --git a/spec/unit/docs_reference_spec.rb b/spec/unit/docs_reference_spec.rb index b09a5247..b3dfb069 100644 --- a/spec/unit/docs_reference_spec.rb +++ b/spec/unit/docs_reference_spec.rb @@ -7,9 +7,10 @@ # defines. The checks run from the code to the page: a class the SDK can hand # to a consumer must be in the reference. Prose is not scanned for names; the # only page-to-code check is on table rows that start with a constant name. -RSpec.describe 'docs/types.md' do +RSpec.describe 'docs/types.md and docs/errors.md' do let(:root) { File.expand_path('../..', __dir__) } let(:types_doc) { File.read(File.join(root, 'docs/types.md')) } + let(:errors_doc) { File.read(File.join(root, 'docs/errors.md')) } def short_name(klass) klass.name.split('::').last @@ -70,4 +71,14 @@ def documented_union(name) expect(names).not_to be_empty expect(unknown).to be_empty, "docs/types.md lists names ClaudeAgentSDK does not define: #{unknown.join(', ')}" end + + it 'has a reference entry and a table row for every error class' do + errors = sdk_classes.select { |klass| klass <= ClaudeAgentSDK::ClaudeSDKError }.map { |klass| short_name(klass) } + without_entry = errors.reject { |name| errors_doc.match?(/^class #{name}\b/) } + without_row = errors.reject { |name| errors_doc.match?(/^\| `#{name}` \|/) } + + expect(errors).to include('ClaudeSDKError', 'ProcessError', 'ResultError') + expect(without_entry).to be_empty, "docs/errors.md has no reference entry for: #{without_entry.join(', ')}" + expect(without_row).to be_empty, "docs/errors.md has no table row for: #{without_row.join(', ')}" + end end From 675f6b266d72edccd50e759adb60edc29d883e07 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:10:07 +0800 Subject: [PATCH 114/194] Add docs/options.md, a reference for every ClaudeAgentOptions attribute Several public options were described nowhere in the README or docs/: extra_args (the only way to pass a CLI flag the SDK has no option for), include_partial_messages (the only way to receive StreamEvent), include_hook_events, task_budget, strict_mcp_config. Neither were the CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS ceiling added in 1.1.0, the transport: argument of query / ask, or get_session_info. Under the 1.0 rule "public API = docs/ + YARD" that is public surface a reader of the docs cannot find. docs/options.md lists all 55 attributes in eight tables: the type from sig/, the default of a fresh ClaudeAgentOptions.new, and the CLI flag, initialize field or environment variable each one becomes. The type and default columns are generated from the code, the last column from the argv / initialize request / child environment the SDK actually produces for a sample value. Prose covers the options that have no guide of their own, how configured defaults combine with per-call values, and the environment variables the SDK and its sessions read, including what CLAUDE_CLI_PATH does when it names a missing file (discovery silently continues). docs/client.md documents query(transport:) / ask(transport:): the instance serves one query, options still drive the SDK side but not the transport's command line, so can_use_tool needs Client there, and store-backed resume is skipped for any transport that is not a SubprocessCLITransport. docs/types.md and the README link the new page and name get_session_info. spec/unit/docs_options_spec.rb requires every attribute that is not tagged `@api private` to be named on the page, and the README to link it. Documentation only. Exploration finding: G-P9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- README.md | 3 +- docs/client.md | 18 +++ docs/options.md | 218 +++++++++++++++++++++++++++++++++ docs/types.md | 4 +- spec/unit/docs_options_spec.rb | 58 +++++++++ 5 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 docs/options.md create mode 100644 spec/unit/docs_options_spec.rb diff --git a/README.md b/README.md index 7307cdb0..15850542 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,8 @@ See [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) for the full | `Client` advanced features and custom transports | [docs/client.md](docs/client.md) | | SDK MCP servers: tools, resources, prompts, schema compatibility | [docs/mcp-servers.md](docs/mcp-servers.md) | | All hook events, typed inputs, permission callbacks | [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) | -| Structured output, thinking, budget, fallback and advisor models, sandbox, bare mode, checkpointing | [docs/configuration.md](docs/configuration.md) | +| Structured output, thinking, budget, fallback and advisor models, sandbox, bare mode, session isolation, checkpointing | [docs/configuration.md](docs/configuration.md) | +| Every `ClaudeAgentOptions` attribute: type, default, and the CLI flag or protocol field it becomes; environment variables | [docs/options.md](docs/options.md) | | Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) | | Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](docs/subagents.md) | | OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](docs/observability.md) | diff --git a/docs/client.md b/docs/client.md index 9d371b2c..515b39a8 100644 --- a/docs/client.md +++ b/docs/client.md @@ -138,6 +138,24 @@ client = ClaudeAgentSDK::Client.new( ) ``` +### One-shot queries over a custom transport + +`ClaudeAgentSDK.query` and `ClaudeAgentSDK.ask` take a transport as well: a ready-made instance in `transport:`, not a class. + +```ruby +transport = MyTransport.new(options, foo: 'bar') +ClaudeAgentSDK.query(prompt: 'Hello', options: options, transport: transport) { |message| puts message } + +result = ClaudeAgentSDK.ask('Hello', options: options, transport: MyTransport.new(options, foo: 'bar')) +``` + +- The SDK connects the transport, runs the query over it and closes it, so one instance serves one query. `close` is called even when `connect` raised, and it must be idempotent. +- `options` still drives everything the SDK does on its own side: hooks, SDK MCP servers, agents, observers, callback scheduling. The command line and the environment are whatever your transport gives the CLI. `query` does not rebuild them from `options`, so build the transport from the same options. +- With a transport of your own, `can_use_tool` works through `Client` only. The CLI asks the callback when it is started with `--permission-prompt-tool stdio`; the options a `transport_class:` transport receives already carry that (`permission_prompt_tool_name: 'stdio'`), and a transport you built yourself does not get it from `query`. +- Anything that does not respond to `connect` raises `ArgumentError`. + +Resuming from a `session_store` is not available over a custom transport, through `query(transport:)` or `transport_class:`: the SDK prepares the transcript only for a CLI it starts with `SubprocessCLITransport` (or a subclass of it). + ### Reference: running `claude` inside an E2B sandbox [`examples/e2b_transport_example.rb`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/e2b_transport_example.rb) is a working transport that runs the Claude Code CLI inside an [E2B](https://e2b.dev) Firecracker microVM instead of on your host. The wire protocol stays identical — only the I/O layer changes: diff --git a/docs/options.md b/docs/options.md new file mode 100644 index 00000000..baf54f9b --- /dev/null +++ b/docs/options.md @@ -0,0 +1,218 @@ +# Options Reference + +Every attribute of `ClaudeAgentSDK::ClaudeAgentOptions`, with its type, its default, and what it turns into: a flag on the `claude` command line, a field of the control protocol's `initialize` request, a variable in the CLI's environment, or something the SDK handles on its own side. The topic guides explain how to use the larger features; this page is the complete list. + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new(model: 'claude-sonnet-5', max_turns: 5) +ClaudeAgentSDK.query(prompt: 'Hello', options: options) { |message| puts message } +``` + +- An unknown option name raises `ArgumentError`, in `.new`, in `#[]=` and in `#dup_with`. Names may be Symbols or Strings, snake_case or camelCase. +- `options.dup_with(max_turns: 1)` returns a changed copy and leaves the original alone. +- `ClaudeAgentSDK.configure { |config| config.default_options = { ... } }` sets defaults for every `ClaudeAgentOptions` built afterwards. A value you pass replaces the configured one, with two exceptions: `nil` keeps the configured value, and a Hash is merged into a configured Hash (that holds for every Hash-valued option, `env`, `mcp_servers`, `settings` and a Hash `system_prompt` or `output_format` included). + +## Reading the tables + +- **Type** is the option's type in the gem's RBS signatures (`sig/claude_agent_sdk/types/options.rbs`). Every option also accepts `nil` in `.new`, which means "not set": the default applies. `bool` is `true` or `false`. A name that starts with an underscore is an interface, so any object with the right method fits: `call` for the callbacks, `puts` for `_Puts`, `append` and `load` for `_SessionStore`. +- **Default** is the value of a fresh `ClaudeAgentOptions.new` with no configured defaults. +- **Sent to the CLI as** names the flag, the `initialize` field (`initialize.hooks` is the `hooks` field of that request) or the environment variable. "Nothing (SDK only)" marks an option that changes only what the SDK does in your process. + +Three types in the tables are aliases: + +| Alias | Stands for | +|-------|------------| +| `system_prompt_config` | `String`, `SystemPromptPreset`, `SystemPromptCustom`, `SystemPromptFile`, or the equivalent Hash (`{ type: 'preset' \| 'custom' \| 'file', ... }`) | +| `thinking_config` | `ThinkingConfigAdaptive`, `ThinkingConfigEnabled`, `ThinkingConfigDisabled`, or the equivalent Hash | +| `mcp_server_config` | `McpStdioServerConfig`, `McpSSEServerConfig`, `McpHttpServerConfig`, `McpSdkServerConfig`, or the equivalent Hash | + +## Prompt, model and limits + +Guides: [Structured Output](configuration.md#structured-output), [Thinking Configuration](configuration.md#thinking-configuration), [Budget Control](configuration.md#budget-control), [Fallback Model](configuration.md#fallback-model), [Advisor Model](configuration.md#advisor-model), [Beta Features](configuration.md#beta-features). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `system_prompt` | `system_prompt_config` | `nil` | `--system-prompt`, `--system-prompt-file` or `--append-system-prompt`, and two `initialize` fields. See [System prompt forms](#system-prompt-forms) | +| `model` | `String` | `nil` | `--model` | +| `fallback_model` | `String` | `nil` | `--fallback-model` | +| `advisor_model` | `String` | `nil` | `--advisor` | +| `max_turns` | `Integer` | `nil` | `--max-turns` | +| `max_budget_usd` | `Numeric` | `nil` | `--max-budget-usd` | +| `task_budget` | `TaskBudget \| Hash[Symbol \| String, untyped]` | `nil` | `--task-budget <total>`. See [`task_budget`](#task_budget) | +| `thinking` | `thinking_config` | `nil` | `--thinking adaptive`, `--thinking disabled` or `--max-thinking-tokens <budget_tokens>`; `display:` adds `--thinking-display` | +| `effort` | `String \| Symbol \| Integer` | `nil` | `--effort` | +| `max_thinking_tokens` | `Integer` | `nil` | `--max-thinking-tokens`. Deprecated: used only when `thinking` is unset | +| `betas` | `Array[String]` | `nil` | `--betas` (comma-joined) | +| `output_format` | `Hash[Symbol \| String, untyped] \| String` | `nil` | `--json-schema <schema as JSON>` | + +`effort` takes one of `ClaudeAgentSDK::EFFORT_LEVELS`; which levels a model supports is in [Thinking Configuration](configuration.md#thinking-configuration). + +### System prompt forms + +| `system_prompt` | Command line | +|-----------------|--------------| +| `nil` (the default) | `--system-prompt ""`: an empty prompt, not Claude Code's default one | +| a String, `SystemPromptCustom`, or `{ type: 'custom', prompt: '...' }` | `--system-prompt <text>` | +| `SystemPromptFile`, or `{ type: 'file', path: '...' }` | `--system-prompt-file <path>` | +| `SystemPromptPreset`, or `{ type: 'preset', preset: 'claude_code' }` | no system prompt flag, so the CLI uses its default prompt; `append:` adds `--append-system-prompt <text>` | + +`exclude_dynamic_sections` on a preset is sent as `initialize.excludeDynamicSections` ([Cross-User Prompt Caching](configuration.md#cross-user-prompt-caching)), and `snapshot` on a preset or a custom prompt as `initialize.systemPromptSnapshot` ([System Prompt Snapshot](configuration.md#system-prompt-snapshot)). Both are left out when unset. + +### `task_budget` + +A token budget for the task, counted on the API side: the model is told how much of it is left, so it can pace its tool use and wrap up before the limit. Pass `ClaudeAgentSDK::TaskBudget.new(total: 50_000)` or `{ total: 50_000 }`. It is not `max_budget_usd`, which is a spending limit in dollars. + +## Tools and permissions + +Guides: [Tools Configuration](configuration.md#tools-configuration), [Skills](configuration.md#skills), [Sandbox Settings](configuration.md#sandbox-settings), [Hooks & Permission Callbacks](hooks-and-permissions.md). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped]` | `nil` | `--tools <comma-joined names>`. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset` sends `--tools default` | +| `allowed_tools` | `Array[String]` | `[]` | `--allowedTools` (comma-joined; no flag when empty) | +| `disallowed_tools` | `Array[String]` | `[]` | `--disallowedTools` (comma-joined; no flag when empty) | +| `permission_mode` | `String` | `nil` | `--permission-mode` | +| `can_use_tool` | `_CanUseTool` | `nil` | `--permission-prompt-tool stdio`: the CLI asks over the control protocol and the SDK calls the callback | +| `permission_prompt_tool_name` | `String` | `nil` | `--permission-prompt-tool <name>`. Combining it with `can_use_tool` raises `ArgumentError` | +| `hooks` | `Hash[String \| Symbol, Array[HookMatcher]?]` | `nil` | `initialize.hooks` (matchers, callback ids, timeouts). The callbacks run in your process | +| `skills` | `String \| Array[String]` | `nil` | `Skill` or `Skill(name)` entries in `--allowedTools`; an Array is also sent as `initialize.skills`; `--setting-sources user,project` when `setting_sources` is `nil` | +| `sandbox` | `SandboxSettings \| Hash[Symbol \| String, untyped] \| bool` | `nil` | The `sandbox` key of `--settings` | + +`permission_mode` takes one of `ClaudeAgentSDK::PERMISSION_MODES`. + +## MCP servers, agents and plugins + +Guides: [Custom Tools (SDK MCP Servers)](mcp-servers.md), [Subagent capabilities](subagents.md). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `mcp_servers` | `Hash[String \| Symbol, mcp_server_config] \| String` | `{}` | `--mcp-config`: a Hash as `{"mcpServers": {...}}` JSON, a String (a file path or JSON) as given. An SDK server contributes only its `type` and `name`; its tools are served in your process over the control protocol | +| `strict_mcp_config` | `bool` | `false` | `--strict-mcp-config`. See [`strict_mcp_config`](#strict_mcp_config) | +| `agents` | `Hash[String \| Symbol, AgentDefinition \| Hash[Symbol \| String, untyped]]` | `nil` | `initialize.agents` | +| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir <path>`, once per plugin | + +### `strict_mcp_config` + +`true` makes the CLI use only the servers in `mcp_servers` and ignore every other MCP configuration (the CLI's own description of `--strict-mcp-config`: "Only use MCP servers from --mcp-config, ignoring all other MCP configurations"). + +## Settings and context + +Guides: [Bare Mode](configuration.md#bare-mode), [Verbatim Prompts](configuration.md#verbatim-prompts), [Session Isolation](configuration.md#session-isolation). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `settings` | `String \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given. With `sandbox` set as well, one merged JSON value | +| `setting_sources` | `Array[String]` | `nil` | `--setting-sources` (comma-joined). `[]` sends `--setting-sources ""`; `nil` sends no flag | +| `add_dirs` | `Array[String \| Pathname]` | `[]` | `--add-dir`, once per directory | +| `bare` | `bool` | `nil` | `--bare` | +| `verbatim_prompts` | `bool` | `false` | `client_composed: true` on every user message the SDK writes | + +`setting_sources` takes entries of `ClaudeAgentSDK::SETTING_SOURCES` (`user`, `project`, `local`). With `nil`, the default, the CLI decides, and it loads the user's and the project's settings and `CLAUDE.md` files. `[]` loads none of the three sources. Neither value affects the auto-memory: see [Session Isolation](configuration.md#session-isolation). + +## Sessions + +Guide: [Session Browsing & Mutations](sessions.md), which also covers `session_store`. [File Checkpointing & Rewind](configuration.md#file-checkpointing--rewind) is in the configuration guide. + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `resume` | `String` | `nil` | `--resume=<session id>` | +| `continue_conversation` | `bool` | `false` | `--continue`. Combining it with `resume` raises `ArgumentError` | +| `fork_session` | `bool` | `false` | `--fork-session` | +| `session_id` | `String` | `nil` | `--session-id=<uuid>` | +| `resume_session_at` | `String` | `nil` | `--resume-session-at=<uuid>`. Without `resume` it raises `ArgumentError` | +| `resume_drops_turn` | `String` | `nil` | `--resume-drops-turn=<uuid>` | +| `enable_file_checkpointing` | `bool` | `false` | `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true` in the CLI's environment | +| `session_store` | `_SessionStore` | `nil` | `--session-mirror`. The SDK appends the mirrored transcript to the store and can resume from it | +| `session_store_flush` | `String \| Symbol` | `"batched"` | Nothing (SDK only): `'batched'` or `'eager'` | +| `load_timeout_ms` | `Numeric` | `60000` | Nothing (SDK only): the limit, in milliseconds, for each store call while a resume loads the transcript | + +To find the session to resume, `ClaudeAgentSDK.list_sessions` lists sessions and `ClaudeAgentSDK.get_session_info(session_id:, directory: nil, session_store: nil)` reads the metadata of one session without listing the others. It returns an `SDKSessionInfo`, or `nil` when there is no session to report under that id. Both are plain functions that need neither a `Client` nor the CLI; the sessions guide has the rest of them. + +## Message stream + +Guides: [Forwarding Subagent Text](configuration.md#forwarding-subagent-text), [Subagent Progress Summaries](configuration.md#subagent-progress-summaries). The message classes are in the [types reference](types.md#message-types). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `include_partial_messages` | `bool` | `false` | `--include-partial-messages`. See [`include_partial_messages`](#include_partial_messages) | +| `include_hook_events` | `bool` | `false` | `--include-hook-events`. See [`include_hook_events`](#include_hook_events) | +| `forward_subagent_text` | `bool` | `false` | `initialize.forwardSubagentText`, sent only when `true` | +| `agent_progress_summaries` | `bool` | `nil` | `initialize.agentProgressSummaries`, left out when `nil` | + +### `include_partial_messages` + +Asks the CLI for partial message chunks while the model is still producing a message. Each chunk arrives as a `StreamEvent` whose `event` is the raw API stream event, a Hash with Symbol keys; the complete `AssistantMessage` still follows. Without the option the CLI sends no `StreamEvent`. + +### `include_hook_events` + +Asks the CLI to put all hook lifecycle events into the message stream, as `HookStartedMessage`, `HookProgressMessage` and `HookResponseMessage` (see [System and progress messages](types.md#system-and-progress-messages)). It only adds messages: the callbacks you register with `hooks` are called whether or not it is set. + +## The CLI process + +Guides: [Vendoring the CLI](cli-installer.md), [Custom Transport](client.md#custom-transport). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `cli_path` | `String \| Pathname` | `nil` | The executable the SDK starts. `nil`: the [discovery order](cli-installer.md#cli-discovery-order) | +| `cwd` | `String \| Pathname` | `nil` | The working directory of the CLI process (and its `PWD`) | +| `env` | `Hash[String \| Symbol, String?]` | `{}` | Merged over the environment the CLI process inherits. A `nil` value unsets the variable | +| `user` | `String \| Integer` | `nil` | The user the CLI process runs as (name or uid; Unix, and the SDK process needs the privilege to switch) | +| `extra_args` | `Hash[String \| Symbol, untyped]` | `{}` | `--<flag> <value>` per pair, `--<flag>` for a `nil` value. See [`extra_args`](#extra_args) | +| `stderr` | `_StderrCallback` | `nil` | Nothing (SDK only): called with each line the CLI writes to stderr | +| `debug_stderr` | `_Puts \| String` | `nil` | Nothing (SDK only): an IO-like object (`puts`) or a file path that receives each CLI stderr line | +| `max_buffer_size` | `Integer` | `nil` | Nothing (SDK only): the largest single message, in bytes, accepted from the CLI. `nil` means 1 MiB | + +These options describe the process the SDK starts itself. A [custom transport](client.md#custom-transport) starts the CLI its own way and decides what to do with them. + +### `extra_args` + +The way to pass a CLI flag the SDK has no option for. A key is the flag's name without the leading dashes; the value follows it, and `nil` means a flag without a value: + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + extra_args: { + 'no-session-persistence' => nil, # --no-session-persistence + 'agent' => 'reviewer' # --agent reviewer + } +) +``` + +- A flag name may contain only lowercase letters, digits and hyphens. Anything else raises `ArgumentError` when the command line is built, before the CLI starts. +- Values are converted with `to_s`. One that starts with `-` is sent as `--flag=value`, so the CLI cannot read it as another flag. +- The flags go at the end of the command line, after everything the options above produce. The SDK does not check that the CLI knows them: an unknown flag makes the CLI exit at startup, which raises `ProcessError` (`error: unknown option '--...'`). +- Use an option when there is one. A flag given here as well as through its option reaches the CLI twice. + +## Callbacks and observers + +Guides: [Observability](observability.md), [Rails Integration](rails.md). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `observers` | `Array[untyped]` | `[]` | Nothing (SDK only): observer instances, or callables that return a fresh one per query or session | +| `callback_scheduling` | `:thread \| :inline` | `:thread` | Nothing (SDK only): `:thread` runs each callback on a plain thread, `:inline` on the reactor fiber | +| `callback_wrapper` | `_CallbackWrapper` | `nil` | Nothing (SDK only): a callable wrapped around every callback dispatch | + +## Environment variables + +Variables that change what the SDK or a session does. "SDK process" means your Ruby process's own environment; `env` means `ClaudeAgentOptions#env`, which reaches only the CLI. + +| Variable | Set it in | Effect | +|----------|-----------|--------| +| `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` | SDK process (the CLI inherits it) or `env` | Credentials the CLI can authenticate with | +| `CLAUDE_CLI_PATH` | SDK process | The CLI executable, ahead of the vendored binary and `PATH`, when `cli_path` is `nil`. A relative path is resolved against the SDK process's working directory. A value that does not name an executable file is skipped without a warning, and discovery goes on to the vendored binary, `PATH` and the common install locations | +| `CLAUDE_CONFIG_DIR` | SDK process, and `env` for the CLI | Where Claude Code keeps its configuration and transcripts, instead of `~/.claude`. The session functions read it from the SDK process ([Sessions](sessions.md)) | +| `CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS` | SDK process | How long the SDK waits for the CLI's answer to a control request. Default 1200 ([Error Handling](errors.md#configuring-timeout)) | +| `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` | `env`, or the SDK process | See below | +| `CLAUDE_CODE_DISABLE_AUTO_MEMORY` | `env` | `'1'` turns the CLI's auto-memory off ([Session Isolation](configuration.md#session-isolation)) | +| `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS` | `env` | `'1'` makes `SessionStateChangedMessage` reach your message block | +| `CLAUDE_CODE_DISABLE_ADVISOR_TOOL` | `env` | `'1'` disables the advisor tool ([Advisor Model](configuration.md#advisor-model)) | + +### `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` + +A one-shot `query()` or `ask` that has hooks, a `can_use_tool` callback or SDK MCP servers keeps the CLI's stdin open after a turn's result, because background work can still wake the session for another turn whose hook, permission and tool requests are answered over stdin. It closes stdin when the CLI reports that it is idle. This variable bounds that wait: if the CLI still reports work this many milliseconds after a result and no new turn has started, the run ends anyway. + +- The default is 600000 (10 minutes). `0` means no limit. +- It is read from `env` first and from the SDK process's environment otherwise. Only a plain non-negative integer counts; anything else gives the default. +- The clock runs only between turns. A turn in progress, a request the SDK is still answering and a background agent that is still running stop it. +- The CLI reads the same variable for its own wait for background work once stdin is closed. +- A CLI that reports no session state (2.1.282 and earlier) is not waited for: stdin closes at the first result with no tracked background task still running. +- A `Client` session driven with `Client#query` is not affected: its stdin stays open until `disconnect`. diff --git a/docs/types.md b/docs/types.md index 777081e9..dab6a849 100644 --- a/docs/types.md +++ b/docs/types.md @@ -433,7 +433,7 @@ end | Type | Description | |------|-------------| | `Configuration` | Global defaults via `ClaudeAgentSDK.configure` block | -| `ClaudeAgentOptions` | Main configuration for queries and clients | +| `ClaudeAgentOptions` | Main configuration for queries and clients. Every option is listed in the [options reference](options.md) | | `HookMatcher` | Hook configuration with matcher pattern and timeout | | `PermissionResultAllow` | Permission callback result to allow tool use | | `PermissionResultDeny` | Permission callback result to deny tool use | @@ -452,7 +452,7 @@ end | `McpToolInfo` | MCP tool name, description, and annotations | | `McpToolAnnotations` | MCP tool annotation hints (`read_only`, `destructive`, `open_world`) | | `TaskUsage` | Typed usage data (`total_tokens`, `tool_uses`, `duration_ms`) with `from_hash` factory | -| `SDKSessionInfo` | Session metadata from `list_sessions` | +| `SDKSessionInfo` | Session metadata from `list_sessions` and `get_session_info` | | `SessionMessage` | Single message from `get_session_messages` | | `SandboxSettings` | Sandbox settings for isolated command execution. `ignore_violations` (which violations to ignore) is a plain Hash | | `SandboxNetworkConfig` | Network configuration for sandbox | diff --git a/spec/unit/docs_options_spec.rb b/spec/unit/docs_options_spec.rb new file mode 100644 index 00000000..5df2975d --- /dev/null +++ b/spec/unit/docs_options_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# docs/options.md is the reference for ClaudeAgentOptions. An option that is +# not on the page is public API a reader of the docs cannot discover, so every +# public attribute has to be named there. +RSpec.describe 'docs/options.md' do + let(:root) { File.expand_path('../..', __dir__) } + let(:page_path) { File.join(root, 'docs/options.md') } + let(:page) { File.exist?(page_path) ? File.read(page_path) : '' } + + # Attributes whose declaration carries a YARD `@api private` tag are not + # public API (CONTRIBUTING.md, "What is public API") and need no entry. + def private_attributes(source) + declaration = /((?:^[ \t]*#.*\n)+)[ \t]*attr_(?:accessor|reader|writer)[ \t]+((?::\w+,?\s*)+)/ + source.scan(declaration).flat_map do |comment, names| + comment.match?(/^\s*#\s*@api private\s*$/) ? names.scan(/:(\w+)/).flatten : [] + end + end + + def public_attributes + source = File.read(File.join(root, 'lib/claude_agent_sdk/types/options.rb')) + ClaudeAgentSDK::ClaudeAgentOptions.attribute_names - private_attributes(source) + end + + it 'names every public ClaudeAgentOptions attribute' do + missing = public_attributes.reject { |name| page.include?("`#{name}`") } + + expect(public_attributes.size).to be >= 55 + expect(missing).to be_empty, "docs/options.md does not mention: #{missing.join(', ')}" + end + + it 'recognizes an attribute tagged @api private as not needing an entry' do + source = <<~RUBY + class ClaudeAgentOptions < Type + attr_accessor :model, :cwd + + # Where the transport keeps its scratch files. + # + # @api private + attr_accessor :scratch_dir, + :scratch_mode + + # Deliver every prompt as written. + attr_reader :verbatim_prompts + end + RUBY + + expect(private_attributes(source)).to eq(%w[scratch_dir scratch_mode]) + end + + it 'is linked from the README documentation table' do + readme = File.read(File.join(root, 'README.md')) + + expect(readme).to match(%r{\]\([^)]*docs/options\.md\)}) + end +end From 3a41eebee0f1a76f78611a12b6458d3c0f922a20 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:13:50 +0800 Subject: [PATCH 115/194] Make the README's links absolute; guard relative links in the gem The README is the landing page of rubydoc.info/gems/claude-agent-sdk, and there a relative href resolves under rubydoc.info: all 25 relative links (every topic guide, CHANGELOG, UPGRADING, LICENSE, the banner image) were 404. The banner also pointed at assets/, which the gem does not ship, so it was dead inside the installed gem too - the class of defect AUDIT-2026-07-03 #6 fixed, back because nothing owned the rule. README links now go to github.com/.../blob/main/ (raw.githubusercontent for the banner), the convention docs/ already uses for examples. spec/unit/docs_links_spec.rb checks the packaged Markdown files without network access: - README.md has no relative link or image; - a relative link in any packaged file points at a packaged file; - an anchor (#x, or file.md#x) names a heading that exists. One known offender is left in place and listed in the spec, because the file belongs to another change: docs/subagents.md links relatively to examples/subagent_status_example.rb. The example fails when that entry stops matching, so the list cannot outlive the fix. Documentation only. Exploration finding: G-P3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- README.md | 50 ++++++++-------- spec/unit/docs_links_spec.rb | 111 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 25 deletions(-) create mode 100644 spec/unit/docs_links_spec.rb diff --git a/README.md b/README.md index 15850542..0ea479c7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Claude Agent SDK for Ruby — a ruby connecting code to terminal, tools, and messages](assets/readme-banner.webp) +![Claude Agent SDK for Ruby — a ruby connecting code to terminal, tools, and messages](https://raw.githubusercontent.com/ya-luotao/claude-agent-sdk-ruby/main/assets/readme-banner.webp) # Claude Agent SDK for Ruby @@ -6,17 +6,17 @@ [![CI](https://github.com/ya-luotao/claude-agent-sdk-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/ya-luotao/claude-agent-sdk-ruby/actions/workflows/ci.yml) [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D)](https://www.ruby-lang.org/) [![Docs](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://rubydoc.info/gems/claude-agent-sdk) -[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/LICENSE) A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-overview) agent runtime, built for running agents in production Ruby and Rails apps. It has the same capabilities as the official [TypeScript](https://github.com/anthropics/claude-agent-sdk-typescript) and [Python](https://github.com/anthropics/claude-agent-sdk-python) SDKs, plus what a Rails deploy needs around them: a generator and CLI-vendoring rake task, callbacks that are safe to touch ActiveRecord from, a pinned CLI binary, built-in OpenTelemetry tracing, and transcript mirroring to your own storage. -> **Unofficial and community-maintained.** This project is not affiliated with or supported by Anthropic. It tracks the official SDKs release by release; see the [CHANGELOG](CHANGELOG.md) for the currently synced version. +> **Unofficial and community-maintained.** This project is not affiliated with or supported by Anthropic. It tracks the official SDKs release by release; see the [CHANGELOG](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CHANGELOG.md) for the currently synced version. -> **Upgrading from 0.x?** 1.0 raises on unknown keys, limits `#[]` to attributes and adds `SessionStoreError`. [UPGRADING-1.0.md](UPGRADING-1.0.md) has the checklist. +> **Upgrading from 0.x?** 1.0 raises on unknown keys, limits `#[]` to attributes and adds `SessionStoreError`. [UPGRADING-1.0.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/UPGRADING-1.0.md) has the checklist. ## Highlights -- **Rails integration.** `bin/rails generate claude_agent_sdk:install` writes the initializer and `bin/rails claude_agent_sdk:install_cli` vendors the CLI; [docs/rails.md](docs/rails.md) covers jobs, ActionCable streaming, session resumption, and solid_queue fiber workers (`callback_scheduling: :inline`). +- **Rails integration.** `bin/rails generate claude_agent_sdk:install` writes the initializer and `bin/rails claude_agent_sdk:install_cli` vendors the CLI; [docs/rails.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md) covers jobs, ActionCable streaming, session resumption, and solid_queue fiber workers (`callback_scheduling: :inline`). - **Callbacks that are safe around ActiveRecord.** Tool handlers, hooks, permission callbacks, and message blocks run on a plain thread by default, outside the SDK's fiber scheduler, so thread-keyed libraries (ActiveRecord, `pg`, per-thread caches) behave as they do everywhere else in your app. `ClaudeAgentSDK::Railtie.callback_wrapper` runs them in the Rails executor so connections go back to the pool, without deadlocking development code reloading. - **Hermetic deploys.** `CLIInstaller` vendors a checksum-verified CLI binary, pinned to the version each gem release is tested with, so production never depends on a global `npm install`. - **Built-in OpenTelemetry observer** with Langfuse support; no third-party instrumentation library required. @@ -46,7 +46,7 @@ Then `bundle install`, or install directly with `gem install claude-agent-sdk`. ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220') # => "/app/vendor/claude/claude" ``` -The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order. +The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order. ### Rails in a minute @@ -70,7 +70,7 @@ class SummarizeTicketJob < ApplicationJob end ``` -The block runs on a plain thread, so ActiveRecord calls inside it just work. [docs/rails.md](docs/rails.md) continues with multi-turn sessions, ActionCable streaming, and fiber workers. +The block runs on a plain thread, so ActiveRecord calls inside it just work. [docs/rails.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md) continues with multi-turn sessions, ActionCable streaming, and fiber workers. ## Quick Start @@ -89,7 +89,7 @@ end puts result ``` -It raises the same errors as `query()` (see [docs/errors.md](docs/errors.md)), plus `CLIConnectionError` if the stream ends without a result. +It raises the same errors as `query()` (see [docs/errors.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/errors.md)), plus `CLIConnectionError` if the stream ends without a result. ### `query()` — one-shot and streaming @@ -148,7 +148,7 @@ ensure end ``` -See [docs/client.md](docs/client.md) for `interrupt`, mid-session model and permission switching, MCP status, and custom transports. +See [docs/client.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/client.md) for `interrupt`, mid-session model and permission switching, MCP status, and custom transports. ### Custom tools (SDK MCP servers) @@ -167,7 +167,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` -A String return is sent to Claude as a single text block. Return a Hash instead (`{ content: [...], is_error: true }`) to flag an error, attach `structured_content:`, or send several content blocks or images. Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details. +A String return is sent to Claude as a single text block. Return a Hash instead (`{ content: [...], is_error: true }`) to flag an error, attach `structured_content:`, or send several content blocks or images. Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details. ### Hooks and permission callbacks @@ -180,24 +180,24 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` -See [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) for the full event list and worked examples. +See [docs/hooks-and-permissions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/hooks-and-permissions.md) for the full event list and worked examples. ## Documentation | Topic | Guide | |-------|-------| -| `Client` advanced features and custom transports | [docs/client.md](docs/client.md) | -| SDK MCP servers: tools, resources, prompts, schema compatibility | [docs/mcp-servers.md](docs/mcp-servers.md) | -| All hook events, typed inputs, permission callbacks | [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) | -| Structured output, thinking, budget, fallback and advisor models, sandbox, bare mode, session isolation, checkpointing | [docs/configuration.md](docs/configuration.md) | -| Every `ClaudeAgentOptions` attribute: type, default, and the CLI flag or protocol field it becomes; environment variables | [docs/options.md](docs/options.md) | -| Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) | -| Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](docs/subagents.md) | -| OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](docs/observability.md) | -| Rails: generator, `install_cli` task, callback wrapper, fiber safety, solid_queue fiber workers, ActionCable, jobs | [docs/rails.md](docs/rails.md) | -| Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](docs/cli-installer.md) | -| Hash-key rule, attribute access, and the message, content block, and configuration type reference | [docs/types.md](docs/types.md) | -| Error handling, exception hierarchy, timeouts | [docs/errors.md](docs/errors.md) | +| `Client` advanced features and custom transports | [docs/client.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/client.md) | +| SDK MCP servers: tools, resources, prompts, schema compatibility | [docs/mcp-servers.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/mcp-servers.md) | +| All hook events, typed inputs, permission callbacks | [docs/hooks-and-permissions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/hooks-and-permissions.md) | +| Structured output, thinking, budget, fallback and advisor models, sandbox, bare mode, session isolation, checkpointing | [docs/configuration.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/configuration.md) | +| Every `ClaudeAgentOptions` attribute: type, default, and the CLI flag or protocol field it becomes; environment variables | [docs/options.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/options.md) | +| Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/sessions.md) | +| Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/subagents.md) | +| OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/observability.md) | +| Rails: generator, `install_cli` task, callback wrapper, fiber safety, solid_queue fiber workers, ActionCable, jobs | [docs/rails.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md) | +| Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/cli-installer.md) | +| Hash-key rule, attribute access, and the message, content block, and configuration type reference | [docs/types.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/types.md) | +| Error handling, exception hierarchy, timeouts | [docs/errors.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/errors.md) | API reference: [rubydoc.info/gems/claude-agent-sdk](https://rubydoc.info/gems/claude-agent-sdk). Available built-in tools: [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude). @@ -265,8 +265,8 @@ CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4 on Linux, the suite on m ## Contributing -Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change; [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) has the details. Report security vulnerabilities privately, as described in [SECURITY.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/SECURITY.md). Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](CHANGELOG.md). +Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change; [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) has the details. Report security vulnerabilities privately, as described in [SECURITY.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/SECURITY.md). Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CHANGELOG.md). ## License -Released under the [MIT License](LICENSE). +Released under the [MIT License](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/LICENSE). diff --git a/spec/unit/docs_links_spec.rb b/spec/unit/docs_links_spec.rb new file mode 100644 index 00000000..1377bc40 --- /dev/null +++ b/spec/unit/docs_links_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'bundler' + +# The Markdown files in the gem are read in three places: on GitHub, inside +# the installed gem, and (README.md, as the landing page) on rubydoc.info. A +# relative link works in all of them only if its target ships in the gem, and +# on rubydoc.info a relative link in the README never works: it resolves under +# rubydoc.info/gems/claude-agent-sdk/. No network access here: only the +# repository's own files are checked. +RSpec.describe 'links in the packaged Markdown files' do + let(:root) { File.expand_path('../..', __dir__) } + let(:packaged) { Bundler.load_gemspec_uncached(File.join(root, 'claude-agent-sdk.gemspec')).files } + let(:markdown_files) { packaged.grep(/\.md\z/) } + + # Relative links that point at a file the gem does not ship, in files this + # change could not touch. Each entry is [file, link target]. An entry that + # no longer matches a link fails the example below, so the list cannot + # outlive its fix. + # + # docs/subagents.md: make the link absolute + # (https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/subagent_status_example.rb) + # and delete the entry. + let(:known_unpackaged_targets) do + [['docs/subagents.md', '../examples/subagent_status_example.rb']] + end + + # Inline links and images, and reference definitions, outside fenced code, + # each as { file:, line:, target: }. + def links_in(file) + links = [] + in_fence = false + File.readlines(File.join(root, file), chomp: true).each_with_index do |line, index| + in_fence = !in_fence if line.match?(/\A\s*```/) + next if in_fence + + # Code spans can hold link-shaped text (`hash[key](arg)`); a link's own + # text may be a code span, so the span is blanked, not removed. + prose = line.gsub(/`[^`]*`/) { |span| 'x' * span.length } + # The link text may hold one level of brackets: a linked badge is + # [![alt](image)](target), and both targets count. + targets = prose.scan(/!?\[(?:[^\]\[]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/).flatten + + prose.scan(/\A\[[^\]]+\]:\s*(\S+)/).flatten + targets.each { |target| links << { file: file, line: index + 1, target: target } } + end + links + end + + def relative?(target) + !target.match?(/\A(?:[a-z][a-z0-9+.-]*:|#)/i) + end + + # The path a relative link resolves to, from the repository root. + def resolve(link) + path = link[:target].split('#', 2).first + File.expand_path(path, File.dirname(File.join(root, link[:file]))).delete_prefix("#{root}/") + end + + # GitHub's heading anchors: lowercase, punctuation dropped, spaces to + # hyphens, a numeric suffix for a repeated heading. + def anchors_in(file) + seen = Hash.new(0) + in_fence = false + File.readlines(File.join(root, file), chomp: true).filter_map do |line| + in_fence = !in_fence if line.match?(/\A\s*```/) + next if in_fence || !(heading = line[/\A\#{1,6}\s+(.*?)\s*#*\z/, 1]) + + text = heading.gsub(/\[([^\]]*)\]\([^)]*\)/, '\1').downcase + slug = text.gsub(/[^\p{L}\p{N}\- _]/u, '').tr(' ', '-') + seen[slug] += 1 + seen[slug] == 1 ? slug : "#{slug}-#{seen[slug] - 1}" + end + end + + def relative_links + markdown_files.flat_map { |file| links_in(file) }.select { |link| relative?(link[:target]) } + end + + it 'finds the Markdown files and their links' do + expect(markdown_files).to include('README.md', 'CHANGELOG.md', 'docs/client.md') + expect(markdown_files.flat_map { |file| links_in(file) }.size).to be > 100 + end + + it 'has no relative link or image in README.md, the rubydoc.info landing page' do + relative = links_in('README.md').select { |link| relative?(link[:target]) } + + expect(relative.map { |link| "README.md:#{link[:line]} #{link[:target]}" }).to eq([]) + end + + it 'points every relative link at a file that ships in the gem' do + unpackaged = relative_links.reject { |link| packaged.include?(resolve(link)) } + + expect(unpackaged.map { |link| link.values_at(:file, :target) }).to match_array(known_unpackaged_targets) + end + + it 'points every anchor at a heading that exists' do + links = markdown_files.flat_map { |file| links_in(file) }.select { |link| link[:target].include?('#') } + broken = links.filter_map do |link| + target = link[:target] + next unless target.start_with?('#') || relative?(target) + + target_file = target.start_with?('#') ? link[:file] : resolve(link) + next unless target_file.end_with?('.md') && File.file?(File.join(root, target_file)) + + "#{link[:file]}:#{link[:line]} #{target}" unless anchors_in(target_file).include?(target.split('#', 2).last) + end + + expect(broken).to eq([]) + end +end From 02ae31bb1cdfe7edcbd923c73cba929416a746d0 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:15:03 +0800 Subject: [PATCH 116/194] README: install the pinned CLI instead of a stale literal The Prerequisites snippet told production users to run CLIInstaller.install(version: '2.1.220'). That version predates what the same docs describe: verbatim_prompts needs 2.1.248, the system prompt snapshot 2.1.257, and the 1.1.0 run-end fix a CLI that reports session state. The gem itself is tested against PINNED_CLI_VERSION (2.1.285 today), and nothing updated the literal when the pin moved. The snippet now calls CLIInstaller.install_pinned, as docs/cli-installer.md already recommends, and says how to pin a version of your own. New spec/unit/release_lockstep_spec.rb pins three strings no code path reads: plugin.json's version equals ClaudeAgentSDK::VERSION, the two copies of the skill are byte-identical, and the README names no 2.1.x CLI release (the minimum requirement, 2.0.0, is the only CLI version it states). Documentation only. Exploration finding: G-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- README.md | 6 ++-- spec/unit/release_lockstep_spec.rb | 50 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 spec/unit/release_lockstep_spec.rb diff --git a/README.md b/README.md index 0ea479c7..ddbd1447 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,11 @@ Then `bundle install`, or install directly with `gem install claude-agent-sdk`. - Claude Code CLI 2.0.0 or newer, either installed globally (`npm install -g @anthropic-ai/claude-code`) or vendored with `CLIInstaller`: ```ruby -# bin/setup or a cached Docker layer — pin a concrete version in production -ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220') # => "/app/vendor/claude/claude" +# bin/setup or a cached Docker layer: the CLI version this gem release was tested with +ClaudeAgentSDK::CLIInstaller.install_pinned # => "/app/vendor/claude/claude" ``` -The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order. +`install_pinned` installs `CLIInstaller::PINNED_CLI_VERSION`, so upgrading the gem carries the CLI forward with it; `CLIInstaller.install(version: 'x.y.z')` pins a version of your own. The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order. ### Rails in a minute diff --git a/spec/unit/release_lockstep_spec.rb b/spec/unit/release_lockstep_spec.rb new file mode 100644 index 00000000..7a5710c4 --- /dev/null +++ b/spec/unit/release_lockstep_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# Version strings and copies that no code path reads, so nothing fails when +# one of them is forgotten in a release or a CLI pin bump. Each example pins +# one of them to its source of truth. +RSpec.describe 'release lockstep' do + let(:root) { File.expand_path('../..', __dir__) } + + def read_json(path) + JSON.parse(File.read(File.join(root, path))) + end + + # Relative path => content, for every file under dir. + def tree(dir) + base = File.join(root, dir) + Dir.glob('**/*', File::FNM_DOTMATCH, base: base) + .select { |path| File.file?(File.join(base, path)) } + .to_h { |path| [path, File.binread(File.join(base, path))] } + end + + it 'gives the Claude Code plugin the gem version' do + plugin = read_json('plugins/claude-agent-ruby/.claude-plugin/plugin.json') + + expect(plugin['version']).to eq(ClaudeAgentSDK::VERSION) + end + + # The skill is published twice: at the repository root and inside the + # plugin. An edit to one copy only would ship two different skills. + it 'keeps the two copies of the skill identical' do + root_copy = tree('skills') + plugin_copy = tree('plugins/claude-agent-ruby/skills/claude-agent-ruby') + + expect(root_copy).not_to be_empty + expect(plugin_copy.keys).to match_array(root_copy.keys) + expect(plugin_copy.select { |path, content| root_copy[path] != content }.keys).to eq([]) + end + + # The CLI version to install is CLIInstaller::PINNED_CLI_VERSION, which + # moves with every pin bump; a literal in the README is stale the day after + # it is written. The minimum requirement (2.0.0) is the only CLI version + # the README states. + it 'names no CLI release (2.1.x) in the README' do + readme = File.read(File.join(root, 'README.md')) + + expect(readme.scan(/\b2\.1\.\d+\b/)).to eq([]) + end +end From 4c71f2916899af669cb4f11f1490dfe1a17e6696 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:16:14 +0800 Subject: [PATCH 117/194] Drop the stale version from the marketplace plugin entry .claude-plugin/marketplace.json listed the claude-agent-ruby plugin as "version": "0.13.1", unchanged since the file was created; the plugin's own plugin.json says 1.1.0. Claude Code takes the version from plugin.json and ignores the entry's when they differ, and `claude plugin validate . --strict` failed on the mismatch (CLI 2.1.285 and 2.1.286: "Entry declares version "0.13.1" but ... plugin.json says "1.1.0""). The entry no longer declares a version, so there is one place to bump (the release checklist already bumps plugin.json). metadata.version, the marketplace manifest's own version, is unchanged. The validator passes on 2.1.285 and 2.1.286. release_lockstep_spec.rb gains the check that the entry has no version key, and a RuboCop fix for the previous commit (select with a negated condition -> reject). Not addressed here: CLI 2.1.287's validator rejects the plugin *name* ("claude-agent-ruby" is reserved: a third-party plugin name cannot start with "claude-"). Renaming the plugin changes the documented install command and is a maintainer decision. Exploration finding: G-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- .claude-plugin/marketplace.json | 1 - spec/unit/release_lockstep_spec.rb | 13 ++++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 47a684b8..37ba72ce 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,7 +14,6 @@ "name": "claude-agent-ruby", "source": "./plugins/claude-agent-ruby", "description": "Build AI agents in Ruby using the claude-agent-sdk gem. Covers query() one-shot calls, Client sessions, hooks, MCP servers, streaming, structured output, sandbox settings, session management, and Rails integration.", - "version": "0.13.1", "author": { "name": "ya-luotao" } diff --git a/spec/unit/release_lockstep_spec.rb b/spec/unit/release_lockstep_spec.rb index 7a5710c4..52618755 100644 --- a/spec/unit/release_lockstep_spec.rb +++ b/spec/unit/release_lockstep_spec.rb @@ -27,6 +27,17 @@ def tree(dir) expect(plugin['version']).to eq(ClaudeAgentSDK::VERSION) end + # The CLI takes a plugin's version from its plugin.json; a `version` on the + # marketplace entry is ignored when the two differ, and `claude plugin + # validate --strict` rejects the mismatch. One place to bump is enough. + it 'declares no version on the marketplace entry' do + marketplace = read_json('.claude-plugin/marketplace.json') + entry = marketplace.fetch('plugins').find { |plugin| plugin['name'] == 'claude-agent-ruby' } + + expect(entry).not_to be_nil + expect(entry).not_to have_key('version') + end + # The skill is published twice: at the repository root and inside the # plugin. An edit to one copy only would ship two different skills. it 'keeps the two copies of the skill identical' do @@ -35,7 +46,7 @@ def tree(dir) expect(root_copy).not_to be_empty expect(plugin_copy.keys).to match_array(root_copy.keys) - expect(plugin_copy.select { |path, content| root_copy[path] != content }.keys).to eq([]) + expect(plugin_copy.reject { |path, content| root_copy[path] == content }.keys).to eq([]) end # The CLI version to install is CLIInstaller::PINNED_CLI_VERSION, which From 2f48cd920595f9ff834b9aa61bf197309b1363a6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:16:31 +0800 Subject: [PATCH 118/194] SECURITY.md: state the supported versions for 1.x "Supported versions" still read "The gem is pre-1.0", written the day 1.0.0 was tagged. The rule itself is unchanged - fixes go into the latest minor only - and is now stated for the 1.x line: a patch on the latest 1.x minor, no backports to earlier minors or to 0.x. Exploration finding: G-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 6a0eba91..d8c06117 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ If you aren't sure which side an issue is on, report it here and we'll help rout ## Supported versions -The gem is pre-1.0. Security fixes go into the latest minor release only; upgrade to receive them. +Security fixes are released as a patch on the latest 1.x minor release only. Earlier minors and the 0.x line get no backports, so upgrade to the latest release to receive a fix. The gem follows [Semantic Versioning](https://semver.org/): an upgrade within 1.x does not break the public API. From f2411444bb4aebc2a7efa0e4f2bdb0dcfd070b62 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:18:00 +0800 Subject: [PATCH 119/194] message_types example: handle all 28 message classes The example's header said "Handling all 24 SDK message types"; the SDK parses 28 typed message classes (plus the generic SystemMessage), and the `case` had no branch for TaskUpdatedMessage, BackgroundTasksChangedMessage, PermissionDeniedMessage and MirrorErrorMessage. A background task that ends only as a TaskUpdatedMessage was therefore invisible in the "comprehensive" handler. The four branches are added, with a last `when SystemMessage` for a system subtype the SDK has no class for, and the header says 28. docs_reference_spec.rb checks both against the code: every typed message class has a `when` branch in the example, and the number in the header is the number of classes. Exploration finding: G-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- examples/message_types_example.rb | 32 ++++++++++++++++++++++++++++--- spec/unit/docs_reference_spec.rb | 23 ++++++++++++++++------ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/examples/message_types_example.rb b/examples/message_types_example.rb index 78821051..99bbb191 100755 --- a/examples/message_types_example.rb +++ b/examples/message_types_example.rb @@ -4,10 +4,11 @@ require 'bundler/setup' require 'claude_agent_sdk' -# Example: Handling all 24 SDK message types +# Example: Handling all 28 SDK message types # -# The Ruby SDK provides typed classes for every message the CLI emits. -# This example shows a comprehensive message handler. +# The Ruby SDK has a typed class for each message type it parses: the 28 +# handled below, plus the generic SystemMessage for a system subtype it has +# no class for. This example shows a comprehensive message handler. puts "=== Message Types Example ===" @@ -72,6 +73,16 @@ when ClaudeAgentSDK::TaskNotificationMessage puts "[task:done] #{msg.task_id} #{msg.status}: #{msg.summary}" + when ClaudeAgentSDK::TaskUpdatedMessage + # A task can finish here without a TaskNotificationMessage: clear your + # tracking when status is in ClaudeAgentSDK::TERMINAL_TASK_STATUSES. + puts "[task:update] #{msg.task_id}: #{msg.status}" if msg.status + puts "[task:update] #{msg.task_id} moved to the background" if msg.is_backgrounded == true + + when ClaudeAgentSDK::BackgroundTasksChangedMessage + # The full set of live background tasks: replace yours with it. + puts "[tasks] live background tasks: #{Array(msg.tasks).map { |t| t[:task_id] }.join(', ')}" + # --- Tool progress --- when ClaudeAgentSDK::ToolProgressMessage puts "[tool:progress] #{msg.tool_name} running (#{msg.elapsed_time_seconds}s)" @@ -110,6 +121,15 @@ when ClaudeAgentSDK::PromptSuggestionMessage puts "[suggestion] Next: #{msg.suggestion}" + # --- Auto-denied tool calls --- + when ClaudeAgentSDK::PermissionDeniedMessage + # Advisory only: ResultMessage#permission_denials is the authoritative list. + puts "[denied] #{msg.tool_name} (#{msg.decision_reason_type}): #{msg.message}" + + # --- SessionStore mirroring (only with session_store:) --- + when ClaudeAgentSDK::MirrorErrorMessage + puts "[mirror] a transcript batch was not stored: #{msg.error}" + # --- Rate limits --- when ClaudeAgentSDK::RateLimitEvent info = msg.rate_limit_info @@ -143,5 +163,11 @@ end puts " Errors: #{msg.errors.join(', ')}" if msg.errors&.any? + + # --- A system subtype this SDK version has no class for --- + # Keep this branch after every typed subclass above: they are all + # SystemMessages, and `case` takes the first branch that matches. + when ClaudeAgentSDK::SystemMessage + puts "[system] #{msg.subtype}" end end diff --git a/spec/unit/docs_reference_spec.rb b/spec/unit/docs_reference_spec.rb index b3dfb069..fad53351 100644 --- a/spec/unit/docs_reference_spec.rb +++ b/spec/unit/docs_reference_spec.rb @@ -7,7 +7,7 @@ # defines. The checks run from the code to the page: a class the SDK can hand # to a consumer must be in the reference. Prose is not scanned for names; the # only page-to-code check is on table rows that start with a constant name. -RSpec.describe 'docs/types.md and docs/errors.md' do +RSpec.describe 'the type and error references' do let(:root) { File.expand_path('../..', __dir__) } let(:types_doc) { File.read(File.join(root, 'docs/types.md')) } let(:errors_doc) { File.read(File.join(root, 'docs/errors.md')) } @@ -47,24 +47,24 @@ def documented_union(name) types_doc[/^#{name} = (.*?)^```/m, 1].to_s.scan(/[A-Z]\w+/) end - it 'names every message and content-block class the parser can produce' do + it 'names every message and content-block class the parser can produce in docs/types.md' do missing = produced_classes.map { |klass| short_name(klass) }.reject { |name| types_doc.match?(/\b#{name}\b/) } expect(produced_classes.size).to be >= 36 expect(missing).to be_empty, "docs/types.md does not mention: #{missing.sort.join(', ')}" end - it 'lists every top-level message class in the Message union' do + it 'lists every top-level message class in the Message union of docs/types.md' do top_level = message_classes.reject { |klass| klass < ClaudeAgentSDK::SystemMessage } expect(documented_union('Message')).to match_array(top_level.map { |klass| short_name(klass) }) end - it 'lists every content-block class in the ContentBlock union' do + it 'lists every content-block class in the ContentBlock union of docs/types.md' do expect(documented_union('ContentBlock')).to match_array(block_classes.map { |klass| short_name(klass) }) end - it 'starts its table rows only with classes and constants that exist' do + it 'starts the table rows of docs/types.md only with classes and constants that exist' do names = types_doc.scan(/^\| `([A-Z]\w+)` \|/).flatten unknown = names.reject { |name| ClaudeAgentSDK.const_defined?(name) } @@ -72,7 +72,7 @@ def documented_union(name) expect(unknown).to be_empty, "docs/types.md lists names ClaudeAgentSDK does not define: #{unknown.join(', ')}" end - it 'has a reference entry and a table row for every error class' do + it 'has a reference entry and a table row in docs/errors.md for every error class' do errors = sdk_classes.select { |klass| klass <= ClaudeAgentSDK::ClaudeSDKError }.map { |klass| short_name(klass) } without_entry = errors.reject { |name| errors_doc.match?(/^class #{name}\b/) } without_row = errors.reject { |name| errors_doc.match?(/^\| `#{name}` \|/) } @@ -81,4 +81,15 @@ def documented_union(name) expect(without_entry).to be_empty, "docs/errors.md has no reference entry for: #{without_entry.join(', ')}" expect(without_row).to be_empty, "docs/errors.md has no table row for: #{without_row.join(', ')}" end + + # The example announces how many message types it handles; both the number + # and the `when` branches are checked against the classes. + it 'handles every typed message class in examples/message_types_example.rb, and counts them' do + example = File.read(File.join(root, 'examples/message_types_example.rb')) + typed = message_classes.map { |klass| short_name(klass) } - ['SystemMessage'] + handled = example.scan(/^\s*when ClaudeAgentSDK::(\w+)\s*$/).flatten + + expect(typed - handled).to eq([]) + expect(example[/Handling all (\d+) SDK message types/, 1]).to eq(typed.size.to_s) + end end From 26d4b8ad7f8a2fe0ebedabe21a90c0af210a98f6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:21:35 +0800 Subject: [PATCH 120/194] Docs: hooks, tools and permission callbacks do not require Client docs/client.md opened with "Unlike query(), Client enables custom tools, hooks, and permission callbacks". That has been false for many releases: query() and ask run the same control protocol, and all three callback kinds fire inside a one-shot query (SDK MCP servers and can_use_tool since 0.7.0, hooks since 0.8.1; 1.1.0's main fix is about that path). The sentence sent readers to the harder API for something the one-liner does. The README and the bundled skill's decision guide repeated it. All three now state the real difference, which is lifecycle: Client adds follow-up queries in one session and control calls while it runs (interrupt, model and permission-mode switching, MCP server control, file rewind). Documentation only. docs/rails.md has the same sentence and belongs to another change. Exploration finding: G-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- README.md | 2 +- docs/client.md | 4 +++- plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md | 4 ++-- skills/SKILL.md | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ddbd1447..540a44b5 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ end ### `Client` — bidirectional sessions -`Client` keeps a session open so you can send follow-up queries, interrupt, switch models, and use hooks, permission callbacks, and custom tools. `Client.open` connects, yields the client, and always disconnects when the block exits, even on an exception. It returns the block's value. +`Client` keeps a session open so you can send follow-up queries, interrupt, and switch the model or the permission mode mid-session. (Hooks, permission callbacks and custom tools are not a reason to choose it: they work with `query()` and `ask` too.) `Client.open` connects, yields the client, and always disconnects when the block exits, even on an exception. It returns the block's value. ```ruby require 'claude_agent_sdk' diff --git a/docs/client.md b/docs/client.md index 515b39a8..5edff8ed 100644 --- a/docs/client.md +++ b/docs/client.md @@ -1,6 +1,8 @@ # Client & Custom Transport -`ClaudeAgentSDK::Client` supports bidirectional, interactive conversations with Claude Code. Unlike `query()`, `Client` enables **custom tools**, **hooks**, and **permission callbacks**, all of which can be defined as Ruby procs/lambdas. The Client class automatically uses streaming mode for bidirectional communication, allowing you to send multiple queries dynamically during a single session without closing the connection. +`ClaudeAgentSDK::Client` keeps one Claude Code session open for a conversation you drive. What it adds over `query()` is lifecycle: you can send follow-up queries in the same session, and you can call the CLI while the session runs (`interrupt`, switch the model or the permission mode, inspect and reconnect MCP servers, rewind files, stop or background a task). + +**Custom tools**, **hooks** and **permission callbacks** are not part of that difference. `query()` and `ask` speak the same control protocol as `Client`, so all three run them; each is a Ruby proc or lambda you pass in `ClaudeAgentOptions`. For a single question you don't need a session: `ClaudeAgentSDK.ask(prompt, options:)` runs `query()` to completion and returns the final `ResultMessage` (`#result` is the answer text), optionally yielding each message to a block on the way. See the README's [Quick Start](../README.md#quick-start). diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md index 79aa1c70..a67fdd82 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md @@ -10,8 +10,8 @@ Use this skill to build or refactor Ruby integrations with Claude Code via `clau ## Decision Guide - Choose `ClaudeAgentSDK.ask(prompt, options:)` when only the final answer matters: it runs `query` to completion and returns the `ResultMessage` (`#result` is the text); an optional block still sees every message. -- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. Internally uses the control protocol (streaming mode). -- Choose `ClaudeAgentSDK::Client` for multi-turn sessions, hooks, permission callbacks, MCP server control, or dynamic model switching. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. +- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. It runs the same control protocol as `Client`, so hooks, `can_use_tool` and SDK MCP tools work with it (and with `ask`). +- Choose `ClaudeAgentSDK::Client` for multi-turn sessions and for control calls while a session runs: interrupt, model or permission-mode switching, MCP server control, file rewind. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. - Choose SDK MCP servers (`create_tool`, `create_sdk_mcp_server`) for in-process tools; choose external MCP configs for subprocess/HTTP servers. - Choose `ClaudeAgentSDK.list_sessions` / `ClaudeAgentSDK.get_session_messages` for browsing previous session transcripts (pure filesystem, no CLI needed). diff --git a/skills/SKILL.md b/skills/SKILL.md index 79aa1c70..a67fdd82 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -10,8 +10,8 @@ Use this skill to build or refactor Ruby integrations with Claude Code via `clau ## Decision Guide - Choose `ClaudeAgentSDK.ask(prompt, options:)` when only the final answer matters: it runs `query` to completion and returns the `ResultMessage` (`#result` is the text); an optional block still sees every message. -- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. Internally uses the control protocol (streaming mode). -- Choose `ClaudeAgentSDK::Client` for multi-turn sessions, hooks, permission callbacks, MCP server control, or dynamic model switching. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. +- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. It runs the same control protocol as `Client`, so hooks, `can_use_tool` and SDK MCP tools work with it (and with `ask`). +- Choose `ClaudeAgentSDK::Client` for multi-turn sessions and for control calls while a session runs: interrupt, model or permission-mode switching, MCP server control, file rewind. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. - Choose SDK MCP servers (`create_tool`, `create_sdk_mcp_server`) for in-process tools; choose external MCP configs for subprocess/HTTP servers. - Choose `ClaudeAgentSDK.list_sessions` / `ClaudeAgentSDK.get_session_messages` for browsing previous session transcripts (pure filesystem, no CLI needed). From b2593c5f33018a2f2667d020d8f03ee3d2d15b4e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:25:02 +0800 Subject: [PATCH 121/194] State one custom-transport contract: five methods, ready? optional The contract existed in three versions. The skill listed four methods (connect, write, read_messages, close), docs/client.md six (adding end_input and ready?), and the _Transport interface in sig/ five. The SDK calls connect, write, read_messages, end_input and close, and never ready?. A transport written from the skill's list delivered every message to query() and then never returned (or raised NotImplementedError, a ScriptError, when it subclassed Transport). docs/client.md and both copies of the skill now list the five required methods and say ready? is optional, close must be idempotent and read_messages yields Symbol-keyed Hashes. The YARD on ClaudeAgentSDK::Transport said the same thing wrongly in another way: it was Python's docstring ("This internal API ... The Claude Code team may change or remove this abstract class in any future release"). In this gem Transport is public, signed and SemVer-covered; the docstring now says so and documents the contract per method. Comment-only change in lib/. spec/unit/docs_transport_contract_spec.rb takes the method list from sig/ and checks that the docs table and the skill name the same methods, and that a transport with exactly those methods serves both query() and Client. No behavior change; no entry-point validation was added. Exploration finding: G-P6 (and the Transport docstring of G-P11) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/client.md | 7 +- lib/claude_agent_sdk/transport.rb | 40 ++++-- .../claude-agent-ruby/references/options.md | 2 +- skills/references/options.md | 2 +- spec/unit/docs_transport_contract_spec.rb | 115 ++++++++++++++++++ 5 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 spec/unit/docs_transport_contract_spec.rb diff --git a/docs/client.md b/docs/client.md index 5edff8ed..0eb3a906 100644 --- a/docs/client.md +++ b/docs/client.md @@ -108,7 +108,7 @@ end By default, `Client` uses `SubprocessCLITransport` to spawn the Claude Code CLI locally. You can provide a custom transport class to connect via other channels (e.g., remote SSH, WebSocket, or a sandbox VM). -A transport must implement six methods: +A transport must implement five methods. It can subclass `ClaudeAgentSDK::Transport`, whose methods raise `NotImplementedError` until you override them, or be any object that has them: | Method | Purpose | |---|---| @@ -116,8 +116,9 @@ A transport must implement six methods: | `write(data)` | Send raw JSON-line bytes to stdin | | `read_messages { \|hash\| ... }` | Yield each stdout line as a Hash parsed with `JSON.parse(line, symbolize_names: true)` (the SDK reads Symbol keys; see [Hash keys](types.md#hash-keys)); block until the stream closes | | `end_input` | Signal EOF on stdin | -| `close` | Terminate and clean up | -| `ready?` | Report whether the transport can accept I/O | +| `close` | Terminate and clean up. Must be safe to call more than once | + +`ready?` (report whether the transport can accept I/O) is on the `Transport` base class as well, but the SDK never calls it, so it is optional. `end_input` is not: a one-shot `query()` calls it when the run is over, and `Client` calls it when a streamed prompt is exhausted. **Environment your transport should give the CLI.** `SubprocessCLITransport` sets a few variables that a custom transport has to set itself. The one that diff --git a/lib/claude_agent_sdk/transport.rb b/lib/claude_agent_sdk/transport.rb index 78edc50b..a7761666 100644 --- a/lib/claude_agent_sdk/transport.rb +++ b/lib/claude_agent_sdk/transport.rb @@ -1,42 +1,58 @@ # frozen_string_literal: true module ClaudeAgentSDK - # Abstract transport for Claude communication + # Abstract base class for transports: the channel between the SDK and a + # Claude Code CLI process. # - # WARNING: This internal API is exposed for custom transport implementations - # (e.g., remote Claude Code connections). The Claude Code team may change or - # remove this abstract class in any future release. Custom implementations - # must be updated to match interface changes. + # Public API, covered by this gem's SemVer promise like the rest of the + # documented surface. {SubprocessCLITransport}, the default, spawns the CLI + # locally. To run the CLI somewhere else (a container, an SSH host, a + # sandbox VM), subclass this class, or write any object with the same + # methods, and pass it to {Client#initialize} as `transport_class:` or to + # {ClaudeAgentSDK.query} / {ClaudeAgentSDK.ask} as `transport:`. See + # docs/client.md, "Custom Transport". + # + # The SDK calls five methods: {#connect}, {#write}, {#read_messages}, + # {#end_input} and {#close}. It never calls {#ready?}, so that one is + # optional. Every method here raises NotImplementedError until a subclass + # overrides it. class Transport - # Connect the transport and prepare for communication + # Establish the connection (spawn or reach the CLI) and prepare for IO. def connect raise NotImplementedError, 'Subclasses must implement #connect' end - # Write raw data to the transport + # Write raw data to the CLI's stdin. # @param data [String] Raw string data to write (typically JSON + newline) def write(data) raise NotImplementedError, 'Subclasses must implement #write' end - # Read and parse messages from the transport - # @return [Enumerator] Async enumerator of parsed JSON messages + # Read the CLI's stdout and yield each line as a parsed message, blocking + # until the stream ends. Parse each line with + # `JSON.parse(line, symbolize_names: true)`: the SDK reads Symbol keys. + # The SDK always passes a block. + # @yield [Hash{Symbol => Object}] Each parsed message def read_messages raise NotImplementedError, 'Subclasses must implement #read_messages' end - # Close the transport connection and clean up resources + # Terminate the CLI and clean up resources. Must be idempotent: the SDK + # can call it more than once, and calls it after a failed {#connect} too. def close raise NotImplementedError, 'Subclasses must implement #close' end - # Check if transport is ready for communication + # Check if transport is ready for communication. Optional: the SDK never + # calls it. # @return [Boolean] True if transport is ready to send/receive messages def ready? raise NotImplementedError, 'Subclasses must implement #ready?' end - # End the input stream (close stdin for process transports) + # End the input stream (close stdin for process transports). The SDK + # calls it when a one-shot query's run is over and when a streamed prompt + # is exhausted. def end_input raise NotImplementedError, 'Subclasses must implement #end_input' end diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index f73aa8fc..582bef5f 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -105,7 +105,7 @@ Use `agents:` to configure sub-agent definitions passed to the CLI via the contr ## Custom transport -`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport (must implement the `Transport` interface: `connect`, `write`, `read_messages`, `close`). +`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport; `ClaudeAgentSDK.query` and `.ask` take a ready-made instance as `transport:`. A custom transport must implement `connect`, `write`, `read_messages`, `end_input` and `close` (`ready?` is optional: the SDK never calls it). A transport without `end_input` makes `query` and `ask` hang (or raise `NotImplementedError`, if it subclasses `Transport`). `read_messages` yields each stdout line parsed with `JSON.parse(line, symbolize_names: true)`, and `close` must be safe to call twice. ```ruby client = ClaudeAgentSDK::Client.new( diff --git a/skills/references/options.md b/skills/references/options.md index f73aa8fc..582bef5f 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -105,7 +105,7 @@ Use `agents:` to configure sub-agent definitions passed to the CLI via the contr ## Custom transport -`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport (must implement the `Transport` interface: `connect`, `write`, `read_messages`, `close`). +`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport; `ClaudeAgentSDK.query` and `.ask` take a ready-made instance as `transport:`. A custom transport must implement `connect`, `write`, `read_messages`, `end_input` and `close` (`ready?` is optional: the SDK never calls it). A transport without `end_input` makes `query` and `ask` hang (or raise `NotImplementedError`, if it subclasses `Transport`). `read_messages` yields each stdout line parsed with `JSON.parse(line, symbolize_names: true)`, and `close` must be safe to call twice. ```ruby client = ClaudeAgentSDK::Client.new( diff --git a/spec/unit/docs_transport_contract_spec.rb b/spec/unit/docs_transport_contract_spec.rb new file mode 100644 index 00000000..4835a689 --- /dev/null +++ b/spec/unit/docs_transport_contract_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# The custom-transport contract is written down in three places: the +# _Transport interface in sig/, the table in docs/client.md and one sentence +# of the bundled skill. They once listed five, six and four methods, and a +# transport written from the four-method list hung query(). The interface in +# sig/ is the reference; the other two must name the same methods, and a +# transport with exactly those methods must work. +RSpec.describe 'the custom transport contract' do + let(:root) { File.expand_path('../..', __dir__) } + + let(:interface_methods) do + sig = File.read(File.join(root, 'sig/claude_agent_sdk/transport.rbs')) + sig[/^ interface _Transport\n(.*?)^ end/m, 1].scan(/def (\w+\??):/).flatten + end + + # A scripted stand-in for the CLI with the interface's methods and nothing + # else. It answers the initialize request, and each user message with the + # frames a turn produces (system/init, assistant, result). + let(:minimal_transport_class) do + Class.new do + def initialize(*) + @frames = Thread::Queue.new + end + + def connect; end + + def write(data) + data.each_line do |line| + frame = JSON.parse(line, symbolize_names: true) + case frame[:type] + when 'control_request' then answer_control_request(frame) + when 'user' then answer_user_message + end + end + end + + def read_messages + while (frame = @frames.pop) != :end + yield frame + end + end + + def end_input + @frames << :end + end + + def close + @frames << :end + end + + private + + def answer_control_request(frame) + @frames << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], response: {} } } + end + + def answer_user_message + session_id = '11111111-1111-4111-8111-111111111111' + @frames << { type: 'system', subtype: 'init', session_id: session_id, uuid: 'init-1', + model: 'claude-haiku-4-5', cwd: '/srv/app', tools: %w[Read Bash], + claude_code_version: '2.1.285', permissionMode: 'default' } + @frames << { type: 'assistant', session_id: session_id, uuid: 'assistant-1', parent_tool_use_id: nil, + message: { id: 'msg_1', role: 'assistant', model: 'claude-haiku-4-5', + content: [{ type: 'text', text: 'Hello.' }], + usage: { input_tokens: 10, output_tokens: 5 } } } + @frames << { type: 'result', subtype: 'success', is_error: false, duration_ms: 2100, + duration_api_ms: 1800, num_turns: 1, session_id: session_id, total_cost_usd: 0.0031, + result: 'Hello.', usage: { input_tokens: 10, output_tokens: 5 }, uuid: 'result-1' } + end + end + end + + it 'has five methods in sig/, none of them ready?' do + expect(interface_methods).to match_array(%w[connect write read_messages end_input close]) + end + + it 'lists the same methods in the docs/client.md table' do + section = File.read(File.join(root, 'docs/client.md'))[/^## Custom Transport\n(.*?)^###? /m, 1] + + expect(section.scan(/^\| `(\w+\??)/).flatten).to match_array(interface_methods) + end + + it 'lists the same methods in both copies of the skill' do + %w[skills plugins/claude-agent-ruby/skills/claude-agent-ruby].each do |tree| + text = File.read(File.join(root, tree, 'references/options.md')) + required = text[/A custom transport must implement ([^(.]*)/, 1].to_s + + expect(required.scan(/`(\w+\??)`/).flatten).to match_array(interface_methods), "in #{tree}" + end + end + + it 'serves query and Client with a transport that has only those methods' do + expect(minimal_transport_class.public_instance_methods(false)).to match_array(interface_methods.map(&:to_sym)) + + from_query = [] + ClaudeAgentSDK.query(prompt: 'Hello', transport: minimal_transport_class.new) { |message| from_query << message } + from_client = [] + ClaudeAgentSDK::Client.open(transport_class: minimal_transport_class) do |client| + client.query('Hello') + client.receive_response { |message| from_client << message } + end + + [from_query, from_client].each do |messages| + expect(messages.map(&:class)).to eq( + [ClaudeAgentSDK::InitMessage, ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage] + ) + expect(messages.last.result).to eq('Hello.') + end + end +end From 3ee9f1a8dd4fd6637911332f68c0c028f14f1a8e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:27:36 +0800 Subject: [PATCH 122/194] Skill: drop ENV.fetch from initializers; say what CLAUDE_CLI_PATH does Two statements in the bundled skill, which Claude Code reads when it writes SDK code for a user: - The configure examples in references/options.md and references/rails.md put `env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') }` into the default options. The CLI process inherits the parent environment, so the line adds nothing, and in a Rails initializer it makes every boot without the key (asset precompilation in a Docker build, db:migrate in CI) raise KeyError. The line is gone and a note says why. - references/troubleshooting.md offered CLAUDE_CLI_PATH without saying that it is only the first place the SDK looks: a value that does not name an executable file is skipped without a warning and discovery continues with the vendored binary, PATH and the common locations. The page now says so. Behavior is unchanged. Both copies of the skill carry the same edits. Exploration finding: G-P12 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- .../skills/claude-agent-ruby/references/options.md | 4 ++-- .../skills/claude-agent-ruby/references/rails.md | 3 ++- .../skills/claude-agent-ruby/references/troubleshooting.md | 1 + skills/references/options.md | 4 ++-- skills/references/rails.md | 3 ++- skills/references/troubleshooting.md | 1 + 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index 582bef5f..3325514a 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -10,13 +10,13 @@ Set defaults once, then override only when needed per call. ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', - permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') } + permission_mode: 'bypassPermissions' } end ``` Notes: +- Credentials need no option. The CLI process inherits your process's environment, so `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) set there already reaches it. Do not copy it into `env` with `ENV.fetch` in an initializer: every boot without the key (asset precompilation in a Docker build, `db:migrate` in CI) would raise `KeyError`. - `ClaudeAgentOptions.new(...)` still overrides defaults you pass explicitly. - Hash options like `env` and `mcp_servers` merge with configured defaults. - Assignment stores a frozen deep copy (containers, typed values and Strings included): change defaults by assigning a new Hash. In-place mutation of `config.default_options` raises `FrozenError`, and later changes to the Hash, typed objects or Strings you passed have no effect. Sessions get mutable copies of the containers and typed values but keep the snapshot's frozen Strings, so reassign `options.model = ...` rather than `options.model << ...`. diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md index a6873f50..ab55fc1d 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md @@ -21,7 +21,6 @@ ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') }, # AR connections go back to the pool after each callback. Never a bare # `->(inv) { Rails.application.executor.wrap { inv.call } }`: that deadlocks # with development code reloading in the default :thread scheduling. @@ -30,6 +29,8 @@ ClaudeAgentSDK.configure do |config| end ``` +The API key needs no entry here: the CLI process inherits the app's environment, so `ANTHROPIC_API_KEY` set for the app already reaches it. An `ENV.fetch('ANTHROPIC_API_KEY')` in the initializer would make every boot without the key (asset precompilation, `db:migrate` in CI) raise `KeyError`. + ## Stream chunks to the frontend (ActionCable) Run the agent in an `ActiveJob`, broadcast assistant text as it arrives, and finalize on `ResultMessage`. diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md index 05458a8d..b8f44738 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md @@ -8,6 +8,7 @@ Symptoms: Fix: - Install Claude Code CLI (Node.js required), or vendor a pinned binary with `ClaudeAgentSDK::CLIInstaller.install_pinned` (0.34.0+; installs the CLI version the gem release was tested against, `CLIInstaller::PINNED_CLI_VERSION`) or `CLIInstaller.install(version: 'x.y.z')` for a concrete pin of your own (`'stable'`, the default, and `'latest'` are floating dist-tags that re-resolve on every install). Downloads into `vendor/claude/`; discovery prefers it over `PATH` — since 0.30.0. - If the CLI is installed in a non-standard path, set `ClaudeAgentSDK::ClaudeAgentOptions#cli_path` (see `references/options.md`) or the `CLAUDE_CLI_PATH` environment variable. +- `CLAUDE_CLI_PATH` is only the first place the SDK looks. A value that does not name an executable file (a typo, a binary not vendored yet) is skipped without a warning, and discovery continues with the vendored binary, `PATH` and the common install locations. If a different CLI than expected starts, check that path first. An explicit `cli_path:` is used as given. ## Session APIs fail in a container without a home directory diff --git a/skills/references/options.md b/skills/references/options.md index 582bef5f..3325514a 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -10,13 +10,13 @@ Set defaults once, then override only when needed per call. ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', - permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') } + permission_mode: 'bypassPermissions' } end ``` Notes: +- Credentials need no option. The CLI process inherits your process's environment, so `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) set there already reaches it. Do not copy it into `env` with `ENV.fetch` in an initializer: every boot without the key (asset precompilation in a Docker build, `db:migrate` in CI) would raise `KeyError`. - `ClaudeAgentOptions.new(...)` still overrides defaults you pass explicitly. - Hash options like `env` and `mcp_servers` merge with configured defaults. - Assignment stores a frozen deep copy (containers, typed values and Strings included): change defaults by assigning a new Hash. In-place mutation of `config.default_options` raises `FrozenError`, and later changes to the Hash, typed objects or Strings you passed have no effect. Sessions get mutable copies of the containers and typed values but keep the snapshot's frozen Strings, so reassign `options.model = ...` rather than `options.model << ...`. diff --git a/skills/references/rails.md b/skills/references/rails.md index a6873f50..ab55fc1d 100644 --- a/skills/references/rails.md +++ b/skills/references/rails.md @@ -21,7 +21,6 @@ ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') }, # AR connections go back to the pool after each callback. Never a bare # `->(inv) { Rails.application.executor.wrap { inv.call } }`: that deadlocks # with development code reloading in the default :thread scheduling. @@ -30,6 +29,8 @@ ClaudeAgentSDK.configure do |config| end ``` +The API key needs no entry here: the CLI process inherits the app's environment, so `ANTHROPIC_API_KEY` set for the app already reaches it. An `ENV.fetch('ANTHROPIC_API_KEY')` in the initializer would make every boot without the key (asset precompilation, `db:migrate` in CI) raise `KeyError`. + ## Stream chunks to the frontend (ActionCable) Run the agent in an `ActiveJob`, broadcast assistant text as it arrives, and finalize on `ResultMessage`. diff --git a/skills/references/troubleshooting.md b/skills/references/troubleshooting.md index 05458a8d..b8f44738 100644 --- a/skills/references/troubleshooting.md +++ b/skills/references/troubleshooting.md @@ -8,6 +8,7 @@ Symptoms: Fix: - Install Claude Code CLI (Node.js required), or vendor a pinned binary with `ClaudeAgentSDK::CLIInstaller.install_pinned` (0.34.0+; installs the CLI version the gem release was tested against, `CLIInstaller::PINNED_CLI_VERSION`) or `CLIInstaller.install(version: 'x.y.z')` for a concrete pin of your own (`'stable'`, the default, and `'latest'` are floating dist-tags that re-resolve on every install). Downloads into `vendor/claude/`; discovery prefers it over `PATH` — since 0.30.0. - If the CLI is installed in a non-standard path, set `ClaudeAgentSDK::ClaudeAgentOptions#cli_path` (see `references/options.md`) or the `CLAUDE_CLI_PATH` environment variable. +- `CLAUDE_CLI_PATH` is only the first place the SDK looks. A value that does not name an executable file (a typo, a binary not vendored yet) is skipped without a warning, and discovery continues with the vendored binary, `PATH` and the common install locations. If a different CLI than expected starts, check that path first. An explicit `cli_path:` is used as given. ## Session APIs fail in a container without a home directory From 00c857717194467ee79e1d8ad52153e191544f17 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:27:36 +0800 Subject: [PATCH 123/194] docs/errors.md: a rejected control request raises StandardError The error reference called ClaudeSDKError the "base error for all SDK errors", and readers rescue it around Client control methods. One failure is outside that hierarchy today: when the CLI answers a control request with an error (reconnect_mcp_server / toggle_mcp_server with an unknown server, rewind_files without checkpointing, set_permission_mode with a mode it rejects), the Client method raises an exception whose class is exactly StandardError, with the CLI's text as its message. `rescue ClaudeAgentSDK::ClaudeSDKError` does not catch it. A new section, "Errors from Client Control Methods", lists the three ways a control method fails (not connected or connection lost: CLIConnectionError; no answer: ControlRequestTimeoutError; refused by the CLI: StandardError) with a rescue example, and the ClaudeSDKError entries point at it. Documentation of current behavior; no code change. Exploration finding: U-P10 (documentation half) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/errors.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index d8c04996..51760bc6 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -87,6 +87,30 @@ initial handshake that was still in flight when the CLI exited. Match on `Resume rejected by --resume-drops-turn:` in the message and treat it as deterministic: clear the fork target and resume plainly rather than retrying. +## Errors from Client Control Methods + +`Client#interrupt`, `#set_model`, `#set_permission_mode`, `#rewind_files`, `#reconnect_mcp_server`, `#toggle_mcp_server`, `#stop_task` and the other control methods (the Ruby-style `model=` and `permission_mode=` included) send a request to the CLI and wait for its answer. They can fail in three ways: + +| What happened | Raised | +|---------------|--------| +| The client is not connected, or the connection ended while the request was waiting | `CLIConnectionError` | +| The CLI did not answer in time | `ControlRequestTimeoutError` | +| The CLI answered with an error: an MCP server name it does not know, a rewind without `enable_file_checkpointing`, a permission mode it does not accept | a plain `StandardError` whose message is the CLI's text | + +The third is **not** a `ClaudeSDKError`, so `rescue ClaudeAgentSDK::ClaudeSDKError` does not catch it: + +```ruby +begin + client.reconnect_mcp_server('my-server') +rescue ClaudeAgentSDK::ClaudeSDKError => e + puts "Connection lost or timed out: #{e.message}" +rescue StandardError => e + puts "The CLI refused: #{e.message}" # e.g. "Server not found: my-server" +end +``` + +Rescue `StandardError` for it, and do not test for the exact class. + ## Configuring Timeout The control request timeout defaults to **1200 seconds** (20 minutes) to accommodate long-running agent sessions. Override it via environment variable: @@ -98,7 +122,7 @@ export CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS=300 # 5 minutes ## Error Type Reference ```ruby -# Base exception class for all SDK errors +# Base class of the SDK's error classes (all the ones below) class ClaudeSDKError < StandardError; end # Raised when connection to Claude Code fails @@ -160,7 +184,7 @@ end | Error | Description | |-------|-------------| -| `ClaudeSDKError` | Base error for all SDK errors | +| `ClaudeSDKError` | Base class of every error class in this table. One failure is not a `ClaudeSDKError`: a control request the CLI rejects, see [Errors from Client Control Methods](#errors-from-client-control-methods) | | `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect), and `ClaudeAgentSDK.ask` when the stream ends without a `ResultMessage` | | `ControlRequestTimeoutError` | Control protocol timeout (configurable via env var) | | `CLINotFoundError` | Claude Code not installed | From 52bd0f04e7587a7f3f3b7e9a3aeb794db830d09e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:27:49 +0800 Subject: [PATCH 124/194] README: correct the Python column of the comparison table Three Python cells described the official Python SDK (0.2.162) wrongly: - "Custom transport (pluggable I/O)" said "-". Python accepts transport= on query() and ClaudeSDKClient and exports Transport. Now a check mark. - "Hooks (all 27 events)" was an unqualified check mark. Python's HookEvent type has ten members; other event names are forwarded unvalidated. Now "10 typed, the rest by name". - "Bare mode (--bare)" was an unqualified check mark. Python has no bare option; the flag is reachable through extra_args. Now "via extra_args". The hooks and bare cells are qualified by API shape rather than turned into "unsupported": both capabilities work in Python through its escape hatches. The TypeScript column was not re-checked. Exploration finding: G-P11 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 540a44b5..b2f0662e 100644 --- a/README.md +++ b/README.md @@ -224,18 +224,18 @@ All three SDKs drive the same CLI over the same protocol, so capabilities line u | Bidirectional `Client` | ✅ | ✅ | ✅ | | Streaming input | `AsyncIterable` | `AsyncIterable` | `Enumerator` | | Custom tools (SDK MCP servers) | `tool()` | `@tool` decorator | `create_tool` block | -| Hooks (all 27 events) | ✅ | ✅ | ✅ | +| Hooks (all 27 events) | ✅ | 10 typed, the rest by name | ✅ | | Permission callbacks | ✅ | ✅ | ✅ | | Structured output | ✅ | ✅ | ✅ | | All 28 message types | ✅ | partial | ✅ | | [Sandbox](https://github.com/anthropic-experimental/sandbox-runtime) settings | ✅ | partial | ✅ | -| Bare mode (`--bare`) | ✅ | ✅ | ✅ | +| Bare mode (`--bare`) | ✅ | via `extra_args` | ✅ | | File checkpointing & rewind | ✅ | ✅ | ✅ | | Session browsing & mutations | ✅ | ✅ | ✅ | | Programmatic subagents | ✅ | ✅ | ✅ | | CLI binary | bundled | bundled | vendored on demand (`CLIInstaller`) | | Observability (OTel / Langfuse) | via [Arize](https://github.com/Arize-ai/openinference) | — | ✅ built-in | -| Custom transport (pluggable I/O) | — | — | ✅ | +| Custom transport (pluggable I/O) | — | ✅ | ✅ | | Rails integration | — | — | ✅ | Types are plain Ruby classes with `attr_accessor` and keyword arguments, mirroring the field names of the TypeScript Zod schemas and Python dataclasses; there is no runtime type checking. From 1667f3b6a0eee68f7799232726f61c3666cae436 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:29:42 +0800 Subject: [PATCH 125/194] README: how the CLI authenticates and what a missing login looks like Prerequisites listed Ruby and the CLI, and nothing in the README said the CLI needs credentials. Following it on a machine where Claude Code was never logged in (a container, CI) ends in "Not logged in - Please run /login": a slash command an SDK host cannot run. Prerequisites now names the three ways the CLI authenticates (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, a stored login), and a short Authentication section says where to set them and what the failure looks like: an AssistantMessage with error == 'authentication_failed', an error ResultMessage, and from query() / ask a ResultError with terminal_reason == 'api_error' (a Client session stays open). A rejected key ends the same way with api_error_status 401, after the CLI's retries. Both cases were run against CLI 2.1.287 with a throwaway config directory; neither reaches a model (total_cost_usd 0). Documentation only. Exploration finding: G-P5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index b2f0662e..14913754 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Then `bundle install`, or install directly with `gem install claude-agent-sdk`. **Prerequisites** - Ruby 3.2 or newer +- Credentials for Claude Code: `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, or a login the CLI has already stored (see [Authentication](#authentication)) - Claude Code CLI 2.0.0 or newer, either installed globally (`npm install -g @anthropic-ai/claude-code`) or vendored with `CLIInstaller`: ```ruby @@ -48,6 +49,16 @@ ClaudeAgentSDK::CLIInstaller.install_pinned # => "/app/vendor/claude/claude" `install_pinned` installs `CLIInstaller::PINNED_CLI_VERSION`, so upgrading the gem carries the CLI forward with it; `CLIInstaller.install(version: 'x.y.z')` pins a version of your own. The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order. +### Authentication + +The SDK holds no credentials of its own. The `claude` process it starts authenticates the way Claude Code does, with one of: + +- `ANTHROPIC_API_KEY`, in the environment of your Ruby process (the CLI inherits it) or per session with `ClaudeAgentOptions.new(env: { 'ANTHROPIC_API_KEY' => key })` +- `CLAUDE_CODE_OAUTH_TOKEN`, a long-lived token for a Claude subscription (`claude setup-token` creates one), set the same way +- a login the CLI has already stored for the user your process runs as (`claude auth login`) + +A machine with none of them, such as a fresh container or a CI runner, does not fail at startup. The first prompt comes back as an `AssistantMessage` whose `error` is `'authentication_failed'` (its text is "Not logged in · Please run /login", a command an SDK host cannot run), followed by a `ResultMessage` with `is_error` set. `query()` and `ask` then raise `ResultError` with `terminal_reason == 'api_error'`; a `Client` session stays open, so check the result's `is_error` there. A key or token the API rejects ends the same way, with `api_error_status` 401, but only after the CLI has retried: watch for `APIRetryMessage` (ten of them over about three minutes when tested) rather than waiting for the error. [docs/errors.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/errors.md) shows how to handle both. + ### Rails in a minute ```bash From 4ad59b5bd0afd349fe1e2e870ae091138e7a43c3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:35:59 +0800 Subject: [PATCH 126/194] Docs: four things options do not do, each checked against the CLI Notes that users of the official SDKs keep asking for upstream, verified against Claude Code 2.1.287 before being written down: - Sandbox: `enabled: true` does not make the sandbox a requirement. Per the CLI's settings schema, when the sandbox cannot start a warning is shown and commands run unsandboxed unless failIfUnavailable is set. docs/configuration.md quotes that text and shows `fail_if_unavailable: true`. The SDK's default is unchanged. (The fallback itself could not be triggered on the test host, where the sandbox is always available; the page says so.) - MCP: `setting_sources: []` does not stop the MCP servers connected to the logged-in claude.ai account; `strict_mcp_config: true` does. A handshake listed 7 `claudeai-proxy` servers with the former and none with the latter. - `Client#query(prompt, session_id:)` does not start a separate conversation: three queries with different ids came back under one CLI session id and one transcript, each prompt chained to the previous turn. - `skills:` with an explicit `tools:` list needs 'Skill' in that list: with `tools: ['Read'], skills: 'all'` the session's tools are ["Read"]. Documentation only. Exploration finding: U-S7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/client.md | 2 ++ docs/configuration.md | 23 ++++++++++++++++++++++- docs/options.md | 2 ++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/client.md b/docs/client.md index 0eb3a906..9497d419 100644 --- a/docs/client.md +++ b/docs/client.md @@ -29,6 +29,8 @@ end Called outside a reactor, `break` inside the `Client.open` block raises `LocalJumpError` (the client still disconnects), so return a value from the block instead. `break` inside `receive_response` / `receive_messages` is fine. It stops the iteration. +Every `client.query` on one client continues the same conversation. The `session_id:` keyword of `Client#query` does not change that: it is a label on the message the SDK sends, and the CLI keeps a single session per client. Every `ResultMessage#session_id` is the CLI's own id, whatever you passed, and all the turns land in one transcript. For separate contexts, one per user for instance, use one `Client` (or one `query()`) each. + If your code already runs inside an `Async` reactor and you want to manage the connection yourself, call `connect` and `disconnect` directly: ```ruby diff --git a/docs/configuration.md b/docs/configuration.md index 1e7fcb66..cdac142c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -195,6 +195,12 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(skills: %w[pdf docx]) Semantics: `nil` (default) leaves CLI defaults untouched; `[]` hides every skill from the listing; an Array adds `Skill(name)` allow-rules per entry (use `plugin:skill` for plugin-qualified names). An explicitly set `setting_sources` (including `[]`) is never overridden. This is a context filter, not a sandbox — skill files remain readable on disk. +If you also give `tools` as a list of names, put `'Skill'` in it. `skills` adds the `Skill` tool to the *allowed* tools, while `tools` decides which tools the session has at all: with `tools: ['Read'], skills: 'all'` there is no `Skill` tool, and no skill can run. Leaving `tools` unset keeps it. + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: %w[Read Skill], skills: %w[pdf docx]) +``` + ## Sandbox Settings Configure [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) restrictions (network policy, filesystem access) with the `sandbox` option. The SDK sends it to the CLI as the `sandbox` key of the `--settings` argument, merged into your `settings:` when you pass both; there is no separate sandbox flag. The CLI handles OS-level process isolation using `srt`. @@ -212,6 +218,18 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +`enabled: true` asks for a sandbox. It does not make one a requirement. When the sandbox cannot start on the host (missing dependencies, an unsupported platform), the CLI carries on without it. Its settings schema describes the setting that decides this, `failIfUnavailable`, as follows (CLI 2.1.287): + +> Exit with an error at startup if sandbox.enabled is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. + +The SDK sends your sandbox settings as you wrote them and does not add this one. If commands must never run unsandboxed, set it yourself: + +```ruby +sandbox = ClaudeAgentSDK::SandboxSettings.new(enabled: true, fail_if_unavailable: true) +``` + +Without it, the only sign is the CLI's warning on stderr ("Sandbox disabled: ... Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced."), and stderr reaches your code only through the `stderr` (or `debug_stderr`) option. This is the CLI's own description of its behavior: the fallback has not been reproduced in this SDK's testing, where the sandbox was always available. + See [examples/sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb). ## Bare Mode @@ -369,7 +387,10 @@ end login, so give the CLI `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` (in the process environment or in `env`). - **`setting_sources: []`** keeps the user, project and local settings and - `CLAUDE.md` files out of a session. The auto-memory index still loads. + `CLAUDE.md` files out of a session. The auto-memory index still loads. So + do the MCP servers connected to the claude.ai account the CLI is logged in + with: `strict_mcp_config: true` is what limits a session to the servers in + `mcp_servers` (see the [options reference](options.md#strict_mcp_config)). "One working directory per tenant" is therefore not enough on its own. Use the switch. diff --git a/docs/options.md b/docs/options.md index baf54f9b..74053e4f 100644 --- a/docs/options.md +++ b/docs/options.md @@ -94,6 +94,8 @@ Guides: [Custom Tools (SDK MCP Servers)](mcp-servers.md), [Subagent capabilities `true` makes the CLI use only the servers in `mcp_servers` and ignore every other MCP configuration (the CLI's own description of `--strict-mcp-config`: "Only use MCP servers from --mcp-config, ignoring all other MCP configurations"). +"Every other" includes the MCP servers connected to the claude.ai account the CLI is logged in with. A session loads those by default, and `setting_sources: []` does not keep them out, because they do not come from a settings file. `client.mcp_status` shows them: their `:config` has `type: 'claudeai-proxy'`. A host that runs sessions for other people under its own login should set `strict_mcp_config: true`. + ## Settings and context Guides: [Bare Mode](configuration.md#bare-mode), [Verbatim Prompts](configuration.md#verbatim-prompts), [Session Isolation](configuration.md#session-isolation). From 2a70758fd79a72864108fbf5b72ee889b18193b2 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:38:06 +0800 Subject: [PATCH 127/194] Docs: use Client.open in the examples that connected by hand Five examples showed `client.connect ... client.disconnect` with no `ensure`. With that shape an exception between the two calls does not crash the program, it hangs it: the user's task fails, the SDK's read task keeps the reactor alive, the CLI idles, and `Async { }.wait` never returns. They now use Client.open, which disconnects when its block exits, also on an exception: the first example of docs/mcp-servers.md, the hook and permission-callback examples of docs/hooks-and-permissions.md (only the connect/disconnect lines), "Advanced Features" in docs/client.md, and the YARD example on Client (comment-only change in lib/). Checked with a scripted transport: the old shape is still blocked after 20 s with the transport open; Client.open raises the block's error and closes the transport, inside an Async block and outside one. Documentation only. Exploration finding: A-S3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/client.md | 9 ++------- docs/hooks-and-permissions.md | 18 ++++++++---------- docs/mcp-servers.md | 8 ++------ lib/claude_agent_sdk.rb | 9 +++------ 4 files changed, 15 insertions(+), 29 deletions(-) diff --git a/docs/client.md b/docs/client.md index 9497d419..a952a0fb 100644 --- a/docs/client.md +++ b/docs/client.md @@ -47,10 +47,7 @@ end ## Advanced Features ```ruby -Async do - client = ClaudeAgentSDK::Client.new - client.connect - +ClaudeAgentSDK::Client.open do |client| client.interrupt # Send interrupt signal client.permission_mode = 'acceptEdits' # Change permission mode mid-conversation client.model = 'claude-sonnet-5' # Switch model mid-conversation (nil = default) @@ -64,9 +61,7 @@ Async do client.background_tasks(tool_use_id: 'toolu_01') # Only the task spawned by that tool_use block # => { backgrounded: true } | { backgrounded: false } (definitive miss) # '' or a non-String raises ArgumentError; nil is the all-tasks form - - client.disconnect -end.wait +end ``` The Ruby-style names above sit next to the Python SDK's spellings, and both work, so code ported from the Python docs runs unchanged: diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index 20e4034b..5f3f0931 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -69,11 +69,10 @@ Async do } ) - client = ClaudeAgentSDK::Client.new(options: options) - client.connect - client.query("Run the bash command: ./foo.sh --help") - client.receive_response { |msg| puts msg } - client.disconnect + ClaudeAgentSDK::Client.open(options: options) do |client| + client.query("Run the bash command: ./foo.sh --help") + client.receive_response { |msg| puts msg } + end end.wait ``` @@ -123,11 +122,10 @@ Async do can_use_tool: permission_callback ) - client = ClaudeAgentSDK::Client.new(options: options) - client.connect - client.query("Create a file called test.txt with content 'Hello'") - client.receive_response { |msg| puts msg } - client.disconnect + ClaudeAgentSDK::Client.open(options: options) do |client| + client.query("Create a file called test.txt with content 'Hello'") + client.receive_response { |msg| puts msg } + end end.wait ``` diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 296cbb6b..b423b3a6 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -8,7 +8,6 @@ A **custom tool** is a Ruby proc/lambda that you can offer to Claude, for Claude ```ruby require 'claude_agent_sdk' -require 'async' greet_tool = ClaudeAgentSDK.create_tool( 'greet', 'Greet a user', { name: :string }, @@ -28,13 +27,10 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( allowed_tools: ['mcp__tools__greet'] ) -Async do - client = ClaudeAgentSDK::Client.new(options: options) - client.connect +ClaudeAgentSDK::Client.open(options: options) do |client| client.query("Greet Alice") client.receive_response { |msg| puts msg } - client.disconnect -end.wait +end ``` ## Handler Return Values diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 1eed0e3a..f3099166 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -903,16 +903,13 @@ def self.ask(prompt, options: nil, transport: nil, &block) # The Client class always uses streaming mode for bidirectional communication. # # @example Basic usage - # Async do - # client = ClaudeAgentSDK::Client.new - # client.connect # No arguments needed - automatically uses streaming mode - # + # # Client.open connects, yields the client and always disconnects, + # # also when the block raises. + # ClaudeAgentSDK::Client.open do |client| # client.query("What is the capital of France?") # client.receive_response do |msg| # puts msg if msg.is_a?(ClaudeAgentSDK::AssistantMessage) # end - # - # client.disconnect # end # # @example With hooks From d42a40f6b598b0b0219380988d1da481660c0235 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:38:44 +0800 Subject: [PATCH 128/194] Skill: list docs/options.md and session isolation in the docs map The bundled skill enumerates the gem's docs pages (SKILL.md, "Where To Look", and the table in references/usage-map.md). With docs/options.md added and a Session Isolation section in docs/configuration.md, both enumerations were one entry short. Both copies of the skill now name them. Exploration finding: G-P9 (follow-up) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md | 2 +- .../skills/claude-agent-ruby/references/usage-map.md | 3 ++- skills/SKILL.md | 2 +- skills/references/usage-map.md | 3 ++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md index a67fdd82..e57344ff 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md @@ -94,7 +94,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ## Where To Look For Exact Details - Locate the gem: `bundle show claude-agent-sdk` - Read `<gem_path>/README.md` for the overview, install, and minimal API examples -- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, file checkpointing), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) +- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) - Inspect `<gem_path>/lib/claude_agent_sdk/types/` for all types (one file per area: messages, hooks, options, ...) - Inspect `<gem_path>/lib/claude_agent_sdk/message_parser.rb` for message parsing - Inspect `<gem_path>/lib/claude_agent_sdk/sessions.rb` for session browsing diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md index b0aac4b7..dabcc6d7 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md @@ -18,7 +18,8 @@ For everything else, open the matching `docs/` subpage: | Bidirectional `Client` advanced features (`interrupt`, mid-session model/permission switching, MCP status); custom transports (E2B example, transport interface) | `docs/client.md` | | SDK MCP servers — tool definitions, JSON schema normalization, resources, prompts, mixed SDK + external server config | `docs/mcp-servers.md` | | All 27 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | -| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, file checkpointing & rewind | `docs/configuration.md` | +| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, session isolation (the CLI's auto-memory and how to turn it off), file checkpointing & rewind | `docs/configuration.md` | +| Every `ClaudeAgentOptions` attribute with its type, default and the CLI flag, `initialize` field or environment variable it becomes; `extra_args`; the environment variables the SDK reads | `docs/options.md` | | Session listing, reading messages, renaming, tagging, deleting, forking, resume-at-message | `docs/sessions.md` | | OpenTelemetry observer setup, Langfuse export, span attributes, W3C trace-context propagation to the CLI, custom `Observer` module | `docs/observability.md` | | Rails integration — fiber-safety guarantee, ActionCable streaming, session resumption, background jobs, HTTP MCP servers, observability initializer | `docs/rails.md` | diff --git a/skills/SKILL.md b/skills/SKILL.md index a67fdd82..e57344ff 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -94,7 +94,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ## Where To Look For Exact Details - Locate the gem: `bundle show claude-agent-sdk` - Read `<gem_path>/README.md` for the overview, install, and minimal API examples -- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, file checkpointing), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) +- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) - Inspect `<gem_path>/lib/claude_agent_sdk/types/` for all types (one file per area: messages, hooks, options, ...) - Inspect `<gem_path>/lib/claude_agent_sdk/message_parser.rb` for message parsing - Inspect `<gem_path>/lib/claude_agent_sdk/sessions.rb` for session browsing diff --git a/skills/references/usage-map.md b/skills/references/usage-map.md index b0aac4b7..dabcc6d7 100644 --- a/skills/references/usage-map.md +++ b/skills/references/usage-map.md @@ -18,7 +18,8 @@ For everything else, open the matching `docs/` subpage: | Bidirectional `Client` advanced features (`interrupt`, mid-session model/permission switching, MCP status); custom transports (E2B example, transport interface) | `docs/client.md` | | SDK MCP servers — tool definitions, JSON schema normalization, resources, prompts, mixed SDK + external server config | `docs/mcp-servers.md` | | All 27 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | -| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, file checkpointing & rewind | `docs/configuration.md` | +| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, session isolation (the CLI's auto-memory and how to turn it off), file checkpointing & rewind | `docs/configuration.md` | +| Every `ClaudeAgentOptions` attribute with its type, default and the CLI flag, `initialize` field or environment variable it becomes; `extra_args`; the environment variables the SDK reads | `docs/options.md` | | Session listing, reading messages, renaming, tagging, deleting, forking, resume-at-message | `docs/sessions.md` | | OpenTelemetry observer setup, Langfuse export, span attributes, W3C trace-context propagation to the CLI, custom `Observer` module | `docs/observability.md` | | Rails integration — fiber-safety guarantee, ActionCable streaming, session resumption, background jobs, HTTP MCP servers, observability initializer | `docs/rails.md` | From f28dd19584bd077e1fb93935da7599a3f6ed8a40 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:55:59 +0800 Subject: [PATCH 129/194] docs/types.md: InitMessage arrives at the start of every turn The table added earlier on this branch said InitMessage is sent at "session start, and again after /clear" (the wording of the class comment). A Client session with three queries receives three of them, one per turn, all with the same session id. The row now says so. Exploration finding: G-P8 (follow-up) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/types.md b/docs/types.md index dab6a849..46ecbf2b 100644 --- a/docs/types.md +++ b/docs/types.md @@ -209,7 +209,7 @@ Symbol-keyed Hash; the last six are message types of their own. | Class | Wire type | Attributes | Notes | |-------|-----------|------------|-------| -| `InitMessage` | `system` / `init` | `uuid`, `session_id`, `model`, `cwd`, `tools`, `mcp_servers`, `agents`, `skills`, `plugins`, `slash_commands`, `permission_mode`, `claude_code_version`, `api_key_source`, `betas`, `output_style`, `fast_mode_state` | Session start, and again after `/clear` | +| `InitMessage` | `system` / `init` | `uuid`, `session_id`, `model`, `cwd`, `tools`, `mcp_servers`, `agents`, `skills`, `plugins`, `slash_commands`, `permission_mode`, `claude_code_version`, `api_key_source`, `betas`, `output_style`, `fast_mode_state` | Start of every turn, with the session as the CLI sees it (so a multi-query `Client` session receives one per query) | | `CompactBoundaryMessage` | `system` / `compact_boundary` | `uuid`, `session_id`, `compact_metadata` (a `CompactMetadata`: `pre_tokens`, `post_tokens`, `trigger`, `preserved_segment`, `custom_instructions`) | Context compaction completed | | `StatusMessage` | `system` / `status` | `uuid`, `session_id`, `status`, `permission_mode` | Compacting status, permission mode changes | | `APIRetryMessage` | `system` / `api_retry` | `uuid`, `session_id`, `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error` | The CLI is retrying an API request | From f33a96ba06a4cf80430bfaea336eb16228de91a3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:01:00 +0800 Subject: [PATCH 130/194] docs/client.md: can_use_tool over query(transport:) needs the stdio flag The paragraph added earlier on this branch said that with a transport of your own, can_use_tool "works through Client only". That overstated it. What query() cannot do is add --permission-prompt-tool stdio to a transport the caller already built; a transport built from options.dup_with(permission_prompt_tool_name: 'stdio'), with the original options still passed to query, gets the flag, and the callback is asked. The bullet now says that, and that passing the copy to query raises ArgumentError. Exploration finding: G-P9 (follow-up) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client.md b/docs/client.md index a952a0fb..ede54813 100644 --- a/docs/client.md +++ b/docs/client.md @@ -151,7 +151,7 @@ result = ClaudeAgentSDK.ask('Hello', options: options, transport: MyTransport.ne - The SDK connects the transport, runs the query over it and closes it, so one instance serves one query. `close` is called even when `connect` raised, and it must be idempotent. - `options` still drives everything the SDK does on its own side: hooks, SDK MCP servers, agents, observers, callback scheduling. The command line and the environment are whatever your transport gives the CLI. `query` does not rebuild them from `options`, so build the transport from the same options. -- With a transport of your own, `can_use_tool` works through `Client` only. The CLI asks the callback when it is started with `--permission-prompt-tool stdio`; the options a `transport_class:` transport receives already carry that (`permission_prompt_tool_name: 'stdio'`), and a transport you built yourself does not get it from `query`. +- `can_use_tool` needs one more step. The CLI asks the callback only when it is started with `--permission-prompt-tool stdio`. The SDK adds that to the options of a transport it builds (a `transport_class:` transport receives options with `permission_prompt_tool_name: 'stdio'`), but `query` cannot add it to a transport you built. Build that transport from `options.dup_with(permission_prompt_tool_name: 'stdio')` and still pass the original `options` to `query`; passing the copy raises `ArgumentError`, because `can_use_tool` and `permission_prompt_tool_name` cannot be combined there. - Anything that does not respond to `connect` raises `ArgumentError`. Resuming from a `session_store` is not available over a custom transport, through `query(transport:)` or `transport_class:`: the SDK prepares the transcript only for a CLI it starts with `SubprocessCLITransport` (or a subclass of it). From 3bed43a34e87f2adc30940ffe89ffe137ea4db5e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:37:19 +0800 Subject: [PATCH 131/194] Link guard: no relative link to an unpackaged file, without exceptions The guard added in dff19ff required exactly one known violation: docs/subagents.md linked relatively to examples/subagent_status_example.rb, a file the gem does not ship, so the link was dead in the installed gem. That made the guard accept the broken link and fail on its repair. The link is now absolute (github.com/.../blob/main/examples/...), the exception list is gone, and the example asserts that no packaged Markdown file has a relative link to an unpackaged file. Review round 1, B4. Exploration finding: G-P3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/subagents.md | 2 +- spec/unit/docs_links_spec.rb | 14 +------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/docs/subagents.md b/docs/subagents.md index 426cc484..1fec2737 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -158,7 +158,7 @@ See [hook fields and permission cancellation](hooks-and-permissions.md). ## Minimal example -[`examples/subagent_status_example.rb`](../examples/subagent_status_example.rb) +[`examples/subagent_status_example.rb`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/subagent_status_example.rb) defines a tool-free reviewer, registers lifecycle hooks, reads metadata, and prints task events and forwarded child text without building a status model: diff --git a/spec/unit/docs_links_spec.rb b/spec/unit/docs_links_spec.rb index 1377bc40..ef05fcf9 100644 --- a/spec/unit/docs_links_spec.rb +++ b/spec/unit/docs_links_spec.rb @@ -14,18 +14,6 @@ let(:packaged) { Bundler.load_gemspec_uncached(File.join(root, 'claude-agent-sdk.gemspec')).files } let(:markdown_files) { packaged.grep(/\.md\z/) } - # Relative links that point at a file the gem does not ship, in files this - # change could not touch. Each entry is [file, link target]. An entry that - # no longer matches a link fails the example below, so the list cannot - # outlive its fix. - # - # docs/subagents.md: make the link absolute - # (https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/subagent_status_example.rb) - # and delete the entry. - let(:known_unpackaged_targets) do - [['docs/subagents.md', '../examples/subagent_status_example.rb']] - end - # Inline links and images, and reference definitions, outside fenced code, # each as { file:, line:, target: }. def links_in(file) @@ -91,7 +79,7 @@ def relative_links it 'points every relative link at a file that ships in the gem' do unpackaged = relative_links.reject { |link| packaged.include?(resolve(link)) } - expect(unpackaged.map { |link| link.values_at(:file, :target) }).to match_array(known_unpackaged_targets) + expect(unpackaged.map { |link| "#{link[:file]}:#{link[:line]} #{link[:target]}" }).to eq([]) end it 'points every anchor at a heading that exists' do From e19bfb7a7c88c51584d60f6cf8c743b3444d0f3a Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:37:40 +0800 Subject: [PATCH 132/194] Session isolation: a custom transport must pass both switches through docs/configuration.md said a custom transport has to forward `env`, then that `settings` "travels in the --settings argument that CommandBuilder builds", as if the SDK always supplied it. A transport that builds its own command line without CommandBuilder never passes `autoMemoryEnabled: false` to its CLI either, so the session is not isolated although the options say it is. The bullet now keeps the two cases apart: the default SubprocessCLITransport delivers `env` and `--settings`; a custom transport delivers neither unless it passes them through (CommandBuilder supplies --settings, a hand-built command line has to add it). Review round 1, B1. Exploration finding: O2-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/configuration.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index cdac142c..5b99cf97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -373,9 +373,15 @@ end - `bare: true` turns auto-memory off as well, but bare mode never reads an OAuth login or the keychain: it authenticates with `ANTHROPIC_API_KEY` (or an `apiKeyHelper` setting) only. See [Bare Mode](#bare-mode). -- With a [custom transport](client.md#custom-transport), `env` reaches the CLI - only if the transport passes it on. `settings` travels in the `--settings` - argument that `CommandBuilder` builds. +- Both switches reach the CLI that `query()` and `Client` start themselves, + through the default `SubprocessCLITransport`: it puts `env` into the CLI's + environment and `settings` on its command line (`--settings`). +- A [custom transport](client.md#custom-transport) starts the CLI its own way, + so neither switch reaches that CLI unless the transport passes it through: + `env` into the environment it gives the CLI, `settings` onto the command + line (a transport that builds its command line with `CommandBuilder` gets + `--settings` from it; one that does not, has to add it). Until then the + session is not isolated. ### What does not isolate sessions From 91135dbfb245ea68b0e19909a4fe00c022a27ce6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:38:46 +0800 Subject: [PATCH 133/194] docs/types.md: say when SystemMessage#data is not the whole frame The "System and progress messages" text said every attribute the CLI did not send reads nil, except RateLimitEvent#rate_limit_info, and that a system message's `data` is the whole frame. Neither holds in general: - SystemMessage#data is the frame's own `data` value when the frame has one (MessageParser.parse(type: 'system', subtype: 'status', data: { marker: 1 }).data == { marker: 1 }); only without that key is it the whole frame; - RateLimitEvent#data is always the whole event, even when the CLI sent no `data` key. The paragraph and the SystemMessage class block now state both. docs_reference_spec.rb gains a parser-backed example that pins the two behaviors and the sentences that describe them. Review round 1, B3. Exploration finding: G-P8 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/types.md | 15 +++++++++------ spec/unit/docs_reference_spec.rb | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/types.md b/docs/types.md index 46ecbf2b..bc1aa09b 100644 --- a/docs/types.md +++ b/docs/types.md @@ -144,7 +144,7 @@ System message with metadata. Task lifecycle events are typed subclasses. ```ruby class SystemMessage attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', 'task_updated', etc.) - :data # Hash + :data # Hash - the whole frame, or the frame's own `data` value when it has one (see below) end # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works) @@ -201,11 +201,14 @@ See [subagent capabilities](subagents.md) for the contracts behind these fields. ### System and progress messages -The remaining typed messages. An attribute the CLI did not send reads `nil` -(except `RateLimitEvent#rate_limit_info`, which is then an empty -`RateLimitInfo`). The first twelve are `SystemMessage` subclasses (wire `type` -is `system`), so they also have `subtype` and `data`, the whole frame as a -Symbol-keyed Hash; the last six are message types of their own. +The remaining typed messages. An attribute the CLI did not send reads `nil`, +with two exceptions on `RateLimitEvent`: `rate_limit_info` is then an empty +`RateLimitInfo`, and `RateLimitEvent#data` is always the whole event as a +Symbol-keyed Hash. The first twelve are `SystemMessage` subclasses (wire +`type` is `system`), so they also have `subtype` and `data`. Their `data` is +the whole frame as a Symbol-keyed Hash, unless the frame has a `data` key of +its own: then `data` is that key's value, and the frame is not kept. The last +six are message types of their own. | Class | Wire type | Attributes | Notes | |-------|-----------|------------|-------| diff --git a/spec/unit/docs_reference_spec.rb b/spec/unit/docs_reference_spec.rb index fad53351..f7dbe7b3 100644 --- a/spec/unit/docs_reference_spec.rb +++ b/spec/unit/docs_reference_spec.rb @@ -82,6 +82,21 @@ def documented_union(name) expect(without_row).to be_empty, "docs/errors.md has no table row for: #{without_row.join(', ')}" end + # docs/types.md: SystemMessage#data is the whole frame unless the frame has + # a `data` key of its own; RateLimitEvent#data is always the whole event. + it 'parses #data of system and rate-limit frames the way docs/types.md describes it' do + own_data = ClaudeAgentSDK::MessageParser.parse(type: 'system', subtype: 'status', status: 'compacting', + data: { marker: 1 }) + plain = ClaudeAgentSDK::MessageParser.parse(type: 'system', subtype: 'status', status: 'compacting') + rate_limit = ClaudeAgentSDK::MessageParser.parse(type: 'rate_limit_event', data: { marker: 1 }) + + expect(own_data.data).to eq(marker: 1) + expect(plain.data).to eq(type: 'system', subtype: 'status', status: 'compacting') + expect(rate_limit.data).to eq(type: 'rate_limit_event', data: { marker: 1 }) + expect(types_doc).to include('unless the frame has a `data` key of its own') + expect(types_doc).to include('`RateLimitEvent#data` is always the whole event') + end + # The example announces how many message types it handles; both the number # and the `when` branches are checked against the classes. it 'handles every typed message class in examples/message_types_example.rb, and counts them' do From 8d468d7c3dc3e9b63ade625e959827cf33317b87 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:39:03 +0800 Subject: [PATCH 134/194] Make the SystemMessage#data guard tolerate line wrapping The example added in 8467563 looked for the documented sentence in the raw Markdown, where it wraps across two lines, so it failed on the correct page; it was committed without its rspec run being checked. It now collapses whitespace before matching. Review round 1, B3 (follow-up). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- spec/unit/docs_reference_spec.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/spec/unit/docs_reference_spec.rb b/spec/unit/docs_reference_spec.rb index f7dbe7b3..afad140a 100644 --- a/spec/unit/docs_reference_spec.rb +++ b/spec/unit/docs_reference_spec.rb @@ -93,8 +93,9 @@ def documented_union(name) expect(own_data.data).to eq(marker: 1) expect(plain.data).to eq(type: 'system', subtype: 'status', status: 'compacting') expect(rate_limit.data).to eq(type: 'rate_limit_event', data: { marker: 1 }) - expect(types_doc).to include('unless the frame has a `data` key of its own') - expect(types_doc).to include('`RateLimitEvent#data` is always the whole event') + prose = types_doc.gsub(/\s+/, ' ') # the sentences may wrap anywhere + expect(prose).to include('unless the frame has a `data` key of its own') + expect(prose).to include('`RateLimitEvent#data` is always the whole event') end # The example announces how many message types it handles; both the number From 1fde393427faa90eb9136702565fcb02da1be1d7 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:59:15 +0800 Subject: [PATCH 135/194] docs/options.md: exact types and nil semantics, checked row by row The page gave callback_scheduling as `:thread | :inline`, but the constructor also accepts the Strings "thread" and "inline" (stored as their Symbols). It also said that a nil passed to .new applies the default; for the eight options whose default is false an explicit nil stays nil. The Type column is now the constructor's keyword type for every option, the eight Default cells are marked, and a new section tabulates a left-out option, an explicit nil and a value, with and without a configured default. The system_prompt row names its two initialize fields. The guard only checked that each option name occurs on the page, so a row with a made-up type, default and flag passed. It now parses the tables and checks every row: the type against the constructor signature in sig/, the default against a bare ClaudeAgentOptions.new, the marked rows against the options where an explicit nil differs, the flags (and literal flag values) against what CommandBuilder builds for sample values, the initialize fields against the request a Client sends, and the environment variables against the transport. Type aliases are checked against their definitions in sig/. Review round 1, B2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/options.md | 36 ++- spec/unit/docs_options_spec.rb | 396 ++++++++++++++++++++++++++++++++- 2 files changed, 413 insertions(+), 19 deletions(-) diff --git a/docs/options.md b/docs/options.md index 74053e4f..b2d6d940 100644 --- a/docs/options.md +++ b/docs/options.md @@ -13,10 +13,22 @@ ClaudeAgentSDK.query(prompt: 'Hello', options: options) { |message| puts message ## Reading the tables -- **Type** is the option's type in the gem's RBS signatures (`sig/claude_agent_sdk/types/options.rbs`). Every option also accepts `nil` in `.new`, which means "not set": the default applies. `bool` is `true` or `false`. A name that starts with an underscore is an interface, so any object with the right method fits: `call` for the callbacks, `puts` for `_Puts`, `append` and `load` for `_SessionStore`. -- **Default** is the value of a fresh `ClaudeAgentOptions.new` with no configured defaults. +- **Type** is what `.new` accepts for the option: the type of the constructor's keyword in the gem's RBS signatures (`sig/claude_agent_sdk/types/options.rbs`). Every option also accepts `nil`. `bool` is `true` or `false`. A name that starts with an underscore is an interface, so any object with the right method fits: `call` for the callbacks, `puts` for `_Puts`, `append` and `load` for `_SessionStore`. +- **Default** is what the option reads after a `ClaudeAgentOptions.new` that leaves it out, with no configured defaults. [Leaving an option out, and passing `nil`](#leaving-an-option-out-and-passing-nil) has the other cases. - **Sent to the CLI as** names the flag, the `initialize` field (`initialize.hooks` is the `hooks` field of that request) or the environment variable. "Nothing (SDK only)" marks an option that changes only what the SDK does in your process. +### Leaving an option out, and passing `nil` + +What an option reads after `.new` depends on what you pass for it, and on whether `ClaudeAgentSDK.configure` has set a default for it: + +| You pass | No default is configured for the option | A default is configured for it | +|----------|------------------------------------------|--------------------------------| +| nothing | the Default column | the configured value | +| `nil` | the Default column, except where it says "explicit `nil` stays `nil`" | the configured value | +| another value | that value | that value; a Hash is merged into a configured Hash | + +The exception is every option whose default is `false`. With no default configured for it, `ClaudeAgentOptions.new(fork_session: nil).fork_session` is `nil`, not `false`. The option still acts as `false`: it adds nothing to what the SDK sends to the CLI, and its predicate (`fork_session?`) returns `false`. Only code that compares the reader's value with `false` sees the difference. + Three types in the tables are aliases: | Alias | Stands for | @@ -31,7 +43,7 @@ Guides: [Structured Output](configuration.md#structured-output), [Thinking Confi | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| -| `system_prompt` | `system_prompt_config` | `nil` | `--system-prompt`, `--system-prompt-file` or `--append-system-prompt`, and two `initialize` fields. See [System prompt forms](#system-prompt-forms) | +| `system_prompt` | `system_prompt_config` | `nil` | `--system-prompt`, `--system-prompt-file` or `--append-system-prompt`, and the fields `initialize.excludeDynamicSections` and `initialize.systemPromptSnapshot`. See [System prompt forms](#system-prompt-forms) | | `model` | `String` | `nil` | `--model` | | `fallback_model` | `String` | `nil` | `--fallback-model` | | `advisor_model` | `String` | `nil` | `--advisor` | @@ -86,7 +98,7 @@ Guides: [Custom Tools (SDK MCP Servers)](mcp-servers.md), [Subagent capabilities | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| | `mcp_servers` | `Hash[String \| Symbol, mcp_server_config] \| String` | `{}` | `--mcp-config`: a Hash as `{"mcpServers": {...}}` JSON, a String (a file path or JSON) as given. An SDK server contributes only its `type` and `name`; its tools are served in your process over the control protocol | -| `strict_mcp_config` | `bool` | `false` | `--strict-mcp-config`. See [`strict_mcp_config`](#strict_mcp_config) | +| `strict_mcp_config` | `bool` | `false`; explicit `nil` stays `nil` | `--strict-mcp-config`. See [`strict_mcp_config`](#strict_mcp_config) | | `agents` | `Hash[String \| Symbol, AgentDefinition \| Hash[Symbol \| String, untyped]]` | `nil` | `initialize.agents` | | `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir <path>`, once per plugin | @@ -106,7 +118,7 @@ Guides: [Bare Mode](configuration.md#bare-mode), [Verbatim Prompts](configuratio | `setting_sources` | `Array[String]` | `nil` | `--setting-sources` (comma-joined). `[]` sends `--setting-sources ""`; `nil` sends no flag | | `add_dirs` | `Array[String \| Pathname]` | `[]` | `--add-dir`, once per directory | | `bare` | `bool` | `nil` | `--bare` | -| `verbatim_prompts` | `bool` | `false` | `client_composed: true` on every user message the SDK writes | +| `verbatim_prompts` | `bool` | `false`; explicit `nil` stays `nil` | `client_composed: true` on every user message the SDK writes | `setting_sources` takes entries of `ClaudeAgentSDK::SETTING_SOURCES` (`user`, `project`, `local`). With `nil`, the default, the CLI decides, and it loads the user's and the project's settings and `CLAUDE.md` files. `[]` loads none of the three sources. Neither value affects the auto-memory: see [Session Isolation](configuration.md#session-isolation). @@ -117,12 +129,12 @@ Guide: [Session Browsing & Mutations](sessions.md), which also covers `session_s | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| | `resume` | `String` | `nil` | `--resume=<session id>` | -| `continue_conversation` | `bool` | `false` | `--continue`. Combining it with `resume` raises `ArgumentError` | -| `fork_session` | `bool` | `false` | `--fork-session` | +| `continue_conversation` | `bool` | `false`; explicit `nil` stays `nil` | `--continue`. Combining it with `resume` raises `ArgumentError` | +| `fork_session` | `bool` | `false`; explicit `nil` stays `nil` | `--fork-session` | | `session_id` | `String` | `nil` | `--session-id=<uuid>` | | `resume_session_at` | `String` | `nil` | `--resume-session-at=<uuid>`. Without `resume` it raises `ArgumentError` | | `resume_drops_turn` | `String` | `nil` | `--resume-drops-turn=<uuid>` | -| `enable_file_checkpointing` | `bool` | `false` | `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true` in the CLI's environment | +| `enable_file_checkpointing` | `bool` | `false`; explicit `nil` stays `nil` | `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true` in the CLI's environment | | `session_store` | `_SessionStore` | `nil` | `--session-mirror`. The SDK appends the mirrored transcript to the store and can resume from it | | `session_store_flush` | `String \| Symbol` | `"batched"` | Nothing (SDK only): `'batched'` or `'eager'` | | `load_timeout_ms` | `Numeric` | `60000` | Nothing (SDK only): the limit, in milliseconds, for each store call while a resume loads the transcript | @@ -135,9 +147,9 @@ Guides: [Forwarding Subagent Text](configuration.md#forwarding-subagent-text), [ | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| -| `include_partial_messages` | `bool` | `false` | `--include-partial-messages`. See [`include_partial_messages`](#include_partial_messages) | -| `include_hook_events` | `bool` | `false` | `--include-hook-events`. See [`include_hook_events`](#include_hook_events) | -| `forward_subagent_text` | `bool` | `false` | `initialize.forwardSubagentText`, sent only when `true` | +| `include_partial_messages` | `bool` | `false`; explicit `nil` stays `nil` | `--include-partial-messages`. See [`include_partial_messages`](#include_partial_messages) | +| `include_hook_events` | `bool` | `false`; explicit `nil` stays `nil` | `--include-hook-events`. See [`include_hook_events`](#include_hook_events) | +| `forward_subagent_text` | `bool` | `false`; explicit `nil` stays `nil` | `initialize.forwardSubagentText`, sent only when `true` | | `agent_progress_summaries` | `bool` | `nil` | `initialize.agentProgressSummaries`, left out when `nil` | ### `include_partial_messages` @@ -190,7 +202,7 @@ Guides: [Observability](observability.md), [Rails Integration](rails.md). | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| | `observers` | `Array[untyped]` | `[]` | Nothing (SDK only): observer instances, or callables that return a fresh one per query or session | -| `callback_scheduling` | `:thread \| :inline` | `:thread` | Nothing (SDK only): `:thread` runs each callback on a plain thread, `:inline` on the reactor fiber | +| `callback_scheduling` | `:thread \| :inline \| "thread" \| "inline"` | `:thread` | Nothing (SDK only): `:thread` runs each callback on a plain thread, `:inline` on the reactor fiber. A String is stored as its Symbol (`callback_scheduling: 'inline'` reads back as `:inline`); anything else but `nil` raises `ArgumentError` | | `callback_wrapper` | `_CallbackWrapper` | `nil` | Nothing (SDK only): a callable wrapped around every callback dispatch | ## Environment variables diff --git a/spec/unit/docs_options_spec.rb b/spec/unit/docs_options_spec.rb index 5df2975d..ae74d8d7 100644 --- a/spec/unit/docs_options_spec.rb +++ b/spec/unit/docs_options_spec.rb @@ -1,14 +1,157 @@ # frozen_string_literal: true require 'spec_helper' +require 'json' -# docs/options.md is the reference for ClaudeAgentOptions. An option that is -# not on the page is public API a reader of the docs cannot discover, so every -# public attribute has to be named there. +# docs/options.md is the reference for ClaudeAgentOptions: one table row per +# public attribute, with the option's type, its default and what it turns +# into. Every cell is checked against the code, so that a row with a made-up +# type, default, flag or initialize field fails: +# +# Type the constructor's keyword type in sig/ +# Default a bare ClaudeAgentOptions.new, and a marker in the cell where an +# explicit nil does not give that default +# Sent ... for sample values of the option: the flags CommandBuilder adds, +# the initialize fields a Client sends, and the variables the +# transport puts into the CLI's environment RSpec.describe 'docs/options.md' do let(:root) { File.expand_path('../..', __dir__) } let(:page_path) { File.join(root, 'docs/options.md') } let(:page) { File.exist?(page_path) ? File.read(page_path) : '' } + let(:options_class) { ClaudeAgentSDK::ClaudeAgentOptions } + + # What a Default cell says when an explicit nil does not give the default. + let(:explicit_nil_marker) { 'explicit `nil` stays `nil`' } + + # The values each option is tried with: { option: value }, or + # [context, { option: value }] where the option needs another one set. + let(:samples) do + sdk = ClaudeAgentSDK + first, second, third = %w[0 1 2].map { |digit| "550e8400-e29b-41d4-a716-44665544000#{digit}" } + callback = ->(*) {} + { + 'system_prompt' => [{ system_prompt: 'Be brief.' }, + { system_prompt: sdk::SystemPromptFile.new(path: '/srv/app/prompt.md') }, + { system_prompt: sdk::SystemPromptCustom.new(prompt: 'Be brief.', snapshot: true) }, + { system_prompt: sdk::SystemPromptPreset.new(preset: 'claude_code', append: 'Be brief.', + exclude_dynamic_sections: true) }], + 'model' => [{ model: 'claude-sonnet-5-5' }], + 'fallback_model' => [{ fallback_model: 'claude-haiku-4-5' }], + 'advisor_model' => [{ advisor_model: 'claude-opus-5-5' }], + 'max_turns' => [{ max_turns: 3 }], + 'max_budget_usd' => [{ max_budget_usd: 0.5 }], + 'task_budget' => [{ task_budget: { total: 50_000 } }], + 'thinking' => [{ thinking: sdk::ThinkingConfigAdaptive.new }, + { thinking: sdk::ThinkingConfigAdaptive.new(display: 'summarized') }, + { thinking: sdk::ThinkingConfigEnabled.new(budget_tokens: 2000) }, + { thinking: sdk::ThinkingConfigDisabled.new }], + 'effort' => [{ effort: 'high' }], + 'max_thinking_tokens' => [{ max_thinking_tokens: 2000 }], + 'betas' => [{ betas: ['context-1m-2025-08-07'] }], + 'output_format' => [{ output_format: { type: 'json_schema', schema: { type: 'object' } } }], + 'tools' => [{ tools: ['Read'] }, { tools: [] }, { tools: sdk::ToolsPreset.new(preset: 'claude_code') }], + 'allowed_tools' => [{ allowed_tools: ['Read'] }], + 'disallowed_tools' => [{ disallowed_tools: ['Bash'] }], + 'permission_mode' => [{ permission_mode: 'acceptEdits' }], + 'can_use_tool' => [{ can_use_tool: callback }], + 'permission_prompt_tool_name' => [{ permission_prompt_tool_name: 'mcp__permissions__ask' }], + 'hooks' => [{ hooks: { 'PreToolUse' => [sdk::HookMatcher.new(matcher: 'Bash', hooks: [callback])] } }], + 'skills' => [{ skills: 'all' }, { skills: ['pdf'] }], + 'sandbox' => [{ sandbox: { enabled: true } }], + 'mcp_servers' => [{ mcp_servers: { docs: { type: 'http', url: 'https://example.com/mcp' } } }], + 'strict_mcp_config' => [{ strict_mcp_config: true }], + 'agents' => [{ agents: { reviewer: sdk::AgentDefinition.new(description: 'Reviews code', + prompt: 'Review the diff.') } }], + 'plugins' => [{ plugins: [{ type: 'local', path: '/srv/app/plugin' }] }], + 'settings' => [{ settings: { 'autoMemoryEnabled' => false } }], + 'setting_sources' => [{ setting_sources: ['project'] }, { setting_sources: [] }], + 'add_dirs' => [{ add_dirs: ['/srv/shared'] }], + 'bare' => [{ bare: true }], + 'verbatim_prompts' => [{ verbatim_prompts: true }], + 'resume' => [{ resume: first }], + 'continue_conversation' => [{ continue_conversation: true }], + 'fork_session' => [{ fork_session: true }], + 'session_id' => [{ session_id: first }], + 'resume_session_at' => [[{ resume: first }, { resume_session_at: second }]], + 'resume_drops_turn' => [[{ resume: first, resume_session_at: second }, { resume_drops_turn: third }]], + 'enable_file_checkpointing' => [{ enable_file_checkpointing: true }], + 'session_store' => [{ session_store: sdk::InMemorySessionStore.new }], + 'session_store_flush' => [{ session_store_flush: 'eager' }], + 'load_timeout_ms' => [{ load_timeout_ms: 1000 }], + 'include_partial_messages' => [{ include_partial_messages: true }], + 'include_hook_events' => [{ include_hook_events: true }], + 'forward_subagent_text' => [{ forward_subagent_text: true }], + 'agent_progress_summaries' => [{ agent_progress_summaries: true }, { agent_progress_summaries: false }], + 'cli_path' => [{ cli_path: '/opt/claude/bin/claude' }], + 'cwd' => [{ cwd: '/srv/app' }], + 'env' => [{ env: { 'ANTHROPIC_BASE_URL' => 'https://proxy.example.com' } }], + 'user' => [{ user: 'deploy' }], + # The flag names are the caller's; the page writes them as `--<flag>`. + 'extra_args' => [{ extra_args: { 'any-flag' => 'value', 'any-switch' => nil } }], + 'stderr' => [{ stderr: callback }], + 'debug_stderr' => [{ debug_stderr: $stderr }], + 'max_buffer_size' => [{ max_buffer_size: 2_000_000 }], + 'observers' => [{ observers: [Object.new.extend(sdk::Observer)] }], + 'callback_scheduling' => [{ callback_scheduling: 'inline' }], + 'callback_wrapper' => [{ callback_wrapper: :call.to_proc }] + } + end + + # The fields of the initialize request each option sets; the options that + # are not listed set none. Hand-written, and checked against the code below. + let(:initialize_fields) do + { 'system_prompt' => %w[excludeDynamicSections systemPromptSnapshot], 'hooks' => %w[hooks], + 'agents' => %w[agents], 'skills' => %w[skills], 'forward_subagent_text' => %w[forwardSubagentText], + 'agent_progress_summaries' => %w[agentProgressSummaries] } + end + + # A stand-in for the CLI that answers every control request and keeps the + # requests it was sent. + let(:recording_transport_class) do + Class.new do + def self.requests + @requests ||= [] + end + + def initialize(*) + @frames = Thread::Queue.new + end + + def connect; end + + def write(data) + data.each_line do |line| + frame = JSON.parse(line, symbolize_names: true) + next unless frame[:type] == 'control_request' + + self.class.requests << frame[:request] + @frames << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], response: {} } } + end + end + + def read_messages + while (frame = @frames.pop) != :end + yield frame + end + end + + def end_input; end + + def close + @frames << :end + end + end + end + + # The page documents a bare ClaudeAgentOptions.new, so no configured + # defaults may be left over from another example. + around do |example| + ClaudeAgentSDK.reset_configuration + example.run + ensure + ClaudeAgentSDK.reset_configuration + end # Attributes whose declaration carries a YARD `@api private` tag are not # public API (CONTRIBUTING.md, "What is public API") and need no entry. @@ -21,14 +164,114 @@ def private_attributes(source) def public_attributes source = File.read(File.join(root, 'lib/claude_agent_sdk/types/options.rb')) - ClaudeAgentSDK::ClaudeAgentOptions.attribute_names - private_attributes(source) + options_class.attribute_names - private_attributes(source) + end + + # The cells of a Markdown table row. An escaped pipe (`\|`) is part of its cell. + def cells(line) + parts = line.split(/(?<!\\)\|/, -1).map(&:strip) + parts.size > 2 && parts.first.empty? && parts.last.empty? ? parts[1..-2] : [] + end + + # The rows of the option tables, as { name:, type:, default:, sent: }: the + # rows with four cells whose first one is a name in backticks. + def table_rows + page.each_line(chomp: true).filter_map do |line| + row = cells(line) + next unless row.size == 4 && (name = row[0][/\A`(\w+)`\z/, 1]) + + { name: name, type: row[1][/\A`(.*)`\z/, 1].to_s.gsub('\|', '|'), default: row[2], sent: row[3] } + end + end + + def rows + table_rows.select { |row| public_attributes.include?(row[:name]) } + end + + # The constructor's keyword types in sig/, the way the page writes them: + # without the optional mark, and without the parentheses around a union. + def constructor_types + sig = File.read(File.join(root, 'sig/claude_agent_sdk/types/options.rbs')) + keywords = sig[/^ class ClaudeAgentOptions\b.*?^ *def initialize: \(\n(.*?)^ *\) -> void/m, 1].to_s + keywords.scan(/^ *\?(\w+): (.+?),?$/).to_h { |name, type| [name, unparenthesized(type.delete_suffix('?'))] } + end + + def unparenthesized(type) + inner = type[/\A\((.*)\)\z/, 1] + inner && balanced?(inner) ? inner : type + end + + def balanced?(text) + depth = 0 + text.each_char do |char| + depth += { '(' => 1, ')' => -1 }.fetch(char, 0) + return false if depth.negative? + end + depth.zero? + end + + def sample_pairs(name) + samples.fetch(name, []).map { |sample| sample.is_a?(Array) ? sample : [{}, sample] } + end + + def command_line(options) + ClaudeAgentSDK::CommandBuilder.new('claude', ClaudeAgentSDK.configure_can_use_tool(options)).build + end + + # The command line as [flag, value] pairs; `--flag=value` counts as one pair. + def flag_pairs(options) + arguments = command_line(options).drop(1) + arguments.each_with_index.filter_map do |argument, index| + next unless argument.start_with?('--') + + flag, value = argument.split('=', 2) + following = arguments[index + 1] + value ||= following unless following.nil? || following.start_with?('--') + [flag, value] + end + end + + # The flags whose presence or value the sample changes. + def flags_changed_by(context, sample) + without = flag_pairs(options_class.new(**context)) + with = flag_pairs(options_class.new(**context, **sample)) + (without | with).reject { |pair| without.count(pair) == with.count(pair) }.map(&:first) + end + + def flags_sent_for(name) + flags = sample_pairs(name).flat_map { |context, sample| flags_changed_by(context, sample) } + (name == 'extra_args' ? flags.map { '--<flag>' } : flags).uniq.sort + end + + # The flags a cell names in its code spans: `--flag`, `--flag <value>`, + # `--flag=<value>`. + def flags_in(cell) + cell.scan(/`([^`]*)`/).flatten.flat_map { |code| code.scan(/(?<![\w-])--(?:<flag>|[a-z][\w-]*)/i) }.uniq.sort + end + + # The [flag, value] pairs a cell gives with a literal value, such as + # `--tools default` or `--tools ""`, and not with a <placeholder>. + def literal_pairs_in(cell) + cell.scan(/`(--[a-z][\w-]*) ([^`<]+)`/i).map { |flag, value| [flag, value == '""' ? '' : value] } end - it 'names every public ClaudeAgentOptions attribute' do - missing = public_attributes.reject { |name| page.include?("`#{name}`") } + def pairs_sent_for(name) + sample_pairs(name).flat_map { |context, sample| flag_pairs(options_class.new(**context, **sample)) } + end + # The fields of the initialize request a Client sends for these options, + # beyond the bare request ({ subtype:, hooks: nil, agents: nil }). + def initialize_fields_sent(options) + transport_class = recording_transport_class + transport_class.requests.clear + ClaudeAgentSDK::Client.open(options: options, transport_class: transport_class) { |_client| nil } + request = transport_class.requests.find { |candidate| candidate[:subtype] == 'initialize' } + request.except(:subtype).compact.keys.map(&:to_s) + end + + it 'has one table row for every public ClaudeAgentOptions attribute, and for nothing else' do expect(public_attributes.size).to be >= 55 - expect(missing).to be_empty, "docs/options.md does not mention: #{missing.join(', ')}" + expect(table_rows.map { |row| row[:name] }).to match_array(public_attributes) end it 'recognizes an attribute tagged @api private as not needing an entry' do @@ -50,6 +293,145 @@ class ClaudeAgentOptions < Type expect(private_attributes(source)).to eq(%w[scratch_dir scratch_mode]) end + it 'gives every option the type its constructor keyword has in sig/' do + wrong = rows.filter_map do |row| + accepted = constructor_types[row[:name]] + "#{row[:name]}: documented #{row[:type].inspect}, constructor #{accepted.inspect}" unless row[:type] == accepted + end + + expect(wrong).to eq([]) + end + + it 'explains each type alias in the Type column with the members of its definition in sig/' do + sig = Dir[File.join(root, 'sig/**/*.rbs')].map { |file| File.read(file) }.join("\n") + aliases = rows.flat_map { |row| row[:type].scan(/(?<![:\w"])[a-z]\w*/) }.uniq - %w[bool untyped] + wrong = aliases.filter_map do |name| + definition = sig[/^ *type #{name} = (.*?)\n *\n/m, 1].to_s + members = definition.gsub(/\[[^\]]*\]/, '').scan(/[A-Z]\w*/) + explanation = page.each_line.map { |line| cells(line) }.find { |row| row.first == "`#{name}`" }.to_a.last.to_s + documented = explanation.scan(/`([A-Z]\w*)`/).flatten + explanation.scan(/\bHash\b/).uniq + "#{name}: documented #{documented.sort.inspect}, defined #{members.sort.inspect}" unless documented.sort == members.sort + end + + expect(aliases).not_to be_empty + expect(wrong).to eq([]) + end + + it 'gives every option the default a bare ClaudeAgentOptions.new has' do + wrong = rows.filter_map do |row| + actual = options_class.new.public_send(row[:name]).inspect + documented = row[:default][/\A`([^`]*)`/, 1] + "#{row[:name]}: documented #{documented.inspect}, actual #{actual.inspect}" unless documented == actual + end + + expect(wrong).to eq([]) + end + + it 'marks exactly the options whose explicit nil stays nil, which still act as false' do + stays_nil = public_attributes.reject do |name| + options_class.new(name.to_sym => nil).public_send(name) == options_class.new.public_send(name) + end + marked = rows.select { |row| row[:default].include?(explicit_nil_marker) }.map { |row| row[:name] } + changes_the_command_line = public_attributes.reject do |name| + command_line(options_class.new(name.to_sym => nil)) == command_line(options_class.new) + end + + expect(marked).to match_array(stays_nil) + expect(stays_nil.map { |name| options_class.new(name.to_sym => nil).public_send(name) }.uniq).to eq([nil]) + expect(stays_nil.map { |name| options_class.new(name.to_sym => nil).public_send(:"#{name}?") }.uniq).to eq([false]) + expect(changes_the_command_line).to eq([]) + end + + # The table under "Leaving an option out, and passing nil". + it 'resolves a left-out option, an explicit nil and a value the way the page tabulates it' do + without_defaults = options_class.new(env: nil, fork_session: nil) + ClaudeAgentSDK.configure do |config| + config.default_options = { env: { 'A' => '1' }, fork_session: true, model: 'claude-opus-5-5' } + end + left_out = options_class.new + with_nil = options_class.new(env: nil, fork_session: nil, model: nil, include_partial_messages: nil) + with_value = options_class.new(env: { 'B' => '2' }, fork_session: false, model: 'claude-haiku-4-5') + + expect([without_defaults.env, without_defaults.fork_session]).to eq([{}, nil]) + expect([left_out.env, left_out.fork_session, left_out.model]).to eq([{ 'A' => '1' }, true, 'claude-opus-5-5']) + expect([with_nil.env, with_nil.fork_session, with_nil.model]).to eq([{ 'A' => '1' }, true, 'claude-opus-5-5']) + expect(with_nil.include_partial_messages).to be_nil # no default is configured for it + expect([with_value.env, with_value.fork_session, with_value.model]) + .to eq([{ 'A' => '1', 'B' => '2' }, false, 'claude-haiku-4-5']) + end + + it 'stores a String callback_scheduling as its Symbol, as the row says' do + sent = rows.find { |row| row[:name] == 'callback_scheduling' }.to_h.fetch(:sent, '') + + expect(options_class.new(callback_scheduling: 'inline').callback_scheduling).to be(:inline) + expect(options_class.new(callback_scheduling: 'thread').callback_scheduling).to be(:thread) + expect(sent).to include('A String is stored as its Symbol') + end + + it 'names, for every option, the flags CommandBuilder sends for it and no other' do + wrong = rows.filter_map do |row| + documented = flags_in(row[:sent]) + sent = flags_sent_for(row[:name]) + "#{row[:name]}: documented #{documented.inspect}, sent #{sent.inspect}" unless documented == sent + end + + expect(samples.keys).to match_array(public_attributes) + expect(wrong).to eq([]) + end + + it 'gives a flag a literal value only where CommandBuilder sends that value' do + literal = rows.flat_map { |row| literal_pairs_in(row[:sent]).map { |pair| [row[:name], pair] } } + wrong = literal.filter_map do |name, pair| + "#{name}: #{pair.join(' ').inspect} is not sent for any of its samples" unless pairs_sent_for(name).include?(pair) + end + + expect(literal.size).to be >= 5 + expect(wrong).to eq([]) + end + + it 'names, for every option, the initialize fields it sets and no other' do + wrong = rows.filter_map do |row| + documented = row[:sent].scan(/`initialize\.(\w+)`/).flatten.sort + sent = initialize_fields.fetch(row[:name], []).sort + "#{row[:name]}: documented #{documented.inspect}, sent #{sent.inspect}" unless documented == sent + end + + expect(wrong).to eq([]) + end + + # initialize_fields is hand-written, so it is checked twice: each entry + # against the request a Client sends for the option's samples, and all of + # them together against the fields Query can put into that request. + it 'expects the initialize fields a Client sends, and Query has no others' do + sent = initialize_fields.keys.to_h do |name| + requests = sample_pairs(name).map { |context, sample| options_class.new(**context, **sample) } + [name, requests.flat_map { |options| initialize_fields_sent(options) }.uniq.sort] + end + query = File.read(File.join(root, 'lib/claude_agent_sdk/query.rb')) + request = query[/^ *request = \{\n *subtype: 'initialize',\n(.*?)send_control_request\(request\)/m, 1].to_s + known_to_query = request.scan(/^ *(\w+): /).flatten + request.scan(/request\[:(\w+)\]/).flatten + + expect(initialize_fields_sent(options_class.new)).to eq([]) + expect(sent).to eq(initialize_fields.transform_values(&:sort)) + expect(known_to_query).to match_array(initialize_fields.values.flatten) + end + + # The transport builds the CLI's environment only while it starts the CLI, + # so the variables it sets for an option are read from its source. + it 'names, for every option, the environment variables the transport sets for it and no other' do + transport = File.read(File.join(root, 'lib/claude_agent_sdk/subprocess_cli_transport.rb')) + set_for = transport.scan(/process_env\['(\w+)'\] = '\w+' if @options\.(\w+)/) + .group_by(&:last).transform_values { |pairs| pairs.map(&:first) } + wrong = rows.filter_map do |row| + documented = row[:sent].scan(/`([A-Z][A-Z0-9_]+)=[^`]*`/).flatten + set = set_for.fetch(row[:name], []) + "#{row[:name]}: documented #{documented.inspect}, set #{set.inspect}" unless documented == set + end + + expect(set_for).not_to be_empty + expect(wrong).to eq([]) + end + it 'is linked from the README documentation table' do readme = File.read(File.join(root, 'README.md')) From 68091eef6c50962fb4de4328eeb6f9b767045d92 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:04:05 +0800 Subject: [PATCH 136/194] Guard the custom-transport paragraph of "Turning auto-memory off" c59af76 corrected docs/configuration.md: a custom transport has to pass both `env` and `settings` on to the CLI it starts. No spec covered the paragraph, so the old wording (only `env` needs passing on, `settings` travels by itself) could come back unnoticed. The new docs_configuration_spec.rb requires one bullet for the default SubprocessCLITransport and a separate one for a custom transport that says neither switch reaches the CLI unless the transport passes it through, and that the session is not isolated until then. It also pins the two facts the paragraph rests on: CommandBuilder puts `settings` on the command line and nothing for `env`, and a Client hands a custom transport both values without building a command line or touching the environment itself. Sandbox Settings: "the only sign is the CLI's warning" becomes "the sign is the CLI's warning". A transport change in review makes the SDK repeat that line as a Ruby warning, and "only" would then be false. Review round 2, item 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/configuration.md | 2 +- spec/unit/docs_configuration_spec.rb | 104 +++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 spec/unit/docs_configuration_spec.rb diff --git a/docs/configuration.md b/docs/configuration.md index 5b99cf97..9221e00e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -228,7 +228,7 @@ The SDK sends your sandbox settings as you wrote them and does not add this one. sandbox = ClaudeAgentSDK::SandboxSettings.new(enabled: true, fail_if_unavailable: true) ``` -Without it, the only sign is the CLI's warning on stderr ("Sandbox disabled: ... Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced."), and stderr reaches your code only through the `stderr` (or `debug_stderr`) option. This is the CLI's own description of its behavior: the fallback has not been reproduced in this SDK's testing, where the sandbox was always available. +Without it, the sign is the CLI's warning on stderr ("Sandbox disabled: ... Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced."), and stderr reaches your code only through the `stderr` (or `debug_stderr`) option. This is the CLI's own description of its behavior: the fallback has not been reproduced in this SDK's testing, where the sandbox was always available. See [examples/sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb). diff --git a/spec/unit/docs_configuration_spec.rb b/spec/unit/docs_configuration_spec.rb new file mode 100644 index 00000000..0ab4284f --- /dev/null +++ b/spec/unit/docs_configuration_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# docs/configuration.md, "Turning auto-memory off", gives a host two switches +# that keep Claude Code's auto-memory out of its sessions: a variable in `env` +# and a key in `settings`. Either one reaches the CLI only through the +# transport. The default transport applies both itself; a custom transport is +# handed the options and has to pass them on. The page once said that only +# `env` needs passing on and that `settings` travels by itself, so a session +# on a custom transport stayed unisolated without a word of warning. +RSpec.describe 'docs/configuration.md on turning auto-memory off' do + let(:root) { File.expand_path('../..', __dir__) } + let(:page) { File.read(File.join(root, 'docs/configuration.md')) } + let(:options_class) { ClaudeAgentSDK::ClaudeAgentOptions } + let(:switches) { { env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, settings: { autoMemoryEnabled: false } } } + + # A custom transport with the five required methods. It answers control + # requests and keeps the options the SDK constructed it with. + let(:transport_class) do + Class.new do + class << self + attr_accessor :options + end + + def initialize(options, **) + self.class.options = options + @frames = Thread::Queue.new + end + + def connect; end + + def write(data) + data.each_line do |line| + frame = JSON.parse(line, symbolize_names: true) + next unless frame[:type] == 'control_request' + + @frames << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], response: {} } } + end + end + + def read_messages + while (frame = @frames.pop) != :end + yield frame + end + end + + def end_input; end + + def close + @frames << :end + end + end + end + + # The list items of the section, each on one line. Fenced code is skipped. + def bullets + section = page[/^### Turning auto-memory off\n(.*?)^\#{2,3} /m, 1].to_s.gsub(/^```.*?^```\n/m, '') + section.scan(/^- .*(?:\n {2}\S.*)*/).map { |item| item.gsub(/\s+/, ' ') } + end + + def command_line(**options) + ClaudeAgentSDK::CommandBuilder.new('claude', options_class.new(**options)).build + end + + it 'keeps the default transport and a custom transport apart, and asks the custom one for both switches', + :aggregate_failures do + default = bullets.select { |bullet| bullet.include?('`SubprocessCLITransport`') } + custom = bullets.select { |bullet| bullet.include?('custom transport') } + + expect([default.size, custom.size]).to eq([1, 1]) + expect(default).not_to eq(custom) + expect(default.first).to include('`env`', '`settings`', '`--settings`') + expect(custom.first).to include('neither switch reaches that CLI unless the transport passes it through', + '`env`', '`settings`', 'the session is not isolated') + end + + # What the default transport's half of the page rests on. That the transport + # puts `env` into the environment of the CLI it starts is pinned by + # subprocess_cli_transport_spec.rb ("environment variable handling"). + it 'builds settings into the command line as --settings, and nothing for env' do + with_settings = command_line(settings: switches[:settings]) + + expect(with_settings.each_cons(2)).to include(['--settings', '{"autoMemoryEnabled":false}']) + expect(command_line(env: switches[:env])).to eq(command_line) + end + + # What the custom transport's half rests on: the SDK constructs the + # transport with the options and does nothing else with either switch. + it 'hands a custom transport both switches and applies neither itself' do + environment = ENV.to_h + expect(ClaudeAgentSDK::CommandBuilder).not_to receive(:new) + + ClaudeAgentSDK::Client.open(options: options_class.new(**switches), transport_class: transport_class) do |_client| + nil + end + + expect(transport_class.options.env).to eq(switches[:env]) + expect(transport_class.options.settings).to eq(switches[:settings]) + expect(ENV.to_h).to eq(environment) + end +end From 9992540b64963eaab07d403e2d2d4c1e8898782e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:05:18 +0800 Subject: [PATCH 137/194] docs/options.md: callback_scheduling rejects what does not convert The row said that anything but a String, a Symbol or nil raises ArgumentError. The setter calls to_sym on every value that responds to it and rejects what does not come out as :thread or :inline, so an object whose to_sym returns :inline is accepted. The row now says that a value which does not convert (through to_sym) to :thread or :inline raises, and that nil means the default. The guard pins both: nil reads :thread, an object converting to :inline is accepted, and "fiber", :fiber and 5 raise. The second example passes values outside the constructor signature, so it is tagged rbs_incompatible. Review round 2, item 2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/options.md | 2 +- spec/unit/docs_options_spec.rb | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/options.md b/docs/options.md index b2d6d940..509739ee 100644 --- a/docs/options.md +++ b/docs/options.md @@ -202,7 +202,7 @@ Guides: [Observability](observability.md), [Rails Integration](rails.md). | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| | `observers` | `Array[untyped]` | `[]` | Nothing (SDK only): observer instances, or callables that return a fresh one per query or session | -| `callback_scheduling` | `:thread \| :inline \| "thread" \| "inline"` | `:thread` | Nothing (SDK only): `:thread` runs each callback on a plain thread, `:inline` on the reactor fiber. A String is stored as its Symbol (`callback_scheduling: 'inline'` reads back as `:inline`); anything else but `nil` raises `ArgumentError` | +| `callback_scheduling` | `:thread \| :inline \| "thread" \| "inline"` | `:thread` | Nothing (SDK only): `:thread` runs each callback on a plain thread, `:inline` on the reactor fiber. A String is stored as its Symbol (`callback_scheduling: 'inline'` reads back as `:inline`). A value that does not convert (through `to_sym`) to `:thread` or `:inline` raises `ArgumentError`; `nil` means the default | | `callback_wrapper` | `_CallbackWrapper` | `nil` | Nothing (SDK only): a callable wrapped around every callback dispatch | ## Environment variables diff --git a/spec/unit/docs_options_spec.rb b/spec/unit/docs_options_spec.rb index ae74d8d7..f441d809 100644 --- a/spec/unit/docs_options_spec.rb +++ b/spec/unit/docs_options_spec.rb @@ -360,12 +360,29 @@ class ClaudeAgentOptions < Type .to eq([{ 'A' => '1', 'B' => '2' }, false, 'claude-haiku-4-5']) end - it 'stores a String callback_scheduling as its Symbol, as the row says' do + it 'stores a String callback_scheduling as its Symbol and reads nil as the default, as the row says' do sent = rows.find { |row| row[:name] == 'callback_scheduling' }.to_h.fetch(:sent, '') expect(options_class.new(callback_scheduling: 'inline').callback_scheduling).to be(:inline) expect(options_class.new(callback_scheduling: 'thread').callback_scheduling).to be(:thread) - expect(sent).to include('A String is stored as its Symbol') + expect(options_class.new(callback_scheduling: nil).callback_scheduling).to be(:thread) + expect(sent).to include('A String is stored as its Symbol', '`nil` means the default') + end + + # The setter calls to_sym on whatever responds to it and rejects what does + # not come out as :thread or :inline, so the row may not say that every + # value other than a String or a Symbol raises. + it 'accepts what converts to :thread or :inline and rejects the rest, as the row says', + rbs_incompatible: 'passes out-of-signature input to test its rejection' do + sent = rows.find { |row| row[:name] == 'callback_scheduling' }.to_h.fetch(:sent, '') + convertible = Struct.new(:to_sym).new(:inline) + + expect(options_class.new(callback_scheduling: convertible).callback_scheduling).to be(:inline) + ['fiber', :fiber, 5].each do |value| + expect { options_class.new(callback_scheduling: value) } + .to raise_error(ArgumentError, /callback_scheduling must be one of/) + end + expect(sent).to include('A value that does not convert (through `to_sym`) to `:thread` or `:inline` raises') end it 'names, for every option, the flags CommandBuilder sends for it and no other' do From 516c405abfbd64e88fdb26aa550d24e93125bd86 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:07:13 +0800 Subject: [PATCH 138/194] docs/types.md: a nil or false data also gives the whole frame The page said SystemMessage#data is the frame's own `data` value whenever the frame has a `data` key. The class assigns it with `||=`, so a frame whose `data` is nil or false reads like one without the key: the whole frame. The paragraph and the class block's comment now say "a `data` value of its own that is neither nil nor false". The guard gains the missing cases: a system frame with `data: nil`, with `data: {}` and with `data: false` (the last one tagged rbs_incompatible: false is outside the signature of SystemMessage#data=), and a rate-limit event with no `data` key, whose #data must equal the whole event. With RateLimitEvent#data changed to return {} for such an event, the guard used to stay green. Review round 2, item 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- docs/types.md | 10 ++++--- spec/unit/docs_reference_spec.rb | 46 +++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/docs/types.md b/docs/types.md index bc1aa09b..cf474008 100644 --- a/docs/types.md +++ b/docs/types.md @@ -144,7 +144,7 @@ System message with metadata. Task lifecycle events are typed subclasses. ```ruby class SystemMessage attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', 'task_updated', etc.) - :data # Hash - the whole frame, or the frame's own `data` value when it has one (see below) + :data # Hash - the frame's own `data` value when that is neither nil nor false, otherwise the whole frame (see below) end # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works) @@ -206,9 +206,11 @@ with two exceptions on `RateLimitEvent`: `rate_limit_info` is then an empty `RateLimitInfo`, and `RateLimitEvent#data` is always the whole event as a Symbol-keyed Hash. The first twelve are `SystemMessage` subclasses (wire `type` is `system`), so they also have `subtype` and `data`. Their `data` is -the whole frame as a Symbol-keyed Hash, unless the frame has a `data` key of -its own: then `data` is that key's value, and the frame is not kept. The last -six are message types of their own. +the whole frame as a Symbol-keyed Hash, unless the frame carries a `data` +value of its own that is neither `nil` nor `false`: then `data` is that +value, and the frame is not kept. A frame whose `data` is `nil` or `false` +reads like one without the key: `data` is the whole frame. The last six are +message types of their own. | Class | Wire type | Attributes | Notes | |-------|-----------|------------|-------| diff --git a/spec/unit/docs_reference_spec.rb b/spec/unit/docs_reference_spec.rb index afad140a..03dfa3e7 100644 --- a/spec/unit/docs_reference_spec.rb +++ b/spec/unit/docs_reference_spec.rb @@ -82,20 +82,40 @@ def documented_union(name) expect(without_row).to be_empty, "docs/errors.md has no table row for: #{without_row.join(', ')}" end - # docs/types.md: SystemMessage#data is the whole frame unless the frame has - # a `data` key of its own; RateLimitEvent#data is always the whole event. - it 'parses #data of system and rate-limit frames the way docs/types.md describes it' do - own_data = ClaudeAgentSDK::MessageParser.parse(type: 'system', subtype: 'status', status: 'compacting', - data: { marker: 1 }) - plain = ClaudeAgentSDK::MessageParser.parse(type: 'system', subtype: 'status', status: 'compacting') - rate_limit = ClaudeAgentSDK::MessageParser.parse(type: 'rate_limit_event', data: { marker: 1 }) - - expect(own_data.data).to eq(marker: 1) - expect(plain.data).to eq(type: 'system', subtype: 'status', status: 'compacting') - expect(rate_limit.data).to eq(type: 'rate_limit_event', data: { marker: 1 }) + # docs/types.md: SystemMessage#data is the whole frame, unless the frame + # carries a `data` value of its own that is neither nil nor false (the class + # assigns it with `||=`, so key presence alone does not decide). + it 'parses #data of a system frame the way docs/types.md describes it' do + frame = { type: 'system', subtype: 'status', uuid: 'u1', session_id: 's1', status: 'compacting' } + data_of = ->(extra) { ClaudeAgentSDK::MessageParser.parse(frame.merge(extra)).data } + + expect(data_of.call({})).to eq(frame) + expect(data_of.call(data: { marker: 1 })).to eq(marker: 1) + expect(data_of.call(data: {})).to eq({}) + expect(data_of.call(data: nil)).to eq(frame.merge(data: nil)) prose = types_doc.gsub(/\s+/, ' ') # the sentences may wrap anywhere - expect(prose).to include('unless the frame has a `data` key of its own') - expect(prose).to include('`RateLimitEvent#data` is always the whole event') + expect(prose).to include('unless the frame carries a `data` value of its own that is neither `nil` nor `false`') + expect(prose).to include('A frame whose `data` is `nil` or `false` reads like one without the key') + expect(types_doc).to include("the frame's own `data` value when that is neither nil nor false, otherwise the whole frame") + end + + it 'parses a system frame whose data is false like one without the key, as docs/types.md says', + rbs_incompatible: 'parses a frame whose data is false, outside the signature of SystemMessage#data=' do + frame = { type: 'system', subtype: 'status', uuid: 'u1', session_id: 's1', status: 'compacting', data: false } + + expect(ClaudeAgentSDK::MessageParser.parse(frame).data).to eq(frame) + end + + # docs/types.md: RateLimitEvent#data is always the whole event, whether or + # not the event has a `data` key. + it 'parses #data of a rate-limit event the way docs/types.md describes it' do + event = { type: 'rate_limit_event', uuid: 'rl_123', session_id: 'sess_456', + rate_limit_info: { status: 'allowed_warning', resetsAt: 1_700_000_000, rateLimitType: 'five_hour' } } + with_data = event.merge(data: { marker: 1 }) + + expect(ClaudeAgentSDK::MessageParser.parse(event).data).to eq(event) + expect(ClaudeAgentSDK::MessageParser.parse(with_data).data).to eq(with_data) + expect(types_doc.gsub(/\s+/, ' ')).to include('`RateLimitEvent#data` is always the whole event') end # The example announces how many message types it handles; both the number From c5824f70a2927e3461496afce66e817ec0305e9b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:13:43 +0800 Subject: [PATCH 139/194] Reset configured defaults around the configuration guard Two examples of docs_configuration_spec.rb build options from the two auto-memory switches alone and compare what comes out. A configured default left over from another example (an `env` or `settings` Hash is merged into the configured one) would change the result, so the group resets the configuration before and after each example, as docs_options_spec.rb does. Review round 2, item 1 (follow-up). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M7qBDC9nZ29kjrvhHSBhJq --- spec/unit/docs_configuration_spec.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/spec/unit/docs_configuration_spec.rb b/spec/unit/docs_configuration_spec.rb index 0ab4284f..3ead599a 100644 --- a/spec/unit/docs_configuration_spec.rb +++ b/spec/unit/docs_configuration_spec.rb @@ -55,6 +55,15 @@ def close end end + # The examples build options from the two switches alone, so no configured + # defaults may be left over from another example. + around do |example| + ClaudeAgentSDK.reset_configuration + example.run + ensure + ClaudeAgentSDK.reset_configuration + end + # The list items of the section, each on one line. Fenced code is skipped. def bullets section = page[/^### Turning auto-memory off\n(.*?)^\#{2,3} /m, 1].to_s.gsub(/^```.*?^```\n/m, '') From 001d3d2a5a78d9e2ea025867c9065fe6adbd6749 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:27:48 +0800 Subject: [PATCH 140/194] List the base64 gem in the documented Gemfile lines The Langfuse snippet in docs/observability.md starts with `require 'base64'`. Since Ruby 3.4 base64 is a bundled gem, not a default one, so under Bundler that require raises LoadError unless the Gemfile lists the gem. The Gemfile lines the page gives now include it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2 --- docs/observability.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/observability.md b/docs/observability.md index fec2ef21..121c1262 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -40,6 +40,7 @@ Or add to your Gemfile: ```ruby gem 'opentelemetry-sdk', '~> 1.4' gem 'opentelemetry-exporter-otlp', '~> 0.28' +gem 'base64' # the snippet below requires it; under Bundler, Ruby 3.4+ needs it listed ``` **2. Configure the OTel SDK** to export to your Langfuse instance: From 6f5e2621095f1ab75a9f3752e385e21af00f0d0a Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:27:55 +0800 Subject: [PATCH 141/194] Take a Pathname again when resolving a missing path c06b5e6, one commit back, stopped passing the directory through File.expand_path, which also turned a Pathname into a String. A `directory:` given as a Pathname for a path File.realpath cannot resolve (a removed directory) then raised NoMethodError from the walk, or ArgumentError for a relative one, where the readers returned their sessions before. The signatures declare `directory:` a String, but a Pathname has always worked. The path is taken through File.path first, as File.expand_path did. Exploration finding: B-P6 (round 2, B5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 6 ++++-- spec/unit/session_deleted_directory_spec.rb | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 91d78f4e..b6fbfd6e 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -189,10 +189,12 @@ def canonicalize_path(dir) # project key as soon as the target was gone and File.realpath raised. # # Otherwise as File.expand_path has it: a relative path starts at the - # working directory, and a leading ~ or ~user is that home directory + # working directory, a leading ~ or ~user is that home directory # (File.realpath expands neither, so `directory: '~/project'` has always - # been resolved through here). + # been resolved through here), and a Pathname is taken as well as a + # String (File.path). def absolute_path_keeping_dots(dir) + dir = File.path(dir) return dir if File.absolute_path?(dir) first, rest = dir.split(File::SEPARATOR, 2) diff --git a/spec/unit/session_deleted_directory_spec.rb b/spec/unit/session_deleted_directory_spec.rb index 2e13ef9a..104ed410 100644 --- a/spec/unit/session_deleted_directory_spec.rb +++ b/spec/unit/session_deleted_directory_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require 'pathname' require_relative '../fixtures/cli_transcript' require_relative '../fixtures/claude_config_dir' @@ -60,6 +61,22 @@ def record_session(directory) expect(File.exist?(transcript_file)).to be(false) end + + # directory: is declared a String. A Pathname has always been taken as + # well, by File.realpath and by what resolves the path when that fails. + it 'is found through the directory given as a Pathname', + rbs_incompatible: 'passes a Pathname for directory:, which the signature declares String?' do + expect(ClaudeAgentSDK.list_sessions(directory: Pathname.new(removed)).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: Pathname.new(removed)).length) + .to eq(2) + Dir.chdir(cwd) do + expect(ClaudeAgentSDK.list_sessions(directory: Pathname.new('removed-worktree')).map(&:session_id)) + .to eq([session_id]) + end + ClaudeAgentSDK.tag_session(session_id: session_id, tag: 'stale', directory: Pathname.new(removed)) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: Pathname.new(removed)).tag) + .to eq('stale') + end end # The CLI recorded the real path; the caller names the directory through a From 99b0b372fcd559f12088d686d285dc1f9d5b2096 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:35:30 +0800 Subject: [PATCH 142/194] Resolve a missing path as bytes b8ecabd (round 1) made canonicalize_path follow the symlinks of a path File.realpath cannot resolve. The walk joins the path with the link targets it reads, and Ruby tags those by the locale: under LANG=C / LC_ALL=C File.readlink returns a non-ASCII target tagged US-ASCII. Splitting it raised ArgumentError ("invalid byte sequence in US-ASCII"), so there every directory-scoped session API raised for a directory reached through a link with a non-ASCII target that is gone; before b8ecabd the readers returned nothing for it. Likewise a non-ASCII relative path in a non-ASCII working directory raised Encoding::CompatibilityError there (the BINARY Dir.pwd joined with a UTF-8 String), as it had from File.expand_path. The walk now works on BINARY Strings throughout: the path, the working directory, the home directory and every link target. nfc_path tags the result UTF-8 as before. The bytes are the same, so nothing changes where the parts already went together. Found while fixing B5 in round 2; not one of the ruling's two items. Exploration finding: B-P6 (round 2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- lib/claude_agent_sdk/sessions.rb | 15 +++- spec/unit/session_c_locale_paths_spec.rb | 95 +++++++++++++++------ spec/unit/session_deleted_directory_spec.rb | 19 ++++- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index b6fbfd6e..3e0e2c9b 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -193,12 +193,20 @@ def canonicalize_path(dir) # (File.realpath expands neither, so `directory: '~/project'` has always # been resolved through here), and a Pathname is taken as well as a # String (File.path). + # + # The result is a BINARY String, and resolve_missing_path works on bytes + # throughout. Its parts come tagged by the locale — under LANG=C the + # working directory BINARY and a link target US-ASCII, whatever their + # bytes — and as tagged Strings they did not always go together: a link + # with a non-ASCII target made the walk raise there ("invalid byte + # sequence in US-ASCII"), as did a non-ASCII relative path in a non-ASCII + # working directory. nfc_path tags the result. def absolute_path_keeping_dots(dir) - dir = File.path(dir) + dir = File.path(dir).b return dir if File.absolute_path?(dir) first, rest = dir.split(File::SEPARATOR, 2) - base = first&.start_with?('~') ? File.expand_path(first) : File.join(Dir.pwd, first.to_s) + base = first&.start_with?('~') ? File.expand_path(first).b : File.join(Dir.pwd.b, first.to_s) rest ? File.join(base, rest) : base end @@ -256,8 +264,9 @@ def resolve_missing_path(path) end # The target of +path+ when it is a symlink (dangling or not), else nil. + # As bytes, like the path it is joined with (absolute_path_keeping_dots). def symlink_target(path) - File.symlink?(path) ? File.readlink(path) : nil + File.symlink?(path) ? File.readlink(path).b : nil rescue SystemCallError nil end diff --git a/spec/unit/session_c_locale_paths_spec.rb b/spec/unit/session_c_locale_paths_spec.rb index a046397e..6ed373a5 100644 --- a/spec/unit/session_c_locale_paths_spec.rb +++ b/spec/unit/session_c_locale_paths_spec.rb @@ -8,39 +8,31 @@ require_relative '../fixtures/claude_config_dir' # Under LANG=C / LC_ALL=C (minimal Docker images, cron, systemd) Ruby hands -# out the paths it gets from the system — ENV values, Dir.pwd, File.realpath — -# tagged BINARY or US-ASCII. They are UTF-8 all the same. The locale of a -# process is fixed when it starts, so this runs the SDK in a child process. +# out the paths it gets from the system — ENV values, Dir.pwd, File.realpath, +# File.readlink — tagged BINARY or US-ASCII. They are UTF-8 all the same. The +# locale of a process is fixed when it starts, so this runs the SDK in a +# child process. RSpec.describe 'session paths under a non-UTF-8 locale' do include_context 'with a Claude config dir' let(:session_id) { '7e6f5d4c-3b2a-4190-8f7e-6d5c4b3a2910' } let(:accented) { "caf#{[0xE9].pack('U')}" } # "café", NFC - # What the SDK reports in a child started with LC_ALL=C, as hex (the pipe - # back is read as bytes) plus the encoding of each value. - let(:probe) do + # The start of each child script. A String is reported as hex (the pipe + # back is read as bytes) with its encoding, an error as its class and + # message. + let(:prelude) do <<~'RUBY' require 'claude_agent_sdk' require 'json' report = lambda do |value| value.is_a?(String) ? [value.unpack1('H*'), value.encoding.name, value.valid_encoding?] : value - rescue StandardError => e - "#{e.class}: #{e.message}" end attempt = lambda do |&block| report.call(block.call) rescue StandardError => e "#{e.class}: #{e.message}" end - puts JSON.generate( - 'external' => Encoding.default_external.name, - 'config_dir' => attempt.call { ClaudeAgentSDK::Sessions.config_dir }, - 'projects_dir' => attempt.call { ClaudeAgentSDK::SessionStores.projects_dir }, - 'key_for_pwd' => attempt.call { ClaudeAgentSDK.project_key_for_directory(Dir.pwd) }, - 'key_for_default' => attempt.call { ClaudeAgentSDK.project_key_for_directory }, - 'listed_for_pwd' => attempt.call { ClaudeAgentSDK.list_sessions(directory: Dir.pwd).map(&:session_id) } - ) RUBY end @@ -48,10 +40,22 @@ def hex(string) [string.unpack1('H*'), 'UTF-8', true] end - def run_under_c_locale(chdir:, config:) - env = { 'LC_ALL' => 'C', 'LANG' => 'C', 'LC_CTYPE' => 'C', 'CLAUDE_CONFIG_DIR' => config } + def key_of(path) + hex(ClaudeAgentSDK::Sessions.sanitize_path(path)) + end + + def record_session(directory, config = config_dir) + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.prompt(:prompt, 'hi') + transcript.write(File.join(config, 'projects', ClaudeAgentSDK::Sessions.sanitize_path(directory), + "#{session_id}.jsonl")) + end + + # What +script+ prints as JSON in a child started with LC_ALL=C in +chdir+. + def run_under_c_locale(script, chdir:, **env) + env = { 'LC_ALL' => 'C', 'LANG' => 'C', 'LC_CTYPE' => 'C' }.merge(env.transform_keys(&:to_s)) output, errors, status = Open3.capture3(env, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), - '-e', probe, chdir: chdir) + '-e', "#{prelude}\n#{script}", chdir: chdir) raise "probe failed: #{errors}" unless status.success? JSON.parse(output) @@ -60,18 +64,59 @@ def run_under_c_locale(chdir:, config:) it 'reads a non-ASCII CLAUDE_CONFIG_DIR and lists the sessions of Dir.pwd' do config = File.join(cwd, "#{accented}-config") project = File.join(cwd, "#{accented}-project").tap { |dir| FileUtils.mkdir_p(dir) } - transcript = CLITranscript.new(session_id: session_id, cwd: project) - transcript.prompt(:prompt, 'hi') - transcript.write(File.join(config, 'projects', ClaudeAgentSDK::Sessions.sanitize_path(project), - "#{session_id}.jsonl")) + record_session(project, config) + script = <<~RUBY + puts JSON.generate( + 'external' => Encoding.default_external.name, + 'config_dir' => attempt.call { ClaudeAgentSDK::Sessions.config_dir }, + 'projects_dir' => attempt.call { ClaudeAgentSDK::SessionStores.projects_dir }, + 'key_for_pwd' => attempt.call { ClaudeAgentSDK.project_key_for_directory(Dir.pwd) }, + 'key_for_default' => attempt.call { ClaudeAgentSDK.project_key_for_directory }, + 'listed_for_pwd' => attempt.call { ClaudeAgentSDK.list_sessions(directory: Dir.pwd).map(&:session_id) } + ) + RUBY - seen = run_under_c_locale(chdir: project, config: config) + seen = run_under_c_locale(script, chdir: project, CLAUDE_CONFIG_DIR: config) expect(seen['external']).to eq('US-ASCII') expect(seen['config_dir']).to eq(hex(config)) expect(seen['projects_dir']).to eq(hex(File.join(config, 'projects'))) - expect(seen['key_for_pwd']).to eq(hex(ClaudeAgentSDK::Sessions.sanitize_path(project))) + expect(seen['key_for_pwd']).to eq(key_of(project)) expect(seen['key_for_default']).to eq(seen['key_for_pwd']) expect(seen['listed_for_pwd']).to eq([session_id]) end + + # A path File.realpath cannot resolve is walked component by component. + # The walk joins what it is given with the working directory (BINARY here) + # and with the targets of the links on the way (US-ASCII here, whatever + # their bytes): a session recorded in café-checkout, reached through the + # link `current` after café-checkout was removed, and a relative path with + # a non-ASCII name of its own. + it 'resolves a missing path through a link with a non-ASCII target, and a non-ASCII relative one' do + checkout = File.join(cwd, "#{accented}-checkout") + link = File.join(cwd, 'current').tap { |path| File.symlink(checkout, path) } + project = File.join(cwd, "#{accented}-project").tap { |dir| FileUtils.mkdir_p(dir) } + record_session(checkout) + script = <<~'RUBY' + link = ENV.fetch('SESSION_LINK') + id = ENV.fetch('SESSION_ID') + puts JSON.generate( + 'key_through_link' => attempt.call { ClaudeAgentSDK.project_key_for_directory(link) }, + 'listed_through_link' => attempt.call { ClaudeAgentSDK.list_sessions(directory: link).map(&:session_id) }, + 'title_after_rename' => attempt.call do + ClaudeAgentSDK.rename_session(session_id: id, title: 'Old checkout', directory: link) + ClaudeAgentSDK.get_session_info(session_id: id, directory: link).custom_title + end, + 'key_for_relative' => attempt.call { ClaudeAgentSDK.project_key_for_directory("caf#{[0xE9].pack('U')}-gone") } + ) + RUBY + + seen = run_under_c_locale(script, chdir: project, CLAUDE_CONFIG_DIR: config_dir, SESSION_LINK: link, + SESSION_ID: session_id) + + expect(seen['key_through_link']).to eq(key_of(checkout)) + expect(seen['listed_through_link']).to eq([session_id]) + expect(seen['title_after_rename']).to eq(hex('Old checkout')) + expect(seen['key_for_relative']).to eq(key_of(File.join(project, "#{accented}-gone"))) + end end diff --git a/spec/unit/session_deleted_directory_spec.rb b/spec/unit/session_deleted_directory_spec.rb index 104ed410..855fa403 100644 --- a/spec/unit/session_deleted_directory_spec.rb +++ b/spec/unit/session_deleted_directory_spec.rb @@ -12,6 +12,7 @@ include_context 'with a Claude config dir' let(:session_id) { '2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091' } + let(:accented) { "caf#{[0xE9].pack('U')}" } # "café", NFC before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } @@ -223,7 +224,7 @@ def record_session(directory) # File.realpath does not expand a leading ~, so a directory named with one # is resolved as a missing path whether it exists or not. context 'when the directory is named with a leading ~' do - let(:home) { File.join(cwd, 'home') } + let(:home) { File.join(cwd, "#{accented}-home") } around do |example| previous_home = ENV.fetch('HOME', nil) # rubocop:disable Style/EnvHome -- raw value; nil when unset @@ -241,10 +242,26 @@ def record_session(directory) expect(ClaudeAgentSDK.list_sessions(directory: '~/project').map(&:session_id)).to eq([session_id]) expect(ClaudeAgentSDK.project_key_for_directory('~/gone/../project')) .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(home, 'project'))) + expect(ClaudeAgentSDK.project_key_for_directory("~/#{accented}-gone")) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(home, "#{accented}-gone"))) expect(ClaudeAgentSDK.project_key_for_directory('~')).to eq(ClaudeAgentSDK::Sessions.sanitize_path(home)) end end + # Non-ASCII names in every part the walk puts together: the working + # directory, the path, and the target of a link on the way. + it 'resolves non-ASCII missing paths through a link with a non-ASCII target, absolute and relative' do + checkout = File.join(cwd, "#{accented}-checkout") + project = File.join(cwd, "#{accented}-project").tap { |dir| FileUtils.mkdir_p(dir) } + File.symlink(checkout, File.join(project, 'current')) + app_key = ClaudeAgentSDK::Sessions.sanitize_path(File.join(checkout, "#{accented}-app")) + + expect(ClaudeAgentSDK.project_key_for_directory(File.join(project, 'current', "#{accented}-app"))).to eq(app_key) + Dir.chdir(project) do + expect(ClaudeAgentSDK.project_key_for_directory(File.join('current', "#{accented}-app"))).to eq(app_key) + end + end + it 'resolves a missing path under symlinks that point at each other without looping' do one = File.join(cwd, 'one') two = File.join(cwd, 'two') From b724e0faf2d15d349de62e9ab62706675892a6a6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:41:35 +0800 Subject: [PATCH 143/194] Send a sandbox Hash under the keys the CLI reads, in any spelling The sandbox: option takes a typed SandboxSettings or a Hash, and a SandboxSettings takes a typed or a Hash network / filesystem. The typed classes have snake_case attributes and write the camelCase keys the CLI reads; a Hash was forwarded as written. The CLI's sandbox schema keeps unknown keys at the top level and strips them below it, so a Hash in Ruby spelling was accepted without an error and not applied: sandbox: { enabled: true, auto_allow_bash_if_sandboxed: false, network: { denied_domains: ['evil.example'] }, filesystem: { deny_read: ['/private/etc/ssh'] } } came back from the CLI's get_settings as {"enabled":true,"network":{}, "filesystem":{}}: no deny rule, and autoAllowBashIfSandboxed left at its default. SandboxSettings.new(network: { denied_domains: [...] }) lost its rules the same way, although a misspelled key of SandboxSettings itself raises. A Hash now stands for the typed value with the same fields. The vocabulary is a table kept next to the three sandbox classes (SandboxKeys, @api private): every attribute and the key its #to_h emits, pinned by a spec that walks the classes. At the top level and inside a Hash network / filesystem, a known field may be written as its attribute name or its wire key, as a Symbol or a String, and is sent under the wire key. This applies to a Hash sandbox: and to a typed SandboxSettings holding Hashes (SandboxSettings#to_h). Any other key is sent as written, so fields the classes do not model (allowAppleEvents, network.strictAllowlist, filesystem.disabled) still get through, and the values of ignore_violations and ripgrep are not touched. The caller's Hash is never modified. When one Hash spells a field both ways the wire spelling is sent, whichever comes first: that is the one the CLI honored until now. A key given as a Symbol and as a String is written once, the later one (json 3.x raised JSON::GeneratorError on such a Hash; 2.x wrote it twice). One rule concerns values. A known key holding nil is left out, as #to_h leaves out a nil attribute. CLI 2.1.287 rejects null under every one of these keys ("sandbox.excludedCommands: Expected array, but received null") and then drops the whole --settings value, sandbox included. Renaming alone would have turned { enabled: true, excluded_commands: nil }, whose nil the CLI ignored, into a session without any sandbox. User-visible: sandbox keys written in snake_case now take effect, so a Hash that carried them is validated and applied by the CLI like its camelCase form. { enabled: true, excludedCommands: nil }, which made the CLI discard the settings, is now sent without that key. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/command_builder.rb | 15 +- lib/claude_agent_sdk/types/option_values.rb | 130 +++++- spec/unit/sandbox_hash_keys_spec.rb | 454 ++++++++++++++++++++ 3 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 spec/unit/sandbox_hash_keys_spec.rb diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index c0882c6c..898bb425 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -327,12 +327,25 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo # read from JSON spell that key as a String; left next to the Symbol # key below it would be written twice (json 3.x raises on that). settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' } - settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox + settings_hash[:sandbox] = sandbox_section(@options.sandbox) end cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty? end + # The sandbox section as the CLI reads it. A Hash stands for the + # SandboxSettings with the same fields: the CLI only knows the camelCase + # keys that class writes, and it ignores the others without an error, so + # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the + # typed value would be (SandboxKeys). Booleans go out as they are. + def sandbox_section(sandbox) + case sandbox + when SandboxSettings then sandbox.to_h + when Hash then SandboxKeys.normalize(sandbox) + else sandbox + end + end + def append_budget(cmd) cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index e0fd8422..0ba227e7 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -158,7 +158,14 @@ def to_h end end - # Sandbox settings for isolated command execution + # Sandbox settings for isolated command execution. + # + # +network+ and +filesystem+ take a SandboxNetworkConfig / + # SandboxFilesystemConfig or a Hash. A Hash may spell the fields of those + # classes as their attributes (+denied_domains+) or as the CLI does + # (+deniedDomains+), with Symbol or String keys; any other key is sent as + # written. The same holds for a Hash given as +sandbox:+ in place of a + # SandboxSettings. class SandboxSettings < Type include Type::OptionValue @@ -169,15 +176,15 @@ class SandboxSettings < Type :ignore_violations, :enable_weaker_nested_sandbox, :enable_weaker_network_isolation, :ripgrep - def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional key per sandbox field + def to_h result = {} result[:enabled] = @enabled unless @enabled.nil? result[:failIfUnavailable] = @fail_if_unavailable unless @fail_if_unavailable.nil? result[:autoAllowBashIfSandboxed] = @auto_allow_bash_if_sandboxed unless @auto_allow_bash_if_sandboxed.nil? result[:excludedCommands] = @excluded_commands if @excluded_commands result[:allowUnsandboxedCommands] = @allow_unsandboxed_commands unless @allow_unsandboxed_commands.nil? - result[:network] = @network.is_a?(SandboxNetworkConfig) ? @network.to_h : @network if @network - result[:filesystem] = @filesystem.is_a?(SandboxFilesystemConfig) ? @filesystem.to_h : @filesystem if @filesystem + result[:network] = SandboxKeys.network(@network) if @network + result[:filesystem] = SandboxKeys.filesystem(@filesystem) if @filesystem result[:ignoreViolations] = @ignore_violations if @ignore_violations result[:enableWeakerNestedSandbox] = @enable_weaker_nested_sandbox unless @enable_weaker_nested_sandbox.nil? unless @enable_weaker_network_isolation.nil? @@ -188,6 +195,121 @@ def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComple end end + # The spellings a sandbox Hash may use for a field of the three sandbox + # classes above, mapped to the key their #to_h emits, which is the key the + # CLI reads. A Hash given as the +sandbox:+ option, or as the network / + # filesystem of a SandboxSettings, stands for the typed value with the same + # fields: its keys may be Symbols or Strings, the attribute names + # (snake_case) or the wire keys (camelCase). + # + # Every attribute is listed, also the ones whose name is their wire key. A + # key that is not listed is sent as written, so a field of the CLI that the + # typed classes do not model (allowAppleEvents, network.strictAllowlist, + # filesystem.disabled, ...) still gets through, in the CLI's own spelling. + # spec/unit/sandbox_hash_keys_spec.rb walks the attributes of the three + # classes and fails when one of them and these tables disagree. + # + # @api private + module SandboxKeys + # SandboxSettings attribute => wire key. + TOP_LEVEL = { + 'enabled' => :enabled, + 'fail_if_unavailable' => :failIfUnavailable, + 'auto_allow_bash_if_sandboxed' => :autoAllowBashIfSandboxed, + 'excluded_commands' => :excludedCommands, + 'allow_unsandboxed_commands' => :allowUnsandboxedCommands, + 'network' => :network, + 'filesystem' => :filesystem, + 'ignore_violations' => :ignoreViolations, + 'enable_weaker_nested_sandbox' => :enableWeakerNestedSandbox, + 'enable_weaker_network_isolation' => :enableWeakerNetworkIsolation, + 'ripgrep' => :ripgrep + }.freeze + + # SandboxNetworkConfig attribute => wire key. + NETWORK = { + 'allowed_domains' => :allowedDomains, + 'denied_domains' => :deniedDomains, + 'allow_managed_domains_only' => :allowManagedDomainsOnly, + 'allow_unix_sockets' => :allowUnixSockets, + 'allow_all_unix_sockets' => :allowAllUnixSockets, + 'allow_local_binding' => :allowLocalBinding, + 'allow_mach_lookup' => :allowMachLookup, + 'http_proxy_port' => :httpProxyPort, + 'socks_proxy_port' => :socksProxyPort + }.freeze + + # SandboxFilesystemConfig attribute => wire key. + FILESYSTEM = { + 'allow_write' => :allowWrite, + 'deny_write' => :denyWrite, + 'deny_read' => :denyRead, + 'allow_read' => :allowRead, + 'allow_managed_read_paths_only' => :allowManagedReadPathsOnly + }.freeze + + # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire + # keys, at the top level and inside a Hash network / filesystem. Values + # are not rewritten: ignore_violations and ripgrep hold structures of + # the CLI's own. + def self.normalize(sandbox) + normalized = rename(sandbox, TOP_LEVEL) + { network: NETWORK, filesystem: FILESYSTEM }.each do |section, table| + normalized[section] = rename(normalized[section], table) if normalized[section].is_a?(Hash) + end + normalized + end + + # The network of a SandboxSettings as the CLI reads it. + def self.network(section) + case section + when SandboxNetworkConfig then section.to_h + when Hash then rename(section, NETWORK) + else section + end + end + + # The filesystem of a SandboxSettings as the CLI reads it. + def self.filesystem(section) + case section + when SandboxFilesystemConfig then section.to_h + when Hash then rename(section, FILESYSTEM) + else section + end + end + + # A known key ends up as the one Symbol #to_h emits, so the spellings of + # a field (attribute name or wire key, Symbol or String) cannot reach + # JSON.generate side by side: json 3.x raises on a key given as a Symbol + # and as a String, 2.x writes it twice. When a Hash carries both + # spellings of a field the wire spelling wins, whichever comes first; + # between two keys in the same spelling the later one does. + # + # A known key holding nil is left out, as #to_h leaves out a nil + # attribute. The CLI rejects null under every one of these keys, and + # when it does it drops the whole --settings value, sandbox included. + def self.rename(hash, table) + renamed = {} + wire_spelled = {} + hash.each do |key, value| + name = key.to_s + wire = table[name] || table.each_value.find { |candidate| candidate.name == name } + if wire.nil? + renamed[key] = value + elsif value.nil? + next + elsif wire.name == name + wire_spelled[wire] = true + renamed[wire] = value + elsif !wire_spelled.key?(wire) + renamed[wire] = value + end + end + renamed + end + private_class_method :rename + end + # API-side task budget in tokens. # When set, the model is made aware of its remaining token budget so it can # pace tool use and wrap up before the limit. diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb new file mode 100644 index 00000000..9d4e7cad --- /dev/null +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -0,0 +1,454 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# `sandbox:` takes a typed SandboxSettings or a Hash, and a SandboxSettings +# takes a typed or a Hash `network` / `filesystem`. The typed classes have +# snake_case attributes and write the camelCase keys the CLI reads; a Hash +# used to be forwarded as written. The CLI's sandbox schema keeps unknown keys +# at the top level and strips them below it, so `deny_read`, `denied_domains` +# or `auto_allow_bash_if_sandboxed` were accepted without an error and never +# applied: the session ran with a weaker sandbox than the one written. +# +# The examples assert on the --settings argument the CLI is started with (and +# on SandboxSettings#to_h, the public form of the same section). +RSpec.describe 'sandbox settings written as a Hash' do + # The --settings argument built for this `sandbox:` option. + def settings_argument(sandbox, **options) + cmd = ClaudeAgentSDK::CommandBuilder.new( + '/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(sandbox: sandbox, **options) + ).build + cmd[cmd.index('--settings') + 1] + end + + # Its sandbox section, as the CLI's JSON parser reads it. + def sandbox_section(sandbox) + JSON.parse(settings_argument(sandbox)).fetch('sandbox') + end + + # What JSON makes of a Ruby value (Symbol keys become Strings). + def on_the_wire(value) + JSON.parse(JSON.generate(value)) + end + + describe 'the same sandbox, written three ways' do + typed = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']), + filesystem: ClaudeAgentSDK::SandboxFilesystemConfig.new(deny_read: ['/private/etc/ssh']) + ) + snake_case_hash = { + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['/private/etc/ssh'] } + } + typed_outer_hash_inner = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['/private/etc/ssh'] } + ) + + it 'reaches the CLI under its own keys when every level is typed' do + expect(settings_argument(typed)).to eq( + '{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false,"excludedCommands":["docker"],' \ + '"network":{"deniedDomains":["evil.example"]},"filesystem":{"denyRead":["/private/etc/ssh"]}}}' + ) + end + + it 'builds the identical --settings value from a snake_case Hash' do + expect(settings_argument(snake_case_hash)).to eq(settings_argument(typed)) + end + + it 'builds the identical --settings value from a typed SandboxSettings holding snake_case Hashes' do + expect(settings_argument(typed_outer_hash_inner)).to eq(settings_argument(typed)) + end + + it 'gives SandboxSettings#to_h the keys a typed network and filesystem give it' do + expect(typed_outer_hash_inner.to_h).to eq(typed.to_h) + end + + it 'builds it from a snake_case Hash with String keys, such as one read from YAML or JSON' do + expect(settings_argument(JSON.parse(JSON.generate(snake_case_hash)))).to eq(settings_argument(typed)) + end + + it 'still merges into settings that carry their own sandbox section' do + settings = '{"permissions":{"deny":["WebFetch"]},"sandbox":{"enabled":false}}' + + expect(JSON.parse(settings_argument(snake_case_hash, settings: settings))).to eq( + 'permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => on_the_wire(typed.to_h) + ) + end + end + + # Every field of the three sandbox classes: attribute => [wire key, value]. + # The wire keys are spelled out here, not derived from the SDK, so a renamed + # key on either side fails the examples below. + network_fields = { + allowed_domains: ['allowedDomains', %w[rubygems.org *.github.com]], + denied_domains: ['deniedDomains', %w[evil.example]], + allow_managed_domains_only: ['allowManagedDomainsOnly', false], + allow_unix_sockets: ['allowUnixSockets', %w[/var/run/docker.sock]], + allow_all_unix_sockets: ['allowAllUnixSockets', false], + allow_local_binding: ['allowLocalBinding', true], + allow_mach_lookup: ['allowMachLookup', %w[com.apple.coreservices.launchservicesd]], + http_proxy_port: ['httpProxyPort', 8080], + socks_proxy_port: ['socksProxyPort', 1080] + }.freeze + + filesystem_fields = { + allow_write: ['allowWrite', %w[/work/app/tmp]], + deny_write: ['denyWrite', %w[/work/app/config]], + deny_read: ['denyRead', %w[/private/etc/ssh ~/.aws]], + allow_read: ['allowRead', %w[/work/app]], + allow_managed_read_paths_only: ['allowManagedReadPathsOnly', false] + }.freeze + + # `network` and `filesystem` are attributes too; each form below fills them + # in with the nested section in the spelling it is about. + top_level_fields = { + enabled: ['enabled', true], + fail_if_unavailable: ['failIfUnavailable', true], + auto_allow_bash_if_sandboxed: ['autoAllowBashIfSandboxed', false], + excluded_commands: ['excludedCommands', %w[docker]], + allow_unsandboxed_commands: ['allowUnsandboxedCommands', false], + network: ['network', nil], + filesystem: ['filesystem', nil], + ignore_violations: ['ignoreViolations', { 'file' => ['/tmp/*'], 'network' => ['localhost'] }], + enable_weaker_nested_sandbox: ['enableWeakerNestedSandbox', false], + enable_weaker_network_isolation: ['enableWeakerNetworkIsolation', false], + ripgrep: ['ripgrep', { 'command' => '/usr/bin/rg', 'args' => ['--hidden'] }] + }.freeze + + # The spellings a Hash may use for those fields. + spellings = { + 'snake_case Symbol keys' => ->(fields) { fields.transform_values(&:last) }, + 'snake_case String keys' => ->(fields) { fields.to_h { |name, (_wire, value)| [name.to_s, value] } }, + 'camelCase Symbol keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire.to_sym, value] } }, + 'camelCase String keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire, value] } } + }.freeze + attributes_of = spellings.fetch('snake_case Symbol keys') + wire_form_of = spellings.fetch('camelCase String keys') + + # The top-level fields around one network and one filesystem section. + around_sections = lambda do |network, filesystem| + top_level_fields.merge(network: ['network', network], filesystem: ['filesystem', filesystem]) + end + + describe 'every field of every sandbox class' do + # The section the CLI must receive, whichever way it was written. + wire = wire_form_of.call( + around_sections.call(wire_form_of.call(network_fields), wire_form_of.call(filesystem_fields)) + ) + + it 'has a field for every attribute' do + expect( + 'SandboxSettings' => top_level_fields.keys.map(&:to_s).sort, + 'SandboxNetworkConfig' => network_fields.keys.map(&:to_s).sort, + 'SandboxFilesystemConfig' => filesystem_fields.keys.map(&:to_s).sort + ).to eq( + 'SandboxSettings' => ClaudeAgentSDK::SandboxSettings.attribute_names, + 'SandboxNetworkConfig' => ClaudeAgentSDK::SandboxNetworkConfig.attribute_names, + 'SandboxFilesystemConfig' => ClaudeAgentSDK::SandboxFilesystemConfig.attribute_names + ) + end + + it 'is written under its wire key when every level is typed' do + typed = ClaudeAgentSDK::SandboxSettings.new( + attributes_of.call( + around_sections.call( + ClaudeAgentSDK::SandboxNetworkConfig.new(attributes_of.call(network_fields)), + ClaudeAgentSDK::SandboxFilesystemConfig.new(attributes_of.call(filesystem_fields)) + ) + ) + ) + + expect(sandbox_section(typed)).to eq(wire) + end + + spellings.each do |inner_spelling, inner| + it "is written the same way from a typed SandboxSettings holding Hashes with #{inner_spelling}" do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + attributes_of.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) + ) + + expect(sandbox_section(sandbox)).to eq(wire) + end + + spellings.each do |outer_spelling, outer| + it "is written the same way from a Hash with #{outer_spelling} holding Hashes with #{inner_spelling}" do + sandbox = outer.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) + + expect(sandbox_section(sandbox)).to eq(wire) + end + end + end + end + + # The vocabulary is a hand-written table next to the typed classes. Walk + # every attribute of every class so the two cannot drift: a new attribute, + # or a renamed wire key in a #to_h, fails here until the table follows. + describe 'ClaudeAgentSDK::SandboxKeys' do + { + 'TOP_LEVEL' => ['SandboxSettings', around_sections.call({ deniedDomains: %w[evil.example] }, { denyRead: %w[~/.aws] })], + 'NETWORK' => ['SandboxNetworkConfig', network_fields], + 'FILESYSTEM' => ['SandboxFilesystemConfig', filesystem_fields] + }.each do |table_name, (class_name, fields)| + describe table_name do + let(:table) { ClaudeAgentSDK::SandboxKeys.const_get(table_name) } + let(:klass) { ClaudeAgentSDK.const_get(class_name) } + + it "maps every #{class_name} attribute to the key its #to_h emits for it" do + emitted = klass.attribute_names.to_h do |name| + [name, klass.new(name => fields.fetch(name.to_sym).last).to_h.keys] + end + + expect(table.transform_values { |wire_key| [wire_key] }).to eq(emitted) + end + + it 'agrees with the wire keys this file spells out' do + expect(table).to eq(fields.to_h { |name, (wire_key, _value)| [name.to_s, wire_key.to_sym] }) + end + end + end + end + + describe 'a key the typed classes do not model' do + # allowAppleEvents, network.strictAllowlist and filesystem.disabled are + # fields of CLI 2.1.287's sandbox schema that no typed class has yet; a + # Hash is the only way to send them. + it 'is sent as written, at every level' do + sandbox = { + enabled: true, + allowAppleEvents: true, + future_field: { inner_key: 1 }, + 'x-vendor' => nil, + network: { denied_domains: ['evil.example'], strictAllowlist: true, future_field: 1 }, + filesystem: { deny_read: ['~/.aws'], 'disabled' => false, 'future_field' => [2] } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'allowAppleEvents' => true, + 'future_field' => { 'inner_key' => 1 }, + 'x-vendor' => nil, + 'network' => { 'deniedDomains' => ['evil.example'], 'strictAllowlist' => true, 'future_field' => 1 }, + 'filesystem' => { 'denyRead' => ['~/.aws'], 'disabled' => false, 'future_field' => [2] } + ) + end + + it 'is sent as written inside a typed SandboxSettings too' do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, + network: { denied_domains: ['evil.example'], strictAllowlist: true }, + filesystem: { 'deny_read' => ['~/.aws'], 'disabled' => false } + ) + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'network' => { 'deniedDomains' => ['evil.example'], 'strictAllowlist' => true }, + 'filesystem' => { 'denyRead' => ['~/.aws'], 'disabled' => false } + ) + expect(sandbox.to_h).to eq( + enabled: true, + network: { deniedDomains: ['evil.example'], strictAllowlist: true }, + filesystem: { denyRead: ['~/.aws'], 'disabled' => false } + ) + end + + it 'includes a name the SDK only knows at another level' do + sandbox = { + enabled: true, + deny_read: ['~/.aws'], + network: { excluded_commands: ['docker'], deny_read: ['~/.aws'] }, + filesystem: { denied_domains: ['evil.example'], auto_allow_bash_if_sandboxed: false } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'deny_read' => ['~/.aws'], + 'network' => { 'excluded_commands' => ['docker'], 'deny_read' => ['~/.aws'] }, + 'filesystem' => { 'denied_domains' => ['evil.example'], 'auto_allow_bash_if_sandboxed' => false } + ) + end + end + + describe 'the value of a field' do + # ignore_violations and ripgrep hold Hashes of their own, and no typed + # class models what is inside them: a key in there that happens to be + # spelled like a sandbox field is not one. + it 'is not rewritten' do + inner = { command: '/usr/bin/rg', args: ['--hidden'], deny_read: 'kept', 'allowed_domains' => 'kept' } + sandbox = { + enabled: true, + ripgrep: inner, + ignore_violations: { 'excluded_commands' => ['/tmp/*'], network: { denied_domains: ['kept'] } }, + network: { allow_unix_sockets: ['/var/run/docker.sock'], allow_mach_lookup: [{ deny_read: 'kept' }] } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'ripgrep' => on_the_wire(inner), + 'ignoreViolations' => { 'excluded_commands' => ['/tmp/*'], 'network' => { 'denied_domains' => ['kept'] } }, + 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => [{ 'deny_read' => 'kept' }] } + ) + end + end + + describe 'a Hash that spells one field both ways' do + it 'sends the camelCase spelling at the top level, whichever comes first' do + sandboxes = [ + { enabled: true, auto_allow_bash_if_sandboxed: true, autoAllowBashIfSandboxed: false }, + { enabled: true, autoAllowBashIfSandboxed: false, auto_allow_bash_if_sandboxed: true }, + { 'enabled' => true, 'auto_allow_bash_if_sandboxed' => true, 'autoAllowBashIfSandboxed' => false }, + { enabled: true, 'autoAllowBashIfSandboxed' => false, auto_allow_bash_if_sandboxed: true } + ] + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }) + .to eq([{ 'enabled' => true, 'autoAllowBashIfSandboxed' => false }] * 4) + end + + it 'sends the camelCase spelling inside network and filesystem, whichever comes first' do + sandboxes = [ + { network: { denied_domains: [], deniedDomains: ['evil.example'] }, + filesystem: { denyRead: ['/private/etc/ssh', '~/.aws'], deny_read: [] } }, + { 'network' => { 'deniedDomains' => ['evil.example'], 'denied_domains' => [] }, + 'filesystem' => { 'deny_read' => [], 'denyRead' => ['/private/etc/ssh', '~/.aws'] } }, + ClaudeAgentSDK::SandboxSettings.new( + network: { deniedDomains: ['evil.example'], denied_domains: [] }, + filesystem: { deny_read: [], denyRead: ['/private/etc/ssh', '~/.aws'] } + ) + ] + expected = { + 'network' => { 'deniedDomains' => ['evil.example'] }, + 'filesystem' => { 'denyRead' => ['/private/etc/ssh', '~/.aws'] } + } + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }).to eq([expected] * 3) + end + + it 'writes the field once' do + argument = settings_argument( + { excluded_commands: ['git'], excludedCommands: ['docker'], + network: { deniedDomains: ['evil.example'], denied_domains: ['other.example'] } } + ) + + expect(argument.scan('"excludedCommands"').size).to eq(1) + expect(argument.scan('"deniedDomains"').size).to eq(1) + expect(argument).not_to include('excluded_commands', 'denied_domains') + end + + # json 3.x raises on a Hash that carries one key as a Symbol and as a + # String; json 2.x writes it twice. + it 'writes a key once when a Symbol and a String spell it, taking the later one' do + argument = settings_argument( + { enabled: false, 'enabled' => true, + 'network' => { 'deniedDomains' => ['other.example'] }, + network: { deniedDomains: [], 'deniedDomains' => ['evil.example'] } } + ) + + expect(JSON.parse(argument).fetch('sandbox')).to eq( + 'enabled' => true, 'network' => { 'deniedDomains' => ['evil.example'] } + ) + expect(%w[enabled network deniedDomains].map { |key| argument.scan("\"#{key}\"").size }).to eq([1, 1, 1]) + end + + it 'takes the later one of a snake_case Symbol and a snake_case String too' do + sandbox = { + 'excluded_commands' => ['git'], excluded_commands: ['docker'], + filesystem: { deny_read: [], 'deny_read' => ['~/.aws'] } + } + + expect(sandbox_section(sandbox)).to eq('excludedCommands' => ['docker'], 'filesystem' => { 'denyRead' => ['~/.aws'] }) + end + end + + # The typed classes leave a nil attribute out of #to_h. A Hash stands for + # the typed value with the same fields, so a known key holding nil is left + # out as well, in either spelling. It matters: CLI 2.1.287 rejects null + # under every one of these keys ("sandbox.excludedCommands: Expected array, + # but received null") and then drops the whole --settings value, sandbox + # included. Written in snake_case the same key used to be one the CLI did + # not know and ignored. + describe 'a nil value under a known key' do + typed = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, excluded_commands: nil, auto_allow_bash_if_sandboxed: nil, filesystem: nil, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example'], allowed_domains: nil) + ) + expected = { 'enabled' => true, 'network' => { 'deniedDomains' => ['evil.example'] } } + + it 'is left out by the typed classes' do + expect(sandbox_section(typed)).to eq(expected) + end + + { + 'snake_case keys' => { + enabled: true, excluded_commands: nil, auto_allow_bash_if_sandboxed: nil, filesystem: nil, + network: { denied_domains: ['evil.example'], allowed_domains: nil } + }, + 'camelCase keys' => { + enabled: true, excludedCommands: nil, autoAllowBashIfSandboxed: nil, filesystem: nil, + network: { deniedDomains: ['evil.example'], allowedDomains: nil } + }, + 'String keys' => { + 'enabled' => true, 'excluded_commands' => nil, 'autoAllowBashIfSandboxed' => nil, 'filesystem' => nil, + 'network' => { 'denied_domains' => ['evil.example'], 'allowedDomains' => nil } + }, + 'a typed SandboxSettings holding a Hash' => ClaudeAgentSDK::SandboxSettings.new( + enabled: true, network: { denied_domains: ['evil.example'], allowed_domains: nil, 'allowLocalBinding' => nil } + ) + }.each do |form, sandbox| + it "is left out of a Hash with #{form}" do + expect(sandbox_section(sandbox)).to eq(expected) + end + end + + it 'does not stand in for a value the other spelling carries' do + sandbox = { filesystem: { deny_read: ['~/.aws'], denyRead: nil }, excludedCommands: nil, excluded_commands: ['docker'] } + + expect(sandbox_section(sandbox)).to eq('filesystem' => { 'denyRead' => ['~/.aws'] }, 'excludedCommands' => ['docker']) + end + + it 'keeps false, which is a value' do + sandbox = { enabled: false, auto_allow_bash_if_sandboxed: false, network: { allow_local_binding: false } } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => false, 'autoAllowBashIfSandboxed' => false, 'network' => { 'allowLocalBinding' => false } + ) + end + end + + describe 'what the caller did not write' do + it 'is not added: an enabled sandbox is sent without failIfUnavailable' do + expect([{ enabled: true }, { 'enabled' => true }, ClaudeAgentSDK::SandboxSettings.new(enabled: true)] + .map { |sandbox| sandbox_section(sandbox) }).to eq([{ 'enabled' => true }] * 3) + end + + it 'stays an empty section for an empty Hash' do + expect(settings_argument({})).to eq('{"sandbox":{}}') + end + end + + describe 'the Hash the caller passed' do + it 'is left untouched, so a frozen constant can be used for every session' do + sandbox = { + enabled: true, excluded_commands: ['docker'].freeze, + network: { denied_domains: ['evil.example'].freeze }.freeze + }.freeze + expected = { 'enabled' => true, 'excludedCommands' => ['docker'], 'network' => { 'deniedDomains' => ['evil.example'] } } + + expect([sandbox_section(sandbox), sandbox_section(sandbox)]).to eq([expected, expected]) + expect(sandbox).to eq( + enabled: true, excluded_commands: ['docker'], network: { denied_domains: ['evil.example'] } + ) + end + + it 'is not modified when a typed SandboxSettings holds it' do + network = { denied_domains: ['evil.example'], deniedDomains: ['other.example'], allowed_domains: nil }.freeze + sandbox = ClaudeAgentSDK::SandboxSettings.new(enabled: true, network: network) + + expect(sandbox.to_h).to eq(enabled: true, network: { deniedDomains: ['other.example'] }) + expect(network).to eq(denied_domains: ['evil.example'], deniedDomains: ['other.example'], allowed_domains: nil) + end + end +end From 774081a348755da658591cac08863219d42f57e2 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:46:09 +0800 Subject: [PATCH 144/194] Honor a Symbol type in system_prompt, tools and output_format Hashes These three options take a Hash tagged with a type. The tag was compared with String literals, so the Ruby spelling `type: :preset` matched no branch, and each option failed in its own way: - tools: { type: :preset, preset: :claude_code } sent the Hash's JSON text as the --tools value. The CLI reads that as a tool NAME, so the session started with no tools at all. - system_prompt: { type: :custom, prompt: '...' } sent no flag: the prompt was dropped. { type: :file, ... } and the append text of { type: :preset, ... } were dropped the same way, and so were exclude_dynamic_sections and snapshot on the initialize request. - output_format: { type: :json_schema, schema: {...} } sent the wrapper as the schema, and the CLI exits on it ("--json-schema is not a valid JSON Schema"). - output_format with `type` under one key style and `schema` under the other ({ 'type' => 'json_schema', schema: {...} }) sent no flag, which turned structured output off. thinking and the MCP server configs already read a Symbol type. The tag is now compared as a String at all five sites: the command builder's system prompt, tools and output format, and the two readers of the initialize fields. output_format finds `schema` under the other key style when the one `type` was written in does not have it. Nothing reads the value of `preset`, so `preset: :claude_code` needs no change. A type the SDK does not know does exactly what it did: no system prompt flag, a tools Hash sent as JSON text, an output_format Hash sent as the schema itself. No warning and no new error for those. User-visible: a Symbol-typed Hash now means what the String-typed one means. That includes its one check: { type: :custom } without a :prompt String raises the ArgumentError that { type: 'custom' } already raised, where it used to be ignored. Exploration finding: D-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk.rb | 5 +- lib/claude_agent_sdk/command_builder.rb | 37 ++- spec/unit/option_hash_symbol_type_spec.rb | 281 ++++++++++++++++++++++ 3 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 spec/unit/option_hash_symbol_type_spec.rb diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 1722a3e2..e30a115b 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -142,7 +142,8 @@ def self.extract_exclude_dynamic_sections(system_prompt) eds = system_prompt.exclude_dynamic_sections return eds if [true, false].include?(eds) elsif system_prompt.is_a?(Hash) - type = system_prompt[:type] || system_prompt['type'] + # The tag may be a Symbol (type: :preset), as CommandBuilder reads it. + type = (system_prompt[:type] || system_prompt['type']).to_s if type == 'preset' eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] } return eds if [true, false].include?(eds) @@ -163,7 +164,7 @@ def self.extract_system_prompt_snapshot(system_prompt) snapshot = system_prompt.snapshot return snapshot if [true, false].include?(snapshot) when Hash - type = system_prompt[:type] || system_prompt['type'] + type = (system_prompt[:type] || system_prompt['type']).to_s if %w[preset custom].include?(type) snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] } return snapshot if [true, false].include?(snapshot) diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index 898bb425..d08543f8 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -90,9 +90,16 @@ def append_system_prompt(cmd) end end + # The type tag of a Hash option, as a String: `type: :preset` is the + # natural Ruby spelling of `type: 'preset'`, and thinking and the MCP + # server configs already read it that way. A missing tag is '', which + # matches no branch, like any other tag the SDK does not know. + def hash_type(hash) + (hash[:type] || hash['type']).to_s + end + def append_hash_system_prompt(cmd, prompt_hash) - prompt_type = prompt_hash[:type] || prompt_hash['type'] - case prompt_type + case hash_type(prompt_hash) when 'file' prompt_path = prompt_hash[:path] || prompt_hash['path'] cmd.push('--system-prompt-file', prompt_path) if prompt_path @@ -433,7 +440,7 @@ def append_tools(cmd) when ToolsPreset cmd.push('--tools', 'default') when Hash - if (@options.tools[:type] || @options.tools['type']) == 'preset' + if hash_type(@options.tools) == 'preset' cmd.push('--tools', 'default') else cmd.push('--tools', JSON.generate(@options.tools)) @@ -444,13 +451,7 @@ def append_tools(cmd) def append_output_format(cmd) return unless @options.output_format - schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_schema' - @options.output_format[:schema] - elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema' - @options.output_format['schema'] - else - @options.output_format - end + schema = output_schema(@options.output_format) # A json_schema output_format with a nil/absent schema must skip the # flag — `--json-schema null` is rejected by the CLI (Python guards # `schema is not None`). @@ -460,6 +461,22 @@ def append_output_format(cmd) cmd.push('--json-schema', schema_json) end + # The schema of a { type: 'json_schema', schema: ... } output format; any + # other value is the schema itself. The tag may be a Symbol, and `schema` + # is read under the key style `type` was written in, or under the other + # one when that key is absent ({ 'type' => 'json_schema', schema: {...} }). + def output_schema(format) + return format unless format.is_a?(Hash) + + if format[:type].to_s == 'json_schema' + format.fetch(:schema) { format['schema'] } + elsif format['type'].to_s == 'json_schema' + format.fetch('schema') { format[:schema] } + else + format + end + end + def append_additional_dirs(cmd) (@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) } end diff --git a/spec/unit/option_hash_symbol_type_spec.rb b/spec/unit/option_hash_symbol_type_spec.rb new file mode 100644 index 00000000..8cc4c1e2 --- /dev/null +++ b/spec/unit/option_hash_symbol_type_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# system_prompt, tools and output_format take a Hash tagged with a `type`. +# The tag was compared with String literals, so the Ruby spelling +# `type: :preset` matched no branch: +# +# tools: { type: :preset, preset: :claude_code } sent the Hash's JSON +# text as a tool NAME, +# so the session had no +# tools at all +# system_prompt: { type: :custom, prompt: '...' } sent no flag: the +# prompt was dropped +# output_format: { type: :json_schema, schema: ... } sent the wrapper as +# the schema, which the +# CLI rejects +# +# and a Hash with `type` under one key style and `schema` under the other +# turned structured output off. The same Hashes with a String tag worked, and +# other options (thinking, MCP server configs) already took a Symbol tag. +RSpec.describe 'an option Hash tagged with a Symbol type' do + # The command line built for these options. + def argv(**options) + ClaudeAgentSDK::CommandBuilder.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + end + + # The value that follows +flag+ on that command line (nil without the flag). + def flag_value(cmd, flag) + index = cmd.index(flag) + index && cmd[index + 1] + end + + schema = { + type: 'object', + properties: { + verdict: { type: 'string', enum: %w[approve reject] }, + reasons: { type: 'array', items: { type: 'string' } } + }, + required: ['verdict'] + }.freeze + + # option => { form => [the Hash with a String tag, the same Hash in other spellings] } + forms = { + system_prompt: { + 'a custom prompt' => [ + { type: 'custom', prompt: 'You are a terse reviewer.' }, + { + 'a Symbol type' => { type: :custom, prompt: 'You are a terse reviewer.' }, + 'a Symbol type under String keys' => { 'type' => :custom, 'prompt' => 'You are a terse reviewer.' } + } + ], + 'a prompt file' => [ + { type: 'file', path: 'prompts/reviewer.md' }, + { + 'a Symbol type' => { type: :file, path: 'prompts/reviewer.md' }, + 'a Symbol type under String keys' => { 'type' => :file, 'path' => 'prompts/reviewer.md' } + } + ], + 'the preset with an appended prompt' => [ + { type: 'preset', preset: 'claude_code', append: 'Answer in one paragraph.' }, + { + 'a Symbol type and preset' => { type: :preset, preset: :claude_code, append: 'Answer in one paragraph.' }, + 'a Symbol type and preset under String keys' => { + 'type' => :preset, 'preset' => :claude_code, 'append' => 'Answer in one paragraph.' + } + } + ] + }, + tools: { + 'the preset' => [ + { type: 'preset', preset: 'claude_code' }, + { + 'a Symbol type and preset' => { type: :preset, preset: :claude_code }, + 'a Symbol type and preset under String keys' => { 'type' => :preset, 'preset' => :claude_code } + } + ] + }, + output_format: { + 'a JSON schema' => [ + { type: 'json_schema', schema: schema }, + { + 'a Symbol type' => { type: :json_schema, schema: schema }, + 'a Symbol type under String keys' => { 'type' => :json_schema, 'schema' => schema }, + 'a String-keyed type and a Symbol-keyed schema' => { 'type' => 'json_schema', schema: schema }, + 'a Symbol-keyed type and a String-keyed schema' => { type: 'json_schema', 'schema' => schema }, + 'a Symbol type and a String-keyed schema' => { type: :json_schema, 'schema' => schema } + } + ] + } + }.freeze + + describe 'the command line' do + it 'carries the custom prompt, the prompt file, the appended prompt, the tools preset and the schema ' \ + 'for a String type' do + custom, file, preset = forms.fetch(:system_prompt).values.map(&:first) + tools = forms.dig(:tools, 'the preset').first + output_format = forms.dig(:output_format, 'a JSON schema').first + + expect(flag_value(argv(system_prompt: custom), '--system-prompt')).to eq('You are a terse reviewer.') + expect(flag_value(argv(system_prompt: file), '--system-prompt-file')).to eq('prompts/reviewer.md') + expect(flag_value(argv(system_prompt: preset), '--append-system-prompt')).to eq('Answer in one paragraph.') + expect(argv(system_prompt: preset)).not_to include('--system-prompt') + expect(flag_value(argv(tools: tools), '--tools')).to eq('default') + expect(JSON.parse(flag_value(argv(output_format: output_format), '--json-schema'))).to eq(JSON.parse(JSON.generate(schema))) + end + + forms.each do |option, by_form| + by_form.each do |form, (string_typed, spellings)| + spellings.each do |spelling, hash| + it "is the same for #{option} given as #{form} with #{spelling}" do + expect(argv(option => hash)).to eq(argv(option => string_typed)) + end + end + end + end + + it 'is the one the typed classes build' do + typed = { + system_prompt: [ + ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'You are a terse reviewer.'), + ClaudeAgentSDK::SystemPromptFile.new(path: 'prompts/reviewer.md'), + ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', append: 'Answer in one paragraph.') + ], + tools: [ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code')] + } + symbol_typed = { + system_prompt: forms.fetch(:system_prompt).values.map { |(_string_typed, spellings)| spellings.values.first }, + tools: [forms.dig(:tools, 'the preset').last.values.first] + } + + built = symbol_typed.to_h { |option, hashes| [option, hashes.map { |hash| argv(option => hash) }] } + + expect(built).to eq(typed.to_h { |option, values| [option, values.map { |value| argv(option => value) }] }) + end + + # A custom prompt without its text must not fall through to the default + # Claude Code prompt; the String spelling already raised here. + it 'rejects a custom system prompt without a prompt, whichever way the type is written' do + errors = [{ type: 'custom' }, { type: :custom }, { 'type' => :custom, 'snapshot' => false }].map do |system_prompt| + argv(system_prompt: system_prompt) + rescue ArgumentError => e + e.message + end + + expect(errors).to eq(["system_prompt of type 'custom' requires a :prompt String"] * 3) + end + end + + describe 'a type the SDK does not know' do + it 'still sends no system prompt flag at all' do + [{ type: 'bogus', prompt: 'x' }, { type: :bogus, prompt: 'x' }, { prompt: 'x' }, { type: nil }].each do |system_prompt| + expect(argv(system_prompt: system_prompt)) + .not_to include('--system-prompt', '--system-prompt-file', '--append-system-prompt') + end + end + + it 'still sends a tools Hash as JSON text' do + expect([{ type: 'bogus', names: ['Read'] }, { type: :bogus, names: ['Read'] }, { names: ['Read'] }] + .map { |tools| flag_value(argv(tools: tools), '--tools') }) + .to eq(['{"type":"bogus","names":["Read"]}', '{"type":"bogus","names":["Read"]}', '{"names":["Read"]}']) + end + + it 'still sends an output_format Hash as the schema itself' do + bare_schema = { type: 'object', properties: { verdict: { type: 'string' } } } + + expect([bare_schema, bare_schema.merge(type: :object), { 'type' => 'text' }] + .map { |output_format| flag_value(argv(output_format: output_format), '--json-schema') }) + .to eq([JSON.generate(bare_schema), JSON.generate(bare_schema), '{"type":"text"}']) + end + end + + # exclude_dynamic_sections and snapshot travel on the initialize request, + # not on the command line, and are read off the same Hash. End to end + # through the real control protocol: a fake Transport records each control + # request as the CLI's JSON parser sees it and answers it. + describe 'the initialize request' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, requests:) + super() + @requests = requests + @queue = Thread::Queue.new + end + + def connect = @ready = true + def ready? = @ready + def end_input; end + def close = @queue.push(:eof) + + def write(data) + message = JSON.parse(data, symbolize_names: true) + return unless message[:type] == 'control_request' + + @requests << JSON.parse(data).fetch('request') + response = { subtype: 'success', request_id: message[:request_id], response: {} } + @queue.push({ type: 'control_response', response: response }) + end + + def read_messages + loop do + message = @queue.pop + break if message == :eof + + yield message + end + end + end + end + + # What a session with this system prompt asks for on initialize. + def prompt_fields_on_the_wire(system_prompt) + requests = [] + Sync do + client = ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: system_prompt), + transport_class: transport_class, transport_args: { requests: requests } + ) + client.connect + ensure + client&.disconnect + end + requests.find { |request| request['subtype'] == 'initialize' }.slice('excludeDynamicSections', 'systemPromptSnapshot') + end + + { + 'the preset' => [ + { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true, snapshot: false }, + { 'excludeDynamicSections' => true, 'systemPromptSnapshot' => false }, + { + 'a Symbol type' => { type: :preset, preset: :claude_code, exclude_dynamic_sections: true, snapshot: false }, + 'a Symbol type under String keys' => { + 'type' => :preset, 'preset' => :claude_code, 'exclude_dynamic_sections' => true, 'snapshot' => false + } + } + ], + 'a custom prompt' => [ + { type: 'custom', prompt: 'You are a terse reviewer.', snapshot: false }, + { 'systemPromptSnapshot' => false }, + { + 'a Symbol type' => { type: :custom, prompt: 'You are a terse reviewer.', snapshot: false }, + 'a Symbol type under String keys' => { + 'type' => :custom, 'prompt' => 'You are a terse reviewer.', 'snapshot' => false + } + } + ] + }.each do |form, (string_typed, fields, spellings)| + it "carries the prompt fields of #{form} with a String type" do + expect(prompt_fields_on_the_wire(string_typed)).to eq(fields) + end + + spellings.each do |spelling, hash| + it "carries the same fields for #{form} with #{spelling}" do + expect(prompt_fields_on_the_wire(hash)).to eq(fields) + end + end + end + + it 'still carries neither field for a prompt file or a type the SDK does not know' do + prompts = [ + { type: 'file', path: 'prompts/reviewer.md', snapshot: false, exclude_dynamic_sections: true }, + { type: :file, path: 'prompts/reviewer.md', snapshot: false, exclude_dynamic_sections: true }, + { type: :bogus, snapshot: false, exclude_dynamic_sections: true }, + { snapshot: false, exclude_dynamic_sections: true } + ] + + expect(prompts.map { |system_prompt| prompt_fields_on_the_wire(system_prompt) }).to eq([{}] * 4) + end + + it 'still leaves exclude_dynamic_sections to the preset: a custom prompt does not carry it' do + prompts = [ + { type: 'custom', prompt: 'You are a terse reviewer.', exclude_dynamic_sections: true }, + { type: :custom, prompt: 'You are a terse reviewer.', exclude_dynamic_sections: true } + ] + + expect(prompts.map { |system_prompt| prompt_fields_on_the_wire(system_prompt) }).to eq([{}] * 2) + end + end +end From 837715bdc23242816bb26048933036e030ea3177 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:21:57 +0800 Subject: [PATCH 145/194] Resolve the CLI executable once, to an absolute path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version probe runs in the process's working directory, but the CLI is spawned with `chdir: options.cwd`. A CLI path that was still relative at the spawn therefore named two files: the probe checked the one under the process cwd, and the session executed whatever sat at the same relative path inside the directory the agent was pointed at — with the host application's environment. Two ways to get there: an explicit `cli_path: 'bin/claude'`, and a `PATH` hit from a relative `PATH` entry (`bin`, `.`, an empty entry), whether found by `which claude` or by spawn's own lookup of a bare `cli_path: 'claude'`. CLAUDE_CLI_PATH was already absolutized; these two were not. The transport now settles one absolute path in #initialize and hands that same String to the probe, to CommandBuilder and to the spawn: - a `cli_path` with a separator is expanded against the process cwd (File.expand_path, like CLAUDE_CLI_PATH; a leading `~` now expands to the home directory); - a bare `cli_path` is searched on PATH by the SDK instead of by spawn: the PATH the child is given (options.env's when it sets one, else the process's, else Ruby's built-in default — what spawn searched), the same test (an executable regular file), relative entries expanded against the process cwd rather than skipped; - the `which claude` hit of discovery is absolutized. The discovery order is unchanged. A `cli_path` that names no file still raises CLINotFoundError from #connect, not from the constructor; the message now carries the absolute path. A path that could not be settled at all (a bare name that is not on PATH) is never handed to the probe or to spawn. Behavior a user can notice: a relative `cli_path`, or a relative PATH entry, that used to resolve inside `cwd:` now resolves against the process's working directory. The version probe of a bare `cli_path` now runs the binary the session runs when options.env sets PATH. Exploration finding: U-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- CLAUDE.md | 4 +- docs/cli-installer.md | 2 + .../subprocess_cli_transport.rb | 90 ++++++- spec/unit/cli_path_resolution_spec.rb | 241 ++++++++++++++++++ 4 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 spec/unit/cli_path_resolution_spec.rb diff --git a/CLAUDE.md b/CLAUDE.md index 0b433ffd..6b42f97d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,9 @@ User code ### CLI Discovery / Installer -`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` → common install locations. +`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` (absolutized the same way: a relative `PATH` entry makes `which` print a relative hit) → common install locations. + +**One absolute path, settled in `#initialize`** (`#resolve_cli_path`): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. Discovery returns absolute paths only; an explicit `cli_path` (`.to_s`, it may be a `Pathname`) with a separator goes through `File.expand_path`; a bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are expanded against the process cwd, not skipped). An explicit path that cannot be settled is kept as given and never executed: `#connect` skips the probe and raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file. `CLIInstaller` (`lib/claude_agent_sdk/cli_installer.rb`) downloads a pinned CLI into `vendor/claude` (relative to `CLIInstaller.root` when set — the Railtie sets it to `Rails.root` — else to `Dir.pwd`, resolved at call time; `root` is a plain frozen-String ivar, absolutized on set, read lock-free) for Docker/CI deploys: dist-tag → concrete version, manifest SHA-256 + declared size, streamed download (bounded by that size) to a random-named sibling temp file (`O_EXCL`), verify, `chmod 0755`, atomically write `VERSION` = version + verified checksum, and **finally** rename the temp file into place. Stdlib only; every failure (filesystem errors included) is wrapped in `CLIInstallError` with `cause` preserved. diff --git a/docs/cli-installer.md b/docs/cli-installer.md index e4ec42b4..6e9be956 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -108,3 +108,5 @@ With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in th 2. The vendored binary (`CLIInstaller.installed_path`, i.e. `vendor/claude` under `CLIInstaller.root` or the working directory — see [above](#where-vendorclaude-is)) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally 3. `which claude` 4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …) — only an executable regular file counts, and the `~` ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME) + +However the CLI is named, the transport settles on one absolute path before it runs anything, and uses that path for both the version check and the session. Anything relative is resolved against the process's working directory, never against `cwd:` — a relative `cli_path:` (a leading `~` expands to the home directory), and a `PATH` hit that came from a relative `PATH` entry (`bin`, `.`, an empty entry). A bare `cli_path: 'claude'` is looked up on `PATH` by the SDK itself: the `PATH` in `env:` when you set one there, otherwise the process's. A `cli_path:` that names no file raises `CLINotFoundError` from `connect`. diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 7218ad0e..d133ed86 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -29,6 +29,12 @@ class SubprocessCLITransport < Transport # rubocop:disable Metrics/ClassLength - SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK' # @api private CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH' + # What Ruby's own spawn searches for a bare command name when PATH is + # unset (dln_find_exe_r). #executable_on_path uses it for the same case, + # so a bare `cli_path` keeps resolving where it did. + # + # @api private + DEFAULT_EXEC_SEARCH_PATH = '/usr/local/bin:/usr/ucb:/usr/bin:/bin:.' # @api private VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2) # @api private @@ -126,7 +132,7 @@ def initialize(options_or_prompt = nil, options = nil) super() # Transport defines no state today; keep the chain intact if it ever does # Support both new single-arg form and legacy two-arg form @options = options.nil? ? options_or_prompt : options - @cli_path = @options.cli_path || find_cli + @cli_path = resolve_cli_path(@options.cli_path) @cwd = @options.cwd @process = nil @stdin = nil @@ -165,6 +171,8 @@ def initialize(options_or_prompt = nil, options = nil) # 3. `which claude`. # 4. Well-known install locations. # + # Every hit is returned as an absolute path — see #resolve_cli_path. + # # @api private def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered discovery probes (env, vendored, PATH, known locations) env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s @@ -193,11 +201,15 @@ def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Me cli = nil begin stdout, _status = Open3.capture2('which', 'claude') - cli = stdout.strip + hit = stdout.strip + # A relative PATH entry (`bin`, `.`, the empty entry) makes most + # `which` implementations print a relative hit. Absolutize it against + # the cwd `which` ran in, for the same reason as CLAUDE_CLI_PATH. + cli = File.expand_path(hit) unless hit.empty? rescue StandardError # which command failed, try common locations end - return cli if cli && !cli.empty? && File.executable?(cli) + return cli if cli && File.executable?(cli) # Try common locations. The home-relative ones are skipped when no # usable home exists (see #home_dir), so a HOME-less container still @@ -313,6 +325,12 @@ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Met should_pipe_stderr = @options.stderr || @options.debug_stderr || @options.extra_args.key?('debug-to-stderr') begin + # A path #resolve_cli_path could not settle is never handed to spawn: + # spawn searches PATH on its own, in this process's cwd, and then + # executes a relative hit inside options.cwd. Reported like any + # missing CLI, by the Errno::ENOENT branch below. + raise Errno::ENOENT, @cli_path unless cli_path_settled? + # Start process using Open3 # :uid mirrors Python's anyio.open_process(user=...): String username # or Integer uid (Unix; requires privileges — typically root). The @@ -920,6 +938,8 @@ def check_claude_version # including '0'/'false'/' '; unset or empty string runs the check. skip = ENV.fetch(SKIP_VERSION_CHECK_ENV_VAR, nil) return if skip && !skip.empty? + # Nothing to probe: #connect reports the unsettled path right after. + return unless cli_path_settled? begin output = capture_cli_version_output @@ -960,6 +980,68 @@ def ready? private + # Settle the CLI on ONE absolute path, used for the version probe and for + # the spawn alike. The probe runs in this process's working directory, + # the CLI is spawned with `chdir: options.cwd`: a path still relative at + # the spawn names a different file there — whatever sits at that path + # inside the directory the agent was pointed at — while the probe vouched + # for the one here. + # + # - nil: discovery (#find_cli), which returns absolute paths only. + # - a path with a separator: expanded against the process cwd, like + # CLAUDE_CLI_PATH. + # - a bare name: searched on PATH here (#executable_on_path) rather + # than left to spawn. + # + # Never raises for an explicit path. One that cannot be settled (not on + # PATH, `~user` naming nobody, a relative path when the cwd is gone) is + # kept as given, and #connect reports it as CLINotFoundError. + def resolve_cli_path(cli_path) + return find_cli if cli_path.nil? + + path = cli_path.to_s # the option may be a Pathname + return executable_on_path(path) || path unless path.include?(File::SEPARATOR) + + begin + File.expand_path(path) + rescue ArgumentError, SystemCallError + path + end + end + + # The lookup spawn used to do for a bare command name, done here so that + # the result is absolute: same PATH (the one the child is given through + # options.env when it sets one, else this process's, else Ruby's built-in + # default), same test (an executable regular file), first hit wins. The + # difference is the point of doing it here: a relative entry — `bin`, + # `.`, the empty entry — is expanded against the process cwd, where spawn + # found the file here and then executed that relative path inside + # options.cwd. (`~` entries expand to the home directory, as spawn's own + # lookup did.) Returns nil when nothing matches. + def executable_on_path(name) + return nil if name.empty? + + env = @options.env + search_path = env.transform_keys(&:to_s)['PATH'] if env.is_a?(Hash) + search_path ||= ENV.fetch('PATH', DEFAULT_EXEC_SEARCH_PATH) + # -1 keeps a trailing empty entry; PATH="" is one empty entry. + entries = search_path.to_s.split(File::PATH_SEPARATOR, -1) + entries = [''] if entries.empty? + + entries.each do |dir| + candidate = File.join(File.expand_path(dir), name) + return candidate if File.file?(candidate) && File.executable?(candidate) + rescue ArgumentError, SystemCallError + next # `~user` naming nobody, or a relative entry when the cwd is gone + end + nil + end + + # False when #resolve_cli_path had to keep an explicit path as given. + def cli_path_settled? + File.absolute_path?(@cli_path) + end + # Run `claude -v` with a hard deadline. Arg-vector popen3 — no shell, same # injection-safety as capture3. Raises Timeout::Error past # VERSION_CHECK_TIMEOUT_SECONDS (swallowed by check_claude_version's @@ -972,7 +1054,7 @@ def ready? # also bounds CLI exit. ensure always reaps the probe (mirrors Python's # finally: terminate(); wait()). def capture_cli_version_output # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded subprocess probe: drained pipes, reaping - stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v') + stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path, '-v') stdin.close drainer = Thread.new { [stdout.read, stderr.read] } deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + VERSION_CHECK_TIMEOUT_SECONDS diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb new file mode 100644 index 00000000..3f67775c --- /dev/null +++ b/spec/unit/cli_path_resolution_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +# The version probe runs in the process's working directory; the session is +# spawned with `chdir: options.cwd`. A CLI path that is still relative when +# it reaches the spawn names one file for the probe and another for the +# session — whatever sits at that path inside the directory the agent was +# pointed at (CWE-427). These examples run real executables: every fake +# `claude` appends "<leg>:<its own path>" to a marker file, so the assertions +# are about which FILE ran for which leg, not about what Open3 was handed. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'CLI path resolution' do + after { described_class.active_processes.clear } + + # `app` is the process cwd, `target` is options.cwd. Canonicalized: the + # macOS tmpdir lives behind the /var symlink, and Dir.pwd reports the + # resolved path. + around do |example| + Dir.mktmpdir('cli-path-spec') do |dir| + @root = File.realpath(dir) + FileUtils.mkdir_p([app, target]) + with_env('CLAUDE_CLI_PATH' => nil, 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK' => nil) { example.run } + end + end + + # Keep a vendored binary (CLIInstaller.root may point anywhere on a + # developer machine) out of the discovery examples; `which` is real. + before { allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(nil) } + + attr_reader :root + + def app = File.join(root, 'app') + def target = File.join(root, 'target') + def marker = File.join(root, 'legs') + + def with_env(changes) + previous = changes.to_h { |key, _| [key, ENV.fetch(key, nil)] } + changes.each { |key, value| value.nil? ? ENV.delete(key) : (ENV[key] = value) } + yield + ensure + previous.each { |key, value| value.nil? ? ENV.delete(key) : (ENV[key] = value) } + end + + # PATH with +entry+ in front of whatever the host has. + def path_with(entry) + "#{entry}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH')}" + end + + # A stand-in `claude` at +relative_path+ under the tmp root. `-v` is the + # SDK's version probe; anything else is the session spawn. + def install_fake_cli(relative_path) + path = File.join(root, relative_path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo 'probe:#{relative_path}' >> '#{marker}' + echo '2.1.286 (Claude Code)' + exit 0 + fi + echo 'spawn:#{relative_path}' >> '#{marker}' + SH + File.chmod(0o755, path) + path + end + + def options(**values) + ClaudeAgentSDK::ClaudeAgentOptions.new(cwd: target, **values) + end + + def legs + File.exist?(marker) ? File.readlines(marker, chomp: true) : [] + end + + # Construct and connect from the process cwd, then read to EOF: the fake + # has exited, so both legs are on record. + def legs_executed(**values) + Dir.chdir(app) do + transport = described_class.new(options(**values)) + begin + transport.connect + transport.read_messages { |_frame| nil } + ensure + transport.close + end + end + legs + end + + describe 'an explicit cli_path' do + it 'resolves a relative path against the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + expect(legs_executed(cli_path: 'bin/claude')).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + + it 'leaves an absolute path alone' do + cli = install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + expect(legs_executed(cli_path: cli)).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + + it 'expands a leading ~ to the home directory, like CLAUDE_CLI_PATH' do + install_fake_cli('app/bin/claude') + + with_env('HOME' => app) do + expect(legs_executed(cli_path: '~/bin/claude')).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end + + it 'reports a missing file as CLINotFoundError at connect, not at construction' do + install_fake_cli('target/bin/missing') + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'bin/missing')) } + + expect { Dir.chdir(app) { transport.connect } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError, %r{Claude Code not found at: .*bin/missing}) + expect(legs).to be_empty + end + + it 'reports a ~user path it cannot expand as CLINotFoundError at connect' do + transport = Dir.chdir(app) { described_class.new(options(cli_path: '~no-such-user-for-this-spec/bin/claude')) } + + expect { Dir.chdir(app) { transport.connect } }.to raise_error(ClaudeAgentSDK::CLINotFoundError) + end + end + + describe 'an explicit bare name' do + it 'is found through a relative PATH entry in the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + with_env('PATH' => path_with('bin')) do + expect(legs_executed(cli_path: 'claude')).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end + + it 'treats `.` and an empty PATH entry as the process cwd' do + install_fake_cli('app/claude') + install_fake_cli('target/claude') + + aggregate_failures do + ['.', ''].each do |entry| + FileUtils.rm_f(marker) + with_env('PATH' => path_with(entry)) do + expect(legs_executed(cli_path: 'claude')).to eq(%w[probe:app/claude spawn:app/claude]), + "PATH entry #{entry.inspect}: #{legs.inspect}" + end + end + end + end + + it 'treats a trailing empty PATH entry as the process cwd' do + install_fake_cli('app/claude-wrapper') + install_fake_cli('target/claude-wrapper') + + with_env('PATH' => "#{ENV.fetch('PATH')}#{File::PATH_SEPARATOR}") do + expect(legs_executed(cli_path: 'claude-wrapper')) + .to eq(%w[probe:app/claude-wrapper spawn:app/claude-wrapper]) + end + end + + # spawn searched the PATH it was about to give the child, so that is the + # PATH which decides here too — and the probe now runs the same file. + it 'is searched on the PATH from options.env, for the probe and the spawn' do + install_fake_cli('tools/claude-wrapper') + env = { 'PATH' => path_with(File.join(root, 'tools')) } + + expect(legs_executed(cli_path: 'claude-wrapper', env: env)) + .to eq(%w[probe:tools/claude-wrapper spawn:tools/claude-wrapper]) + end + + it 'anchors a relative entry of the options.env PATH to the process cwd as well' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + expect(legs_executed(cli_path: 'claude', env: { PATH: path_with('bin') })) + .to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + + it 'reports a name that is not on PATH as CLINotFoundError at connect, not at construction' do + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'claude-that-is-not-installed')) } + + expect { Dir.chdir(app) { transport.connect } }.to raise_error( + ClaudeAgentSDK::CLINotFoundError, 'Claude Code not found at: claude-that-is-not-installed' + ) + end + + # A name the SDK could not settle must not reach spawn: spawn searches + # PATH on its own, in the parent, and then runs a relative hit after + # chdir-ing into options.cwd. + it 'never hands an unresolved name to spawn' do + with_env('PATH' => path_with('bin')) do + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'late-claude')) } + install_fake_cli('app/bin/late-claude') + install_fake_cli('target/bin/late-claude') + + expect { Dir.chdir(app) { transport.connect } }.to raise_error(ClaudeAgentSDK::CLINotFoundError) + ensure + transport&.close + end + expect(legs).to be_empty + end + end + + describe 'discovery (no cli_path)' do + it 'runs the `which` hit of a relative PATH entry from the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + with_env('PATH' => path_with('bin')) do + expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end + + it 'runs the `which` hit of a `.` PATH entry from the process cwd' do + install_fake_cli('app/claude') + install_fake_cli('target/claude') + + with_env('PATH' => path_with('.')) do + expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) + end + end + + # `which` implementations differ (BSD, busybox and Debian's print the + # relative hit, GNU's prints an absolute one), so pin the relative + # spelling itself. + it 'absolutizes a relative `which` hit against the process cwd' do + install_fake_cli('app/bin/claude') + allow(Open3).to receive(:capture2).and_call_original + allow(Open3).to receive(:capture2).with('which', 'claude').and_return(["bin/claude\n", nil]) + + transport = Dir.chdir(app) { described_class.new(options) } + + expect(transport.build_command.first).to eq(File.join(app, 'bin', 'claude')) + end + end +end From 081d2452ad285bce0c970f99f9ade02753cee4e0 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:22:09 +0800 Subject: [PATCH 146/194] Pin a Pathname cli_path with real-spawn specs ClaudeAgentOptions#cli_path is signed `(String | Pathname)?`, and `Rails.root.join('vendor/claude/claude')` is the obvious value to pass. The version probe stringified it, the spawn did not, so every connect failed with "CLIConnectionError: Failed to start Claude Code: no implicit conversion of Pathname into String". The previous commit normalizes the option once, where the path is resolved (`cli_path.to_s` in #resolve_cli_path), so the probe, CommandBuilder and the spawn all receive a String. These specs fail on the commit before it and pin the behavior through a real spawn rather than an argv assertion: an absolute Pathname, a relative one (resolved against the process cwd like a relative String), and the argv's first element being a String. Behavior a user can notice: `cli_path:` accepts a Pathname, as its signature already said. Exploration finding: C-P4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- spec/unit/cli_path_pathname_spec.rb | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 spec/unit/cli_path_pathname_spec.rb diff --git a/spec/unit/cli_path_pathname_spec.rb b/spec/unit/cli_path_pathname_spec.rb new file mode 100644 index 00000000..7537ed19 --- /dev/null +++ b/spec/unit/cli_path_pathname_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'pathname' +require 'tmpdir' +require 'fileutils' + +# ClaudeAgentOptions#cli_path is signed `(String | Pathname)?`, and +# `Rails.root.join('vendor/claude/claude')` is the obvious value to pass. +# The version probe stringified it; the spawn did not. Real spawns here: an +# argv assertion alone would not have caught the TypeError. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'cli_path given as a Pathname' do + after { described_class.active_processes.clear } + + around do |example| + previous = ENV.fetch('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK', nil) + ENV.delete('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK') + Dir.mktmpdir('cli-pathname-spec') do |dir| + @root = File.realpath(dir) + example.run + end + ensure + ENV['CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'] = previous if previous + end + + attr_reader :root + + def marker = File.join(root, 'legs') + + # Records the version probe (`-v`) and the session spawn separately. + def install_fake_cli(relative_path) + path = File.join(root, relative_path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo 'probe' >> '#{marker}' + echo '2.1.286 (Claude Code)' + exit 0 + fi + echo 'spawn' >> '#{marker}' + SH + File.chmod(0o755, path) + path + end + + def run_session(transport) + transport.connect + transport.read_messages { |_frame| nil } + File.readlines(marker, chomp: true) + ensure + transport.close + end + + it 'spawns the CLI an absolute Pathname names' do + cli = Pathname.new(install_fake_cli('bin/claude')) + transport = described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: cli)) + + expect(run_session(transport)).to eq(%w[probe spawn]) + end + + it 'resolves a relative Pathname like a relative String: against the process cwd' do + install_fake_cli('app/bin/claude') + FileUtils.mkdir_p(File.join(root, 'target')) + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + cli_path: Pathname.new('bin/claude'), cwd: Pathname.new(File.join(root, 'target')) + ) + + legs = Dir.chdir(File.join(root, 'app')) { run_session(described_class.new(options)) } + + expect(legs).to eq(%w[probe spawn]) + end + + it 'hands CommandBuilder a String' do + cli = install_fake_cli('bin/claude') + transport = described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: Pathname.new(cli))) + + expect(transport.build_command.first).to be_a(String).and eq(cli) + end +end From 96e952bb8197da12560bb824f625601f1cb3241e Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:23:26 +0800 Subject: [PATCH 147/194] Keep draining stderr when the stderr callback raises a ScriptError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `stderr:` callback runs on the transport's stderr drain thread, and only a StandardError from it was contained. A callback that raised NotImplementedError or LoadError (both ScriptErrors) or SystemStackError ended the thread. Nothing read the CLI's stderr pipe after that, so a CLI that went on to write one more pipe buffer of stderr (64 KiB) blocked in write(2) and the query never finished. The per-line rescue around the callback now also covers ScriptError and SystemStackError. Such a failure is handled like a StandardError from the callback always was: it is dropped, the line is already in the recent-stderr buffer that ProcessError#stderr reports, and the callback is called again for the next line. Behavior a user can notice: none unless the callback raises one of these — then the session completes instead of hanging. Exploration finding: A-P2 (the stderr callback part) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- .../subprocess_cli_transport.rb | 7 +- spec/unit/stderr_callback_isolation_spec.rb | 86 +++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 spec/unit/stderr_callback_isolation_spec.rb diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index d133ed86..9eb99893 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -413,9 +413,14 @@ def handle_stderr # Without this, the first exception terminates the each_line loop and # the SDK silently stops capturing stderr for the lifetime of the # process. Matches Python SDK v0.2.82 (PR #932). + # ScriptError and SystemStackError as well: a callback raising + # NotImplementedError or LoadError (both ScriptErrors), or recursing + # too deep, used to end this thread, and with nothing reading the + # pipe a CLI that wrote one more pipe buffer of stderr (64 KiB) + # blocked in write(2) for good. begin @options.stderr&.call(line_str) - rescue StandardError + rescue StandardError, ScriptError, SystemStackError # Drop the callback error; the line is already in the recent-stderr # ring buffer, which is what ProcessError surfaces on non-zero exit. end diff --git a/spec/unit/stderr_callback_isolation_spec.rb b/spec/unit/stderr_callback_isolation_spec.rb new file mode 100644 index 00000000..027d4564 --- /dev/null +++ b/spec/unit/stderr_callback_isolation_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' + +# The `stderr:` callback runs on the transport's stderr drain thread. An +# exception that is not a StandardError (NotImplementedError and LoadError +# are ScriptErrors; SystemStackError is its own branch) used to end that +# thread. Nothing read the pipe any more, and a CLI that then wrote more than +# one pipe buffer of stderr (64 KiB) blocked in write(2) for good. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'stderr callback isolation' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('stderr-callback-spec') do |dir| + @dir = dir + example.run + end + end + + # 800 lines of ~120 bytes: about 94 KiB, well past one pipe buffer. + let(:stderr_line_count) { 800 } + let(:last_stderr_line) { format('stderr line %<n>04d %<pad>s', n: stderr_line_count - 1, pad: 'x' * 100) } + + # Fills stderr, then ends the way a failed run does: a result frame on + # stdout and a non-zero exit — which is when the SDK reports what it kept + # of stderr (ProcessError#stderr). + let(:fake_cli) do + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo '2.1.286 (Claude Code)' + exit 0 + fi + pad=#{'x' * 100} + i=0 + while [ "$i" -lt #{stderr_line_count} ]; do + printf 'stderr line %04d %s\\n' "$i" "$pad" >&2 + i=$((i + 1)) + done + printf '%s\\n' '#{JSON.generate(sample_result_message)}' + exit 1 + SH + File.chmod(0o755, path) + path + end + + { + NotImplementedError => 'Logger#write is abstract', + LoadError => 'cannot load such file -- some_logger', + SystemStackError => 'stack level too deep' + }.each do |error_class, message| + it "keeps draining after the callback raises #{error_class} on the first line" do + seen = [] + callback = lambda do |line| + seen << line + raise error_class, message if seen.size == 1 + end + transport = described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: fake_cli, stderr: callback)) + frames = [] + + begin + transport.connect + # A drain thread the callback killed leaves the fake blocked on a + # full pipe, short of its result frame, so reading stdout would + # hang. Joining the thread first reports that as the callback's own + # exception instead. + transport.instance_variable_get(:@stderr_task).join + + expect { transport.read_messages { |frame| frames << frame } } + .to raise_error(ClaudeAgentSDK::ProcessError) { |error| + expect(error.exit_code).to eq(1) + expect(error.stderr.lines.last).to eq(last_stderr_line) + } + ensure + transport.close + end + + expect(frames.map { |frame| frame[:type] }).to eq(['result']) + expect(seen.size).to eq(stderr_line_count) + expect(seen.last).to eq(last_stderr_line) + end + end +end From 32cb5255a78ee530783b03cdc15b24a8500b926b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:23:55 +0800 Subject: [PATCH 148/194] Tolerate env and extra_args set back to nil in the transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaudeAgentOptions defaults `env` and `extra_args` to `{}` in the constructor only, and both are signed nilable: after `options.dup_with(env: nil)` or `options.env = nil` — the natural way to clear one — the attribute is nil. SubprocessCLITransport#connect read both as Hashes and raised "NoMethodError: undefined method 'transform_keys' for nil" (env) or "undefined method 'key?' for nil" (extra_args) instead of connecting. #connect now reads `(@options.env || {})` and `(@options.extra_args || {})` at the two use sites. The attributes themselves still read nil, as their signatures document. `extra_args: nil` does not connect end to end yet: CommandBuilder reads the option before the transport does and has the same assumption. The spec stubs the argv to pin the transport's own read. Behavior a user can notice: clearing `env` no longer breaks connect. Exploration finding: C-P6 (subprocess_cli_transport.rb only) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- .../subprocess_cli_transport.rb | 9 ++- spec/unit/transport_nil_options_spec.rb | 73 +++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 spec/unit/transport_nil_options_spec.rb diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 9eb99893..6b3c05ec 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -291,8 +291,10 @@ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Met cmd = build_command # Build environment - # Convert symbol keys to strings for spawn compatibility - custom_env = @options.env.transform_keys(&:to_s) + # Convert symbol keys to strings for spawn compatibility. `|| {}`: the + # constructor defaults env (and extra_args, below) to {}, but both are + # nil again after `options.dup_with(env: nil)` or `options.env = nil`. + custom_env = (@options.env || {}).transform_keys(&:to_s) # Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK # launches Claude Code from within an existing Claude Code terminal. # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays @@ -322,7 +324,8 @@ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Met process_env['PWD'] = @cwd.to_s if @cwd # Determine stderr handling - should_pipe_stderr = @options.stderr || @options.debug_stderr || @options.extra_args.key?('debug-to-stderr') + should_pipe_stderr = @options.stderr || @options.debug_stderr || + (@options.extra_args || {}).key?('debug-to-stderr') begin # A path #resolve_cli_path could not settle is never handed to spawn: diff --git a/spec/unit/transport_nil_options_spec.rb b/spec/unit/transport_nil_options_spec.rb new file mode 100644 index 00000000..96bc85d5 --- /dev/null +++ b/spec/unit/transport_nil_options_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' + +# `env` and `extra_args` are signed nilable: the constructor fills in `{}`, +# but `options.dup_with(env: nil)` and `options.env = nil` — the natural way +# to clear one — keep the nil. The transport read both as Hashes. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'options set back to nil' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('nil-options-spec') do |dir| + @dir = dir + example.run + end + end + + let(:seen_sdk_version) { File.join(@dir, 'sdk-version') } + + # Answers the version probe; as a session it records the SDK version the + # transport put in its environment, then ends the run. + let(:fake_cli) do + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo '2.1.286 (Claude Code)' + exit 0 + fi + printf '%s' "$CLAUDE_AGENT_SDK_VERSION" > '#{seen_sdk_version}' + printf '%s\\n' '#{JSON.generate(sample_result_message)}' + SH + File.chmod(0o755, path) + path + end + + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: fake_cli) } + + def frame_types_from(transport) + types = [] + transport.connect + transport.read_messages { |frame| types << frame[:type] } + types + ensure + transport.close + end + + it 'connects when env was cleared with dup_with(env: nil)' do + transport = described_class.new(options.dup_with(env: nil)) + + expect(frame_types_from(transport)).to eq(['result']) + # The SDK's own additions to the environment still reach the CLI. + expect(File.read(seen_sdk_version)).to eq(ClaudeAgentSDK::VERSION) + end + + it 'connects when env was cleared with the setter' do + options.env = nil + + expect(frame_types_from(described_class.new(options))).to eq(['result']) + end + + # CommandBuilder reads extra_args too and is nil-unsafe in the same way + # (command_builder.rb, fixed separately); stubbing the argv keeps this + # example on the transport's own read of the option. + it 'connects when extra_args was cleared with dup_with(extra_args: nil)' do + transport = described_class.new(options.dup_with(extra_args: nil)) + allow(transport).to receive(:build_command).and_return([fake_cli]) + + expect(frame_types_from(transport)).to eq(['result']) + end +end From 271ad57e07036372c05d420a703b51eee086bf6d Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:25:38 +0800 Subject: [PATCH 149/194] Poll with Kernel#sleep instead of the deprecated Async::Task#sleep The transport polls every 50 ms in two places: while it waits for the CLI to exit (#wait_process_with_timeout, used by #close and after stdout EOF) and while the version probe runs. Inside a task both called Async::Task#sleep, which async deprecated in favor of Kernel#sleep and which prints "`Async::Task#sleep` is deprecated" on every call when Ruby runs with warnings on. A single connect and disconnect under `ruby -w` printed that line dozens of times. Both loops now call Kernel#sleep, which is fiber-scheduler aware: on a reactor it parks only the calling fiber, off a reactor it is the plain sleep the loops already fell back to. The spec covers both sites with warnings enabled and checks, with async 2.10.0 as well, that another task on the same reactor runs while the first waits for the CLI to exit. Behavior a user can notice: no deprecation warnings from the transport under `ruby -w`. Exploration finding: G-P4 (the Async::Task#sleep part, transport only) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- .../subprocess_cli_transport.rb | 14 +-- spec/unit/transport_poll_sleep_spec.rb | 115 ++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 spec/unit/transport_poll_sleep_spec.rb diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 6b3c05ec..4c5ebb32 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -667,17 +667,18 @@ def force_terminate_in_background(process, grace_seconds: 2) # Wait for the spawned process to exit, up to +timeout_seconds+. Polls # process.alive? rather than using stdlib Timeout.timeout, which raises # across threads via Thread#raise and corrupts Async fiber-scheduler state - # (close is always called inside an Async task). Yields to the current - # Async task when one is active so the reactor keeps running. + # (close is always called inside an Async task). Kernel#sleep is + # scheduler-aware: on a reactor it parks only the calling fiber, so the + # reactor keeps running. (Async::Task#sleep did the same but is + # deprecated, and warns on every call under `ruby -w`.) # # @api private def wait_process_with_timeout(timeout_seconds, process = @process) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds - task = defined?(Async::Task) ? Async::Task.current? : nil while process.alive? raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - task ? task.sleep(0.05) : sleep(0.05) + sleep(0.05) end process.value end @@ -859,7 +860,7 @@ def read_messages(&) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComple # reached query()/receive_response. Past the grace period escalate # like #close (TERM, then KILL) and report it as an error below: a # child that outlives its stdout is wedged, whatever its exit code. - # The poll parks only this task (task.sleep) on a reactor. + # The poll parks only this fiber (Kernel#sleep) on a reactor. if process && !process_exited_within?(process, EOF_EXIT_GRACE_SECONDS) forced_exit = true begin @@ -1066,11 +1067,10 @@ def capture_cli_version_output # rubocop:disable Metrics/AbcSize, Metrics/Cyclom stdin.close drainer = Thread.new { [stdout.read, stderr.read] } deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + VERSION_CHECK_TIMEOUT_SECONDS - task = defined?(Async::Task) ? Async::Task.current? : nil until drainer.join(0) raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - task ? task.sleep(0.05) : sleep(0.05) + sleep(0.05) end out, err = drainer.value (out.to_s + err.to_s).force_encoding(Encoding::UTF_8).scrub.strip diff --git a/spec/unit/transport_poll_sleep_spec.rb b/spec/unit/transport_poll_sleep_spec.rb new file mode 100644 index 00000000..c527cef7 --- /dev/null +++ b/spec/unit/transport_poll_sleep_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# The transport polls in two places: while it waits for the CLI to exit, and +# while the version probe runs. Both used Async::Task#sleep when a task was +# current — deprecated in favor of the scheduler-aware Kernel#sleep, and a +# warning per call (every 50 ms) under `ruby -w`. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'polling sleeps' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('poll-sleep-spec') do |dir| + @dir = dir + example.run + end + end + + # What `ruby -w` turns on: verbose mode and the :deprecated category + # (async tags its warning with it, and $VERBOSE alone does not enable it). + def with_deprecation_warnings + verbose = $VERBOSE + deprecated = Warning[:deprecated] + $VERBOSE = true + Warning[:deprecated] = true + yield + ensure + $VERBOSE = verbose + Warning[:deprecated] = deprecated + end + + def transport_for(cli_path) + described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: cli_path)) + end + + describe 'waiting for the CLI to exit' do + let(:transport) { transport_for('/usr/bin/claude') } + let(:process) { instance_double(Process::Waiter, value: :exited) } + + it 'does not use the deprecated Async::Task#sleep inside a task' do + allow(process).to receive(:alive?).and_return(true, false) + + expect do + with_deprecation_warnings { Sync { transport.send(:wait_process_with_timeout, 5, process) } } + end.not_to output(/Async::Task#sleep/).to_stderr + end + + # Two polls, whatever happens: a sleep that blocked the thread instead + # of parking the fiber would finish the wait before the other task ever + # ran, and the order below would be reversed. + it 'parks only the waiting fiber: another task on the reactor runs meanwhile' do + allow(process).to receive(:alive?).and_return(true, true, false) + events = [] + + result = Sync do |task| + waiter = task.async do + transport.send(:wait_process_with_timeout, 5, process).tap { events << :wait_returned } + end + # Control comes back here when the waiter parks in its first poll. + events << :other_task_ran + waiter.wait + end + + expect(result).to eq(:exited) + expect(events).to eq(%i[other_task_ran wait_returned]) + end + end + + describe 'the version probe' do + around do |example| + previous = ENV.fetch('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK', nil) + ENV.delete('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK') + example.run + ensure + ENV['CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'] = previous if previous + end + + let(:gate_path) { File.join(@dir, 'gate') } + + # `claude -v` that answers only once a line arrives on the gate FIFO, so + # the probe is still running when the poll loop first goes round. + let(:gated_cli) do + File.mkfifo(gate_path) + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + read _ < '#{gate_path}' + echo '2.1.286 (Claude Code)' + SH + File.chmod(0o755, path) + path + end + + it 'does not use the deprecated Async::Task#sleep inside a task' do + transport = transport_for(gated_cli) + + # Opened read-write so neither end blocks in open(2). + File.open(gate_path, 'r+') do |gate| + # The poll's own sleep opens the gate: the loop has then run at + # least once, and the probe finishes right after. A poll that sleeps + # some other way never opens it and runs into the probe's deadline. + allow(transport).to receive(:sleep).and_wrap_original do |original, *args| + gate.syswrite("go\n") + original.call(*args) + end + + expect do + with_deprecation_warnings { Sync { transport.check_claude_version } } + end.not_to output(/Async::Task#sleep/).to_stderr + expect(transport).to have_received(:sleep).at_least(:once) + end + end + end +end From 733c0012b488ab21be1515b925aaf915fa0f99a4 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:28:24 +0800 Subject: [PATCH 150/194] Warn when a requested sandbox is not active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sandbox: { enabled: true }` asks the CLI for a sandbox. When the CLI cannot start one (a Linux container without bubblewrap and socat, an unsupported platform) and sandbox.failIfUnavailable is not set, it writes "⚠ Sandbox disabled: <reason>" to stderr and runs the session's commands unsandboxed. An SDK host only sees CLI stderr if it passed a `stderr:` callback, so the session silently ran without the isolation its options asked for. The transport already reads every stderr line (with or without a callback). When ClaudeAgentOptions#sandbox enables the sandbox (`true`, a SandboxSettings or a Hash with `enabled` true) and a line contains "Sandbox disabled:", it now emits one Ruby warning per transport: [claude-agent-sdk] The sandbox this session requested is not active: the CLI is running commands WITHOUT sandboxing. It reported: "<the CLI's line>". To make this an error instead, set fail_if_unavailable: true on SandboxSettings (failIfUnavailable: true in a Hash). The warning is best-effort, like OptionWarnings: a broken $stderr never stops the stderr drain. Nothing else changes: failIfUnavailable is still sent only when the caller set it, and the session still starts. Behavior a user can notice: the new warning, in the case above only. Exploration finding: U-P2 (the diagnostic half; the default is unchanged) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- docs/configuration.md | 2 + .../subprocess_cli_transport.rb | 45 ++++++ spec/unit/sandbox_unavailable_warning_spec.rb | 144 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 spec/unit/sandbox_unavailable_warning_spec.rb diff --git a/docs/configuration.md b/docs/configuration.md index 66f988f8..f4c842c4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -212,6 +212,8 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +`enabled: true` asks for the sandbox; it does not guarantee one. When the CLI cannot start it (a Linux container without `bubblewrap` and `socat` is the usual case), it prints `⚠ Sandbox disabled: …` on its stderr and runs commands **without** sandboxing. If the `sandbox:` option enabled the sandbox, the SDK repeats that line as a Ruby warning, once per session, prefixed `[claude-agent-sdk]`. Set `fail_if_unavailable: true` (`failIfUnavailable` in a Hash) to make the CLI exit with an error instead. + See [examples/sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb). ## Bare Mode diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 4c5ebb32..b85dd0ae 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -39,6 +39,12 @@ class SubprocessCLITransport < Transport # rubocop:disable Metrics/ClassLength - VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2) # @api private RECENT_STDERR_LINES_LIMIT = 20 + # How the CLI's stderr says that a requested sandbox could not start and + # that the session carries on without one — see + # #warn_if_sandbox_unavailable. + # + # @api private + SANDBOX_DISABLED_MARKER = 'Sandbox disabled:' # After stdout EOF the child has closed (or lost) its last stdout handle, # so it is normally already exiting; a CLI still running this long # afterwards is wedged and gets the same TERM -> KILL ladder as #close. @@ -144,6 +150,7 @@ def initialize(options_or_prompt = nil, options = nil) @stderr_task = nil @recent_stderr = [] @recent_stderr_mutex = Mutex.new + @sandbox_warning_emitted = false # Serializes stdin access across the reactor fiber (transport writes # from inside Async) and user-callback threads spawned via FiberBoundary # (tool handlers / hooks calling Client#query). Without this lock, @@ -410,6 +417,7 @@ def handle_stderr next if line_str.empty? record_bounded_stderr(line_str) + warn_if_sandbox_unavailable(line_str) # Per-line isolation: a callback that raises (e.g. user's logger # transiently failing) must not poison the rest of the stderr stream. @@ -455,6 +463,7 @@ def drain_stderr_with_accumulation next if line_str.empty? record_bounded_stderr(line_str) + warn_if_sandbox_unavailable(line_str) end end @@ -1196,6 +1205,42 @@ def record_bounded_stderr(line) end end + # When the sandbox was requested but cannot start (and + # sandbox.failIfUnavailable is not set), the CLI writes + # "⚠ Sandbox disabled: <reason>" to stderr and runs the session's + # commands unsandboxed. A host only sees stderr through a `stderr:` + # callback, so repeat that line as a Ruby warning: once per transport, + # and only when this session's own options asked for the sandbox. + # + # Called for every stderr line, on the drain thread. Best-effort like + # OptionWarnings#emit: a closed or broken $stderr must not raise out of + # here and end the drain (the CLI would stall on a full stderr pipe). + def warn_if_sandbox_unavailable(line) + return if @sandbox_warning_emitted || !line.include?(SANDBOX_DISABLED_MARKER) + return unless sandbox_requested? + + @sandbox_warning_emitted = true + begin + warn '[claude-agent-sdk] The sandbox this session requested is not active: the CLI is running ' \ + "commands WITHOUT sandboxing. It reported: \"#{line.strip}\". To make this an error instead, " \ + 'set fail_if_unavailable: true on SandboxSettings (failIfUnavailable: true in a Hash).' + rescue StandardError + nil + end + end + + # True when ClaudeAgentOptions#sandbox enables the sandbox: `true`, a + # SandboxSettings with `enabled` true, or a Hash with an `enabled` / + # 'enabled' key that is true (Hashes are forwarded to the CLI verbatim). + def sandbox_requested? + sandbox = @options.sandbox + case sandbox + when SandboxSettings then sandbox.enabled == true + when Hash then sandbox[:enabled] == true || sandbox['enabled'] == true + else sandbox == true + end + end + # The (parent's) home directory for the well-known install probes, or nil # when none is usable — see Sessions.home_dir, the one definition. def home_dir diff --git a/spec/unit/sandbox_unavailable_warning_spec.rb b/spec/unit/sandbox_unavailable_warning_spec.rb new file mode 100644 index 00000000..77def566 --- /dev/null +++ b/spec/unit/sandbox_unavailable_warning_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'stringio' +require 'tmpdir' + +# `sandbox: { enabled: true }` asks for a sandbox; when the CLI cannot start +# one (and failIfUnavailable is not set) it says so on stderr and carries on +# unsandboxed. An SDK host never sees that line unless it passed a `stderr:` +# callback, so the transport repeats it as a Ruby warning. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'unavailable sandbox warning' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('sandbox-warning-spec') do |dir| + @dir = dir + example.run + end + end + + # Byte for byte what CLI 2.1.286 / 2.1.287 write at startup + # (`process.stderr.write(`\n⚠ Sandbox disabled: ${reason}\n Commands + # will run WITHOUT sandboxing. ...\n\n`)`), with the reason the CLI builds + # on a Linux host that has neither bubblewrap nor socat. + let(:cli_line) do + '⚠ Sandbox disabled: sandbox is enabled but dependencies are missing: ' \ + 'bubblewrap (bwrap) not installed, socat not installed ' \ + '· install missing tools (e.g. apt install bubblewrap socat) ' \ + 'or see https://code.claude.com/docs/en/sandboxing' + end + let(:cli_notice) do + "\n#{cli_line}\n " \ + "Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced.\n\n" + end + + # Writes +stderr_text+ to stderr, then ends the run with a result frame. + def install_fake_cli(stderr_text, exit_status: 0) + stderr_file = File.join(@dir, 'stderr.txt') + File.write(stderr_file, stderr_text) + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo '2.1.286 (Claude Code)' + exit 0 + fi + cat '#{stderr_file}' >&2 + printf '%s\\n' '#{JSON.generate(sample_result_message)}' + exit #{exit_status} + SH + File.chmod(0o755, path) + path + end + + # Everything written to $stderr while a session runs to its end. The + # warning is issued on the stderr drain thread, so the swap covers that + # thread's whole life: it ends at EOF, when the fake has exited. + def stderr_during_session(transport, stderr: StringIO.new) + original = $stderr + $stderr = stderr + begin + transport.connect + transport.instance_variable_get(:@stderr_task).join + transport.read_messages { |_frame| nil } + ensure + $stderr = original + transport.close + end + stderr.string if stderr.respond_to?(:string) + end + + def transport_with(**values) + described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(**values)) + end + + # The real CLI prints the notice once; twice pins "one warning per + # transport" rather than "one warning per line". + let(:cli) { install_fake_cli(cli_notice * 2) } + + { + 'a SandboxSettings with enabled: true' => -> { ClaudeAgentSDK::SandboxSettings.new(enabled: true) }, + 'a Hash with enabled: true' => -> { { enabled: true } }, + 'a Hash with a String "enabled" key' => -> { { 'enabled' => true } }, + 'sandbox: true' => -> { true } + }.each do |description, sandbox| + it "warns once when the sandbox was requested by #{description}" do + output = stderr_during_session(transport_with(cli_path: cli, sandbox: sandbox.call)) + + expect(output.scan('[claude-agent-sdk]').size).to eq(1) + expect(output).to include(cli_line).and include('fail_if_unavailable: true') + end + end + + it 'warns once on the stderr-callback path too, and still hands the callback the line' do + seen = [] + transport = transport_with(cli_path: cli, sandbox: { enabled: true }, stderr: ->(line) { seen << line }) + + output = stderr_during_session(transport) + + expect(output.scan('[claude-agent-sdk]').size).to eq(1) + expect(seen.count(cli_line)).to eq(2) + end + + { + 'no sandbox option' => -> {}, + 'sandbox: false' => -> { false }, + 'a Hash with enabled: false' => -> { { enabled: false } }, + 'an empty Hash' => -> { {} }, + 'a SandboxSettings with enabled: false' => -> { ClaudeAgentSDK::SandboxSettings.new(enabled: false) }, + 'a SandboxSettings that leaves enabled unset' => -> { ClaudeAgentSDK::SandboxSettings.new } + }.each do |description, sandbox| + it "stays silent with #{description}" do + output = stderr_during_session(transport_with(cli_path: cli, sandbox: sandbox.call)) + + expect(output).to be_empty + end + end + + it 'stays silent when the CLI did not report the sandbox as disabled' do + quiet_cli = install_fake_cli("[SandboxDebug] sandbox initialized\nSandbox enabled: bubblewrap\n") + + output = stderr_during_session(transport_with(cli_path: quiet_cli, sandbox: { enabled: true })) + + expect(output).to be_empty + end + + # A host's $stderr can be a pipe nobody reads any more. The warning is + # advisory: failing to print it must not end the drain, or the CLI stalls + # on a full stderr pipe. + it 'keeps draining when $stderr is broken' do + broken = Object.new + def broken.write(*) = raise(Errno::EPIPE) + # spawn flushes $stderr before it forks + def broken.flush = self + failing_cli = install_fake_cli("#{cli_notice}a later stderr line\n", exit_status: 1) + transport = transport_with(cli_path: failing_cli, sandbox: { enabled: true }) + + expect { stderr_during_session(transport, stderr: broken) } + .to raise_error(ClaudeAgentSDK::ProcessError) { |error| + expect(error.stderr.lines.last).to eq('a later stderr line') + } + end +end From 719bffe09990c395adf605a28bbfdfe1c3dcacc3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:33:07 +0800 Subject: [PATCH 151/194] Keep the new transport specs from hanging on Ruby 3.2 or a loaded host Follow-up to the specs added in the previous commits; no change in lib/. - transport_poll_sleep_spec: the version-probe example opens its gate from the poll loop's own sleep. That loop is `until drainer.join(0)`, and under a fiber scheduler Ruby 3.2's Thread#join ignores its timeout (it returns only once the thread has finished), so the loop body never runs there and the example would wait forever. It is skipped below Ruby 3.3, where there is no sleep at that site to test. - cli_path_resolution_spec, cli_path_pathname_spec: the examples assert on the marker line the version probe's leg writes. The probe has a 2 s deadline; on a starved machine a fake that answers at once can still miss it, which silently drops the line. The specs give the probe 60 s. - cli_path_resolution_spec: the two discovery examples that run the host's real `which` are skipped where `which` is not installed (the third one stubs it and runs everywhere). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- spec/unit/cli_path_pathname_spec.rb | 4 ++++ spec/unit/cli_path_resolution_spec.rb | 33 +++++++++++++++++--------- spec/unit/transport_poll_sleep_spec.rb | 6 +++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/spec/unit/cli_path_pathname_spec.rb b/spec/unit/cli_path_pathname_spec.rb index 7537ed19..6b5bf483 100644 --- a/spec/unit/cli_path_pathname_spec.rb +++ b/spec/unit/cli_path_pathname_spec.rb @@ -23,6 +23,10 @@ ENV['CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'] = previous if previous end + # The fake answers `-v` at once, but on a starved machine "at once" can + # exceed the probe's 2 s deadline, which would drop the probe's marker line. + before { stub_const("#{described_class}::VERSION_CHECK_TIMEOUT_SECONDS", 60) } + attr_reader :root def marker = File.join(root, 'legs') diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb index 3f67775c..36e935ae 100644 --- a/spec/unit/cli_path_resolution_spec.rb +++ b/spec/unit/cli_path_resolution_spec.rb @@ -29,6 +29,11 @@ # developer machine) out of the discovery examples; `which` is real. before { allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(nil) } + # The examples assert on the marker line the probe leg writes. The fakes + # answer `-v` at once, but on a starved machine "at once" can exceed the + # probe's 2 s deadline, which would silently drop that line. + before { stub_const("#{described_class}::VERSION_CHECK_TIMEOUT_SECONDS", 60) } + attr_reader :root def app = File.join(root, 'app') @@ -207,21 +212,27 @@ def legs_executed(**values) end describe 'discovery (no cli_path)' do - it 'runs the `which` hit of a relative PATH entry from the process cwd, for the probe and the spawn' do - install_fake_cli('app/bin/claude') - install_fake_cli('target/bin/claude') + context "with the host's own `which`" do + before do + skip 'which(1) is not installed here' unless system('which', 'which', out: File::NULL, err: File::NULL) + end - with_env('PATH' => path_with('bin')) do - expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + it 'runs the hit of a relative PATH entry from the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + with_env('PATH' => path_with('bin')) do + expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end end - end - it 'runs the `which` hit of a `.` PATH entry from the process cwd' do - install_fake_cli('app/claude') - install_fake_cli('target/claude') + it 'runs the hit of a `.` PATH entry from the process cwd' do + install_fake_cli('app/claude') + install_fake_cli('target/claude') - with_env('PATH' => path_with('.')) do - expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) + with_env('PATH' => path_with('.')) do + expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) + end end end diff --git a/spec/unit/transport_poll_sleep_spec.rb b/spec/unit/transport_poll_sleep_spec.rb index c527cef7..84752dca 100644 --- a/spec/unit/transport_poll_sleep_spec.rb +++ b/spec/unit/transport_poll_sleep_spec.rb @@ -93,6 +93,12 @@ def transport_for(cli_path) end it 'does not use the deprecated Async::Task#sleep inside a task' do + # The loop is `until drainer.join(0)`, and under a fiber scheduler + # Ruby 3.2's Thread#join ignores its timeout: it returns only when the + # thread has finished, so the loop body — the sleep under test, and + # with it the gate below — is never reached there. + skip 'on Ruby 3.2 this poll never sleeps inside a task' if RUBY_VERSION < '3.3' + transport = transport_for(gated_cli) # Opened read-write so neither end blocks in open(2). From 15cc8dbb6276db0444e2b508e8d5941b1f4f9208 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:00:45 +0800 Subject: [PATCH 152/194] Spawn a wrapping subclass's argv as built Follow-up to "Resolve the CLI executable once, to an absolute path". That commit made #connect refuse to spawn whenever the CLI path could not be settled to an absolute one. The refusal is meant for the SDK's own argv, whose program is that path. A SubprocessCLITransport subclass that builds its own argv to run the CLI through another program (docker exec, ssh) passes a `cli_path` that names a file on the far side; when it is a bare name the host does not have, the subclass used to connect and now got "CLINotFoundError: Claude Code not found at: claude". #connect now refuses only when the unsettled path is the program about to be spawned (`cmd.first == @cli_path`), which is always the case for the transport's own #build_command. The spec example passes on the base commit, failed on the commit named above, and passes again. Still different from the base commit for such a subclass: a bare `cli_path` that IS on the local PATH reaches `super`'s argv as the local absolute path. Exploration finding: U-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- .../subprocess_cli_transport.rb | 7 ++++-- spec/unit/cli_path_resolution_spec.rb | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index b85dd0ae..1049fad6 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -338,8 +338,11 @@ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Met # A path #resolve_cli_path could not settle is never handed to spawn: # spawn searches PATH on its own, in this process's cwd, and then # executes a relative hit inside options.cwd. Reported like any - # missing CLI, by the Errno::ENOENT branch below. - raise Errno::ENOENT, @cli_path unless cli_path_settled? + # missing CLI, by the Errno::ENOENT branch below. Only when that + # path is the program about to be spawned: a subclass whose + # #build_command wraps the CLI in another program (docker exec, ssh) + # names a file on the far side, and its argv is spawned as built. + raise Errno::ENOENT, @cli_path if cmd.first == @cli_path && !cli_path_settled? # Start process using Open3 # :uid mirrors Python's anyio.open_process(user=...): String username diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb index 36e935ae..5659c4d6 100644 --- a/spec/unit/cli_path_resolution_spec.rb +++ b/spec/unit/cli_path_resolution_spec.rb @@ -249,4 +249,29 @@ def legs_executed(**values) expect(transport.build_command.first).to eq(File.join(app, 'bin', 'claude')) end end + + # #build_command is not public API, but a transport that runs the CLI + # through another program (docker exec, ssh) has no other hook, and its + # cli_path names a file on the far side. The refusal to spawn an unsettled + # path is about the SDK's own argv; an argv like this is spawned as built. + describe 'a subclass that wraps the CLI in another program' do + it 'still spawns its argv when cli_path is a bare name this host does not have' do + wrapper = File.join(root, 'wrapper') + File.write(wrapper, "#!/bin/sh\necho \"wrapped:$1\" >> '#{marker}'\n") + File.chmod(0o755, wrapper) + wrapping = Class.new(described_class) do + define_method(:build_command) { [wrapper, *super()] } + end + transport = Dir.chdir(app) { wrapping.new(options(cli_path: 'claude-on-the-far-side')) } + + begin + transport.connect + transport.read_messages { |_frame| nil } + ensure + transport.close + end + + expect(legs).to eq(['wrapped:claude-on-the-far-side']) + end + end end From e3e3959e9cc8bdf3e1dbd9833d8958347dd0b246 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:01:58 +0800 Subject: [PATCH 153/194] Note the wrapped-argv exception in CLAUDE.md; plain name in a fake Follow-up to the two previous commits, no change in lib/. - CLAUDE.md: the "one absolute path" paragraph said an unsettled CLI path makes #connect raise CLINotFoundError. Since the previous commit that holds when the path is the program of the argv; the argv of a subclass whose #build_command wraps the CLI is spawned as built. - transport_poll_sleep_spec: the gated fake `claude -v` read its gate line into `_`; use an ordinary variable name, which every /bin/sh accepts without question. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- CLAUDE.md | 2 +- spec/unit/transport_poll_sleep_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6b42f97d..c9e911aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ User code `SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` (absolutized the same way: a relative `PATH` entry makes `which` print a relative hit) → common install locations. -**One absolute path, settled in `#initialize`** (`#resolve_cli_path`): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. Discovery returns absolute paths only; an explicit `cli_path` (`.to_s`, it may be a `Pathname`) with a separator goes through `File.expand_path`; a bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are expanded against the process cwd, not skipped). An explicit path that cannot be settled is kept as given and never executed: `#connect` skips the probe and raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file. +**One absolute path, settled in `#initialize`** (`#resolve_cli_path`): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. Discovery returns absolute paths only; an explicit `cli_path` (`.to_s`, it may be a `Pathname`) with a separator goes through `File.expand_path`; a bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are expanded against the process cwd, not skipped). An explicit path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). `CLIInstaller` (`lib/claude_agent_sdk/cli_installer.rb`) downloads a pinned CLI into `vendor/claude` (relative to `CLIInstaller.root` when set — the Railtie sets it to `Rails.root` — else to `Dir.pwd`, resolved at call time; `root` is a plain frozen-String ivar, absolutized on set, read lock-free) for Docker/CI deploys: dist-tag → concrete version, manifest SHA-256 + declared size, streamed download (bounded by that size) to a random-named sibling temp file (`O_EXCL`), verify, `chmod 0755`, atomically write `VERSION` = version + verified checksum, and **finally** rename the temp file into place. Stdlib only; every failure (filesystem errors included) is wrapped in `CLIInstallError` with `cause` preserved. diff --git a/spec/unit/transport_poll_sleep_spec.rb b/spec/unit/transport_poll_sleep_spec.rb index 84752dca..61b204b9 100644 --- a/spec/unit/transport_poll_sleep_spec.rb +++ b/spec/unit/transport_poll_sleep_spec.rb @@ -85,7 +85,7 @@ def transport_for(cli_path) path = File.join(@dir, 'claude') File.write(path, <<~SH) #!/bin/sh - read _ < '#{gate_path}' + read go < '#{gate_path}' echo '2.1.286 (Claude Code)' SH File.chmod(0o755, path) From 95577e549c80f73403cf9771e5970fa007454b86 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:39:34 +0800 Subject: [PATCH 154/194] Anchor CLI paths without normalizing; a falsy cli_path still discovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections to "Resolve the CLI executable once, to an absolute path" (00ea691), found by an independent review. Each one changed behavior against the base commit that the fix had no reason to change: - File.expand_path normalized every path it saw, absolute ones too, and collapsed `..` as text. With `link -> real/sub`, `cli_path: "/x/link/../bin/claude"` names /x/real/bin/claude on the filesystem; after 00ea691 the SDK ran /x/bin/claude instead — another binary, or CLINotFoundError when there is none. The same held for a PATH entry like `/x/link/../bin` and for the `which` hit. (00ea691's message says the explicit path goes through File.expand_path; that is what this commit takes back.) Paths are now anchored, not rewritten: an absolute path is kept as given, a relative one is joined to the process cwd, and only a leading `~` is expanded (`~` and `~user` for cli_path, `~` and `~/x` for PATH entries, as spawn's own lookup did). CommandBuilder#load_settings_file follows the same rule. - `cli_path: false` — outside the signature, but what `cond && '/opt/claude'` produces — meant discovery and ran /usr/bin/false after 00ea691. Any falsy value means discovery again. - A subclass whose #find_cli returns a bare or relative name got CLINotFoundError; discovery's result now goes through the same settling as an explicit cli_path. - Odd input no longer raises out of the constructor or as a raw ArgumentError from #connect: a PATH that is not valid in its encoding is split as bytes (spawn searched bytes), and a cli_path with a NUL byte or an incompatible encoding stays unsettled and is reported as CLINotFoundError (a CLIConnectionError, as before). The new examples are green on the base commit except the invalid-UTF-8 PATH one, which also checks the probe leg; all seven are red on fdfe038. Exploration finding: U-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- CLAUDE.md | 4 +- .../subprocess_cli_transport.rb | 112 +++++++++++------- spec/unit/cli_path_resolution_spec.rb | 81 ++++++++++++- 3 files changed, 152 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c9e911aa..7d355c09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,9 @@ User code ### CLI Discovery / Installer -`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` (absolutized the same way: a relative `PATH` entry makes `which` print a relative hit) → common install locations. +`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` (a relative hit — a relative `PATH` entry makes `which` print one — is anchored to the process cwd) → common install locations. -**One absolute path, settled in `#initialize`** (`#resolve_cli_path`): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. Discovery returns absolute paths only; an explicit `cli_path` (`.to_s`, it may be a `Pathname`) with a separator goes through `File.expand_path`; a bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are expanded against the process cwd, not skipped). An explicit path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). +**One absolute path, settled in `#initialize`** (`#settle_cli_path`): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). `CLIInstaller` (`lib/claude_agent_sdk/cli_installer.rb`) downloads a pinned CLI into `vendor/claude` (relative to `CLIInstaller.root` when set — the Railtie sets it to `Rails.root` — else to `Dir.pwd`, resolved at call time; `root` is a plain frozen-String ivar, absolutized on set, read lock-free) for Docker/CI deploys: dist-tag → concrete version, manifest SHA-256 + declared size, streamed download (bounded by that size) to a random-named sibling temp file (`O_EXCL`), verify, `chmod 0755`, atomically write `VERSION` = version + verified checksum, and **finally** rename the temp file into place. Stdlib only; every failure (filesystem errors included) is wrapped in `CLIInstallError` with `cause` preserved. diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 1049fad6..857f129f 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -138,7 +138,9 @@ def initialize(options_or_prompt = nil, options = nil) super() # Transport defines no state today; keep the chain intact if it ever does # Support both new single-arg form and legacy two-arg form @options = options.nil? ? options_or_prompt : options - @cli_path = resolve_cli_path(@options.cli_path) + # Any falsy cli_path means discovery, as it always did. `.to_s`: the + # option may be a Pathname. + @cli_path = settle_cli_path((@options.cli_path || find_cli).to_s) @cwd = @options.cwd @process = nil @stdin = nil @@ -178,7 +180,7 @@ def initialize(options_or_prompt = nil, options = nil) # 3. `which claude`. # 4. Well-known install locations. # - # Every hit is returned as an absolute path — see #resolve_cli_path. + # Every hit is returned as an absolute path — see #settle_cli_path. # # @api private def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered discovery probes (env, vendored, PATH, known locations) @@ -210,9 +212,9 @@ def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Me stdout, _status = Open3.capture2('which', 'claude') hit = stdout.strip # A relative PATH entry (`bin`, `.`, the empty entry) makes most - # `which` implementations print a relative hit. Absolutize it against - # the cwd `which` ran in, for the same reason as CLAUDE_CLI_PATH. - cli = File.expand_path(hit) unless hit.empty? + # `which` implementations print a relative hit. Anchor it to the cwd + # `which` ran in, for the same reason as CLAUDE_CLI_PATH. + cli = anchor_to_cwd(hit) unless hit.empty? rescue StandardError # which command failed, try common locations end @@ -335,7 +337,7 @@ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Met (@options.extra_args || {}).key?('debug-to-stderr') begin - # A path #resolve_cli_path could not settle is never handed to spawn: + # A path #settle_cli_path could not settle is never handed to spawn: # spawn searches PATH on its own, in this process's cwd, and then # executes a relative hit inside options.cwd. Reported like any # missing CLI, by the Errno::ENOENT branch below. Only when that @@ -1008,59 +1010,87 @@ def ready? # inside the directory the agent was pointed at — while the probe vouched # for the one here. # - # - nil: discovery (#find_cli), which returns absolute paths only. - # - a path with a separator: expanded against the process cwd, like - # CLAUDE_CLI_PATH. - # - a bare name: searched on PATH here (#executable_on_path) rather + # - a path with a separator is anchored to the process cwd (an absolute + # one is kept as given); a leading `~` becomes a home directory, as + # for CLAUDE_CLI_PATH; + # - a bare name is searched on PATH here (#executable_on_path) rather # than left to spawn. # - # Never raises for an explicit path. One that cannot be settled (not on - # PATH, `~user` naming nobody, a relative path when the cwd is gone) is - # kept as given, and #connect reports it as CLINotFoundError. - def resolve_cli_path(cli_path) - return find_cli if cli_path.nil? - - path = cli_path.to_s # the option may be a Pathname + # Nothing is normalized: `link/..` must resolve through the filesystem, + # as it did when the path went to spawn as given. (File.expand_path + # collapses it lexically, which names another file when `link` is a + # symlink.) + # + # Never raises. A path that cannot be settled (a bare name not on PATH, + # `~user` naming nobody, a relative path when the cwd is gone, a NUL + # byte) is returned as given, and #connect reports it as + # CLINotFoundError. + def settle_cli_path(path) return executable_on_path(path) || path unless path.include?(File::SEPARATOR) - begin - File.expand_path(path) - rescue ArgumentError, SystemCallError - path - end + path.start_with?('~') ? expand_leading_tilde(path) : anchor_to_cwd(path) + rescue ArgumentError, EncodingError, SystemCallError + path + end + + # An absolute path as given, a relative one joined to the process cwd. + def anchor_to_cwd(path) + File.absolute_path?(path) ? path : File.join(Dir.pwd, path) + end + + # `~/x` or `~user/x`: the first component becomes that home directory, + # the rest is kept as written. + def expand_leading_tilde(path) + head, rest = path.split(File::SEPARATOR, 2) + File.join(File.expand_path(head), rest) end # The lookup spawn used to do for a bare command name, done here so that - # the result is absolute: same PATH (the one the child is given through - # options.env when it sets one, else this process's, else Ruby's built-in - # default), same test (an executable regular file), first hit wins. The - # difference is the point of doing it here: a relative entry — `bin`, - # `.`, the empty entry — is expanded against the process cwd, where spawn - # found the file here and then executed that relative path inside - # options.cwd. (`~` entries expand to the home directory, as spawn's own - # lookup did.) Returns nil when nothing matches. + # the result is absolute: same PATH (#search_path_entries), same test (an + # executable regular file), first hit wins. The difference is the point + # of doing it here: a relative entry — `bin`, `.`, the empty entry — is + # anchored to the process cwd, where spawn found the file here and then + # executed that relative path inside options.cwd. Returns nil when + # nothing matches. def executable_on_path(name) return nil if name.empty? + search_path_entries.each do |entry| + candidate = File.join(path_entry_dir(entry), name) + return candidate if File.file?(candidate) && File.executable?(candidate) + rescue ArgumentError, EncodingError, SystemCallError + next # a NUL byte, mixed encodings, or a relative entry when the cwd is gone + end + nil + end + + # The PATH spawn searched: the one the child is given through options.env + # when it sets one, else this process's, else Ruby's built-in default. + def search_path_entries env = @options.env search_path = env.transform_keys(&:to_s)['PATH'] if env.is_a?(Hash) - search_path ||= ENV.fetch('PATH', DEFAULT_EXEC_SEARCH_PATH) + search_path = (search_path || ENV.fetch('PATH', DEFAULT_EXEC_SEARCH_PATH)).to_s + # spawn searched bytes; a PATH that is invalid in its encoding cannot + # be split as text. + search_path = search_path.b unless search_path.valid_encoding? # -1 keeps a trailing empty entry; PATH="" is one empty entry. - entries = search_path.to_s.split(File::PATH_SEPARATOR, -1) - entries = [''] if entries.empty? + entries = search_path.split(File::PATH_SEPARATOR, -1) + entries.empty? ? [''] : entries + end - entries.each do |dir| - candidate = File.join(File.expand_path(dir), name) - return candidate if File.file?(candidate) && File.executable?(candidate) - rescue ArgumentError, SystemCallError - next # `~user` naming nobody, or a relative entry when the cwd is gone - end - nil + # As in spawn's lookup, `~` and `~/x` entries start at the home directory + # (a `~user` entry is taken literally, as there). + def path_entry_dir(entry) + return File.join(File.expand_path('~'), entry[1..]) if entry == '~' || entry.start_with?('~/') + + anchor_to_cwd(entry) end - # False when #resolve_cli_path had to keep an explicit path as given. + # False when #settle_cli_path had to keep a path as given. def cli_path_settled? File.absolute_path?(@cli_path) + rescue StandardError + false # nil from a subclass's #find_cli, a NUL byte, an incompatible encoding end # Run `claude -v` with a hard deadline. Arg-vector popen3 — no shell, same diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb index 5659c4d6..1dd02b90 100644 --- a/spec/unit/cli_path_resolution_spec.rb +++ b/spec/unit/cli_path_resolution_spec.rb @@ -81,9 +81,9 @@ def legs # Construct and connect from the process cwd, then read to EOF: the fake # has exited, so both legs are on record. - def legs_executed(**values) + def legs_executed(transport_class = described_class, **values) Dir.chdir(app) do - transport = described_class.new(options(**values)) + transport = transport_class.new(options(**values)) begin transport.connect transport.read_messages { |_frame| nil } @@ -94,6 +94,16 @@ def legs_executed(**values) legs end + # `link` -> `real/sub`, so `link/..` is `real` on the filesystem but the + # root itself when collapsed as text. Fakes on both sides tell them apart. + def symlinked_dotdot(name) + FileUtils.mkdir_p(File.join(root, 'real', 'sub')) + File.symlink(File.join(root, 'real', 'sub'), File.join(root, 'link')) + install_fake_cli("real/bin/#{name}") + install_fake_cli("bin/#{name}") + File.join(root, 'link', '..', 'bin') + end + describe 'an explicit cli_path' do it 'resolves a relative path against the process cwd, for the probe and the spawn' do install_fake_cli('app/bin/claude') @@ -274,4 +284,71 @@ def legs_executed(**values) expect(legs).to eq(['wrapped:claude-on-the-far-side']) end end + + # spawn resolved `link/..` through the filesystem; a path rewritten as + # text (File.expand_path) would name another file when `link` is a + # symlink. Paths are anchored, never normalized. + describe 'a symlink followed by `..`' do + it 'runs an absolute cli_path the way the filesystem resolves it' do + bin = symlinked_dotdot('fake-claude-cli') + + expect(legs_executed(cli_path: File.join(bin, 'fake-claude-cli'))) + .to eq(%w[probe:real/bin/fake-claude-cli spawn:real/bin/fake-claude-cli]) + end + + it 'searches a PATH entry the way the filesystem resolves it' do + bin = symlinked_dotdot('fake-claude-cli') + + with_env('PATH' => path_with(bin)) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:real/bin/fake-claude-cli spawn:real/bin/fake-claude-cli]) + end + end + + it 'keeps an absolute `which` hit exactly as printed' do + hit = File.join(symlinked_dotdot('claude'), 'claude') + allow(Open3).to receive(:capture2).and_call_original + allow(Open3).to receive(:capture2).with('which', 'claude').and_return(["#{hit}\n", nil]) + + # HOME at the tmp root: no well-known install location to fall back on. + transport = with_env('HOME' => root) { Dir.chdir(app) { described_class.new(options) } } + + expect(transport.build_command.first).to eq(hit) + end + end + + describe 'inputs that worked before this resolution existed' do + it 'treats any falsy cli_path as "discover"', rbs_incompatible: 'cli_path: false is outside the signature' do + env_cli = install_fake_cli('tools/claude-from-env') + + with_env('CLAUDE_CLI_PATH' => env_cli) do + expect(legs_executed(cli_path: false)).to eq(%w[probe:tools/claude-from-env spawn:tools/claude-from-env]) + end + end + + it "settles a bare name from a subclass's #find_cli like an explicit cli_path" do + install_fake_cli('tools/claude-from-subclass') + discovering = Class.new(described_class) { define_method(:find_cli) { 'claude-from-subclass' } } + + with_env('PATH' => path_with(File.join(root, 'tools'))) do + expect(legs_executed(discovering)) + .to eq(%w[probe:tools/claude-from-subclass spawn:tools/claude-from-subclass]) + end + end + + it 'searches a PATH that is not valid UTF-8 byte by byte' do + install_fake_cli('tools/claude-bytewise') + not_utf8 = "#{root}/\xFFdir".force_encoding(Encoding::UTF_8) + env = { 'PATH' => "#{not_utf8}#{File::PATH_SEPARATOR}#{File.join(root, 'tools')}" } + + expect(legs_executed(cli_path: 'claude-bytewise', env: env)) + .to eq(%w[probe:tools/claude-bytewise spawn:tools/claude-bytewise]) + end + + it 'reports a cli_path with a NUL byte as a CLIConnectionError, not a raw ArgumentError' do + transport = Dir.chdir(app) { described_class.new(options(cli_path: "bin/cl\0aude")) } + + expect { Dir.chdir(app) { transport.connect } }.to raise_error(ClaudeAgentSDK::CLIConnectionError) + end + end end From ef9b8153d3afaf3897df198ef6526e5d4ef3ded6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:41:02 +0800 Subject: [PATCH 155/194] Settle the CLI path again when the transport connects Before 00ea691 a bare `cli_path: 'claude'` was looked up by spawn, at connect, in the process cwd of that moment. 00ea691 moved the lookup to the constructor, so a CLI installed between `SubprocessCLITransport.new` and `#connect` was never found (CLINotFoundError where the base commit connected), and a relative path followed the cwd at construction rather than the one the version probe runs in. query() and Client build the transport right before connecting, so this mostly affected direct users of the transport. The transport keeps what it was given (`@given_cli_path`) and settles it again at the top of #connect, before the version probe; the constructor still settles it too, so #build_command shows the absolute path before connecting. Discovery still runs once, in the constructor, so when it raises CLINotFoundError is unchanged. The "never hands an unresolved name to spawn" example installed its fake after construction, which a connect-time lookup now rightly finds. It now makes the SDK's lookup miss the file (stubbing File.file?, the SDK's test) while spawn's own lookup would still see it: it fails on the base commit and with the guard removed, and passes here. Exploration finding: U-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- CLAUDE.md | 2 +- .../subprocess_cli_transport.rb | 12 +++++-- spec/unit/cli_path_resolution_spec.rb | 31 ++++++++++++++++--- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d355c09..da62c82c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ User code `SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` (a relative hit — a relative `PATH` entry makes `which` print one — is anchored to the process cwd) → common install locations. -**One absolute path, settled in `#initialize`** (`#settle_cli_path`): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). +**One absolute path, settled in `#initialize` and again at the top of `#connect`** (`#settle_cli_path`, from `@given_cli_path` — the lookup happens when spawn used to do it): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). `CLIInstaller` (`lib/claude_agent_sdk/cli_installer.rb`) downloads a pinned CLI into `vendor/claude` (relative to `CLIInstaller.root` when set — the Railtie sets it to `Rails.root` — else to `Dir.pwd`, resolved at call time; `root` is a plain frozen-String ivar, absolutized on set, read lock-free) for Docker/CI deploys: dist-tag → concrete version, manifest SHA-256 + declared size, streamed download (bounded by that size) to a random-named sibling temp file (`O_EXCL`), verify, `chmod 0755`, atomically write `VERSION` = version + verified checksum, and **finally** rename the temp file into place. Stdlib only; every failure (filesystem errors included) is wrapped in `CLIInstallError` with `cause` preserved. diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 857f129f..61b301dc 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -138,9 +138,11 @@ def initialize(options_or_prompt = nil, options = nil) super() # Transport defines no state today; keep the chain intact if it ever does # Support both new single-arg form and legacy two-arg form @options = options.nil? ? options_or_prompt : options - # Any falsy cli_path means discovery, as it always did. `.to_s`: the - # option may be a Pathname. - @cli_path = settle_cli_path((@options.cli_path || find_cli).to_s) + # What the caller named (any falsy cli_path means discovery, as it + # always did), as a String: the option may be a Pathname. Settled + # here, and again by #connect. + @given_cli_path = (@options.cli_path || find_cli).to_s + @cli_path = settle_cli_path(@given_cli_path) @cwd = @options.cwd @process = nil @stdin = nil @@ -295,6 +297,10 @@ def build_command def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order return if @process + # Settled again where spawn used to look the CLI up: a CLI installed + # after this transport was built is found, and a relative path + # follows the process cwd of this connect, where the probe runs. + @cli_path = settle_cli_path(@given_cli_path) check_claude_version cmd = build_command diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb index 1dd02b90..6a9780cf 100644 --- a/spec/unit/cli_path_resolution_spec.rb +++ b/spec/unit/cli_path_resolution_spec.rb @@ -206,12 +206,17 @@ def symlinked_dotdot(name) # A name the SDK could not settle must not reach spawn: spawn searches # PATH on its own, in the parent, and then runs a relative hit after - # chdir-ing into options.cwd. + # chdir-ing into options.cwd. Here the SDK's lookup is made to miss the + # process-cwd file (File.file? is its test); spawn's own lookup, done in + # C, would still find it and run target/bin/unsettled-claude. it 'never hands an unresolved name to spawn' do + install_fake_cli('app/bin/unsettled-claude') + install_fake_cli('target/bin/unsettled-claude') + allow(File).to receive(:file?).and_call_original + allow(File).to receive(:file?).with(File.join(app, 'bin', 'unsettled-claude')).and_return(false) + with_env('PATH' => path_with('bin')) do - transport = Dir.chdir(app) { described_class.new(options(cli_path: 'late-claude')) } - install_fake_cli('app/bin/late-claude') - install_fake_cli('target/bin/late-claude') + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'unsettled-claude')) } expect { Dir.chdir(app) { transport.connect } }.to raise_error(ClaudeAgentSDK::CLINotFoundError) ensure @@ -219,6 +224,24 @@ def symlinked_dotdot(name) end expect(legs).to be_empty end + + it 'is looked up again at connect, as spawn did: a CLI installed after construction is found' do + with_env('PATH' => path_with(File.join(root, 'tools'))) do + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'claude-installed-later')) } + install_fake_cli('tools/claude-installed-later') + + begin + Dir.chdir(app) do + transport.connect + transport.read_messages { |_frame| nil } + end + ensure + transport.close + end + end + + expect(legs).to eq(%w[probe:tools/claude-installed-later spawn:tools/claude-installed-later]) + end end describe 'discovery (no cli_path)' do From 9dcef901add5e403ed792b08f30917ba0d997702 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:42:44 +0800 Subject: [PATCH 156/194] Harden the CLI path and sandbox warning specs Spec-only follow-ups from the independent review of this branch. - The bare-name examples used the name `claude`. Had the lookup regressed, they would have fallen through to the developer's real CLI, spawned it in the target directory with the host environment, and hung the run waiting for a prompt. They now use names no host has (`fake-claude-cli`), so a regression fails with CLINotFoundError. The discovery examples must look for `claude`; they now run with HOME at the tmp root, so the well-known `~/...` locations are empty. - Nothing pinned the regular-file half of the PATH test (Ruby's spawn accepts an executable regular file; File.executable? alone is true for a directory). New example: a directory of the same name earlier on PATH is passed over. It fails with that test removed. - The sandbox warning's negative fixture was invented. It is now the text CLI 2.1.286/2.1.287 print when failIfUnavailable makes them refuse to start ("Error: sandbox required but unavailable: ..."), with the exit status 1 and no result frame that go with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- spec/unit/cli_path_resolution_spec.rb | 47 ++++++++++++++----- spec/unit/sandbox_unavailable_warning_spec.rb | 30 +++++++----- 2 files changed, 53 insertions(+), 24 deletions(-) diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb index 6a9780cf..cf3f4038 100644 --- a/spec/unit/cli_path_resolution_spec.rb +++ b/spec/unit/cli_path_resolution_spec.rb @@ -143,31 +143,49 @@ def symlinked_dotdot(name) end end + # Names no host has on PATH: if the lookup regressed, an example must fail + # with CLINotFoundError, not fall through to the developer's real `claude` + # (which would then wait for a prompt on stdin and hang the run). describe 'an explicit bare name' do it 'is found through a relative PATH entry in the process cwd, for the probe and the spawn' do - install_fake_cli('app/bin/claude') - install_fake_cli('target/bin/claude') + install_fake_cli('app/bin/fake-claude-cli') + install_fake_cli('target/bin/fake-claude-cli') with_env('PATH' => path_with('bin')) do - expect(legs_executed(cli_path: 'claude')).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:app/bin/fake-claude-cli spawn:app/bin/fake-claude-cli]) end end it 'treats `.` and an empty PATH entry as the process cwd' do - install_fake_cli('app/claude') - install_fake_cli('target/claude') + install_fake_cli('app/fake-claude-cli') + install_fake_cli('target/fake-claude-cli') aggregate_failures do ['.', ''].each do |entry| FileUtils.rm_f(marker) with_env('PATH' => path_with(entry)) do - expect(legs_executed(cli_path: 'claude')).to eq(%w[probe:app/claude spawn:app/claude]), - "PATH entry #{entry.inspect}: #{legs.inspect}" + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:app/fake-claude-cli spawn:app/fake-claude-cli]), "PATH entry #{entry.inspect}: #{legs.inspect}" end end end end + # Ruby's spawn tests for an executable regular file; a directory of the + # same name earlier on PATH (executable? is true for directories) is + # passed over. + it 'takes the first executable regular file on PATH, not a directory of that name' do + FileUtils.mkdir_p(File.join(root, 'dirs', 'fake-claude-cli')) + install_fake_cli('tools/fake-claude-cli') + search = [File.join(root, 'dirs'), File.join(root, 'tools'), ENV.fetch('PATH')].join(File::PATH_SEPARATOR) + + with_env('PATH' => search) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:tools/fake-claude-cli spawn:tools/fake-claude-cli]) + end + end + it 'treats a trailing empty PATH entry as the process cwd' do install_fake_cli('app/claude-wrapper') install_fake_cli('target/claude-wrapper') @@ -189,11 +207,11 @@ def symlinked_dotdot(name) end it 'anchors a relative entry of the options.env PATH to the process cwd as well' do - install_fake_cli('app/bin/claude') - install_fake_cli('target/bin/claude') + install_fake_cli('app/bin/fake-claude-cli') + install_fake_cli('target/bin/fake-claude-cli') - expect(legs_executed(cli_path: 'claude', env: { PATH: path_with('bin') })) - .to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + expect(legs_executed(cli_path: 'fake-claude-cli', env: { PATH: path_with('bin') })) + .to eq(%w[probe:app/bin/fake-claude-cli spawn:app/bin/fake-claude-cli]) end it 'reports a name that is not on PATH as CLINotFoundError at connect, not at construction' do @@ -245,6 +263,9 @@ def symlinked_dotdot(name) end describe 'discovery (no cli_path)' do + # Discovery looks for `claude` itself. HOME at the tmp root keeps the + # well-known `~/...` install locations empty, so a regression in the + # `which` step fails here instead of reaching the developer's real CLI. context "with the host's own `which`" do before do skip 'which(1) is not installed here' unless system('which', 'which', out: File::NULL, err: File::NULL) @@ -254,7 +275,7 @@ def symlinked_dotdot(name) install_fake_cli('app/bin/claude') install_fake_cli('target/bin/claude') - with_env('PATH' => path_with('bin')) do + with_env('PATH' => path_with('bin'), 'HOME' => root) do expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) end end @@ -263,7 +284,7 @@ def symlinked_dotdot(name) install_fake_cli('app/claude') install_fake_cli('target/claude') - with_env('PATH' => path_with('.')) do + with_env('PATH' => path_with('.'), 'HOME' => root) do expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) end end diff --git a/spec/unit/sandbox_unavailable_warning_spec.rb b/spec/unit/sandbox_unavailable_warning_spec.rb index 77def566..85d45c1d 100644 --- a/spec/unit/sandbox_unavailable_warning_spec.rb +++ b/spec/unit/sandbox_unavailable_warning_spec.rb @@ -23,19 +23,21 @@ # (`process.stderr.write(`\n⚠ Sandbox disabled: ${reason}\n Commands # will run WITHOUT sandboxing. ...\n\n`)`), with the reason the CLI builds # on a Linux host that has neither bubblewrap nor socat. - let(:cli_line) do - '⚠ Sandbox disabled: sandbox is enabled but dependencies are missing: ' \ + let(:unavailable_reason) do + 'sandbox is enabled but dependencies are missing: ' \ 'bubblewrap (bwrap) not installed, socat not installed ' \ '· install missing tools (e.g. apt install bubblewrap socat) ' \ 'or see https://code.claude.com/docs/en/sandboxing' end + let(:cli_line) { "⚠ Sandbox disabled: #{unavailable_reason}" } let(:cli_notice) do "\n#{cli_line}\n " \ "Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced.\n\n" end - # Writes +stderr_text+ to stderr, then ends the run with a result frame. - def install_fake_cli(stderr_text, exit_status: 0) + # Writes +stderr_text+ to stderr, then ends the run (with a result frame + # unless +result+ is false). + def install_fake_cli(stderr_text, exit_status: 0, result: true) stderr_file = File.join(@dir, 'stderr.txt') File.write(stderr_file, stderr_text) path = File.join(@dir, 'claude') @@ -46,7 +48,7 @@ def install_fake_cli(stderr_text, exit_status: 0) exit 0 fi cat '#{stderr_file}' >&2 - printf '%s\\n' '#{JSON.generate(sample_result_message)}' + #{"printf '%s\\n' '#{JSON.generate(sample_result_message)}'" if result} exit #{exit_status} SH File.chmod(0o755, path) @@ -117,12 +119,18 @@ def transport_with(**values) end end - it 'stays silent when the CLI did not report the sandbox as disabled' do - quiet_cli = install_fake_cli("[SandboxDebug] sandbox initialized\nSandbox enabled: bubblewrap\n") - - output = stderr_during_session(transport_with(cli_path: quiet_cli, sandbox: { enabled: true })) - - expect(output).to be_empty + # With failIfUnavailable the CLI does not carry on unsandboxed: it prints + # this instead (2.1.286 / 2.1.287, same startup branch) and exits 1, which + # the session already reports as a ProcessError carrying the text. + it 'stays silent when the CLI refuses to start without the sandbox' do + refusal = "\nError: sandbox required but unavailable: #{unavailable_reason}\n " \ + "sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n" + refusing_cli = install_fake_cli(refusal, exit_status: 1, result: false) + transport = transport_with(cli_path: refusing_cli, sandbox: { enabled: true, failIfUnavailable: true }) + captured = StringIO.new + + expect { stderr_during_session(transport, stderr: captured) }.to raise_error(ClaudeAgentSDK::ProcessError) + expect(captured.string).to be_empty end # A host's $stderr can be a pipe nobody reads any more. The warning is From 6ef53568152b9026606e2c2e30a0aba604332a7d Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 03:59:22 +0800 Subject: [PATCH 157/194] Search PATH for the default claude instead of running which Discovery's PATH step ran `which claude` and used its answer. `which` is a program like any other: it is itself looked up on PATH, relative entries included, and whatever it prints had to be believed. With `PATH=bin:...`, a `which` under the process cwd's `bin` that printed the path of a `claude` inside options.cwd was executed, and that file then ran for both the version probe and the session. The earlier commits on this branch anchored `which`'s answer but still asked it. #find_cli now searches PATH itself, with the lookup an explicit bare `cli_path` already used (#executable_on_path): the first executable regular file named `claude`, relative entries, `.` and the empty entry anchored to the process cwd. Nothing is run to find the CLI. The order is unchanged: CLAUDE_CLI_PATH, the vendored binary, PATH, the well-known locations. Each lookup searches the PATH of the mechanism it replaces: discovery the process's PATH, as `which` did (a PATH passed in options.env is the session's and is not searched there); an explicit bare `cli_path` the PATH the child is given, as spawn did. spec/unit/subprocess_cli_transport_spec.rb, `#find_cli` section only: the examples stubbed `Open3.capture2('which', 'claude')`, both to inject a PATH hit and to keep the host's real `claude` out. PATH is now a directory of the example's own, and the two examples that asserted trust in `which`'s output assert the PATH hit instead. Behavior a user can notice: none where PATH has only absolute entries and `which` was honest; a host without `which` now finds `claude` on PATH instead of skipping to the well-known locations. Exploration finding: U-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- CLAUDE.md | 4 +- docs/cli-installer.md | 4 +- .../subprocess_cli_transport.rb | 65 +++++++------- spec/unit/cli_path_resolution_spec.rb | 90 ++++++++++++------- spec/unit/subprocess_cli_transport_spec.rb | 32 ++++--- 5 files changed, 115 insertions(+), 80 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index da62c82c..bad4cf55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,9 @@ User code ### CLI Discovery / Installer -`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` (a relative hit — a relative `PATH` entry makes `which` print one — is anchored to the process cwd) → common install locations. +`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `claude` on the process `PATH`, searched by the SDK itself (`#executable_on_path`; no `which` — it would be found through `PATH` too, relative entries included, and its answer would have to be believed) → common install locations. -**One absolute path, settled in `#initialize` and again at the top of `#connect`** (`#settle_cli_path`, from `@given_cli_path` — the lookup happens when spawn used to do it): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path` — the `PATH` the child will get: `options.env`'s, else the process's, else Ruby's built-in default; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). +**One absolute path, settled in `#initialize` and again at the top of `#connect`** (`#settle_cli_path`, from `@given_cli_path` — the lookup happens when spawn used to do it): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path`: an executable regular file; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped). Each lookup uses the `PATH` of the mechanism it replaced: discovery the process's (what `which claude` saw), an explicit bare `cli_path` the one the child will get (`#spawn_search_path`: `options.env`'s, else the process's, else Ruby's built-in default — what spawn searched). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). `CLIInstaller` (`lib/claude_agent_sdk/cli_installer.rb`) downloads a pinned CLI into `vendor/claude` (relative to `CLIInstaller.root` when set — the Railtie sets it to `Rails.root` — else to `Dir.pwd`, resolved at call time; `root` is a plain frozen-String ivar, absolutized on set, read lock-free) for Docker/CI deploys: dist-tag → concrete version, manifest SHA-256 + declared size, streamed download (bounded by that size) to a random-named sibling temp file (`O_EXCL`), verify, `chmod 0755`, atomically write `VERSION` = version + verified checksum, and **finally** rename the temp file into place. Stdlib only; every failure (filesystem errors included) is wrapped in `CLIInstallError` with `cause` preserved. diff --git a/docs/cli-installer.md b/docs/cli-installer.md index 6e9be956..3db2f1f0 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -106,7 +106,7 @@ With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in th 1. `CLAUDE_CLI_PATH` — an explicit path to an executable, no discovery at all (a relative value is resolved against the process's working directory, not `cwd:`) 2. The vendored binary (`CLIInstaller.installed_path`, i.e. `vendor/claude` under `CLIInstaller.root` or the working directory — see [above](#where-vendorclaude-is)) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally -3. `which claude` +3. `claude` on the process's `PATH` — the first executable regular file of that name. The SDK searches `PATH` itself and does not run `which`. A `PATH` passed in `env:` belongs to the session and is not searched here 4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …) — only an executable regular file counts, and the `~` ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME) -However the CLI is named, the transport settles on one absolute path before it runs anything, and uses that path for both the version check and the session. Anything relative is resolved against the process's working directory, never against `cwd:` — a relative `cli_path:` (a leading `~` expands to the home directory), and a `PATH` hit that came from a relative `PATH` entry (`bin`, `.`, an empty entry). A bare `cli_path: 'claude'` is looked up on `PATH` by the SDK itself: the `PATH` in `env:` when you set one there, otherwise the process's. A `cli_path:` that names no file raises `CLINotFoundError` from `connect`. +However the CLI is named, the transport settles on one absolute path before it runs anything, and uses that path for both the version check and the session. Anything relative is resolved against the process's working directory, never against `cwd:` — a relative `cli_path:` (a leading `~` expands to the home directory), and a `PATH` hit that came from a relative `PATH` entry (`bin`, `.`, an empty entry). A bare `cli_path: 'claude'` is looked up by the SDK in the same way, but on the `PATH` the session will get: the one in `env:` when you set one there, otherwise the process's. A `cli_path:` that names no file raises `CLINotFoundError` from `connect`. diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 61b301dc..51777c45 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -179,13 +179,16 @@ def initialize(options_or_prompt = nil, options = nil) # 2. A project-local vendored binary (CLIInstaller). Deliberately ahead # of PATH: the point of a pinned, vendored CLI is that it beats # whatever version happens to be installed globally on the host. - # 3. `which claude`. + # 3. `claude` on this process's PATH, searched here + # (#executable_on_path). Not by running `which`: that program would + # itself be looked up on PATH, relative entries included, and its + # answer would have to be believed. # 4. Well-known install locations. # # Every hit is returned as an absolute path — see #settle_cli_path. # # @api private - def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered discovery probes (env, vendored, PATH, known locations) + def find_cli # rubocop:disable Metrics/MethodLength -- ordered discovery probes (env, vendored, PATH, known locations) env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s unless env_path.empty? # Absolutize against the CURRENT working directory, which is where the @@ -208,19 +211,11 @@ def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Me end return vendored if vendored - # Try which command first (using Open3 for thread safety) - cli = nil - begin - stdout, _status = Open3.capture2('which', 'claude') - hit = stdout.strip - # A relative PATH entry (`bin`, `.`, the empty entry) makes most - # `which` implementations print a relative hit. Anchor it to the cwd - # `which` ran in, for the same reason as CLAUDE_CLI_PATH. - cli = anchor_to_cwd(hit) unless hit.empty? - rescue StandardError - # which command failed, try common locations - end - return cli if cli && File.executable?(cli) + # The process's own PATH, the one `which claude` used to search here: a + # PATH set through options.env is the session's, and only a bare + # cli_path is searched on it (#spawn_search_path). No PATH, no step. + on_path = executable_on_path('claude', ENV.fetch('PATH', nil)) + return on_path if on_path # Try common locations. The home-relative ones are skipped when no # usable home exists (see #home_dir), so a HOME-less container still @@ -1032,7 +1027,7 @@ def ready? # byte) is returned as given, and #connect reports it as # CLINotFoundError. def settle_cli_path(path) - return executable_on_path(path) || path unless path.include?(File::SEPARATOR) + return executable_on_path(path, spawn_search_path) || path unless path.include?(File::SEPARATOR) path.start_with?('~') ? expand_leading_tilde(path) : anchor_to_cwd(path) rescue ArgumentError, EncodingError, SystemCallError @@ -1051,17 +1046,17 @@ def expand_leading_tilde(path) File.join(File.expand_path(head), rest) end - # The lookup spawn used to do for a bare command name, done here so that - # the result is absolute: same PATH (#search_path_entries), same test (an - # executable regular file), first hit wins. The difference is the point - # of doing it here: a relative entry — `bin`, `.`, the empty entry — is - # anchored to the process cwd, where spawn found the file here and then - # executed that relative path inside options.cwd. Returns nil when - # nothing matches. - def executable_on_path(name) - return nil if name.empty? - - search_path_entries.each do |entry| + # The first executable regular file called +name+ on +search_path+, as an + # absolute path, or nil. This is the lookup spawn did for a bare command + # name (and `which` for discovery), done here so that nothing is run to + # find the CLI and nothing relative comes back: a relative entry — `bin`, + # `.`, the empty entry — is anchored to the process cwd, where spawn + # found the file here and then executed that relative path inside + # options.cwd. + def executable_on_path(name, search_path) + return nil if name.empty? || search_path.nil? + + search_path_entries(search_path).each do |entry| candidate = File.join(path_entry_dir(entry), name) return candidate if File.file?(candidate) && File.executable?(candidate) rescue ArgumentError, EncodingError, SystemCallError @@ -1070,12 +1065,18 @@ def executable_on_path(name) nil end - # The PATH spawn searched: the one the child is given through options.env - # when it sets one, else this process's, else Ruby's built-in default. - def search_path_entries + # The PATH spawn searched for a bare cli_path: the one the child is given + # through options.env when it sets one, else this process's, else Ruby's + # built-in default. (Discovery searches the process's PATH — #find_cli.) + def spawn_search_path env = @options.env - search_path = env.transform_keys(&:to_s)['PATH'] if env.is_a?(Hash) - search_path = (search_path || ENV.fetch('PATH', DEFAULT_EXEC_SEARCH_PATH)).to_s + from_options = env.transform_keys(&:to_s)['PATH'] if env.is_a?(Hash) + from_options || ENV.fetch('PATH', DEFAULT_EXEC_SEARCH_PATH) + end + + # A PATH value as its entries. + def search_path_entries(search_path) + search_path = search_path.to_s # spawn searched bytes; a PATH that is invalid in its encoding cannot # be split as text. search_path = search_path.b unless search_path.valid_encoding? diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb index cf3f4038..c6d558f1 100644 --- a/spec/unit/cli_path_resolution_spec.rb +++ b/spec/unit/cli_path_resolution_spec.rb @@ -26,7 +26,7 @@ end # Keep a vendored binary (CLIInstaller.root may point anywhere on a - # developer machine) out of the discovery examples; `which` is real. + # developer machine) out of the discovery examples. before { allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(nil) } # The examples assert on the marker line the probe leg writes. The fakes @@ -262,43 +262,69 @@ def symlinked_dotdot(name) end end + # Discovery looks for `claude` itself, on the process's PATH. HOME at the + # tmp root keeps the well-known `~/...` install locations empty, so a + # regression in the PATH step fails here instead of reaching the + # developer's real CLI. describe 'discovery (no cli_path)' do - # Discovery looks for `claude` itself. HOME at the tmp root keeps the - # well-known `~/...` install locations empty, so a regression in the - # `which` step fails here instead of reaching the developer's real CLI. - context "with the host's own `which`" do - before do - skip 'which(1) is not installed here' unless system('which', 'which', out: File::NULL, err: File::NULL) + it 'runs the hit of a relative PATH entry from the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + with_env('PATH' => path_with('bin'), 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) end + end - it 'runs the hit of a relative PATH entry from the process cwd, for the probe and the spawn' do - install_fake_cli('app/bin/claude') - install_fake_cli('target/bin/claude') + it 'runs the hit of a `.` PATH entry from the process cwd' do + install_fake_cli('app/claude') + install_fake_cli('target/claude') - with_env('PATH' => path_with('bin'), 'HOME' => root) do - expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) - end + with_env('PATH' => path_with('.'), 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) end + end - it 'runs the hit of a `.` PATH entry from the process cwd' do - install_fake_cli('app/claude') - install_fake_cli('target/claude') + # `which` is a program like any other: looked up on PATH — here through + # the relative `bin` entry — and free to answer anything. This one names + # the binary inside options.cwd. Discovery searches PATH itself, so it + # neither runs `which` nor has an answer to believe. + it 'does not run or believe a `which` found on PATH' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + lying_which = File.join(app, 'bin', 'which') + File.write(lying_which, <<~SH) + #!/bin/sh + echo 'which-ran' >> '#{marker}' + echo '#{File.join(target, 'bin', 'claude')}' + SH + File.chmod(0o755, lying_which) + search = ['bin', '/usr/bin', '/bin'].join(File::PATH_SEPARATOR) + + with_env('PATH' => search, 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end - with_env('PATH' => path_with('.'), 'HOME' => root) do - expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) - end + # A PATH in options.env is the session's. Discovery searches the PATH + # `which claude` used to see, the process's; only an explicit bare + # cli_path is looked up on the session's. + it 'searches the process PATH, not the one options.env gives the session' do + install_fake_cli('app/bin/claude') + install_fake_cli('tools/claude') + session_env = { 'PATH' => path_with(File.join(root, 'tools')) } + + with_env('PATH' => path_with('bin'), 'HOME' => root) do + expect(legs_executed(env: session_env)).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) end end - # `which` implementations differ (BSD, busybox and Debian's print the - # relative hit, GNU's prints an absolute one), so pin the relative - # spelling itself. - it 'absolutizes a relative `which` hit against the process cwd' do + it 'puts the anchored absolute path in the argv before connecting' do install_fake_cli('app/bin/claude') - allow(Open3).to receive(:capture2).and_call_original - allow(Open3).to receive(:capture2).with('which', 'claude').and_return(["bin/claude\n", nil]) - transport = Dir.chdir(app) { described_class.new(options) } + transport = with_env('PATH' => path_with('bin'), 'HOME' => root) do + Dir.chdir(app) { described_class.new(options) } + end expect(transport.build_command.first).to eq(File.join(app, 'bin', 'claude')) end @@ -349,15 +375,15 @@ def symlinked_dotdot(name) end end - it 'keeps an absolute `which` hit exactly as printed' do - hit = File.join(symlinked_dotdot('claude'), 'claude') - allow(Open3).to receive(:capture2).and_call_original - allow(Open3).to receive(:capture2).with('which', 'claude').and_return(["#{hit}\n", nil]) + it 'discovers `claude` through such a PATH entry and keeps the entry as written' do + bin = symlinked_dotdot('claude') # HOME at the tmp root: no well-known install location to fall back on. - transport = with_env('HOME' => root) { Dir.chdir(app) { described_class.new(options) } } + transport = with_env('PATH' => path_with(bin), 'HOME' => root) do + Dir.chdir(app) { described_class.new(options) } + end - expect(transport.build_command.first).to eq(hit) + expect(transport.build_command.first).to eq(File.join(bin, 'claude')) end end diff --git a/spec/unit/subprocess_cli_transport_spec.rb b/spec/unit/subprocess_cli_transport_spec.rb index dbcb967f..11c5abf1 100644 --- a/spec/unit/subprocess_cli_transport_spec.rb +++ b/spec/unit/subprocess_cli_transport_spec.rb @@ -459,10 +459,17 @@ let(:tmp_dir) { @tmp_dir } + # PATH is one directory of the example's own, empty unless the example + # calls #claude_on_path: discovery searches PATH itself, and the host's + # real `claude` must never be what it finds. around do |example| Dir.mktmpdir('find-cli-spec') do |dir| @tmp_dir = dir + previous_path = ENV.fetch('PATH', nil) + ENV['PATH'] = File.join(dir, 'on-path').tap { |path_dir| FileUtils.mkdir_p(path_dir) } example.run + ensure + previous_path.nil? ? ENV.delete('PATH') : (ENV['PATH'] = previous_path) end end @@ -472,8 +479,6 @@ allow(ENV).to receive(:fetch).and_call_original allow(ENV).to receive(:fetch).with('CLAUDE_CLI_PATH', nil).and_return(nil) allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(nil) - allow(Open3).to receive(:capture2).and_call_original - allow(Open3).to receive(:capture2).with('which', 'claude').and_return(['', nil]) end def executable(name) @@ -483,11 +488,16 @@ def executable(name) path end + # A `claude` in the one directory PATH names. + def claude_on_path + executable(File.join('on-path', 'claude')) + end + it 'prefers CLAUDE_CLI_PATH over everything else' do env_cli = executable('env-claude') allow(ENV).to receive(:fetch).with('CLAUDE_CLI_PATH', nil).and_return(env_cli) allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(executable('vendored-claude')) - allow(Open3).to receive(:capture2).with('which', 'claude').and_return([executable('which-claude'), nil]) + claude_on_path expect(transport.find_cli).to eq(env_cli) end @@ -525,27 +535,25 @@ def executable(name) expect(transport.find_cli).to eq(vendored) end - it 'prefers the vendored binary over a `which`-discovered one' do + it 'prefers the vendored binary over one on PATH' do vendored = executable('vendored-claude') allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(vendored) - allow(Open3).to receive(:capture2).with('which', 'claude').and_return([executable('which-claude'), nil]) + claude_on_path expect(transport.find_cli).to eq(vendored) end - it 'falls back to `which` when no override and no vendored binary exist' do - which_cli = executable('which-claude') - allow(Open3).to receive(:capture2).with('which', 'claude').and_return(["#{which_cli}\n", nil]) + it 'falls back to PATH when no override and no vendored binary exist' do + path_cli = claude_on_path - expect(transport.find_cli).to eq(which_cli) + expect(transport.find_cli).to eq(path_cli) end it 'tolerates CLIInstaller.installed_path raising' do - which_cli = executable('which-claude') + path_cli = claude_on_path allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_raise(Errno::ENOENT) - allow(Open3).to receive(:capture2).with('which', 'claude').and_return([which_cli, nil]) - expect(transport.find_cli).to eq(which_cli) + expect(transport.find_cli).to eq(path_cli) end it 'mentions the installer and CLAUDE_CLI_PATH when nothing is found' do From 25a9cd976ebb2e41d77245f7436f988c1f551be8 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 03:59:46 +0800 Subject: [PATCH 158/194] Move the sandbox warning note below the example link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/configuration.md: the paragraph this branch added right after the sandbox code block explained that `enabled: true` does not guarantee a sandbox and pointed at `fail_if_unavailable`. That explanation belongs to the documentation change that rewrites the same spot, and two changes inserting there would conflict and say it twice. The paragraph is removed. One sentence stays, for what this branch adds — the SDK repeats the CLI's "Sandbox disabled:" stderr line as a Ruby warning, prefixed `[claude-agent-sdk]`, once per session, when the `sandbox:` option enabled the sandbox, whether or not `stderr:` is set — on its own line after the "See examples/sandbox_example.rb" link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- docs/configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index f4c842c4..98972793 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -212,10 +212,10 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` -`enabled: true` asks for the sandbox; it does not guarantee one. When the CLI cannot start it (a Linux container without `bubblewrap` and `socat` is the usual case), it prints `⚠ Sandbox disabled: …` on its stderr and runs commands **without** sandboxing. If the `sandbox:` option enabled the sandbox, the SDK repeats that line as a Ruby warning, once per session, prefixed `[claude-agent-sdk]`. Set `fail_if_unavailable: true` (`failIfUnavailable` in a Hash) to make the CLI exit with an error instead. - See [examples/sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb). +When the `sandbox:` option enabled the sandbox and the CLI reports `Sandbox disabled: …` on its stderr, the SDK repeats that line as a Ruby warning prefixed `[claude-agent-sdk]`, once per session, whether or not you set `stderr:`. + ## Bare Mode Bare mode (`--bare`) is a minimal startup mode that skips hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. It sets `CLAUDE_CODE_SIMPLE=1` internally. Useful for scripted/programmatic usage where you want fast startup and full control over what's loaded. From abc00c7fe0e82bff69110175dd6fc7a908d8ae99 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:26:13 +0800 Subject: [PATCH 159/194] Pin discovery's empty and ~ PATH entry rules; document the ~ one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No change in lib/. Two rules of the PATH search that discovery now does itself were implemented but not pinned for discovery, and one of them was not documented. - An empty PATH entry is the process cwd. Only explicit bare names had an example for it; a version of #find_cli that dropped empty entries before searching passed the whole suite. New real-spawn example: discovery with PATH = "<a directory that does not exist>:", a fake `claude` in the process cwd and another in options.cwd — the process-cwd file runs for the version probe and for the session. It fails (CLINotFoundError) with the empty entries rejected. - A PATH entry that is `~` or starts with `~/` is the home directory, not a directory called `~` under the cwd. That is what Ruby's own command lookup does (system, spawn, Open3; sh and bash agree) and what the SDK did for an explicit bare cli_path all along; for discovery it changed when `which` — which reads the entry literally — was dropped. The behavior is kept on purpose: the SDK spawns through Open3, so a bare name resolves the way Ruby would resolve it. docs/cli-installer.md and CLAUDE.md now say so, and two real-spawn examples (discovery and an explicit bare name) pin it with competing `app/~/bin/claude` and `$HOME/bin/claude`: the `$HOME` one runs for both legs. They fail when `~` entries are anchored to the cwd like other relative entries. Exploration finding: U-P6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- CLAUDE.md | 2 +- docs/cli-installer.md | 2 +- spec/unit/cli_path_resolution_spec.rb | 43 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bad4cf55..dcac12f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ User code `SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `claude` on the process `PATH`, searched by the SDK itself (`#executable_on_path`; no `which` — it would be found through `PATH` too, relative entries included, and its answer would have to be believed) → common install locations. -**One absolute path, settled in `#initialize` and again at the top of `#connect`** (`#settle_cli_path`, from `@given_cli_path` — the lookup happens when spawn used to do it): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path`: an executable regular file; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped). Each lookup uses the `PATH` of the mechanism it replaced: discovery the process's (what `which claude` saw), an explicit bare `cli_path` the one the child will get (`#spawn_search_path`: `options.env`'s, else the process's, else Ruby's built-in default — what spawn searched). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). +**One absolute path, settled in `#initialize` and again at the top of `#connect`** (`#settle_cli_path`, from `@given_cli_path` — the lookup happens when spawn used to do it): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path`: an executable regular file; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped; a `~` or `~/x` entry is the home directory, as in Ruby's own command lookup, so deliberately not one of them — `#path_entry_dir`). Each lookup uses the `PATH` of the mechanism it replaced: discovery the process's (what `which claude` saw), an explicit bare `cli_path` the one the child will get (`#spawn_search_path`: `options.env`'s, else the process's, else Ruby's built-in default — what spawn searched). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). `CLIInstaller` (`lib/claude_agent_sdk/cli_installer.rb`) downloads a pinned CLI into `vendor/claude` (relative to `CLIInstaller.root` when set — the Railtie sets it to `Rails.root` — else to `Dir.pwd`, resolved at call time; `root` is a plain frozen-String ivar, absolutized on set, read lock-free) for Docker/CI deploys: dist-tag → concrete version, manifest SHA-256 + declared size, streamed download (bounded by that size) to a random-named sibling temp file (`O_EXCL`), verify, `chmod 0755`, atomically write `VERSION` = version + verified checksum, and **finally** rename the temp file into place. Stdlib only; every failure (filesystem errors included) is wrapped in `CLIInstallError` with `cause` preserved. diff --git a/docs/cli-installer.md b/docs/cli-installer.md index 3db2f1f0..57a25e3e 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -109,4 +109,4 @@ With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in th 3. `claude` on the process's `PATH` — the first executable regular file of that name. The SDK searches `PATH` itself and does not run `which`. A `PATH` passed in `env:` belongs to the session and is not searched here 4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …) — only an executable regular file counts, and the `~` ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME) -However the CLI is named, the transport settles on one absolute path before it runs anything, and uses that path for both the version check and the session. Anything relative is resolved against the process's working directory, never against `cwd:` — a relative `cli_path:` (a leading `~` expands to the home directory), and a `PATH` hit that came from a relative `PATH` entry (`bin`, `.`, an empty entry). A bare `cli_path: 'claude'` is looked up by the SDK in the same way, but on the `PATH` the session will get: the one in `env:` when you set one there, otherwise the process's. A `cli_path:` that names no file raises `CLINotFoundError` from `connect`. +However the CLI is named, the transport settles on one absolute path before it runs anything, and uses that path for both the version check and the session. Anything relative is resolved against the process's working directory, never against `cwd:` — a relative `cli_path:` (a leading `~` expands to the home directory), and a `PATH` hit that came from a relative `PATH` entry (`bin`, `.`, an empty entry). A `PATH` entry that is `~` or starts with `~/` is not one of those: it is expanded against the home directory, as Ruby's own command lookup does. A bare `cli_path: 'claude'` is looked up by the SDK in the same way, but on the `PATH` the session will get: the one in `env:` when you set one there, otherwise the process's. A `cli_path:` that names no file raises `CLINotFoundError` from `connect`. diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb index c6d558f1..0e0b7b62 100644 --- a/spec/unit/cli_path_resolution_spec.rb +++ b/spec/unit/cli_path_resolution_spec.rb @@ -196,6 +196,19 @@ def symlinked_dotdot(name) end end + # As spawn's own lookup did for a bare name: a `~/` entry is the home + # directory (HOME is the tmp root here), not `~` under the cwd. The same + # rule as for discovery, pinned there too. + it 'expands a `~/` PATH entry against HOME, not against the process cwd' do + install_fake_cli('app/~/bin/fake-claude-cli') + install_fake_cli('bin/fake-claude-cli') + + with_env('PATH' => path_with('~/bin'), 'HOME' => root) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:bin/fake-claude-cli spawn:bin/fake-claude-cli]) + end + end + # spawn searched the PATH it was about to give the child, so that is the # PATH which decides here too — and the probe now runs the same file. it 'is searched on the PATH from options.env, for the probe and the spawn' do @@ -285,6 +298,36 @@ def symlinked_dotdot(name) end end + # An empty entry is the current directory to a PATH lookup, and the easy + # one to lose in a lookup written by hand (a split that drops empty + # strings is enough). The first entry names nothing, so only the + # trailing empty one can find `claude` here. + it 'treats a trailing empty PATH entry as the process cwd' do + install_fake_cli('app/claude') + install_fake_cli('target/claude') + search = "#{File.join(root, 'no-such-directory')}#{File::PATH_SEPARATOR}" + + with_env('PATH' => search, 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) + end + end + + # A PATH entry that is `~` or starts with `~/` is the home directory to + # Ruby's own command lookup (system, spawn, Open3; sh and bash agree), + # not a directory called `~` under the cwd — only `which` and execvp read + # it literally. The SDK spawns through Open3, so it resolves the name the + # way Ruby would: on purpose, this is not one of the relative entries + # anchored to the process cwd. HOME is the tmp root here. + it "expands a `~/` PATH entry against HOME, as Ruby's own lookup does" do + install_fake_cli('app/~/bin/claude') + install_fake_cli('bin/claude') + search = ['~/bin', '/usr/bin', '/bin'].join(File::PATH_SEPARATOR) + + with_env('PATH' => search, 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:bin/claude spawn:bin/claude]) + end + end + # `which` is a program like any other: looked up on PATH — here through # the relative `bin` entry — and free to answer anything. This one names # the binary inside options.cwd. Discovery searches PATH itself, so it From ae5599ba69cbf2f0043ce43d4ef241cef8baa7e2 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:45:09 +0800 Subject: [PATCH 160/194] Say what an unusable CLAUDE_CLI_PATH does: skipped, no warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/cli-installer.md and the comment above #find_cli both described CLAUDE_CLI_PATH as "no discovery at all". That is only true when the variable names an executable regular file. A value that names anything else — a missing file, a directory, a file that is not executable — is skipped without a warning, and discovery goes on with the vendored binary, PATH and the well-known locations. Both now say so. The note that a relative value is resolved against the process's working directory, not `cwd:`, stays. Words only: no code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J6benx4nCho1nSrVnmKJrg --- docs/cli-installer.md | 2 +- lib/claude_agent_sdk/subprocess_cli_transport.rb | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/cli-installer.md b/docs/cli-installer.md index 57a25e3e..0230dc2a 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -104,7 +104,7 @@ The variable is deliberately not rake's conventional `VERSION`, which Rails' `db With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in this order: -1. `CLAUDE_CLI_PATH` — an explicit path to an executable, no discovery at all (a relative value is resolved against the process's working directory, not `cwd:`) +1. `CLAUDE_CLI_PATH` — a path to the CLI, used when it names an executable regular file (a relative value is resolved against the process's working directory, not `cwd:`). A value that names anything else — a missing file, a directory, a file that is not executable — is skipped without a warning, and discovery continues with the steps below 2. The vendored binary (`CLIInstaller.installed_path`, i.e. `vendor/claude` under `CLIInstaller.root` or the working directory — see [above](#where-vendorclaude-is)) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally 3. `claude` on the process's `PATH` — the first executable regular file of that name. The SDK searches `PATH` itself and does not run `which`. A `PATH` passed in `env:` belongs to the session and is not searched here 4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …) — only an executable regular file counts, and the `~` ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME) diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 51777c45..6f1eea3f 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -175,7 +175,10 @@ def initialize(options_or_prompt = nil, options = nil) end # Probe order (first hit wins): - # 1. CLAUDE_CLI_PATH — explicit operator override, no discovery at all. + # 1. CLAUDE_CLI_PATH — the operator's override, when it names an + # executable regular file. A value that does not (a missing file, a + # directory, a file that is not executable) is skipped without a + # warning, and discovery goes on with the steps below. # 2. A project-local vendored binary (CLIInstaller). Deliberately ahead # of PATH: the point of a pinned, vendored CLI is that it beats # whatever version happens to be installed globally on the host. From fcad99e871e7f4c1f341030b3a11be77ec18a993 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:56:09 +0800 Subject: [PATCH 161/194] Keep a preserved resume dir when cleanup runs after it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A teardown can run twice. Client#disconnect lets go of its MaterializedResume only once preserve_transcripts returned; when that was cut short by something that is not a StandardError (a cancellation while the warning is written, a signal), a later disconnect finds the query handler gone. Nothing then remembers that the mirror dropped batches, so it calls #cleanup on the same object, which deleted the directory the warning had just pointed at — the only copy of the dropped turns. MaterializedResume#cleanup now does nothing once preserve_transcripts was called, however that went: finished, skipped, or interrupted before the directory was even moved. Copies that were not scrubbed yet stay where the warning says the transcript is; losing the transcript is the worse outcome. A cleanup that is not preceded by preserve_transcripts removes the temp dir as before. Three examples: #cleanup after a finished preservation, after a scrub interrupted by a non-StandardError, and after an interruption before the move. Each of the first two lost the transcript before this change (`got: {}`); the third lost the whole directory. Exploration finding: B-P8 (round 3 follow-up, ruling r4 item 1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- lib/claude_agent_sdk/session_resume.rb | 10 +++++ spec/unit/materialized_resume_spec.rb | 58 ++++++++++++++++++++++---- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index fb5fd7ed..d747c345 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -37,10 +37,19 @@ def initialize(config_dir:, resume_session_id:, root_identity: nil) @config_dir = config_dir @resume_session_id = resume_session_id @root_identity = root_identity || SessionResume.directory_identity(config_dir) + @kept = false end # Best-effort removal of the temp config dir (never raises). + # + # Does nothing once preserve_transcripts was called, however that went. A + # teardown can run twice — a disconnect after one that was cut short — + # and the second time nothing remembers that the mirror dropped batches: + # it asks for a cleanup of the directory holding the only copy of them. + # Leaving copies that were not scrubbed yet is the lesser harm. def cleanup + return if @kept + SessionResume.rmtree_with_retry(@config_dir) end @@ -76,6 +85,7 @@ def cleanup # # config_dir is the new location afterwards, the one the warning names. def preserve_transcripts + @kept = true # first: whatever happens below, #cleanup must not delete it announced = false warn "#{MIRROR_DROPPED} #{move_aside_and_scrub}" announced = true diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index 2205f329..f318cae1 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -109,6 +109,23 @@ def preserve stderr_of { materialized.preserve_transcripts } end + # Runs preserve_transcripts while +method+ of +receiver+ raises something + # that is not a StandardError — what a cancellation (Async::Stop) or a + # signal looks like — and returns the warning. The wrapper is disarmed + # afterwards: the examples and the teardown use the same methods. + def preserve_interrupted_at(receiver, method) + cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException -- see above + interrupting = true + allow(receiver).to receive(method).and_wrap_original do |original, *args| + raise cancellation if interrupting + + original.call(*args) + end + stderr_of { expect { materialized.preserve_transcripts }.to raise_error(cancellation) } + ensure + interrupting = false + end + # Where the directory lives once it was moved, and the private directory # it was moved into. def preserved @@ -165,19 +182,42 @@ def staging # not a StandardError (a cancellation, a signal), the new location must # still be said. it 'says where the transcript is when the scrub is interrupted' do - cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException -- what Async::Stop looks like - interrupting = true - allow(File).to receive(:unlink).and_wrap_original do |original, *paths| - raise cancellation if interrupting + warning = preserve_interrupted_at(File, :unlink) - original.call(*paths) + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/transcript mirror dropped batches.*#{Regexp.escape(File.join(preserved, 'projects'))}/) + end + + # A teardown can run twice. Client#disconnect lets go of its + # MaterializedResume only once preserve_transcripts returned; cut short, a + # later disconnect finds the query handler gone — nothing remembers that + # the mirror dropped batches — and calls #cleanup on the same object. What + # was kept is the only copy of the dropped turns. + context 'when #cleanup is called afterwards' do + it 'leaves the preserved directory alone' do + preserve + + materialized.cleanup + + expect(tree(preserved)).to eq(transcripts) end - warning = stderr_of { expect { materialized.preserve_transcripts }.to raise_error(cancellation) } - interrupting = false + it 'leaves the transcript in place when the scrub had been interrupted' do + preserve_interrupted_at(File, :unlink) - expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) - expect(warning).to match(/transcript mirror dropped batches.*#{Regexp.escape(File.join(preserved, 'projects'))}/) + materialized.cleanup + + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + end + + it 'deletes nothing when it had been interrupted before the directory was moved' do + preserve_interrupted_at(File, :lstat) + + materialized.cleanup + + expect(preserved).to eq(config_dir) + expect(tree(config_dir)).to eq(secrets.merge(transcripts)) + end end # An entry that cannot be removed must not pass for scrubbed: backups/ can From c85d62f832f634473c0862b70e029398eef81f28 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:01:42 +0800 Subject: [PATCH 162/194] Scrub a preserved resume dir without recursing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrubber#remove called itself once per directory level. Teardown runs on the reactor, where a fiber's stack is small: a tree a few hundred levels deep (one `mkdir -p` inside the temp config dir) ended the scrub with SystemStackError. That is not a StandardError, so it left preserve_transcripts and the teardown, replaced an exception in flight, and the entries not reached yet stayed where they were. remove now keeps a list of what is still to be removed. Emptying a directory already moved its entries into the trash, which is flat, so the list holds trash paths whatever depth they came from; nothing recurses and no path grows. What it does to each entry is unchanged: take it out, look at it, unlink it or — for a directory — repair it if needed, take its entries out (up to three passes) and rmdir it. A failure anywhere under an entry still ends that entry's removal and is reported under its name. Specs: a chain of 1000 directories scrubbed inside the reactor (SystemStackError before this change). The chain is built in pieces of 150 levels, and whatever a failing run leaves is flattened before the usual teardown, which recurses and would hit the path length limit. Two examples pin behavior the rewrite could have dropped silently: an entry that arrives in a directory while it is being emptied is taken out too, and a directory that could be moved but not searched (0600) is made accessible before it is emptied. Exploration finding: B-P8 (round 3 follow-up, ruling r4 item 2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- lib/claude_agent_sdk/session_resume.rb | 32 +++++++--- spec/unit/materialized_resume_spec.rb | 82 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index d747c345..4d7ed174 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -921,23 +921,39 @@ def initialize(trash) # Remove the entry +name+ of +dir+ and everything under it. Raises # SystemCallError when something cannot be removed, and Changed when an # entry was swapped while it was being made accessible. + # + # Nothing here recurses: this runs on the reactor, and a tree can be + # deeper than a fiber's stack. Emptying a directory moves its entries + # into the trash, which is flat, so what is still to be removed is a + # list of trash paths, whatever depth they came from. def remove(dir, name) - path = take(dir, name) - stat = File.lstat(path) - return File.unlink(path) unless stat.directory? + pending = [take(dir, name)] + until pending.empty? + path = pending.pop + stat = File.lstat(path) + next File.unlink(path) unless stat.directory? + + self.class.make_accessible(path, [stat.dev, stat.ino]) unless self.class.owner_rwx?(stat) + pending.concat(take_entries_of(path)) + Dir.rmdir(path) + end + end - self.class.make_accessible(path, [stat.dev, stat.ino]) unless self.class.owner_rwx?(stat) + private + + # Empty the directory at +path+ into the trash; returns where its + # entries are now. + def take_entries_of(path) + taken = [] EMPTYING_PASSES.times do children = Dir.children(path) break if children.empty? - children.each { |child| remove(path, child) } + children.each { |child| taken << take(path, child) } end - Dir.rmdir(path) + taken end - private - # Move dir/name into the trash, whatever it is, and return its new path. def take(dir, name) source = File.join(dir, name) diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index f318cae1..e356cd78 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -236,6 +236,16 @@ def staging expect(warning).not_to match(/Scrubbing failed/) end + it 'makes a directory accessible that it could take out but not empty' do + File.chmod(0o600, backups) # writable, so it can be moved; not searchable, so nothing in it can be + + warning = preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(warning).not_to match(/Scrubbing failed/) + end + it 'neither follows nor changes a symlink while it makes a directory writable' do File.symlink(outside, File.join(backups, 'elsewhere')) File.chmod(0o555, outside) @@ -295,6 +305,78 @@ def staging end end + # Something that still holds a directory open can write into it after it + # was moved into the trash. + it 'takes out what arrives in a directory while it is being emptied' do + arrived = false + allow(Dir).to receive(:children).and_wrap_original do |original, path| + names = original.call(path) + if !arrived && File.basename(File.dirname(path)).start_with?('scrub-') + arrived = true + File.write(File.join(path, 'late.json'), claude_json) + end + names + end + + warning = preserve + + expect(arrived).to be(true) + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(warning).not_to match(/Scrubbing/) + end + + # Teardown runs on the reactor, and a fiber's stack is small. A scrub that + # recursed once per directory level ran out of it a few hundred levels + # down — one `mkdir -p` makes such a tree — and SystemStackError is not a + # StandardError: it left preserve_transcripts. + context 'when a tree is very deep' do + # A path can only be so long, so the chain is built in pieces of at most + # 150 levels: each new piece takes the chain so far under its deepest + # directory. Returns the top of the chain. + def deep_chain(levels) + chain = nil + levels.fdiv(150).ceil.times do |index| + top = File.join(scratch, "piece-#{index}") + deepest = File.join(top, Array.new([150, levels - (index * 150)].min - 1, 'd')) + FileUtils.mkdir_p(deepest) + chain ? File.rename(chain, File.join(deepest, 'd')) : File.write(File.join(deepest, 'secret.json'), claude_json) + chain = top + end + chain + end + + # The teardown above cannot take such a tree down: unlock and FileUtils + # recurse, and the paths get too long. So whatever a failing example left + # is removed here first, without either: every entry is renamed to the + # top of the scratch directory before it is looked at. + after do + flattened = 0 + pending = Dir.children(scratch).map { |name| File.join(scratch, name) } + until pending.empty? + path = pending.pop + next File.unlink(path) unless File.lstat(path).directory? + + Dir.children(path).each do |child| + pending << File.join(scratch, "flat-#{flattened += 1}") + File.rename(File.join(path, child), pending.last) + end + Dir.rmdir(path) + end + end + + it 'scrubs a chain of 1000 directories on the reactor' do + File.rename(deep_chain(1000), File.join(config_dir, 'deep')) + + warning = Sync { preserve } + + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) # and nothing is left in the trash + expect(tree(preserved)).to eq(transcripts) + expect(warning).not_to match(/Scrubbing/) + end + end + # A read-only root cannot be moved. Skipping the scrub would leave the # credential copies behind, so the root is repaired first — through a # handle that has to be the directory the SDK created. From 919ebbb9c4c741e06910b4259d22b63df7a7b305 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:04:56 +0800 Subject: [PATCH 163/194] Let a close from another thread finish its graceful teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client#disconnect (Query#close) called from a thread other than the session's reactor — an application thread cancelling a session, or a tool handler / hook / can_use_tool callback on its :thread-mode worker — is handed to a reactor-side watcher task, and the caller waits. The watcher ran the close itself, and it is transient so that a session nobody closes never keeps its reactor alive. A reactor does not wait for transient tasks: the close stops the read loop, the end of the stream releases the session's own task, and when that task had nothing left to do the reactor wound down and stopped the watcher at the first point where the transport teardown waited for the CLI. SubprocessCLITransport treats that as a cancelled close: the CLI got SIGTERM instead of stdin EOF and the 5 s grace period that exists so it can finish its last session write (CLI event log: start, signal TERM, exit 143), and disconnect returned before the child was reaped. A custom transport's #close was unwound part-way. The watcher now passes the request to a task of its own that is not transient, so the reactor stays alive until the teardown is done (bounded by the transport: 5 s grace, then TERM, then KILL). The task is a sibling of the watcher, created under the watcher's current parent. A reactor counts only its direct children, and once the task that started the session is gone async re-parents the watcher to the reactor, where Scheduler#async would make the new task a child of the transient watcher. Deferring the stop on the watcher instead holds on async 2.36+ but not on the 2.10 floor, which stops the tasks of a finished reactor repeatedly. The handle a waiting caller polls for liveness (@close_watcher) follows the close to that task. Otherwise a teardown that outlasts one 100 ms poll looks abandoned: the caller falls back to a direct close, finds it already started, and returns while the CLI is still exiting. Unchanged: a close on the reactor itself, a close from inside an inline callback, first-caller-wins, the watcher exiting unserved when the reactor closes first, the direct fallback once the reactor is gone, a caller being released when the teardown itself is stopped, and an idle watcher staying transient. What a user can notice: such a disconnect now takes as long as the teardown does — about 5 s when the CLI does not exit on stdin EOF, as a disconnect on the reactor always has — and a Sync / Async block whose own work is finished returns only once the teardown is over. Exploration finding: A-P9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6FdVbAW83wS6vxQcgLinn --- lib/claude_agent_sdk/query.rb | 85 +++-- spec/unit/close_from_another_thread_spec.rb | 391 ++++++++++++++++++++ 2 files changed, 454 insertions(+), 22 deletions(-) create mode 100644 spec/unit/close_from_another_thread_spec.rb diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index d1fa62cd..df15e437 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -369,18 +369,15 @@ def start # Reactor-side agent for #close calls arriving from foreign threads # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs # the owning thread's Fiber.scheduler, so the off-thread caller hands the - # whole close over and waits. Transient: must never keep the reactor - # alive, and is stopped automatically when the parent task finishes. - # One-shot: after serving a close it is done; a reactor-side close wakes - # it via @close_requests.close (pop -> nil) so it exits without serving. + # whole close over and waits. Transient: while it waits for a request it + # must never keep the reactor alive, and it is stopped automatically + # once the reactor has nothing else to run. One-shot: it passes the + # first request on to a task of its own (#serve_marshalled_close) and + # is done; a reactor-side close wakes it via @close_requests.close + # (pop -> nil) so it exits without serving. @close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do - if (reply = @close_requests.pop) - begin - close - ensure - reply << true - end - end + reply = @close_requests.pop + serve_marshalled_close(reply) if reply end) end @@ -1819,14 +1816,18 @@ def receive_messages(&block) # plain user threads — don't have; stopping from one raised NoMethodError # and left the read/child tasks running. Such callers hand the close to # the reactor-side watcher (spawned in #start) and wait for it to finish, - # so close semantics are identical regardless of the calling thread. + # so close semantics are identical regardless of the calling thread. The + # reactor waits for a close it was handed: it stays alive until the + # transport's teardown is done, even when the session's own task has + # nothing left to do. def close if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler) marshal_close_to_reactor else - # Same scheduler (reactor-side caller, including the watcher itself), - # or no live watcher: when the reactor is gone its task fibers are - # dead, so stopping them no longer touches Fiber.scheduler. + # Same scheduler (reactor-side caller, including the task serving a + # marshalled close), or no live reactor-side task to hand it to: when + # the reactor is gone its task fibers are dead, so stopping them no + # longer touches Fiber.scheduler. close_now end end @@ -1907,8 +1908,8 @@ def close_now # surfaces as Async::Stop (Python parity: a hook that awaits # disconnect() gets CancelledError). Applied ONLY inside the trees of # the tasks being stopped: the reactor-side caller (Client#disconnect - # from the connect task, the close watcher) and foreign threads keep - # the plain path, unchanged. + # from the connect task, the task serving a marshalled close) and + # foreign threads keep the plain path, unchanged. # # The deferred Stop SUPERSEDES anything the teardown raises: async # raises it from defer_stop's ensure with an explicit `cause:`, so a @@ -1984,11 +1985,51 @@ def task_inside_stopped_trees nil end - # Hand the close to the reactor and wait for completion. Polls watcher - # liveness instead of waiting forever: if the reactor shuts down - # concurrently (the transient watcher is stopped without serving the - # request), no reply will ever arrive — fall back to a direct close, - # which is safe once the reactor's fibers are dead. + # Reactor side of a marshalled close; runs on the close watcher, which + # must not run the close itself. The watcher is transient, and a reactor + # does not wait for transient tasks: stopping the read task releases the + # session's own task (the end sentinel), and if that was the last + # non-transient task the reactor winds down and stops the watcher at the + # first suspension point of the transport teardown. SubprocessCLITransport + # then TERMs the CLI instead of closing stdin and granting the grace + # period that lets it finish its last session write, and the caller is + # released before the child is reaped. + # + # So the close runs in a task of its own that the reactor does wait for + # (bounded by the transport's teardown). `defer_stop` on the watcher is + # not enough: async 2.10 terminates the tasks of a finished reactor with + # repeated stops, and a deferral survives only the first. + # + # The task is a SIBLING of the watcher, created under the watcher's + # current parent. A reactor counts only its direct children, so a child + # of the transient watcher would not hold it open — and that is what + # `parent.async` creates once the task that started the session is gone + # and async has re-parented the watcher to the reactor (Scheduler#async + # adopts the current task as the parent). + # + # @close_watcher follows the close: the waiting caller polls it for + # liveness (#marshal_close_to_reactor), so it has to name the task that + # will answer. Switched before the new task can suspend, while the + # watcher that spawned it is still alive. + def serve_marshalled_close(reply) + body = FiberBoundary.capture_otel_context do |task| + @close_watcher = task + begin + close + ensure + reply << true + end + end + Async::Task.new(Async::Task.current.parent, &body).run + end + + # Hand the close to the reactor and wait for completion. Polls the + # liveness of the reactor-side task that is to answer (@close_watcher: + # the idle watcher, then the task serving the close) instead of waiting + # forever: if the reactor shuts down concurrently (the transient watcher + # is stopped without serving the request), no reply will ever arrive — + # fall back to a direct close, which is safe once the reactor's fibers + # are dead. def marshal_close_to_reactor reply = ::Thread::Queue.new @close_requests << reply diff --git a/spec/unit/close_from_another_thread_spec.rb b/spec/unit/close_from_another_thread_spec.rb new file mode 100644 index 00000000..17854704 --- /dev/null +++ b/spec/unit/close_from_another_thread_spec.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'timeout' + +# Query#close — and so Client#disconnect — may be called from a thread other +# than the one whose reactor owns the session: an application thread that +# cancels a session, or a tool handler / hook / can_use_tool callback on its +# :thread-mode worker. Async::Task#stop needs the owning reactor, so such a +# caller hands the close to a task on that reactor and waits for it. +# +# The task that takes the request is an idle watcher, and it is `transient` +# so that a session nobody closes never keeps its reactor alive. A transient +# task is also the one kind a reactor does not wait for. The close stops the +# read loop, the end of the stream wakes the session's own task, and once +# that task has nothing left to do the reactor is finished: it used to wind +# down and stop the watcher at the first point where the transport's teardown +# waited for the CLI. SubprocessCLITransport then does what it does for a +# cancelled close — SIGTERM — instead of closing stdin and giving the CLI the +# grace period it needs for its last session write, and the caller was +# released before the child was reaped. The close now runs in a task the +# reactor waits for. +# +# No clock orders anything below. The one bound, around each example, only +# turns a lost wake-up into a failure instead of a hung suite. +RSpec.describe ClaudeAgentSDK::Query, 'closed from another thread' do + # Stand-in for SubprocessCLITransport, with the part of its #close that + # matters here: stdin EOF, then a WAIT for the CLI to exit — a suspension + # point on the reactor, like wait_process_with_timeout — and SIGTERM from + # the ensure when the close is unwound while it waits. The journal reads + # like the CLI's own event log. + let(:transport_class) do + Class.new do + attr_reader :journal, :teardown_task, :teardown_task_was_transient + + def initialize(_options = nil, **_transport_args) + @stdout = Thread::Queue.new + @journal = [] + # Closed queues as latches: #pop parks a fiber or a thread until + # #close, and returns at once ever after. + @stdin_closed = Thread::Queue.new + @exited = Thread::Queue.new + @closing = false + end + + def connect; end + + def ready? + !@closing + end + + # What CLI 2.1.286 answers to `initialize` (payload trimmed). + def write(line) + raise ClaudeAgentSDK::CLIConnectionError, 'ProcessTransport is not ready for writing' if @closing + + frame = JSON.parse(line, symbolize_names: true) + return unless frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + + deliver(type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + end + + def deliver(frame) + @stdout << frame + end + + def read_messages + while (frame = @stdout.pop) + yield frame + end + end + + def end_input + close_stdin + end + + def close + return if @closing + + @closing = true + @teardown_task = Async::Task.current? + @teardown_task_was_transient = @teardown_task&.transient? + graceful = false + begin + close_stdin + @exited.pop # parks the closing task until the CLI is gone + @journal << :cli_exited + graceful = true + ensure + @journal << :sigterm unless graceful + @stdout.close + end + end + + # Parks the caller until the teardown has closed stdin. + def wait_for_stdin_eof + @stdin_closed.pop + end + + def exit_cli + @exited.close + end + + # What the CLI does about stdin EOF: it exits. + def exit_on_stdin_eof + wait_for_stdin_eof + exit_cli + end + + # The CLI's stdout ends: the read loop is over, the process is not. + def end_stdout + @stdout.close + end + + private + + def close_stdin + return if @stdin_closed.closed? + + @journal << :stdin_eof + @stdin_closed.close + end + end + end + + let(:transport) { transport_class.new } + let(:query) { described_class.new(transport: transport, is_streaming_mode: true) } + let(:threads) { [] } + + around do |example| + Timeout.timeout(60) { example.run } + ensure + threads.each { |thread| reap(thread) } + end + + def in_thread(&block) + thread = Thread.new do + Thread.current.report_on_exception = false + block.call + end + threads << thread + thread + end + + # A failed example can leave a thread parked; one that raised has already + # failed the example through its own join. + def reap(thread) + thread.kill + thread.join + rescue StandardError + nil + end + + # The session on a reactor of its own. Nothing was asked of the CLI, so its + # task parks until the close ends the stream — and then has nothing left to + # do. Its last act scripts the CLI (exit on stdin EOF) from the reactor's + # own thread. That puts the wake-up of the teardown, which is waiting for + # that exit, behind the end of the task: a reactor that winds down as soon + # as the task is over stops the teardown before it can see the exit. + def session_with_nothing_left_to_do(parked) + in_thread do + Sync do + query.start + parked << true + query.receive_messages { |_message| nil } + transport.exit_on_stdin_eof + end + end + end + + describe 'when the session task has nothing left to do' do + it 'closes stdin and waits for the CLI to exit before it returns' do + parked = Thread::Queue.new + session = session_with_nothing_left_to_do(parked) + parked.pop + + query.close + transport.journal << :close_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned]) + end + + it 'runs the teardown on a task the reactor waits for' do + parked = Thread::Queue.new + session = session_with_nothing_left_to_do(parked) + parked.pop + + query.close + session.join + + expect(transport.teardown_task_was_transient).to be(false) + end + + # The watcher is a child of the task that started the session. When that + # task and the read loop are both over, async moves it up the tree — here + # all the way to the reactor, which unrelated work keeps alive. A task + # spawned through the reactor from there would become a child of the + # watcher, and a reactor only counts its own children. + it 'does so after the watcher has been re-parented to the reactor' do + started = Thread::Queue.new + stream_ended = Thread::Queue.new + session = in_thread do + Fiber.set_scheduler(Async::Scheduler.new) + Fiber.schedule { query.start } # over at once; the read loop keeps it in the tree + Fiber.schedule do # unrelated work on the same reactor + started << true + query.receive_messages { |_message| nil } + stream_ended << true + transport.exit_on_stdin_eof + end + ensure + Fiber.set_scheduler(nil) # runs the reactor until its tasks have finished + end + started.pop + transport.end_stdout + stream_ended.pop + expect(query.instance_variable_get(:@close_watcher).parent).to be_a(Async::Scheduler) + + query.close + transport.journal << :close_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned]) + end + end + + # What the watcher's `ensure` guaranteed still holds: a teardown that is + # itself cancelled — the transport TERMs the CLI then — never strands the + # caller. + it 'still releases the caller when the teardown itself is stopped' do + parked = Thread::Queue.new + session = in_thread do + Sync do |task| + query.start + parked << true + query.receive_messages { |_message| nil } + transport.wait_for_stdin_eof + task.stop # the application stops the session's tasks, the teardown among them + end + end + parked.pop + + query.close + transport.journal << :close_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof sigterm close_returned]) + end + + # A waiting caller checks every 100 ms that the reactor-side task answering + # it is still alive, and falls back to a direct close when it is not (the + # reactor is gone). While a teardown is in flight that check has to see the + # task running it. The session's own task stays busy in these examples, so + # no reactor winds down: the teardown is simply slow. + describe 'while the teardown is still waiting for the CLI' do + # Reports, as the asking Thread, every check whether +task+ is alive. + def report_liveness_checks(task, signals) + task.define_singleton_method(:alive?) do + signals << Thread.current + super() + end + end + + def busy_session(hold) + started = Thread::Queue.new + session = in_thread do + Sync do + query.start + started << true + hold.pop + end + end + started.pop + session + end + + def closing_thread(signals) + in_thread do + query.close + transport.journal << :close_returned + signals << :returned + end + end + + it 'does not return before the CLI has exited, however long that takes' do + hold = Thread::Queue.new + session = busy_session(hold) + signals = Thread::Queue.new + closer = closing_thread(signals) + transport.wait_for_stdin_eof + report_liveness_checks(transport.teardown_task, signals) + + # Two liveness checks later — or once the caller has (wrongly) returned. + 2.times { break if signals.pop == :returned } + transport.exit_cli + closer.join + hold << true + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned]) + end + + it 'keeps a second caller waiting for the same teardown' do + hold = Thread::Queue.new + session = busy_session(hold) + signals = Thread::Queue.new + first = closing_thread(signals) + transport.wait_for_stdin_eof + report_liveness_checks(transport.teardown_task, signals) + second = closing_thread(signals) + + # Until the second caller has seen the teardown in flight — or has + # (wrongly) returned. + loop do + signal = signals.pop + break if signal == :returned || signal == second + end + transport.exit_cli + [first, second].each(&:join) + hold << true + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned close_returned]) + end + end + + describe 'through Client#disconnect' do + # Client builds its transport itself; hand it the scripted one. + let(:client_transport_class) do + scripted = transport + Class.new { define_singleton_method(:new) { |*_args, **_kwargs| scripted } } + end + + it 'returns to a plain thread once the CLI has exited' do + client = ClaudeAgentSDK::Client.new(transport_class: client_transport_class) + parked = Thread::Queue.new + session = in_thread do + Sync do + client.connect + parked << true + client.receive_response { |_message| nil } + transport.exit_on_stdin_eof + end + end + parked.pop + + client.disconnect + transport.journal << :disconnect_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited disconnect_returned]) + end + + # A :thread-mode callback runs on a worker thread; the task that waits + # for it is a child of the read loop the close stops. + it 'returns to a :thread-mode callback once the CLI has exited' do + client = nil + returned = Thread::Queue.new + can_use_tool = lambda do |_tool_name, _input, _context| + client.disconnect + transport.journal << :disconnect_returned + ClaudeAgentSDK::PermissionResultAllow.new + ensure + returned << true + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new(callback_scheduling: :thread, can_use_tool: can_use_tool) + client = ClaudeAgentSDK::Client.new(options: options, transport_class: client_transport_class) + session = in_thread do + Sync do + client.connect + transport.deliver(type: 'control_request', request_id: 'f3a1c2d4-0b7e-4c1a-9d52-6e8f0a1b2c3d', + request: { subtype: 'can_use_tool', tool_name: 'Bash', + input: { command: 'git status', description: 'Show working tree status' }, + permission_suggestions: [], tool_use_id: 'toolu_01Hq7XvKc2mRt9LwZ3nYb5Ds' }) + client.receive_response { |_message| nil } + transport.exit_on_stdin_eof + end + end + + returned.pop + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited disconnect_returned]) + end + end +end From cd706ffde1ea3b7850ae8ceed07fab40d5c27df3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:06:24 +0800 Subject: [PATCH 164/194] Disarm the preserve spec's wrappers before its teardown The examples that force an interleaving wrap a File or Dir method and make their swap when a given call comes. Stubs are still in place in an `after` hook, and the teardown makes the same calls. When an example failed before its wrapper had fired, the wrapper fired during the teardown instead: it swapped backups/ for a symlink under the code that was unlocking the tree, a read-only directory stayed read-only, the removal failed and the scratch directory was left in $TMPDIR. It took a broken scrub to get there (seen under a mutation run: one `preserve-spec-*` directory left per run), so no green run was affected. The hook now stubs those methods back to the originals first. With it, a run where 26 of the 28 examples fail (the spec against the round-2 library) and each of 33 mutation runs leave nothing behind. Spec only; no library change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- spec/unit/materialized_resume_spec.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index e356cd78..4b86c296 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -62,7 +62,13 @@ materialized # built now, like the SDK does: it records which directory this is end + # Stubs are still in place while this runs. A wrapper that waits for a + # call its example never got to must not make its swap now, in the middle + # of the teardown: the scratch directory would stay behind in $TMPDIR. after do + { File => %i[lstat open rename unlink], Dir => %i[children rmdir] }.each do |receiver, wrapped| + wrapped.each { |name| allow(receiver).to receive(name).and_call_original } + end unlock(scratch) FileUtils.remove_entry(scratch) end From 218eec705bd6e3c7b197c17fe93e0b4cff66834c Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 00:57:05 +0800 Subject: [PATCH 165/194] Stop the Railtie wrapper reporting SDK-handled errors to Rails.error In production, Railtie.callback_wrapper ran each callback inside executor.wrap, which reports whatever passes through it to Rails.error as an unhandled error (every Exception on Rails 7.2.3, 8.0.2+ and 8.1; StandardError on 7.1, 7.2.0-7.2.2 and 8.0.0-8.0.1). Around an SDK callback that produced three kinds of bad report: - An exception raised in a message block was reported from the callback thread, with an empty context and source "application.active_support". When the same exception then reached the request or job, Rails skipped it as already reported, so the only report the error tracker received had lost the controller / job context. - Failures the SDK handles were reported as unhandled: a hook or can_use_tool error it answers to the CLI, an SDK MCP tool error the model receives as an error result, an observer error it swallows (one report per message). - Under :inline scheduling the SDK's own cancellations (a hook timeout, a CLI cancel) were reported too, the timeout under a different anonymous class name on every occurrence. The wrapper now enters the executor with run! / complete!, which run the same hooks as wrap (once each, also when the callback raises), and reports nothing itself. An exception that escapes a callback reaches the caller's layer, whose reporter has the full context. The reloading / allow_concurrency = false branch and the already-active branch are unchanged. Noticeable to users: error trackers stop receiving the reports above; a message-block exception is now reported once by the request / job layer instead of once by the callback thread. docs/rails.md describes what is reported where. Exploration finding: H-P4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 9 +- lib/claude_agent_sdk/railtie.rb | 28 ++- .../callback_wrapper_error_reporting_spec.rb | 191 ++++++++++++++ spec/rails/support/scripted_cli_transport.rb | 234 ++++++++++++++++++ 4 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 spec/rails/callback_wrapper_error_reporting_spec.rb create mode 100644 spec/rails/support/scripted_cli_transport.rb diff --git a/docs/rails.md b/docs/rails.md index 04518dad..383b43e8 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -75,13 +75,18 @@ ClaudeAgentSDK.configure do |config| end ``` -It runs each callback inside `Rails.application.executor.wrap` — except where that would deadlock, which is why it replaces the bare `->(invocation) { Rails.application.executor.wrap { invocation.call } }` this guide used to recommend: +It runs each callback inside the Rails executor — except where that would deadlock, which is why it replaces the bare `->(invocation) { Rails.application.executor.wrap { invocation.call } }` this guide used to recommend: - **Development (code reloading enabled).** Every executor then holds a share of the code-reload interlock. The request or job calling the SDK is already inside the executor, and in `:thread` mode it waits for the callback's thread. If a reload is requested meanwhile (say, another request arrives after the agent edited an app file), the reloader queues for the exclusive unload lock, and a callback thread entering `executor.wrap` queues behind it for a fresh share — which the reloader can never let through while the waiting caller holds its own. Everything hangs. The helper instead runs the callback without entering the executor (the caller's share still keeps code from being unloaded under it) and returns the thread's ActiveRecord connections to the pool when the callback finishes. The executor's other per-run hooks (query cache, `CurrentAttributes` reset) do not run for callbacks in this case. - **`config.allow_concurrency = false`.** The executor holds a process-wide monitor that the calling thread already owns, so a callback thread's `executor.wrap` would block every time; same treatment. - **Already inside the executor** (`:inline` scheduling on a job's own fiber): the callback runs straight through, leaving cleanup to the enclosing executor. -Everywhere else — production, with no reloading — it is exactly `executor.wrap`. The configuration is read per call, so one initializer is correct in every environment. +Everywhere else — production, with no reloading — the callback runs inside the executor: its run hooks before the callback and its complete hooks after it, also when the callback raises. The configuration is read per call, so one initializer is correct in every environment. + +**Errors and `Rails.error`.** The wrapper reports nothing to `Rails.error` itself, which is the one difference from `executor.wrap` (that reports whatever passes through it as an unhandled error). What your error tracker sees is therefore decided by where an exception ends up: + +- An exception that escapes a callback — one raised in a message block, say — comes out of `query` / `receive_response` on the thread that called the SDK. The request middleware or ActiveJob reports it there, once, with the controller or job context that thread has. +- A failure the SDK handles is not reported: an exception in a hook or `can_use_tool` is answered to the CLI as an error response, one in an SDK MCP tool handler becomes an error result the model sees, one in an observer is swallowed, and a timed-out or cancelled callback is cancellation, not an error. To track these, report them where they happen: `rescue` inside the callback, call `Rails.error.report(e, handled: true)`, and re-raise. Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc. — to combine one with the Rails wrapper, call it from yours: `rails = ClaudeAgentSDK::Railtie.callback_wrapper` then `->(inv) { MyApm.trace { rails.call(inv) } }`. diff --git a/lib/claude_agent_sdk/railtie.rb b/lib/claude_agent_sdk/railtie.rb index d2791850..f37e4f84 100644 --- a/lib/claude_agent_sdk/railtie.rb +++ b/lib/claude_agent_sdk/railtie.rb @@ -57,11 +57,18 @@ class Railtie < ::Rails::Railtie # the interlock keeps a reload from unloading code under it until the # whole SDK call returns. # 3. Otherwise (production: no reloading, concurrency allowed) — run the - # callback inside `Rails.application.executor.wrap`. + # callback inside the executor (see .run_in_executor). # # The configuration is read on every call, so the same wrapper is correct # in every environment. # + # The wrapper reports nothing to `Rails.error`, in any branch. An exception + # that escapes a callback reaches the code that called the SDK, whose own + # layer (the request middleware, ActiveJob) reports it with its context; + # the failures the SDK handles itself — a hook or tool error answered to + # the CLI, a swallowed observer error, a cancellation — are not errors of + # the application. + # # @return [Proc] a callable suitable for `callback_wrapper:` # @example config/initializers/claude_agent_sdk.rb # ClaudeAgentSDK.configure do |config| @@ -71,7 +78,7 @@ def self.callback_wrapper lambda do |invocation| app = ::Rails.application next invocation.call if app.nil? || app.executor.active? - next app.executor.wrap { invocation.call } unless executor_locks?(app.config) + next run_in_executor(app.executor, invocation) unless executor_locks?(app.config) begin invocation.call @@ -81,6 +88,23 @@ def self.callback_wrapper end end + # `executor.wrap { invocation.call }` minus its error report. `wrap` + # rescues what passes through it (every Exception from Rails 7.2 on) and + # reports it to `Rails.error` as unhandled, from a callback thread that + # has none of the caller's context — and Rails then skips the same + # exception as already reported when it reaches the request or job. It + # reported the SDK's own cancellations and the callback failures the SDK + # answers or swallows, too. `run!` / `complete!` run the same hooks. + def self.run_in_executor(executor, invocation) + execution = executor.run! + begin + invocation.call + ensure + execution.complete! + end + end + private_class_method :run_in_executor + # Whether railties registered a process-wide lock hook on the executor # (Rails::Application::Finisher, initializer # :configure_executor_for_concurrency). diff --git a/spec/rails/callback_wrapper_error_reporting_spec.rb b/spec/rails/callback_wrapper_error_reporting_spec.rb new file mode 100644 index 00000000..b3b1c3f0 --- /dev/null +++ b/spec/rails/callback_wrapper_error_reporting_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require_relative 'support/scripted_cli_transport' + +# What Rails.error — the reporter Sentry, Honeybadger, AppSignal and Solid +# Errors subscribe to — receives when SDK callbacks go through the wrapper's +# executor branch (production: no reloading, concurrency allowed). +# +# `executor.wrap` reports every exception passing through it as an unhandled +# error. Around an SDK callback that is wrong twice: the SDK handles most +# callback failures itself (a hook or tool error becomes an error response, +# an observer error is swallowed, a cancellation is control flow), and the one +# that does escape was reported from a thread with none of the caller's +# context, after which Rails skips it at the request / job layer as already +# reported. So the wrapper enters the executor without reporting. +RSpec.describe 'ClaudeAgentSDK::Railtie.callback_wrapper and Rails.error' do + let(:app) { Rails.application } + let(:wrapper) { ClaudeAgentSDK::Railtie.callback_wrapper } + # A fresh executor per example, so hooks registered here never leak. + let(:executor) { Class.new(ActiveSupport::Executor) } + let(:error) { Class.new(StandardError).new('boom') } + # A Rails.error subscriber; reports arrive on callback threads. + let(:subscriber) do + Class.new do + attr_reader :reports + + def initialize + @reports = Thread::Queue.new + end + + def report(error, handled:, context:, source: nil, **) + @reports << { error: error, handled: handled, source: source, context: context } + end + end.new + end + + around do |example| + config = app.config + saved = [config.enable_reloading, config.allow_concurrency] + config.enable_reloading = false + config.allow_concurrency = nil + Rails.error.subscribe(subscriber) + example.run + ensure + Rails.error.unsubscribe(subscriber) + ActiveSupport::ExecutionContext.clear + config.enable_reloading, config.allow_concurrency = saved + end + + before { allow(app).to receive(:executor).and_return(executor) } + + def drain(queue) + Array.new(queue.size) { queue.pop } + end + + def reported + drain(subscriber.reports) + end + + # The FiberBoundary hop in :thread scheduling: the callback runs on a thread + # of its own, and what it raises is raised again on the caller. + def call_on_thread(invocation) + Thread.new do + Thread.current.report_on_exception = false + wrapper.call(invocation) + end.value + end + + # The caller: a job (a request likewise) runs inside the executor, sets its + # context, and reports what escapes it under its own source. + def as_a_job(&) + executor.wrap(source: 'application.active_job') do + Rails.error.set_context(job: 'ChatAgentJob') + yield + end + end + + def caller_report + include(error: error, handled: false, source: 'application.active_job', context: include(job: 'ChatAgentJob')) + end + + describe 'an exception from a wrapped callback' do + it 'propagates unchanged and is not reported by the wrapper' do + expect { call_on_thread(-> { raise error }) }.to raise_error(equal(error)) + + expect(reported).to be_empty + end + + it "is left to the caller's reporter, which has the caller's context" do + expect { as_a_job { call_on_thread(-> { raise error }) } }.to raise_error(equal(error)) + + expect(reported).to contain_exactly(caller_report) + end + + it 'is not reported when it is a cancellation (hook timeout or CLI cancel under :inline scheduling)' do + [Async::Stop, Class.new(ClaudeAgentSDK::FiberBoundary::InlineCancellation)].each do |cancellation| + expect { call_on_thread(-> { raise cancellation }) }.to raise_error(cancellation) + end + + expect(reported).to be_empty + end + end + + describe 'the executor around a wrapped callback' do + let(:events) { Thread::Queue.new } + + before do + queue = events # the hook blocks are instance_exec'd on the executor + executor.to_run { queue << :run } + executor.to_complete { queue << :complete } + end + + it 'runs its run and complete hooks once each' do + result = call_on_thread(lambda { + events << :callback + :result + }) + + expect(result).to eq(:result) + expect(drain(events)).to eq(%i[run callback complete]) + end + + it 'runs them once each when the callback raises' do + callback = lambda { + events << :callback + raise error + } + + expect { call_on_thread(callback) }.to raise_error(equal(error)) + expect(drain(events)).to eq(%i[run callback complete]) + end + end + + describe 'over a whole query' do + let(:frames) { ClaudeAgentSDKRailsSpec::Frames } + + def run_query(turn, **options, &block) + transport = ClaudeAgentSDKRailsSpec::ScriptedCLITransport.new(turns: [turn]) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(callback_wrapper: wrapper, **options) + ClaudeAgentSDK.query(prompt: 'hi', options: options, transport: transport, &block || proc {}) + transport + end + + it "leaves a message-block exception to the caller's reporter, with the caller's context" do + expect do + as_a_job do + run_query(frames.text_turn) { |message| raise error if message.is_a?(ClaudeAgentSDK::ResultMessage) } + end + end.to raise_error(equal(error)) + + expect(reported).to contain_exactly(caller_report) + end + + it 'reports nothing when an observer raises: the SDK swallows it' do + observer = Class.new do + include ClaudeAgentSDK::Observer + + def on_message(_message) + raise 'observer boom' + end + end.new + + run_query(frames.text_turn, observers: [observer]) + + expect(reported).to be_empty + end + + it 'reports nothing when a hook raises: the SDK answers the CLI with an error response' do + hook = ->(_input, _tool_use_id, _context) { raise 'hook boom' } + hooks = { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(hooks: [hook])] } + + transport = run_query(frames.tool_turn(ask: [:hook]), hooks: hooks) + + expect(transport.control_responses.values).to contain_exactly(include(subtype: 'error', error: 'hook boom')) + expect(reported).to be_empty + end + + it 'reports nothing when an SDK MCP tool raises: the model gets an error result' do + tool = ClaudeAgentSDK.create_tool('lookup', 'Look up a record', { id: Integer }) { |_args| raise 'tool boom' } + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'app', tools: [tool]) + + transport = run_query(frames.tool_turn(ask: [:tool]), mcp_servers: { 'app' => server }) + + expect(transport.control_responses.values).to contain_exactly( + include(subtype: 'success', response: include(mcp_response: include(result: include(isError: true)))) + ) + expect(reported).to be_empty + end + end +end diff --git a/spec/rails/support/scripted_cli_transport.rb b/spec/rails/support/scripted_cli_transport.rb new file mode 100644 index 00000000..5b9a218b --- /dev/null +++ b/spec/rails/support/scripted_cli_transport.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require 'json' +require 'securerandom' + +module ClaudeAgentSDKRailsSpec + # A scripted stand-in for the CLI process, for ClaudeAgentSDK.query(transport:) + # and Client (transport_class: / transport_args:). No subprocess, no model + # call and no clock: it answers the initialize request, and every user + # message the SDK writes plays the next turn of the script — frames in + # order, stopping at each control request until the SDK has answered it, + # so the script alone decides the order user callbacks run in. + # + # Like the process it replaces, it ends the stream once the script is + # played and the SDK has closed stdin (#end_input), or on #close. + class ScriptedCLITransport < ClaudeAgentSDK::Transport + END_OF_STREAM = :end_of_stream + + # The hook callback ids the SDK registered in its initialize request, in + # order, and its control responses by request id. + attr_reader :hook_callback_ids, :control_responses + + # @param turns [Array<Array>] one script per user message. A step is a + # frame (Hash), a control request (Frames.control_request: the script + # waits for the SDK's answer before going on), or a callable that is + # given this transport and returns either. + def initialize(_options = nil, turns:) + super() + @turns = turns.map(&:dup) + @steps = [] + @stdout = Thread::Queue.new + @lock = Mutex.new + @hook_callback_ids = [] + @control_responses = {} + @awaited_request_id = nil + @input_ended = false + @closed = false + end + + def connect; end + + def ready? + !@closed + end + + def write(data) + message = JSON.parse(data, symbolize_names: true) + case message[:type] + when 'control_request' then answer_control_request(message) + when 'control_response' then record_control_response(message[:response]) + when 'user' then play_next_turn + end + end + + def read_messages + loop do + frame = @stdout.pop + break if frame == END_OF_STREAM + + yield frame + end + end + + def end_input + @lock.synchronize { @input_ended = true } + end_stream_if_finished + end + + def close + @closed = true + @stdout << END_OF_STREAM + end + + private + + def answer_control_request(message) + request = message[:request] + if request[:subtype] == 'initialize' + (request[:hooks] || {}).each_value do |matchers| + matchers.each { |matcher| @hook_callback_ids.concat(matcher[:hookCallbackIds]) } + end + end + @stdout << Frames.control_success(message[:request_id], request[:subtype]) + end + + def record_control_response(response) + request_id = response[:request_id] + resume = @lock.synchronize do + @control_responses[request_id] = response + next false unless @awaited_request_id == request_id + + @awaited_request_id = nil + true + end + play if resume + end + + def play_next_turn + @lock.synchronize { @steps = @turns.shift || raise('the SDK sent more user messages than the script has turns') } + play + end + + # Emit steps until the script pauses on a control request or runs out. + def play + loop do + step = @lock.synchronize { @steps.shift } + return end_stream_if_finished if step.nil? + + step = step.call(self) if step.respond_to?(:call) + @stdout << step + next unless step[:type] == 'control_request' + + @lock.synchronize { @awaited_request_id = step[:request_id] } + return + end + end + + def end_stream_if_finished + finished = @lock.synchronize { @input_ended && @steps.empty? && @turns.empty? && @awaited_request_id.nil? } + @stdout << END_OF_STREAM if finished + end + end + + # Frames as CLI 2.1.286 writes them to an SDK-spawned session, trimmed from + # a recorded session to the fields the SDK reads (Symbol keys, as + # SubprocessCLITransport#read_messages yields them). The mcp_message and + # can_use_tool requests follow the SDK's own unit fixtures; the rest follow + # the recording, including one content block per `assistant` frame and + # the `session_state_changed` pair around every turn. + module Frames + SESSION_ID = 'fd9f2708-3a4e-460c-8bd7-561c3feb755e' + MODEL = 'claude-haiku-4-5-20251001' + TOOL_USE_ID = 'toolu_01CHuGKVaNkChVspFMWK3zcj' + USAGE = { input_tokens: 8, cache_creation_input_tokens: 214, cache_read_input_tokens: 24_871, + output_tokens: 2, service_tier: 'standard' }.freeze + + module_function + + # The CLI's answer to an SDK control request. + def control_success(request_id, subtype) + response = subtype == 'initialize' ? { commands: [], agents: [], output_style: 'default', models: [], pid: 11_687 } : {} + { type: 'control_response', response: { subtype: 'success', request_id: request_id, response: response } } + end + + # A control request from the CLI; the script waits for the SDK's answer. + def control_request(request) + { type: 'control_request', request_id: SecureRandom.uuid, request: request } + end + + def session_state(state) + { type: 'system', subtype: 'session_state_changed', state: state, sdk_host_only: true, + uuid: SecureRandom.uuid, session_id: SESSION_ID } + end + + def init + { type: 'system', subtype: 'init', cwd: '/srv/app', session_id: SESSION_ID, + tools: %w[Bash Read mcp__app__lookup], mcp_servers: [{ name: 'app', status: 'connected' }], model: MODEL, + permissionMode: 'default', slash_commands: [], apiKeySource: 'none', claude_code_version: '2.1.286', + output_style: 'default', agents: [], skills: [], plugins: [], uuid: SecureRandom.uuid } + end + + def assistant(block, message_id:) + { type: 'assistant', + message: { model: MODEL, id: message_id, type: 'message', role: 'assistant', content: [block], + stop_reason: nil, stop_sequence: nil, usage: USAGE }, + parent_tool_use_id: nil, session_id: SESSION_ID, uuid: SecureRandom.uuid } + end + + def assistant_text(text, message_id: 'msg_011Cfc2rFTtZTztTpTJF3xPG') + assistant({ type: 'text', text: text }, message_id: message_id) + end + + def assistant_tool_use(name:, input:, message_id: 'msg_011Cfc2qvqiaFFYGxduAzxDr') + assistant({ type: 'tool_use', id: TOOL_USE_ID, name: name, input: input }, message_id: message_id) + end + + def tool_result(content) + { type: 'user', + message: { role: 'user', content: [{ tool_use_id: TOOL_USE_ID, type: 'tool_result', content: content }] }, + parent_tool_use_id: nil, session_id: SESSION_ID, uuid: SecureRandom.uuid } + end + + def result(text = 'DONE') + { type: 'result', subtype: 'success', is_error: false, duration_ms: 6591, duration_api_ms: 5094, + num_turns: 2, result: text, stop_reason: 'end_turn', session_id: SESSION_ID, total_cost_usd: 0.0161638, + usage: USAGE, permission_denials: [], terminal_reason: 'completed', uuid: SecureRandom.uuid } + end + + def hook_callback(callback_id, tool_name:, tool_input:) + control_request( + subtype: 'hook_callback', callback_id: callback_id, tool_use_id: TOOL_USE_ID, + input: { session_id: SESSION_ID, transcript_path: "/srv/.claude/projects/-srv-app/#{SESSION_ID}.jsonl", + cwd: '/srv/app', permission_mode: 'default', hook_event_name: 'PreToolUse', + tool_name: tool_name, tool_input: tool_input, tool_use_id: TOOL_USE_ID } + ) + end + + def can_use_tool(tool_name:, input:) + control_request(subtype: 'can_use_tool', tool_name: tool_name, input: input, permission_suggestions: [], + tool_use_id: TOOL_USE_ID) + end + + def mcp_tools_call(server:, tool:, arguments:) + control_request(subtype: 'mcp_message', server_name: server, + message: { jsonrpc: '2.0', id: 1, method: 'tools/call', + params: { name: tool, arguments: arguments } }) + end + + # A turn that answers in text only. + def text_turn(text = 'DONE') + [session_state('running'), init, assistant_text(text), result(text), session_state('idle')] + end + + # A turn in which the model calls the SDK MCP tool `lookup` on server + # `app`. +ask+ lists the control requests the CLI sends on the way, in + # its order: the PreToolUse :hook, the :permission prompt, the :tool call. + def tool_turn(text = 'DONE', ask: %i[hook permission tool]) + tool_name = 'mcp__app__lookup' + input = { id: 7 } + requests = { + hook: ->(cli) { hook_callback(cli.hook_callback_ids.first, tool_name: tool_name, tool_input: input) }, + permission: can_use_tool(tool_name: tool_name, input: input), + tool: mcp_tools_call(server: 'app', tool: 'lookup', arguments: input) + } + [ + session_state('running'), init, + assistant_tool_use(name: tool_name, input: input), + *requests.values_at(*ask), + tool_result('ok'), + assistant_text(text), result(text), session_state('idle') + ] + end + end +end From 23cc31ce23a52b5a06c9e65f7d12b23e4f85f446 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:15:03 +0800 Subject: [PATCH 166/194] Document that request state does not follow into SDK callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/rails.md told Rails users they "do not need to think about" where callbacks run, and that with :inline every callback runs "on the reactor fiber of the job". Neither holds for what Rails keeps per request or job. A message block, observer, hook, can_use_tool or SDK MCP tool handler runs on a thread or fiber Rails never set up, so Current attributes, Time.zone, log tags, the error context and the database role / shard / prevent_writes all read as their defaults there — with or without Railtie.callback_wrapper, in development and in production. Under connected_to(shard:) a write in a callback lands in the default shard; under connected_to(role: :reading) it succeeds on the primary. The guide now says so: a table of what a callback sees, the one exception (:inline, a Client's message block and observers run on the caller's fiber), and a recipe that captures the state on the caller and restores it inside the Rails wrapper. The recipe differs from the obvious one in ways that matter: - it is built per call: a wrapper kept by a long-lived Client would run every later turn's callbacks as the first caller; - it restores inside the Rails wrapper, because the executor resets Current and the error context on the way in; - it restores ActiveSupport::ExecutionContext explicitly; - it skips the restore on the fiber that captured the state, which would push the log tags twice; - it pushes and pops log tags instead of Rails.logger.tagged { }, which on a BroadcastLogger runs its block once per tagged logger — with two of them the callback would run twice. spec/rails/request_state_spec.rb boots a Rails application in a child process for production / development x thread / fiber isolation, pins the table rows that need only ActiveSupport, and evaluates the recipe block exactly as the guide prints it, through all five kinds of callback, :thread and :inline scheduling, query and Client.open. The connected_to rows are not covered (the Rails bundles carry no ActiveRecord); the guide says which rows the spec covers. No code change; no API is added for this. Exploration finding: H-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 155 ++++++++++- spec/rails/request_state_spec.rb | 130 +++++++++ spec/rails/support/booted_app.rb | 56 ++++ spec/rails/support/booted_app_child.rb | 270 +++++++++++++++++++ spec/rails/support/scripted_cli_transport.rb | 9 +- 5 files changed, 611 insertions(+), 9 deletions(-) create mode 100644 spec/rails/request_state_spec.rb create mode 100644 spec/rails/support/booted_app.rb create mode 100644 spec/rails/support/booted_app_child.rb diff --git a/docs/rails.md b/docs/rails.md index 383b43e8..a684f7a5 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -43,24 +43,26 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the end ``` - The block runs on a plain thread (see the next section), so ActiveRecord calls inside it just work. For multi-turn sessions, hooks, custom tools and interrupts use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below. + The block runs on a plain thread (see the next section), so ActiveRecord calls inside it are safe. It is a thread of its own, though: the job's `Current` attributes, time zone, log tags and database role or shard are not set there — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks). For multi-turn sessions, hooks, custom tools and interrupts use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below. ## Thread-keyed libraries are safe inside SDK callbacks The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread. -You do **not** need to think about this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) the SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes: +The SDK keeps your callbacks out of this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) it hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active, and thread-keyed libraries behave there as they do on any other thread: ```ruby tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args| - User.find(args[:id]).name # just works + User.find(args[:id]).name # a connection of this thread's own: safe end ClaudeAgentSDK.query(prompt: '...') do |message| - Message.create!(role: 'assistant', body: message.to_s) # just works + Message.create!(role: 'assistant', body: message.to_s) # likewise end ``` +That thread is a new one, not the thread that called the SDK. The connection pool does not mind; everything Rails keeps for the current request or job does — `Current` attributes, `Time.zone`, log tags, the database role and shard are all back at their defaults inside a callback. Read [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks) before a callback touches anything scoped to the request. + The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there. ### Rails executor around callbacks: `callback_wrapper` @@ -79,7 +81,7 @@ It runs each callback inside the Rails executor — except where that would dead - **Development (code reloading enabled).** Every executor then holds a share of the code-reload interlock. The request or job calling the SDK is already inside the executor, and in `:thread` mode it waits for the callback's thread. If a reload is requested meanwhile (say, another request arrives after the agent edited an app file), the reloader queues for the exclusive unload lock, and a callback thread entering `executor.wrap` queues behind it for a fresh share — which the reloader can never let through while the waiting caller holds its own. Everything hangs. The helper instead runs the callback without entering the executor (the caller's share still keeps code from being unloaded under it) and returns the thread's ActiveRecord connections to the pool when the callback finishes. The executor's other per-run hooks (query cache, `CurrentAttributes` reset) do not run for callbacks in this case. - **`config.allow_concurrency = false`.** The executor holds a process-wide monitor that the calling thread already owns, so a callback thread's `executor.wrap` would block every time; same treatment. -- **Already inside the executor** (`:inline` scheduling on a job's own fiber): the callback runs straight through, leaving cleanup to the enclosing executor. +- **Already inside the executor** — a callback that runs on the caller's own fiber, which under `:inline` scheduling is a `Client`'s message block and observers: it runs straight through, leaving cleanup to the enclosing executor. The other `:inline` callbacks run on fibers of their own, where under fiber isolation the executor is not active; they take one of the other paths. Everywhere else — production, with no reloading — the callback runs inside the executor: its run hooks before the callback and its complete hooks after it, also when the callback raises. The configuration is read per call, so one initializer is correct in every environment. @@ -94,6 +96,141 @@ The wrapper also composes around every timeout-bounded `SessionStore` adapter ca When do you want this vs `callback_scheduling: :inline`? `callback_wrapper` + default `:thread` mode is the right choice for ordinary threaded hosts (Puma, threaded Sidekiq/solid_queue): it fixes connection hygiene without any fiber-isolation precondition. `:inline` is only for hosts that are fiber-isolated end to end (solid_queue fiber workers with `isolation_level = :fiber`); there the wrapper still applies — it simply runs in place on the reactor fiber. +## Request state does not follow into callbacks + +A callback is written inside your controller action or job, a few lines below its `Current.set` or `connected_to` block — but it does not run there. It runs on a thread the SDK starts for it (the default `:thread` scheduling) or on a fiber of the SDK's reactor (`:inline`), and Rails keeps what belongs to the current request or job per thread or per fiber. A thread or fiber Rails never set up reads all of it as the default: + +| Set on the caller | What a callback sees | +| --- | --- | +| `Current.user = alice` (any `ActiveSupport::CurrentAttributes`) | `nil` | +| `Time.zone = "Tokyo"`, `Time.use_zone` | the application's default zone (`UTC` unless configured) | +| log tags: `Rails.logger.tagged("req-123")`, the request id, ActiveJob's job id | none | +| `connected_to(role: :reading)` | `:writing` | +| `connected_to(shard: :tenant_b)` | `:default` | +| `connected_to(prevent_writes: true)` | writes allowed | +| the error context: `Rails.error.set_context`, the controller or job Rails records | empty | +| `I18n.locale = :de` | `:de` — i18n 1.14 and later keep it in fiber storage, which new threads and fibers inherit | +| the OpenTelemetry context | kept — the SDK carries it across | + +This is the same in a message block, an observer, a hook, `can_use_tool` and an SDK MCP tool handler; with and without `Railtie.callback_wrapper`; in development and in production. It is the same under `callback_scheduling: :inline` with fiber isolation, with one exception: there a `Client`'s message block and observers run on the fiber that called the SDK, and see its state. (`ClaudeAgentSDK.query` runs every callback on another fiber, and hooks, `can_use_tool` and tool handlers always do.) + +Nothing raises. The consequences are silent: + +- A job inside `connected_to(shard: :tenant_b)` writes its own records to `tenant_b`; the same `create!` in a tool handler or in the message block writes to the **default** shard. Inside `connected_to(role: :reading)` the caller's own write raises `ActiveRecord::ReadOnlyError`; the same write in a callback succeeds, on the primary. +- A scope or policy that reads `Current.account` runs unscoped. Log lines written by callbacks carry no request id, errors reported from them no controller or job, and times are formatted in the default zone. + +### Carrying the caller's state into callbacks + +The SDK has no option for this yet. The supported way today is a wrapper of your own that captures the state **on the caller** and restores it **inside** the Rails wrapper, on the callback's thread or fiber: + +<!-- spec/rails/request_state_spec.rb runs the next code block verbatim --> +```ruby +# app/lib/agent_context.rb +module AgentContext + # A callback_wrapper that runs SDK callbacks under the state of whoever + # calls this method. Call it from the request or job, once per SDK call. + def self.callback_wrapper + rails = ClaudeAgentSDK::Railtie.callback_wrapper + origin = Fiber.current + restore = capture + + lambda do |invocation| + # The callback is running on the caller itself: the state is there. + next rails.call(invocation) if Fiber.current.equal?(origin) + + # Restore inside the Rails wrapper: the executor it enters starts + # every execution from a clean slate. + rails.call(-> { restore.call(invocation) }) + end + end + + # Snapshots the caller. Returns a lambda that runs an invocation under + # that snapshot, on whatever thread or fiber it is called on. + def self.capture + attributes = Current.attributes.dup + context = ActiveSupport::ExecutionContext.to_h + zone = Time.zone + tags = log_tags + + lambda do |invocation| + Current.set(attributes) do + ActiveSupport::ExecutionContext.set(**context) do + Time.use_zone(zone) do + with_log_tags(tags) { invocation.call } + end + end + end + end + end + + def self.log_tags + formatter = Rails.logger.formatter if Rails.logger.respond_to?(:formatter) + formatter.respond_to?(:current_tags) ? formatter.current_tags.dup : [] + end + + # push / pop, not `Rails.logger.tagged(*tags) { ... }`: a BroadcastLogger + # runs that block once for every tagged logger it broadcasts to. + def self.with_log_tags(tags) + logger = Rails.logger + return yield if tags.empty? || !logger.respond_to?(:push_tags) + + logger.push_tags(*tags) + begin + yield + ensure + logger.pop_tags(tags.size) + end + end +end +``` + +Build the wrapper where the state is set, and pass it in that call's options: + +```ruby +class SummarizeTicketJob < ApplicationJob + def perform(ticket) + Current.account = ticket.account + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + callback_wrapper: AgentContext.callback_wrapper, # replaces the configured Rails wrapper, and calls it + tools: [], max_turns: 1 + ) + + ClaudeAgentSDK.query(prompt: "Summarize:\n\n#{ticket.body}", options: options) do |message| + # Current.account, Time.zone, the job's log tags and error context are set here + ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage) + end + end +end +``` + +What the recipe depends on: + +- **One wrapper per call, built on the caller.** `AgentContext.callback_wrapper` snapshots whoever calls it. Call it from the request or job, after its state is set and before `query` / `Client.open` — not inside their blocks, which may already run on another fiber. It cannot go into the initializer: a process-wide wrapper only ever runs at the destination and has no caller to look at. And it must not outlive the call: a wrapper is fixed for the lifetime of the session it was passed to, so one built when a long-lived `Client` connects makes every later turn run its callbacks as the **first** caller — another user's turn would read and write under the first user's account and shard. Open a session per request or job and resume it by id, as the examples below do, and the capture happens once per call. +- **The restore happens inside `rails.call`.** In production the Rails wrapper enters the executor, and the executor starts every execution from a clean slate: it resets `Current` and the error context. State set around `rails.call` is wiped on the way in; state set inside it survives. +- **No restore on the fiber that captured the state.** Under `:inline` scheduling a `Client`'s message block and observers run on the caller itself. The state is already there, and restoring it again would add the log tags a second time. +- **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every logger and run nothing. The two `respond_to?` checks keep the recipe working with a logger that has no tags. +- **`ActiveSupport::ExecutionContext` is restored explicitly.** It is where `Rails.error.set_context` and Rails' own controller and job entries live; `Current.set` does not bring it back. Rails has no public reader for it, so recheck this line when you upgrade Rails. +- **Values travel, containers do not.** The recipe rebuilds the caller's state from values. The objects themselves — `Current.user`, say — are shared with the caller, so treat them as read-only in callbacks. Do not go further and copy thread-local variables wholesale, hand the caller's ActiveRecord connection to a callback, or pass `ActiveRecord::Base.connected_to_stack` across: those are mutable and belong to one thread. + +An application with replicas or shards also carries the role, the shard and `prevent_writes`. Capture them in `capture`: + +```ruby + role, shard = ApplicationRecord.current_role, ApplicationRecord.current_shard + prevent_writes = ApplicationRecord.current_preventing_writes +``` + +and re-enter them innermost, in place of `with_log_tags(tags) { invocation.call }`: + +```ruby + with_log_tags(tags) do + ApplicationRecord.connected_to(role: role, shard: shard, prevent_writes: prevent_writes) { invocation.call } + end +``` + +`connected_to` on `ApplicationRecord` switches the models that inherit from it; an application with several connection classes captures and re-enters each of them. + +What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag, error context and `I18n.locale` rows of the table, and runs the `AgentContext` block exactly as printed above through all five kinds of callback. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. + ## Fiber workers (solid_queue) and `callback_scheduling: :inline` [solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end: @@ -116,9 +253,13 @@ ClaudeAgentSDK.configure do |config| end ``` -With `:inline`, every user callback — message blocks, hooks, permission callbacks, SDK MCP handlers, observers — runs in place on the reactor fiber of the job. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). Concretely: +With `:inline`, no user callback leaves the job's reactor: message blocks, hooks, permission callbacks, SDK MCP handlers and observers all run in place, on a fiber of that reactor. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). + +In place on the reactor is not the same as on the job's own fiber, and under fiber isolation the fiber is what Rails keys the job's state on. A `Client`'s message block and observers run on the fiber that called the SDK. Hooks, permission callbacks and SDK MCP handlers run on child fibers of the session's read task, and `ClaudeAgentSDK.query` runs its whole body, message block included, on a fiber of its own. On those fibers `Current`, `Time.zone`, the log tags and the database role and shard start from their defaults, exactly as on a callback thread — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks). + +Concretely: -- `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool under the same assumptions as the rest of your fiber-worker jobs. +- `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool, as in the rest of your fiber-worker jobs. - No per-call threads exist, so nothing can strand an AR connection. - The whole SDK session can live directly on the job fiber — no bridge threads. `Client#connect` already requires an Async context, and the transport's pipe I/O is scheduler-aware. - Hook timeouts become **cooperative**: a timed-out inline hook is cancelled at its next suspension point (its `ensure` blocks run), instead of being abandoned on a worker thread. A CPU-stuck hook cannot be timed out. diff --git a/spec/rails/request_state_spec.rb b/spec/rails/request_state_spec.rb new file mode 100644 index 00000000..8509355a --- /dev/null +++ b/spec/rails/request_state_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require_relative 'support/booted_app' + +# What an SDK callback sees of the request (or job) that runs the agent, and +# the recipe docs/rails.md gives for carrying that state into callbacks. +# +# Callbacks run on a thread or fiber Rails never set up, so whatever Rails +# keeps per execution — Current attributes, Time.zone, log tags, the error +# context — reads as its default there, with or without the Railtie wrapper. +# docs/rails.md ("Request state does not follow into callbacks") says so and +# prints a wrapper that captures the state on the caller and restores it in +# each callback; the child process evaluates that block verbatim. +# +# Observed in a booted application (support/booted_app.rb), one child process +# per configuration: production and development (code reloading, where the +# Railtie wrapper stays out of the executor), default :thread scheduling +# under both isolation levels, and :inline under fiber isolation, its +# documented precondition. ActiveRecord's role / shard / prevent_writes are +# not covered here: the Rails gemfiles carry no ActiveRecord. +RSpec.describe 'Request state inside SDK callbacks' do + callbacks = %w[message_block observer hook can_use_tool tool] + caller_state = { 'user' => 'alice', 'time_zone' => 'Tokyo', 'log_tags' => ['req-123'], + 'error_context' => { 'request_id' => 'req-123' } } + rails_defaults = { 'user' => nil, 'time_zone' => 'UTC', 'log_tags' => [], 'error_context' => {} } + + # What the SDK answered the CLI with: the hook's output, the permission + # result and the tool's result, in the order the CLI asked. + let(:answers) do + tool_result = include('content' => [{ 'type' => 'text', 'text' => 'found' }]) + [ + { 'subtype' => 'success', 'response' => {} }, + { 'subtype' => 'success', 'response' => { 'behavior' => 'allow', 'updatedInput' => { 'id' => 7 } } }, + { 'subtype' => 'success', 'response' => { 'mcp_response' => include('result' => tool_result) } } + ] + end + + def booted_app(reloading:, isolation:) + ClaudeAgentSDKRailsSpec::BootedApp.observe(reloading: reloading, isolation: isolation) + end + + { 'production' => false, 'development (code reloading)' => true }.each do |environment, reloading| + [%i[thread thread], %i[fiber inline], %i[fiber thread]].each do |isolation, scheduling| + { 'ClaudeAgentSDK.query' => 'query', 'Client.open' => 'client' }.each do |entry_point, api| + context "in #{environment}, #{isolation} isolation, callback_scheduling: :#{scheduling}, #{entry_point}" do + let(:observed) { booted_app(reloading: reloading, isolation: isolation) } + let(:with_railtie_wrapper) { observed.fetch("#{api}/#{scheduling}/railtie") } + let(:with_recipe) { observed.fetch("#{api}/#{scheduling}/recipe") } + # Only a Client's message block and observers run on the fiber + # that called the SDK, and only under :inline scheduling. + let(:on_caller_fiber) { scheduling == :inline && api == 'client' ? %w[message_block observer] : [] } + + def seen(state, kind) + [state.merge('locale' => 'de', 'on_caller_fiber' => on_caller_fiber.include?(kind))] + end + + it 'boots the application it describes' do + expect(observed).to include('reloading' => reloading, 'isolation' => isolation.to_s, + 'logger' => 'ActiveSupport::BroadcastLogger') + expect(with_railtie_wrapper.fetch('caller')).to eq( + [caller_state.merge('locale' => 'de', 'on_caller_fiber' => true)] + ) + end + + it 'shows Rails defaults to every callback off the caller fiber, with Railtie.callback_wrapper alone' do + callbacks.each do |kind| + state = on_caller_fiber.include?(kind) ? caller_state : rails_defaults + expect(with_railtie_wrapper.fetch(kind)).to eq(seen(state, kind)), "in #{kind}" + end + end + + it 'shows the caller state to every callback, once, with the documented recipe' do + expect(with_recipe).not_to include('error') + callbacks.each do |kind| + expect(with_recipe.fetch(kind)).to eq(seen(caller_state, kind)), "in #{kind}" + end + end + + it "hands the callbacks' return values back unchanged through the documented recipe" do + expect(with_railtie_wrapper.fetch('answers')).to match(answers) + expect(with_recipe.fetch('answers')).to match(answers) + end + end + end + end + end + + # The capture must happen per call: a wrapper is fixed for the lifetime of + # the session it was passed to. + describe 'a recipe wrapper built by one request and still in use during another (a long-lived Client)' do + let(:turns) { booted_app(reloading: false, isolation: :thread).fetch('reused wrapper').fetch('turns') } + let(:later_request) { turns.last } + + it 'shows the callbacks of the later request the state of the first one' do + expect(later_request.fetch('caller')).to contain_exactly( + include('user' => 'bob', 'time_zone' => 'Berlin', 'log_tags' => ['req-456'], + 'error_context' => { 'request_id' => 'req-456' }) + ) + callbacks.each do |kind| + expect(later_request.fetch(kind)).to contain_exactly(include(caller_state)), "in #{kind}" + end + end + end + + # Rails.logger.tagged { } on a broadcast runs its block once per tagged + # logger; the recipe pushes and pops the tags instead. + describe 'the documented recipe with two tagged loggers in the Rails.logger broadcast' do + [%i[thread thread], %i[fiber inline]].each do |isolation, scheduling| + context "under callback_scheduling: :#{scheduling}" do + let(:observed) { booted_app(reloading: false, isolation: isolation).fetch('two tagged loggers') } + + it 'runs every callback once and hands its return value back' do + expect(observed).not_to include('error') + callbacks.each do |kind| + expect(observed.fetch(kind)).to contain_exactly(include(caller_state)), "in #{kind}" + end + expect(observed.fetch('answers')).to match(answers) + end + + it 'tags the lines callbacks log on both loggers, once' do + expect(observed.fetch('logs').size).to eq(2) + expect(observed.fetch('logs')).to all( + match_array(['caller', *callbacks].map { |kind| "[req-123] seen by #{kind}" }) + ) + end + end + end + end +end diff --git a/spec/rails/support/booted_app.rb b/spec/rails/support/booted_app.rb new file mode 100644 index 00000000..23a61689 --- /dev/null +++ b/spec/rails/support/booted_app.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'json' +require 'open3' +require 'rbconfig' + +module ClaudeAgentSDKRailsSpec + # Observations made inside a real, booted Rails application. + # + # The spec process's own application (rails_helper.rb) is never + # initialized: its executor has none of the hooks Rails registers at boot, + # Rails.logger is nil, and the isolation level is whatever the process + # started with. What an SDK callback sees of its caller's state depends on + # all three, so specs about that state read it from a child process that + # boots an application first (booted_app_child.rb), once per configuration. + module BootedApp + CHILD = File.expand_path('booted_app_child.rb', __dir__) + LIB = File.expand_path('../../../lib', __dir__) + RESULT = 'BOOTED_APP_RESULT ' + # A hang guard, not a synchronization point: nothing in the child waits + # on a clock, so a healthy run ends in a second or two on an idle machine. + LIMIT_SECONDS = 600 + + @results = {} + @lock = Mutex.new + + # What the child observed, as parsed JSON (String keys). + # + # @param reloading [Boolean] config.enable_reloading — development (true) + # or production (false) + # @param isolation [Symbol] ActiveSupport::IsolatedExecutionState.isolation_level + def self.observe(reloading:, isolation:) + @lock.synchronize { @results[[reloading, isolation]] ||= run(reloading: reloading, isolation: isolation) } + end + + def self.run(reloading:, isolation:) + env = { 'RAILS_ENV' => reloading ? 'development' : 'production', 'BOOTED_APP_ISOLATION' => isolation.to_s } + out, err, status = capture(env, RbConfig.ruby, '-I', LIB, CHILD) + line = out.lines.reverse.find { |candidate| candidate.start_with?(RESULT) } + return JSON.parse(line.delete_prefix(RESULT)) if status.success? && line + + raise "booted app (#{env}) failed with #{status.inspect}:\n#{err}\n#{out}" + end + private_class_method :run + + def self.capture(env, *command) + Open3.popen3(env, *command) do |stdin, stdout, stderr, wait_thread| + stdin.close + readers = [stdout, stderr].map { |io| Thread.new { io.read } } + Process.kill('KILL', wait_thread.pid) unless wait_thread.join(LIMIT_SECONDS) + [*readers.map(&:value), wait_thread.value] + end + end + private_class_method :capture + end +end diff --git a/spec/rails/support/booted_app_child.rb b/spec/rails/support/booted_app_child.rb new file mode 100644 index 00000000..156805a1 --- /dev/null +++ b/spec/rails/support/booted_app_child.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +# Runs in a child process started by booted_app.rb: boots a real Rails +# application, plays requests that run an agent against a scripted CLI, and +# prints what every kind of SDK callback saw of the request's state. +# +# RAILS_ENV production, or development (code reloading enabled) +# BOOTED_APP_ISOLATION thread | fiber +# +# Plain Ruby, no RSpec: the parent asserts on the JSON printed at the end. +require 'rails' +require 'claude_agent_sdk' # after Rails, as Bundler.require does: this loads the Railtie +require 'json' +require 'stringio' +require 'tmpdir' +require_relative 'scripted_cli_transport' + +# What an application has in app/models/current.rb. +class Current < ActiveSupport::CurrentAttributes + attribute :user +end + +module ClaudeAgentSDKRailsSpec + LOG = StringIO.new + + class BootedApplication < Rails::Application + config.root = Dir.mktmpdir('claude_agent_sdk_booted_app') + config.load_defaults "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}" + config.eager_load = false + config.enable_reloading = Rails.env.development? + config.secret_key_base = 'booted-app-child' + config.i18n.available_locales = %i[en de fr] + # A tagged logger, as generated production.rb files set up. Rails wraps + # it in an ActiveSupport::BroadcastLogger at boot. + config.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(LOG)) + # docs/rails.md, "Fiber workers": what a fiber-isolated host sets. + ActiveSupport::IsolatedExecutionState.isolation_level = :fiber if ENV['BOOTED_APP_ISOLATION'] == 'fiber' + end + + # The recipe as printed in docs/rails.md: the first Ruby block after MARKER + # is evaluated verbatim, so the guide cannot drift from what runs here. + module DocumentedRecipe + GUIDE = File.expand_path('../../../docs/rails.md', __dir__) + MARKER = '<!-- spec/rails/request_state_spec.rb runs the next code block verbatim -->' + + # @return [String, nil] why the recipe could not be loaded + def self.load + before, marker, after = File.read(GUIDE).partition(MARKER) + code = after[/^```ruby\n(.*?)^```$/m, 1] + return "docs/rails.md has no Ruby block after #{MARKER}" if marker.empty? || code.nil? + + prelude = before + marker + after[/\A.*?^```ruby\n/m] + TOPLEVEL_BINDING.eval(code, GUIDE, prelude.count("\n") + 1) + nil + end + end + + # What callbacks saw, by kind of callback, in the order they ran. + class Recorder + include ClaudeAgentSDK::Observer + + def initialize + @lock = Mutex.new + @turns = [Hash.new { |seen, kind| seen[kind] = [] }] + end + + # The request that is calling the SDK: observations are relative to it. + def caller! + @caller_fiber = Fiber.current + record('caller') + end + + def next_turn! + @lock.synchronize { @turns << Hash.new { |seen, kind| seen[kind] = [] } } + end + + def record(kind) + Rails.logger.info("seen by #{kind}") + snapshot = { + 'user' => Current.user, + 'time_zone' => Time.zone.name, + 'log_tags' => Rails.logger.formatter.current_tags.dup, + 'error_context' => ActiveSupport::ExecutionContext.to_h, + 'locale' => I18n.locale.to_s, + 'on_caller_fiber' => Fiber.current.equal?(@caller_fiber) + } + @lock.synchronize { @turns.last[kind] << snapshot } + end + + # Observer and message block see every message; one observation each is + # enough, and the result is the one every turn has. + def on_message(message) + record('observer') if message.is_a?(ClaudeAgentSDK::ResultMessage) + end + + def message_block + ->(message) { record('message_block') if message.is_a?(ClaudeAgentSDK::ResultMessage) } + end + + def turns + @lock.synchronize { @turns.map(&:dup) } + end + end + + # Requests (or jobs) that run an agent. Each one runs inside the executor + # with the state a controller or job would have set, and its agent turn + # fires every kind of SDK callback: PreToolUse hook, can_use_tool, an SDK + # MCP tool, an observer and the message block. + class Requests + ALICE = { user: 'alice', zone: 'Tokyo', request_id: 'req-123', locale: :de }.freeze + BOB = { user: 'bob', zone: 'Berlin', request_id: 'req-456', locale: :fr }.freeze + + def initialize(recipe_error) + @recipe_error = recipe_error + # Fiber-isolated hosts (solid_queue fiber workers, Falcon) run a request + # or job as a task on a reactor; thread-isolated ones on a plain thread. + @reactor = ActiveSupport::IsolatedExecutionState.isolation_level == :fiber + end + + # One request, one agent run. + def run(api:, scheduling:, wrapper:) + return { 'error' => @recipe_error } if wrapper == 'recipe' && @recipe_error + + recorder = Recorder.new + responses = {} + in_executor do + with_request_state(**ALICE) do + recorder.caller! + options = options_for(recorder, scheduling: scheduling, wrapper: wrapper_named(wrapper)) + run_agent(api, options, recorder, responses) + end + end + recorder.turns.first.merge('answers' => answers(responses)) + end + + # One Client connected by a first request, with a wrapper built there, + # and used again by a second request with different state. + def run_reusing_a_wrapper + return { 'error' => @recipe_error } if @recipe_error + + recorder = Recorder.new + Sync do + client = nil + in_this_executor(**ALICE) do + recorder.caller! + options = options_for(recorder, scheduling: :thread, wrapper: wrapper_named('recipe')) + client = ClaudeAgentSDK::Client.new(options: options, transport_class: ScriptedCLITransport, + transport_args: { turns: [Frames.tool_turn, Frames.tool_turn] }) + client.connect + take_turn(client, recorder) + end + recorder.next_turn! + in_this_executor(**BOB) do + recorder.caller! + take_turn(client, recorder) + end + ensure + client&.disconnect + end + { 'turns' => recorder.turns } + end + + # The same request with a second tagged logger in Rails.logger's broadcast. + def run_with_two_tagged_loggers(scheduling:) + second_log = StringIO.new + second = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(second_log)) + Rails.logger.broadcast_to(second) + LOG.rewind + LOG.truncate(0) + seen = run(api: 'query', scheduling: scheduling, wrapper: 'recipe') + seen.merge('logs' => [LOG.string, second_log.string].map { |log| log.lines(chomp: true) }) + ensure + Rails.logger.stop_broadcasting_to(second) + end + + private + + def in_executor(&) + return Rails.application.executor.wrap(&) unless @reactor + + Async { Rails.application.executor.wrap(&) }.wait + end + + def in_this_executor(**state, &block) + Rails.application.executor.wrap { with_request_state(**state, &block) } + end + + # What a controller's callbacks (or a job) set before calling the SDK. + # The executor resets Current and the error context when the request ends. + def with_request_state(user:, zone:, request_id:, locale:, &) + Current.user = user + Rails.error.set_context(request_id: request_id) + Rails.logger.push_tags(request_id) # as Rails::Rack::Logger tags a request + I18n.with_locale(locale) { Time.use_zone(zone, &) } + ensure + Rails.logger.pop_tags + end + + def wrapper_named(name) + case name + when 'railtie' then ClaudeAgentSDK::Railtie.callback_wrapper # what the generated initializer configures + when 'recipe' then AgentContext.callback_wrapper # docs/rails.md: built per call, on the caller + else raise ArgumentError, "unknown wrapper #{name}" + end + end + + def options_for(recorder, scheduling:, wrapper:) + hook = lambda do |_input, _tool_use_id, _context| + recorder.record('hook') + {} + end + can_use_tool = lambda do |_tool_name, _input, _context| + recorder.record('can_use_tool') + ClaudeAgentSDK::PermissionResultAllow.new + end + tool = ClaudeAgentSDK.create_tool('lookup', 'Look up a record', { id: Integer }) do |_args| + recorder.record('tool') + { content: [{ type: 'text', text: 'found' }] } + end + ClaudeAgentSDK::ClaudeAgentOptions.new( + hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(hooks: [hook])] }, + can_use_tool: can_use_tool, + mcp_servers: { 'app' => ClaudeAgentSDK.create_sdk_mcp_server(name: 'app', tools: [tool]) }, + observers: [recorder], callback_scheduling: scheduling.to_sym, callback_wrapper: wrapper + ) + end + + def run_agent(api, options, recorder, responses) + transport_args = { turns: [Frames.tool_turn], control_responses: responses } + if api == 'client' + ClaudeAgentSDK::Client.open(options: options, transport_class: ScriptedCLITransport, + transport_args: transport_args) { |client| take_turn(client, recorder) } + else + ClaudeAgentSDK.query(prompt: 'Look up record 7', options: options, + transport: ScriptedCLITransport.new(**transport_args), &recorder.message_block) + end + end + + def take_turn(client, recorder) + client.query('Look up record 7') + client.receive_response(&recorder.message_block) + end + + # What the SDK answered the CLI's hook, permission and tool requests with + # — the callbacks' return values, as they came back through the wrapper. + def answers(responses) + responses.values.map { |response| response.slice(:subtype, :response, :error) } + end + end +end + +Rails.application.initialize! + +requests = ClaudeAgentSDKRailsSpec::Requests.new(ClaudeAgentSDKRailsSpec::DocumentedRecipe.load) +isolation = ActiveSupport::IsolatedExecutionState.isolation_level +# :inline needs fiber isolation (the SDK warns otherwise); :thread works under both. +schedulings = isolation == :fiber ? %w[inline thread] : %w[thread] + +observed = { + 'rails' => Rails.version, 'reloading' => Rails.application.config.reloading_enabled?, + 'isolation' => isolation.to_s, 'logger' => Rails.logger.class.name +} +%w[query client].product(schedulings, %w[railtie recipe]).each do |api, scheduling, wrapper| + observed["#{api}/#{scheduling}/#{wrapper}"] = requests.run(api: api, scheduling: scheduling, wrapper: wrapper) +end +observed['reused wrapper'] = requests.run_reusing_a_wrapper +observed['two tagged loggers'] = requests.run_with_two_tagged_loggers(scheduling: schedulings.first) + +puts "BOOTED_APP_RESULT #{JSON.generate(observed)}" +FileUtils.rm_rf(Rails.root.to_s) diff --git a/spec/rails/support/scripted_cli_transport.rb b/spec/rails/support/scripted_cli_transport.rb index 5b9a218b..8a7bf74c 100644 --- a/spec/rails/support/scripted_cli_transport.rb +++ b/spec/rails/support/scripted_cli_transport.rb @@ -13,6 +13,9 @@ module ClaudeAgentSDKRailsSpec # # Like the process it replaces, it ends the stream once the script is # played and the SDK has closed stdin (#end_input), or on #close. + # + # Plain Ruby with no RSpec dependency: booted_app_child.rb loads it into a + # child process too. class ScriptedCLITransport < ClaudeAgentSDK::Transport END_OF_STREAM = :end_of_stream @@ -24,14 +27,16 @@ class ScriptedCLITransport < ClaudeAgentSDK::Transport # frame (Hash), a control request (Frames.control_request: the script # waits for the SDK's answer before going on), or a callable that is # given this transport and returns either. - def initialize(_options = nil, turns:) + # @param control_responses [Hash] where to record the SDK's answers; pass + # one to read them when a Client builds the transport itself. + def initialize(_options = nil, turns:, control_responses: {}) super() @turns = turns.map(&:dup) @steps = [] @stdout = Thread::Queue.new @lock = Mutex.new @hook_callback_ids = [] - @control_responses = {} + @control_responses = control_responses @awaited_request_id = nil @input_ended = false @closed = false From f40a1d6765b620df6a8288fd706ef02ec6e3af2d Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:18:56 +0800 Subject: [PATCH 167/194] Document where a custom wrapper restores state around the Rails one docs/rails.md listed "CurrentAttributes / logging context" as a use for callback_wrapper and showed the user's wrapper around the Rails one: ->(inv) { MyApm.trace { rails.call(inv) } }. Followed for Current, that shape keeps Current.user in development and loses it in every callback in production: there Railtie.callback_wrapper enters the Rails executor, whose run hooks reset CurrentAttributes and the error context after the outer wrapper has already set them. In development the helper stays out of the executor, so the same code works; Time.zone, log tags and connected_to are not reset by the executor and survive on either side, which makes the loss look selective. The guide now states the rule with both shapes: what only has to surround the callback (an APM span) can stay outside rails.call; state Rails resets per execution is set inside it. It also notes that Current.set does not carry the error context. spec/rails/callback_wrapper_composition_spec.rb pins the rule in a booted application for both supported Rails versions (7.1 resets with ExecutionContext.clear + CurrentAttributes.reset_all, 8.1 with ExecutionContext.push), production and development. It holds unchanged with the run! / complete! wrapper. Exploration finding: H-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 19 +++++- .../callback_wrapper_composition_spec.rb | 58 +++++++++++++++++++ spec/rails/support/booted_app_child.rb | 21 +++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 spec/rails/callback_wrapper_composition_spec.rb diff --git a/docs/rails.md b/docs/rails.md index a684f7a5..64d167cc 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -90,7 +90,24 @@ Everywhere else — production, with no reloading — the callback runs inside t - An exception that escapes a callback — one raised in a message block, say — comes out of `query` / `receive_response` on the thread that called the SDK. The request middleware or ActiveJob reports it there, once, with the controller or job context that thread has. - A failure the SDK handles is not reported: an exception in a hook or `can_use_tool` is answered to the CLI as an error response, one in an SDK MCP tool handler becomes an error result the model sees, one in an observer is swallowed, and a timed-out or cancelled callback is cancellation, not an error. To track these, report them where they happen: `rescue` inside the callback, call `Rails.error.report(e, handled: true)`, and re-raise. -Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc. — to combine one with the Rails wrapper, call it from yours: `rails = ClaudeAgentSDK::Railtie.callback_wrapper` then `->(inv) { MyApm.trace { rails.call(inv) } }`. +Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: the executor runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers are safe, including around a `break` from a message block. + +Beyond the executor, this is a generic hook: APM spans, logging context, per-request state. To combine a wrapper of your own with the Rails one, call the Rails one from yours — and mind which side of that call your code is on. In production `rails.call` enters the Rails executor, and the executor starts every execution from a clean slate: on the way in it resets `CurrentAttributes` and the error context (`Rails.error.set_context`). + +```ruby +rails = ClaudeAgentSDK::Railtie.callback_wrapper + +# What only has to surround the callback can stay outside: +->(invocation) { MyApm.trace('agent.callback') { rails.call(invocation) } } + +# State that Rails resets per execution is set inside: +->(invocation) { rails.call(-> { Current.set(account: account) { invocation.call } }) } + +# Not around it. This loses Current.account in every callback, in production only: +->(invocation) { Current.set(account: account) { rails.call(invocation) } } +``` + +The last form is a trap because it works in development, where the Rails wrapper stays out of the executor (see above), and because the loss looks selective: `Time.zone`, log tags and `connected_to` are not reset by the executor and survive on either side. `Current.set` does not carry the error context either; that needs `ActiveSupport::ExecutionContext.set`. The [recipe below](#carrying-the-callers-state-into-callbacks) puts all of it in the right place, and `spec/rails/callback_wrapper_composition_spec.rb` pins this rule against the executor hooks of both supported Rails versions. The wrapper also composes around every timeout-bounded `SessionStore` adapter call (mirror-batcher appends, resume-materialization loads and listings), inside the timeout bound — so an ActiveRecord-backed store adapter gets the same connection hygiene as your callbacks. diff --git a/spec/rails/callback_wrapper_composition_spec.rb b/spec/rails/callback_wrapper_composition_spec.rb new file mode 100644 index 00000000..5f4e93f8 --- /dev/null +++ b/spec/rails/callback_wrapper_composition_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require_relative 'support/booted_app' + +# Combining a wrapper of your own with Railtie.callback_wrapper: which side +# of `rails.call` your code is on decides what the callback sees. +# +# In production the Rails wrapper enters the executor, whose run hooks start +# every execution from a clean slate (Rails resets CurrentAttributes and the +# error context there). In development it stays out of the executor. So a +# wrapper that sets state around `rails.call` works in development and loses +# Current and the error context in production only — while Time.zone and log +# tags, which the executor does not reset, survive in both. docs/rails.md +# ("Writing your own wrapper") states the rule; these examples pin it against +# the hooks a booted application of each Rails version really registers. +RSpec.describe 'Composing a wrapper with ClaudeAgentSDK::Railtie.callback_wrapper' do + callbacks = %w[message_block observer hook can_use_tool tool] + everything = { 'user' => 'alice', 'time_zone' => 'Tokyo', 'log_tags' => ['req-123'], + 'error_context' => { 'request_id' => 'req-123' } } + # What the executor's run hooks reset, and what they leave alone. + after_executor_reset = everything.merge('user' => nil, 'error_context' => {}) + + def seen_with(composition, reloading:) + observed = ClaudeAgentSDKRailsSpec::BootedApp.observe(reloading: reloading, isolation: :thread) + observed.fetch("query/thread/state set #{composition} the Rails wrapper") + end + + context 'in production (the Rails wrapper enters the executor)' do + it 'keeps state set inside rails.call' do + seen = seen_with('inside', reloading: false) + + callbacks.each { |kind| expect(seen.fetch(kind)).to contain_exactly(include(everything)), "in #{kind}" } + end + + it 'resets Current and the error context set around rails.call, and nothing else' do + seen = seen_with('outside', reloading: false) + + callbacks.each do |kind| + expect(seen.fetch(kind)).to contain_exactly(include(after_executor_reset)), "in #{kind}" + end + end + end + + context 'in development (code reloading: the Rails wrapper stays out of the executor)' do + it 'keeps state set inside rails.call' do + seen = seen_with('inside', reloading: true) + + callbacks.each { |kind| expect(seen.fetch(kind)).to contain_exactly(include(everything)), "in #{kind}" } + end + + it 'keeps state set around rails.call too, which is what hides the production loss' do + seen = seen_with('outside', reloading: true) + + callbacks.each { |kind| expect(seen.fetch(kind)).to contain_exactly(include(everything)), "in #{kind}" } + end + end +end diff --git a/spec/rails/support/booted_app_child.rb b/spec/rails/support/booted_app_child.rb index 156805a1..4be47b2b 100644 --- a/spec/rails/support/booted_app_child.rb +++ b/spec/rails/support/booted_app_child.rb @@ -133,6 +133,24 @@ def run(api:, scheduling:, wrapper:) recorder.turns.first.merge('answers' => answers(responses)) end + # A wrapper of the application's own that sets request state itself, + # composed with the Rails wrapper from the outside or from the inside + # (docs/rails.md, "Writing your own wrapper"). The caller sets nothing. + def run_composing(side) + rails = ClaudeAgentSDK::Railtie.callback_wrapper + state = ->(invocation) { with_request_state(**ALICE) { invocation.call } } + wrapper = if side == 'outside' + ->(invocation) { state.call(-> { rails.call(invocation) }) } + else + ->(invocation) { rails.call(-> { state.call(invocation) }) } + end + recorder = Recorder.new + in_executor do + run_agent('query', options_for(recorder, scheduling: :thread, wrapper: wrapper), recorder, {}) + end + recorder.turns.first + end + # One Client connected by a first request, with a wrapper built there, # and used again by a second request with different state. def run_reusing_a_wrapper @@ -263,6 +281,9 @@ def answers(responses) %w[query client].product(schedulings, %w[railtie recipe]).each do |api, scheduling, wrapper| observed["#{api}/#{scheduling}/#{wrapper}"] = requests.run(api: api, scheduling: scheduling, wrapper: wrapper) end +%w[outside inside].each do |side| + observed["query/thread/state set #{side} the Rails wrapper"] = requests.run_composing(side) +end observed['reused wrapper'] = requests.run_reusing_a_wrapper observed['two tagged loggers'] = requests.run_with_two_tagged_loggers(scheduling: schedulings.first) From 29d1e5ea51d5f65ddb7c669ce2764a4457a3b3ac Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:21:59 +0800 Subject: [PATCH 168/194] Document that callbacks run outside the caller's transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/rails.md said ActiveRecord calls in callbacks "just work" and never mentioned transactions, locks or pool size. A callback runs on another thread (another fiber under :inline), so it uses a connection of its own and is outside whatever transaction the caller has open: - it does not see the caller's uncommitted rows; - what it writes commits independently and survives the caller's rollback; - if it needs a lock the caller's transaction holds, it waits for the caller while the caller waits for it (record.with_lock { run the agent; record.update! } is exactly that) — "database is locked" on SQLite, an open-ended row-lock wait on a server database. New section "Transactions and the connection pool": finish the transaction before calling the SDK and pass ids in explicitly. Pool sizing is stated as the caller's connection plus the peak number of callback invocations using the database at the same time, not one extra connection per session: the SDK answers control requests concurrently, so one session can have several callbacks in the database at once (two hooks in flight plus a caller in a transaction held three connections when measured). With a pool of one the first query in a callback raises ConnectionTimeoutError. Under :inline with fiber isolation only a Client's message block and observers share the caller's connection. Documentation only. Measured against ActiveRecord 8.1 on SQLite; the Rails test bundles carry no ActiveRecord, so no spec accompanies it. Exploration finding: H-P7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/rails.md b/docs/rails.md index 64d167cc..c5bbaa39 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -61,7 +61,7 @@ ClaudeAgentSDK.query(prompt: '...') do |message| end ``` -That thread is a new one, not the thread that called the SDK. The connection pool does not mind; everything Rails keeps for the current request or job does — `Current` attributes, `Time.zone`, log tags, the database role and shard are all back at their defaults inside a callback. Read [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks) before a callback touches anything scoped to the request. +That thread is a new one, not the thread that called the SDK. The connection pool does not mind; everything Rails keeps for the current request or job does — `Current` attributes, `Time.zone`, log tags, the database role and shard are all back at their defaults inside a callback, and the callback is outside the caller's transaction. Read [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks) and [Transactions and the connection pool](#transactions-and-the-connection-pool) before a callback touches anything scoped to the request. The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there. @@ -248,6 +248,43 @@ and re-enter them innermost, in place of `with_log_tags(tags) { invocation.call What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag, error context and `I18n.locale` rows of the table, and runs the `AgentContext` block exactly as printed above through all five kinds of callback. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. +## Transactions and the connection pool + +For the same reason — another thread — a callback is outside the caller's database transaction, on a connection of its own. Wrapping an SDK call in `transaction` or `with_lock` does not do what it looks like: + +```ruby +ticket.with_lock do # the job's connection holds the row lock + ClaudeAgentSDK.query(prompt: prompt) do |message| + next unless message.is_a?(ClaudeAgentSDK::ResultMessage) + + ticket.update!(summary: message.result) # another connection: waits for that lock, + end # while the job waits for this block +end +``` + +- A callback does not see rows the caller has not committed. +- What a callback writes is committed on its own connection. Rolling the caller's transaction back does not undo it. +- A callback that needs a lock the caller's transaction holds waits for the caller, which is waiting for the callback. SQLite gives up after its busy timeout (`database is locked`); a server database keeps the callback waiting for as long as its lock timeout allows, which for PostgreSQL is forever by default. No database can detect the cycle, because half of it is in your process. + +So finish the transaction **before** you call the SDK, and hand the callbacks what they need explicitly — ids rather than records in an unsaved state: + +```ruby +ticket.update!(state: 'summarizing') # committed before the agent starts +ticket_id = ticket.id + +ClaudeAgentSDK.query(prompt: prompt) do |message| + next unless message.is_a?(ClaudeAgentSDK::ResultMessage) + + Ticket.find(ticket_id).update!(summary: message.result, state: 'summarized') # commits here, independently +end +``` + +**Pool size.** A callback that uses the database needs a connection while the caller may still be holding one: inside a transaction, under an explicit lease, and for the whole request or job on Rails 7.1, where a connection stays with its thread until the request ends. And one session can have several callbacks in the database at the same moment — the SDK answers the CLI's hook, permission and tool requests concurrently, so two parallel tool calls mean two hooks running at once. Size the pool for the caller's connection **plus the peak number of callback invocations that use the database at the same time**, summed over the sessions a process runs concurrently — not for one extra connection per session. With a pool of one and the caller inside a transaction, the first query in a callback raises `ActiveRecord::ConnectionTimeoutError`. + +Under `callback_scheduling: :inline` with fiber isolation all of this applies to every callback that runs on a fiber other than the caller's: hooks, permission callbacks, tool handlers, and everything under `ClaudeAgentSDK.query`. Only a `Client`'s message block and observers share the caller's connection and transaction there. + +(Measured with ActiveRecord 8.1 on SQLite; the PostgreSQL and MySQL lock waits follow from how those databases wait for row locks and were not measured.) + ## Fiber workers (solid_queue) and `callback_scheduling: :inline` [solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end: From 86058cb1bae76cbbdd5f2ec85736f70242ed5323 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:25:31 +0800 Subject: [PATCH 169/194] Turn off CLI auto-memory in the Rails guide and examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI keeps an auto-memory per project directory (git root, else the cwd), not per session or user. Every SDK session reads its index — it is injected under the same header as CLAUDE.md instructions — and a session on the claude_code preset also writes it when asked to remember something, without any permission check. setting_sources: [] does not turn this off. The Rails guide and both Rails examples showed exactly that setup: the preset, one working directory, one job class or executor for every chat. Run as written, one user's "remember ..." is stored in the app's single memory directory and loaded into every other user's session. - docs/rails.md: the ActionCable chat job and the session-resumption options set env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }; a new "Per-user isolation" subsection says why and links to the general section (configuration.md#session-isolation). - examples/rails_actioncable_example.rb and examples/rails_background_job_example.rb set it in their options. - The generated initializer offers it as a commented default. Defaults merge per key, so it survives a per-call env. The value is '1' everywhere: the CLI reads '0' / 'false' as "force on", over autoMemoryEnabled: false. No option is added for this. spec/rails/session_isolation_spec.rb checks the options the examples actually build, the guide's two code blocks, the template line and what uncommenting it does, and that no file gives the variable another value. Exploration finding: O2-P1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 23 +++- examples/rails_actioncable_example.rb | 7 ++ examples/rails_background_job_example.rb | 7 ++ .../install/templates/claude_agent_sdk.rb.tt | 6 + spec/rails/session_isolation_spec.rb | 111 ++++++++++++++++++ 5 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 spec/rails/session_isolation_spec.rb diff --git a/docs/rails.md b/docs/rails.md index c5bbaa39..6327d424 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -16,7 +16,7 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the bin/rails generate claude_agent_sdk:install ``` - This writes `config/initializers/claude_agent_sdk.rb` — a `ClaudeAgentSDK.configure` block with commented defaults (model, permission mode, CLI path, OpenTelemetry) and the Rails callback wrapper [described below](#rails-executor-around-callbacks-callback_wrapper) switched on — and adds `/vendor/claude/` to `.gitignore`. + This writes `config/initializers/claude_agent_sdk.rb` — a `ClaudeAgentSDK.configure` block with commented defaults (model, permission mode, CLI path, [per-user isolation](#per-user-isolation), OpenTelemetry) and the Rails callback wrapper [described below](#rails-executor-around-callbacks-callback_wrapper) switched on — and adds `/vendor/claude/` to `.gitignore`. 3. Vendor the Claude Code CLI: @@ -348,7 +348,8 @@ class ChatAgentJob < ApplicationJob def perform(chat_id, message_content) options = ClaudeAgentSDK::ClaudeAgentOptions.new( system_prompt: { type: 'preset', preset: 'claude_code' }, - permission_mode: 'bypassPermissions' + permission_mode: 'bypassPermissions', + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # one job class serves every chat: see "Per-user isolation" ) ClaudeAgentSDK::Client.open(options: options) do |client| @@ -373,6 +374,18 @@ end `Client.open` connects, yields the client, and always disconnects — also when the block raises, so the job's error handling sees the original exception. It runs inside an existing reactor or starts its own, so a job needs no `Async { }.wait` wrapper. Its return value is the block's; to leave the block early use `next`, not `break` (outside an `Async` block, `break` raises `LocalJumpError`, though the session is still torn down). `break` inside `receive_response` itself is fine. +### Per-user isolation + +One job class serves every chat here, from one working directory — and the CLI keeps an auto-memory per *project directory*, not per session or per user: + +- Every SDK session reads that project's memory index. The CLI injects it next to the `CLAUDE.md` instructions, under the same "these instructions override default behavior" header. +- A session on the `claude_code` preset also **writes** it when a user asks it to remember something, and that write passes no permission check: no `permission_mode`, `can_use_tool` callback or hook is consulted. +- `setting_sources: []` isolates settings files. It does not turn this off. + +So in a multi-user app one user's "remember that…" becomes part of every other user's context. `env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }` turns auto-memory off for the session; the examples on this page set it, and the generated initializer carries the line, commented out, to make it a process-wide default. The value has to be `'1'`: the CLI reads `'0'` or `'false'` as "force auto-memory on", which overrides even `autoMemoryEnabled: false` in settings. To see what a session loaded, call `client.context_usage[:memoryFiles]` (an entry with `type: "AutoMem"` is the memory index); it costs no model call. + +What else isolates sessions, and what only appears to, is covered in [Session isolation](configuration.md#session-isolation). + ## Session Resumption Persist Claude sessions for multi-turn conversations: @@ -395,7 +408,11 @@ class ChatSession < ApplicationRecord private def build_options - opts = { permission_mode: 'bypassPermissions', setting_sources: [] } + opts = { + permission_mode: 'bypassPermissions', + setting_sources: [], # isolates settings files, not the CLI's auto-memory: + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # this does (see "Per-user isolation") + } opts[:resume] = claude_session_id if claude_session_id.present? ClaudeAgentSDK::ClaudeAgentOptions.new(**opts) end diff --git a/examples/rails_actioncable_example.rb b/examples/rails_actioncable_example.rb index 5d9a4b24..addb6809 100644 --- a/examples/rails_actioncable_example.rb +++ b/examples/rails_actioncable_example.rb @@ -94,6 +94,13 @@ def execute(prompt, session_id: nil, resume_session_id: nil) }, permission_mode: 'bypassPermissions', setting_sources: [], + # One executor serves every chat from one working directory, and the + # CLI's auto-memory is per project directory: every session reads it, + # and a claude_code-preset session writes it without a permission check + # (setting_sources: [] does not turn it off). Without this line, what + # one user asks the agent to remember reaches every other user's + # session. Use exactly '1' — the CLI reads '0' / 'false' as "force on". + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, resume: resume_session_id # Resume existing session if provided ) diff --git a/examples/rails_background_job_example.rb b/examples/rails_background_job_example.rb index 6a4532cd..b190c046 100644 --- a/examples/rails_background_job_example.rb +++ b/examples/rails_background_job_example.rb @@ -124,6 +124,13 @@ def build_options(session) system_prompt: build_system_prompt, permission_mode: 'bypassPermissions', setting_sources: [], + # Every job runs from the same cwd, and the CLI's auto-memory is per + # project directory: every session reads it, and a claude_code-preset + # session writes it without a permission check (setting_sources: [] + # does not turn it off). Without this line, what one user asks the + # agent to remember reaches every other user's session. Use exactly + # '1' — the CLI reads '0' / 'false' as "force on". + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, model: 'claude-sonnet-5', # Sonnet for faster responses in jobs max_turns: 50, # Limit turns for safety cwd: Dir.pwd diff --git a/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt b/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt index 2a2b67fd..31952cbd 100644 --- a/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +++ b/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt @@ -22,6 +22,12 @@ ClaudeAgentSDK.configure do |config| # whatever the process cwd. To run a binary from somewhere else instead: # cli_path: '/usr/local/bin/claude', + # Multi-user apps: turn off the CLI's auto-memory — kept per project + # directory, read by every session and written by claude_code-preset + # sessions, so one user's "remember ..." reaches all others (the value + # must stay '1'; '0' forces it on). See docs/rails.md ("Per-user isolation"). + # env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, + # OpenTelemetry tracing (Langfuse, Honeycomb, ...). A factory lambda gives # every query/session its own observer — safe under Puma and job threads. # observers: [-> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }], diff --git a/spec/rails/session_isolation_spec.rb b/spec/rails/session_isolation_spec.rb new file mode 100644 index 00000000..984e085e --- /dev/null +++ b/spec/rails/session_isolation_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require 'tmpdir' +require 'generators/claude_agent_sdk/install/install_generator' + +# The Rails guide, its two examples and the generated initializer describe +# one process that serves every user from one working directory. The CLI's +# auto-memory is kept per project directory, every session reads it, and a +# session on the claude_code preset writes it without a permission check +# (setting_sources: [] does not turn it off) — so one user's "remember ..." +# would reach every other user's session. Those option sets therefore carry +# the switch that turns auto-memory off, and its value matters: the CLI reads +# '0' / 'false' as "force on", over autoMemoryEnabled: false. +RSpec.describe 'Auto-memory isolation in the Rails guide, examples and initializer' do + root = File.expand_path('../..', __dir__) + variable = 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' + switch = "env: { '#{variable}' => '1' }" + guide = 'docs/rails.md' + actioncable_example = 'examples/rails_actioncable_example.rb' + background_job_example = 'examples/rails_background_job_example.rb' + template = 'lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt' + + # An example script's classes, loaded under a module of their own so they + # stay out of this process's top level. The demo at the bottom of each + # script only runs when the script is the program. + define_method(:load_example) do |path| + Module.new.tap { |namespace| load(File.join(root, path), namespace) } + end + + [guide, actioncable_example, background_job_example, template].each do |path| + it "#{path} never gives the variable a value other than '1'" do + values = File.read(File.join(root, path)).scan(/#{variable}['"]?\s*(?:=>|=|:)\s*(['"]?\w+['"]?)/).flatten + + expect(values).not_to be_empty + expect(values).to all(eq("'1'")) + end + end + + describe guide do + let(:ruby_blocks) { File.read(File.join(root, guide)).scan(/^```ruby\n(.*?)^```$/m).flatten } + + it 'turns auto-memory off in the ActionCable chat job' do + chat_job = ruby_blocks.find { |block| block.include?('class ChatAgentJob') } + + expect(chat_job).to include("preset: 'claude_code'").and include(switch) + end + + it 'turns auto-memory off in the session-resumption options' do + build_options = ruby_blocks.find { |block| block.include?('def build_options') } + + expect(build_options).to include(switch) + end + end + + describe actioncable_example do + it 'opens every chat session with auto-memory off' do + options = nil + allow(ClaudeAgentSDK::Client).to receive(:open) { |**kwargs| options = kwargs.fetch(:options) } + + load_example(actioncable_example)::ChatExecutor.new(chat_id: 'chat_1', message_id: 'msg_1').execute('hi') + + expect(options.system_prompt).to include(preset: 'claude_code') + expect(options.env).to include(variable => '1') + end + end + + describe background_job_example do + it 'builds every job session with auto-memory off' do + example = load_example(background_job_example) + + options = example::ChatAgentJob.new.send(:build_options, example::ChatSession.new(id: 'session_1')) + + expect(options.system_prompt).to include(preset: 'claude_code') + expect(options.env).to include(variable => '1') + end + end + + describe 'the generated initializer' do + let(:destination) { Dir.mktmpdir('claude_agent_sdk_generator') } + let(:initializer) { File.join(destination, 'config/initializers/claude_agent_sdk.rb') } + + before do + generate = -> { ClaudeAgentSDK::Generators::InstallGenerator.start([], destination_root: destination) } + expect(&generate).to output.to_stdout + end + + after do + FileUtils.rm_rf(destination) + ClaudeAgentSDK.reset_configuration + end + + it 'offers the switch as a commented default' do + expect(File.read(initializer)).to include(" # #{switch},\n") + + load initializer + + expect(ClaudeAgentSDK::ClaudeAgentOptions.new.env).not_to include(variable) + end + + it 'turns auto-memory off for every session once the line is uncommented, next to a per-call env' do + File.write(initializer, File.read(initializer).sub("# #{switch}", switch)) + load initializer + + expect(ClaudeAgentSDK::ClaudeAgentOptions.new.env).to eq(variable => '1') + expect(ClaudeAgentSDK::ClaudeAgentOptions.new(env: { 'ANTHROPIC_API_KEY' => 'key' }).env).to eq( + variable => '1', 'ANTHROPIC_API_KEY' => 'key' + ) + end + end +end From c885b0a128cefa32e4d6a912fcb5bb056db6864d Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:26:07 +0800 Subject: [PATCH 170/194] Note that an agent run holds the reload lock in development MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide explains the development deadlock Railtie.callback_wrapper avoids, but not the wait that remains: a request or in-process job that runs an agent stays inside the executor, holding its share of the reload interlock, until the run returns. Once a file changes — an agent editing the app does that — the next request asks to reload, the reloader waits for that share, and every other request waits behind it. Rails treats any long request this way; it is not an SDK defect, but agent runs last minutes and edit files, so users meet it on day one. One paragraph next to the deadlock explanation: run agent jobs in a separate process in development, point a code-editing agent at another checkout, and use ActionDispatch::DebugLocks (/rails/locks) to see who waits for whom. Documentation only. Exploration finding: H-P9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rails.md b/docs/rails.md index 6327d424..eab5858c 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -85,6 +85,8 @@ It runs each callback inside the Rails executor — except where that would dead Everywhere else — production, with no reloading — the callback runs inside the executor: its run hooks before the callback and its complete hooks after it, also when the callback raises. The configuration is read per call, so one initializer is correct in every environment. +**Development: an agent run still holds the reload lock.** The helper removes the deadlock, not the wait. A request or in-process job that runs an agent stays inside the executor, with its share of the interlock, until the run returns. Once a file changes — an agent editing your app does that — the next request asks to reload, the reloader waits for that share, and every other request waits behind the reloader. Rails treats any long request this way; it is not specific to the SDK. In development, run agent jobs in a separate process (`bin/jobs`, Sidekiq) rather than in a controller action or the in-process `:async` adapter, and point an agent that edits code at a different checkout than the one the server runs from. To see who is waiting for whom, add `config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks` and open `/rails/locks`. + **Errors and `Rails.error`.** The wrapper reports nothing to `Rails.error` itself, which is the one difference from `executor.wrap` (that reports whatever passes through it as an unhandled error). What your error tracker sees is therefore decided by where an exception ends up: - An exception that escapes a callback — one raised in a message block, say — comes out of `query` / `receive_response` on the thread that called the SDK. The request middleware or ActiveJob reports it there, once, with the controller or job context that thread has. From f04786082061f416505900a3eb78578215a91195 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:35:22 +0800 Subject: [PATCH 171/194] Name forwarded blocks in the Rails specs; correct a version note Two follow-ups to the previous commits on this branch: - spec/rails/support/booted_app_child.rb forwarded an anonymous block parameter (&) from inside a block in two methods. Ruby 3.3.0 rejects that as a syntax error (fixed in 3.3.1), and the spec support files should load on every Ruby the gem supports, so the blocks are named. - The comment on Railtie.run_in_executor (and the commit that added it) said executor.wrap rescues every Exception "from Rails 7.2 on". The change is in Rails 7.2.3, 8.0.2 and 8.1; 7.1, 7.2.0-7.2.2 and 8.0.0-8.0.1 rescue StandardError only. The comments now say so. What the wrapper does is unaffected: it reports nothing on any version. No behavior change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- lib/claude_agent_sdk/railtie.rb | 13 +++++++------ .../callback_wrapper_error_reporting_spec.rb | 17 +++++++++-------- spec/rails/support/booted_app_child.rb | 10 +++++----- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/lib/claude_agent_sdk/railtie.rb b/lib/claude_agent_sdk/railtie.rb index f37e4f84..4fdd1212 100644 --- a/lib/claude_agent_sdk/railtie.rb +++ b/lib/claude_agent_sdk/railtie.rb @@ -89,12 +89,13 @@ def self.callback_wrapper end # `executor.wrap { invocation.call }` minus its error report. `wrap` - # rescues what passes through it (every Exception from Rails 7.2 on) and - # reports it to `Rails.error` as unhandled, from a callback thread that - # has none of the caller's context — and Rails then skips the same - # exception as already reported when it reaches the request or job. It - # reported the SDK's own cancellations and the callback failures the SDK - # answers or swallows, too. `run!` / `complete!` run the same hooks. + # rescues what passes through it (every Exception on Rails 8.1, 8.0.2+ + # and 7.2.3+; StandardError before) and reports it to `Rails.error` as + # unhandled, from a callback thread that has none of the caller's + # context — and Rails then skips the same exception as already reported + # when it reaches the request or job. It reported the SDK's own + # cancellations and the callback failures the SDK answers or swallows, + # too. `run!` / `complete!` run the same hooks. def self.run_in_executor(executor, invocation) execution = executor.run! begin diff --git a/spec/rails/callback_wrapper_error_reporting_spec.rb b/spec/rails/callback_wrapper_error_reporting_spec.rb index b3b1c3f0..6514bbc6 100644 --- a/spec/rails/callback_wrapper_error_reporting_spec.rb +++ b/spec/rails/callback_wrapper_error_reporting_spec.rb @@ -7,13 +7,14 @@ # Errors subscribe to — receives when SDK callbacks go through the wrapper's # executor branch (production: no reloading, concurrency allowed). # -# `executor.wrap` reports every exception passing through it as an unhandled -# error. Around an SDK callback that is wrong twice: the SDK handles most -# callback failures itself (a hook or tool error becomes an error response, -# an observer error is swallowed, a cancellation is control flow), and the one -# that does escape was reported from a thread with none of the caller's -# context, after which Rails skips it at the request / job layer as already -# reported. So the wrapper enters the executor without reporting. +# `executor.wrap` reports what passes through it as an unhandled error (a +# StandardError on Rails 7.1, any Exception on current releases). Around an +# SDK callback that is wrong twice: the SDK handles most callback failures +# itself (a hook or tool error becomes an error response, an observer error +# is swallowed, a cancellation is control flow), and the one that does escape +# was reported from a thread with none of the caller's context, after which +# Rails skips it at the request / job layer as already reported. So the +# wrapper enters the executor without reporting. RSpec.describe 'ClaudeAgentSDK::Railtie.callback_wrapper and Rails.error' do let(:app) { Rails.application } let(:wrapper) { ClaudeAgentSDK::Railtie.callback_wrapper } @@ -69,7 +70,7 @@ def call_on_thread(invocation) # The caller: a job (a request likewise) runs inside the executor, sets its # context, and reports what escapes it under its own source. - def as_a_job(&) + def as_a_job executor.wrap(source: 'application.active_job') do Rails.error.set_context(job: 'ChatAgentJob') yield diff --git a/spec/rails/support/booted_app_child.rb b/spec/rails/support/booted_app_child.rb index 4be47b2b..ca8fc734 100644 --- a/spec/rails/support/booted_app_child.rb +++ b/spec/rails/support/booted_app_child.rb @@ -193,10 +193,10 @@ def run_with_two_tagged_loggers(scheduling:) private - def in_executor(&) - return Rails.application.executor.wrap(&) unless @reactor + def in_executor(&block) + return Rails.application.executor.wrap(&block) unless @reactor - Async { Rails.application.executor.wrap(&) }.wait + Async { Rails.application.executor.wrap(&block) }.wait end def in_this_executor(**state, &block) @@ -205,11 +205,11 @@ def in_this_executor(**state, &block) # What a controller's callbacks (or a job) set before calling the SDK. # The executor resets Current and the error context when the request ends. - def with_request_state(user:, zone:, request_id:, locale:, &) + def with_request_state(user:, zone:, request_id:, locale:, &block) Current.user = user Rails.error.set_context(request_id: request_id) Rails.logger.push_tags(request_id) # as Rails::Rack::Logger tags a request - I18n.with_locale(locale) { Time.use_zone(zone, &) } + I18n.with_locale(locale) { Time.use_zone(zone, &block) } ensure Rails.logger.pop_tags end From 5d39fbfeb47dca1b01938536248235197bbfb703 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 01:47:00 +0800 Subject: [PATCH 172/194] Restore I18n.locale in the recipe; correct the i18n table row The table added two commits ago said a callback keeps the caller's I18n.locale because "i18n 1.14 and later keep it in fiber storage". That is only true from i18n 1.15.0 (released 2026-06-17). Up to 1.14.7 the config is a fiber-local, and in 1.14.8 a thread variable, so on those versions a callback thread starts from the default locale like everything else in the table. Measured in a booted Rails 8.1 app with i18n 1.14.7: every callback off the caller's fiber saw :en while the caller was on :de, under :thread and :inline scheduling alike. - The row now states both cases. - The recipe captures I18n.locale and restores it with I18n.with_locale, so it carries the locale on any i18n version instead of relying on inheritance (harmless on 1.15+). - The spec no longer asserts the inherited locale unconditionally: that example skips on i18n < 1.15. The recipe examples expect the caller's locale in every callback on every version, and the reused-wrapper example now also shows the first caller's locale reaching a later request, which is what pins the restore under i18n 1.15+. Documentation and spec only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 20 +++++++++++-------- spec/rails/request_state_spec.rb | 27 ++++++++++++++++++-------- spec/rails/support/booted_app_child.rb | 2 +- 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index eab5858c..fc16c37c 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -128,7 +128,7 @@ A callback is written inside your controller action or job, a few lines below it | `connected_to(shard: :tenant_b)` | `:default` | | `connected_to(prevent_writes: true)` | writes allowed | | the error context: `Rails.error.set_context`, the controller or job Rails records | empty | -| `I18n.locale = :de` | `:de` — i18n 1.14 and later keep it in fiber storage, which new threads and fibers inherit | +| `I18n.locale = :de` | `:de` with i18n 1.15 or later, which keeps the locale in fiber storage that new threads and fibers inherit; the default locale with older i18n | | the OpenTelemetry context | kept — the SDK carries it across | This is the same in a message block, an observer, a hook, `can_use_tool` and an SDK MCP tool handler; with and without `Railtie.callback_wrapper`; in development and in production. It is the same under `callback_scheduling: :inline` with fiber isolation, with one exception: there a `Client`'s message block and observers run on the fiber that called the SDK, and see its state. (`ClaudeAgentSDK.query` runs every callback on another fiber, and hooks, `can_use_tool` and tool handlers always do.) @@ -168,14 +168,17 @@ module AgentContext def self.capture attributes = Current.attributes.dup context = ActiveSupport::ExecutionContext.to_h + locale = I18n.locale zone = Time.zone tags = log_tags lambda do |invocation| Current.set(attributes) do ActiveSupport::ExecutionContext.set(**context) do - Time.use_zone(zone) do - with_log_tags(tags) { invocation.call } + I18n.with_locale(locale) do + Time.use_zone(zone) do + with_log_tags(tags) { invocation.call } + end end end end @@ -215,7 +218,7 @@ class SummarizeTicketJob < ApplicationJob ) ClaudeAgentSDK.query(prompt: "Summarize:\n\n#{ticket.body}", options: options) do |message| - # Current.account, Time.zone, the job's log tags and error context are set here + # Current.account, the locale, Time.zone, the job's log tags and error context are set here ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage) end end @@ -229,6 +232,7 @@ What the recipe depends on: - **No restore on the fiber that captured the state.** Under `:inline` scheduling a `Client`'s message block and observers run on the caller itself. The state is already there, and restoring it again would add the log tags a second time. - **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every logger and run nothing. The two `respond_to?` checks keep the recipe working with a logger that has no tags. - **`ActiveSupport::ExecutionContext` is restored explicitly.** It is where `Rails.error.set_context` and Rails' own controller and job entries live; `Current.set` does not bring it back. Rails has no public reader for it, so recheck this line when you upgrade Rails. +- **The locale is restored rather than assumed.** Only i18n 1.15 and later hand the caller's locale down to new threads and fibers; with an older i18n a callback starts from the default locale, like the rest of the table. - **Values travel, containers do not.** The recipe rebuilds the caller's state from values. The objects themselves — `Current.user`, say — are shared with the caller, so treat them as read-only in callbacks. Do not go further and copy thread-local variables wholesale, hand the caller's ActiveRecord connection to a callback, or pass `ActiveRecord::Base.connected_to_stack` across: those are mutable and belong to one thread. An application with replicas or shards also carries the role, the shard and `prevent_writes`. Capture them in `capture`: @@ -241,14 +245,14 @@ An application with replicas or shards also carries the role, the shard and `pre and re-enter them innermost, in place of `with_log_tags(tags) { invocation.call }`: ```ruby - with_log_tags(tags) do - ApplicationRecord.connected_to(role: role, shard: shard, prevent_writes: prevent_writes) { invocation.call } - end + with_log_tags(tags) do + ApplicationRecord.connected_to(role: role, shard: shard, prevent_writes: prevent_writes) { invocation.call } + end ``` `connected_to` on `ApplicationRecord` switches the models that inherit from it; an application with several connection classes captures and re-enters each of them. -What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag, error context and `I18n.locale` rows of the table, and runs the `AgentContext` block exactly as printed above through all five kinds of callback. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. +What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag and error context rows of the table, the `I18n.locale` row for the i18n the bundle resolves (1.15 or later), and runs the `AgentContext` block exactly as printed above through all five kinds of callback. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. ## Transactions and the connection pool diff --git a/spec/rails/request_state_spec.rb b/spec/rails/request_state_spec.rb index 8509355a..46351749 100644 --- a/spec/rails/request_state_spec.rb +++ b/spec/rails/request_state_spec.rb @@ -22,7 +22,8 @@ RSpec.describe 'Request state inside SDK callbacks' do callbacks = %w[message_block observer hook can_use_tool tool] caller_state = { 'user' => 'alice', 'time_zone' => 'Tokyo', 'log_tags' => ['req-123'], - 'error_context' => { 'request_id' => 'req-123' } } + 'error_context' => { 'request_id' => 'req-123' }, 'locale' => 'de' } + # The locale is left out: what a callback gets by itself depends on i18n. rails_defaults = { 'user' => nil, 'time_zone' => 'UTC', 'log_tags' => [], 'error_context' => {} } # What the SDK answered the CLI with: the hook's output, the permission @@ -51,29 +52,39 @@ def booted_app(reloading:, isolation:) # that called the SDK, and only under :inline scheduling. let(:on_caller_fiber) { scheduling == :inline && api == 'client' ? %w[message_block observer] : [] } - def seen(state, kind) - [state.merge('locale' => 'de', 'on_caller_fiber' => on_caller_fiber.include?(kind))] + # The callback ran once, saw this state, on the caller's fiber or not. + def once_with(state, kind) + contain_exactly(include(state.merge('on_caller_fiber' => on_caller_fiber.include?(kind)))) end it 'boots the application it describes' do expect(observed).to include('reloading' => reloading, 'isolation' => isolation.to_s, 'logger' => 'ActiveSupport::BroadcastLogger') - expect(with_railtie_wrapper.fetch('caller')).to eq( - [caller_state.merge('locale' => 'de', 'on_caller_fiber' => true)] + expect(with_railtie_wrapper.fetch('caller')).to contain_exactly( + include(caller_state.merge('on_caller_fiber' => true)) ) end it 'shows Rails defaults to every callback off the caller fiber, with Railtie.callback_wrapper alone' do callbacks.each do |kind| state = on_caller_fiber.include?(kind) ? caller_state : rails_defaults - expect(with_railtie_wrapper.fetch(kind)).to eq(seen(state, kind)), "in #{kind}" + expect(with_railtie_wrapper.fetch(kind)).to once_with(state, kind), "in #{kind}" + end + end + + it 'hands I18n.locale down to every callback by itself when i18n keeps it in fiber storage (1.15+)' do + i18n = observed.fetch('i18n') + skip "i18n #{i18n} keeps the locale per thread or fiber" if Gem::Version.new(i18n) < Gem::Version.new('1.15') + + callbacks.each do |kind| + expect(with_railtie_wrapper.fetch(kind)).to contain_exactly(include('locale' => 'de')), "in #{kind}" end end it 'shows the caller state to every callback, once, with the documented recipe' do expect(with_recipe).not_to include('error') callbacks.each do |kind| - expect(with_recipe.fetch(kind)).to eq(seen(caller_state, kind)), "in #{kind}" + expect(with_recipe.fetch(kind)).to once_with(caller_state, kind), "in #{kind}" end end @@ -95,7 +106,7 @@ def seen(state, kind) it 'shows the callbacks of the later request the state of the first one' do expect(later_request.fetch('caller')).to contain_exactly( include('user' => 'bob', 'time_zone' => 'Berlin', 'log_tags' => ['req-456'], - 'error_context' => { 'request_id' => 'req-456' }) + 'error_context' => { 'request_id' => 'req-456' }, 'locale' => 'fr') ) callbacks.each do |kind| expect(later_request.fetch(kind)).to contain_exactly(include(caller_state)), "in #{kind}" diff --git a/spec/rails/support/booted_app_child.rb b/spec/rails/support/booted_app_child.rb index ca8fc734..13281242 100644 --- a/spec/rails/support/booted_app_child.rb +++ b/spec/rails/support/booted_app_child.rb @@ -276,7 +276,7 @@ def answers(responses) observed = { 'rails' => Rails.version, 'reloading' => Rails.application.config.reloading_enabled?, - 'isolation' => isolation.to_s, 'logger' => Rails.logger.class.name + 'isolation' => isolation.to_s, 'logger' => Rails.logger.class.name, 'i18n' => I18n::VERSION } %w[query client].product(schedulings, %w[railtie recipe]).each do |api, scheduling, wrapper| observed["#{api}/#{scheduling}/#{wrapper}"] = requests.run(api: api, scheduling: scheduling, wrapper: wrapper) From 6da4d7be056c5ec851f06a7e21ecc99f7d74e97a Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:38:49 +0800 Subject: [PATCH 173/194] Turn off auto-memory in every options set of the Rails guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Per-user isolation" subsection said "the examples on this page set" the auto-memory switch, but only the ActionCable chat job and the session-resumption model did. Three job recipes — the getting-started SummarizeTicketJob, the SummarizeTicketJob that shows the state recipe, and the error-handling ClaudeAgentJob — and the HTTP MCP example built options without it. Every session reads the project's memory index whatever tools: [] or setting_sources: [] say, so a multi-user job copied from one of them would still read memory other users' sessions wrote. The spec only looked at the two blocks that had the switch. - Every ClaudeAgentOptions the guide builds now carries env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }. - The paragraph says exactly that, and how to cover sessions that pass no options: uncomment the initializer's line (defaults merge per key). - spec/rails/session_isolation_spec.rb finds every ClaudeAgentOptions.new( call in the guide's Ruby blocks, indented ones included, resolves a **opts argument to its hash literal, and checks each for the switch. A commented allow-list for an option set that must not carry it is empty. It also checks that it finds an option set in each of the six sections that build one, so the extraction cannot pass by finding nothing. Before this change four of the six failed (e.g. expected "max_turns: 10" to match the switch). Review round 1, item B1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 20 ++++++++--- spec/rails/session_isolation_spec.rb | 52 +++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index fc16c37c..1c1b57a7 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -33,7 +33,10 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the # app/jobs/summarize_ticket_job.rb class SummarizeTicketJob < ApplicationJob def perform(ticket) - options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: [], max_turns: 1) # text only, no built-in tools + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + tools: [], max_turns: 1, # text only, no built-in tools + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation" + ) prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}" ClaudeAgentSDK.query(prompt: prompt, options: options) do |message| @@ -214,7 +217,8 @@ class SummarizeTicketJob < ApplicationJob Current.account = ticket.account options = ClaudeAgentSDK::ClaudeAgentOptions.new( callback_wrapper: AgentContext.callback_wrapper, # replaces the configured Rails wrapper, and calls it - tools: [], max_turns: 1 + tools: [], max_turns: 1, + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } ) ClaudeAgentSDK.query(prompt: "Summarize:\n\n#{ticket.body}", options: options) do |message| @@ -388,7 +392,7 @@ One job class serves every chat here, from one working directory — and the CLI - A session on the `claude_code` preset also **writes** it when a user asks it to remember something, and that write passes no permission check: no `permission_mode`, `can_use_tool` callback or hook is consulted. - `setting_sources: []` isolates settings files. It does not turn this off. -So in a multi-user app one user's "remember that…" becomes part of every other user's context. `env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }` turns auto-memory off for the session; the examples on this page set it, and the generated initializer carries the line, commented out, to make it a process-wide default. The value has to be `'1'`: the CLI reads `'0'` or `'false'` as "force auto-memory on", which overrides even `autoMemoryEnabled: false` in settings. To see what a session loaded, call `client.context_usage[:memoryFiles]` (an entry with `type: "AutoMem"` is the memory index); it costs no model call. +So in a multi-user app one user's "remember that…" becomes part of every other user's context. `env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }` turns auto-memory off for the session. Every set of options built on this page carries it (`spec/rails/session_isolation_spec.rb` checks each one). Set it for every session a multi-user app runs — per call, as here, or once for the whole process by uncommenting the line the generated initializer carries; defaults merge per key, so a per-call `env:` keeps it, and the short snippets on this page that pass no options get it too. The value has to be `'1'`: the CLI reads `'0'` or `'false'` as "force auto-memory on", which overrides even `autoMemoryEnabled: false` in settings. To see what a session loaded, call `client.context_usage[:memoryFiles]` (an entry with `type: "AutoMem"` is the memory index); it costs no model call. What else isolates sessions, and what only appears to, is covered in [Session isolation](configuration.md#session-isolation). @@ -437,7 +441,12 @@ class ClaudeAgentJob < ApplicationJob def perform(task_id) task = Task.find(task_id) - ClaudeAgentSDK::Client.open(options: ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 10)) do |client| + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + max_turns: 10, + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation" + ) + + ClaudeAgentSDK::Client.open(options: options) do |client| client.query(task.prompt) client.receive_response do |message| task.update!(status: 'done', result: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage) @@ -464,7 +473,8 @@ mcp_servers = { options = ClaudeAgentSDK::ClaudeAgentOptions.new( mcp_servers: mcp_servers, - permission_mode: 'bypassPermissions' + permission_mode: 'bypassPermissions', + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation" ) ``` diff --git a/spec/rails/session_isolation_spec.rb b/spec/rails/session_isolation_spec.rb index 984e085e..1f4b473f 100644 --- a/spec/rails/session_isolation_spec.rb +++ b/spec/rails/session_isolation_spec.rb @@ -37,19 +37,55 @@ end end - describe guide do - let(:ruby_blocks) { File.read(File.join(root, guide)).scan(/^```ruby\n(.*?)^```$/m).flatten } + # The text between `ClaudeAgentOptions.new(` and its closing parenthesis. + arguments_from = lambda do |code, from| + depth = 1 + index = from + until depth.zero? + raise "unbalanced ClaudeAgentOptions.new( in #{guide}" if index >= code.size + + depth += { '(' => 1, ')' => -1 }.fetch(code[index], 0) + index += 1 + end + code[from...(index - 1)] + end + + # Every ClaudeAgentOptions the guide builds, as [the heading it sits under, + # the arguments it is built from]. A `**name` argument is resolved to the + # `name = { ... }` literal in the same block, which is how the + # session-resumption model builds its options. + option_sets = [] + heading = nil + File.read(File.join(root, guide)).scan(/^(\#{2,3} [^\n]+)$|^ *```ruby\n(.*?)^ *```$/m) do |title, code| + next heading = title.sub(/\A#+ /, '') if title + + code.enum_for(:scan, /ClaudeAgentOptions\.new\(/).each do + arguments = arguments_from.call(code, Regexp.last_match.end(0)) + if (name = arguments[/\A\s*\*\*(\w+)\s*\z/, 1]) + arguments = code[/^( *)#{name} = \{\n.*?^\1\}/m] || raise("no `#{name} = { ... }` under #{heading}") + end + option_sets << [heading, arguments] + end + end - it 'turns auto-memory off in the ActionCable chat job' do - chat_job = ruby_blocks.find { |block| block.include?('class ChatAgentJob') } + # An option set in the guide that must not carry the switch goes here, + # keyed by the heading it sits under, with the reason. None does. + allowed_without_switch = {}.freeze - expect(chat_job).to include("preset: 'claude_code'").and include(switch) + describe "#{guide}, every ClaudeAgentOptions it builds" do + it 'finds an option set in every section that builds one' do + expect(option_sets.map(&:first)).to include( + 'Getting started', "Carrying the caller's state into callbacks", 'ActionCable Streaming', + 'Session Resumption', 'Background Jobs with Error Handling', 'HTTP MCP Servers' + ) end - it 'turns auto-memory off in the session-resumption options' do - build_options = ruby_blocks.find { |block| block.include?('def build_options') } + option_sets.each do |section, arguments| + it "turns auto-memory off in the options under \"#{section}\"" do + skip allowed_without_switch.fetch(section) if allowed_without_switch.key?(section) - expect(build_options).to include(switch) + expect(arguments).to match(/env: \{[^}]*'#{variable}' => '1'[^}]*\}/) + end end end From e7f0b94191e61393e860aba3ee8026a89f460d22 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:47:09 +0800 Subject: [PATCH 174/194] Describe i18n 1.14.8's thread-shared locale in the Rails guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locale row said a callback gets the caller's locale with i18n 1.15+ and the default locale "with older i18n". i18n 1.14.8 fits neither: it keeps its config in a thread variable, so a callback on a thread of its own (:thread scheduling) starts from the default locale, but one on another fiber of the caller's thread (:inline) shares the caller's locale — and so does every other fiber on that thread. Under :inline fiber workers that makes the recipe's I18n.with_locale unsafe on 1.14.8: it changes the locale of every job on the worker while the callback runs, and another job's I18n.locale= changes the callback's. Measured with the real i18n 1.14.8 (the released gem, unpacked into a scratch bundle — not installed): :thread callbacks saw :en, :inline callbacks :de; with two jobs interleaved on one reactor thread, the second saw the first's :de and the first then saw the second's :fr. 1.14.7 and 1.15.2 kept the two jobs apart. - docs/rails.md: the row points to a small table with three cases (1.14.7 and earlier: fiber-local; 1.14.8: thread variable; 1.15 and later: fiber storage), and the recipe's locale note says the restore is safe wherever a callback has a thread of its own, is not safe under :inline on 1.14.8, and that keeping fiber-worker jobs' locales apart needs i18n 1.15+ or :thread scheduling. - spec/rails/request_state_spec.rb: the locale example no longer skips below 1.15; it expects the table's value for whichever i18n the bundle resolves. A new example interleaves two jobs on one reactor thread (booted_app_child.rb, queues only, no clock) and expects them kept apart, or sharing the locale on 1.14.8. With the old description as the expectation, a run against real 1.14.8 failed 5 examples; the new spec passes on 1.15.2 (both Rails bundles), 1.14.8 and 1.14.7. Review round 1, item B2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 14 ++++++-- spec/rails/request_state_spec.rb | 44 +++++++++++++++++++++++--- spec/rails/support/booted_app_child.rb | 29 +++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index 1c1b57a7..6f7b2098 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -131,11 +131,19 @@ A callback is written inside your controller action or job, a few lines below it | `connected_to(shard: :tenant_b)` | `:default` | | `connected_to(prevent_writes: true)` | writes allowed | | the error context: `Rails.error.set_context`, the controller or job Rails records | empty | -| `I18n.locale = :de` | `:de` with i18n 1.15 or later, which keeps the locale in fiber storage that new threads and fibers inherit; the default locale with older i18n | +| `I18n.locale = :de` | depends on the i18n version — see below | | the OpenTelemetry context | kept — the SDK carries it across | This is the same in a message block, an observer, a hook, `can_use_tool` and an SDK MCP tool handler; with and without `Railtie.callback_wrapper`; in development and in production. It is the same under `callback_scheduling: :inline` with fiber isolation, with one exception: there a `Client`'s message block and observers run on the fiber that called the SDK, and see its state. (`ClaudeAgentSDK.query` runs every callback on another fiber, and hooks, `can_use_tool` and tool handlers always do.) +What a callback gets of `I18n.locale` depends on where i18n keeps its configuration: + +| i18n | Callback on a thread of its own (`:thread` scheduling) | Callback on another fiber of the caller's thread (`:inline`) | +| --- | --- | --- | +| 1.14.7 and earlier: a fiber-local | the default locale | the default locale | +| 1.14.8: a thread variable | the default locale | `:de` — every fiber of the thread shares one locale | +| 1.15 and later: fiber storage | `:de` — new threads and fibers inherit it | `:de` | + Nothing raises. The consequences are silent: - A job inside `connected_to(shard: :tenant_b)` writes its own records to `tenant_b`; the same `create!` in a tool handler or in the message block writes to the **default** shard. Inside `connected_to(role: :reading)` the caller's own write raises `ActiveRecord::ReadOnlyError`; the same write in a callback succeeds, on the primary. @@ -236,7 +244,7 @@ What the recipe depends on: - **No restore on the fiber that captured the state.** Under `:inline` scheduling a `Client`'s message block and observers run on the caller itself. The state is already there, and restoring it again would add the log tags a second time. - **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every logger and run nothing. The two `respond_to?` checks keep the recipe working with a logger that has no tags. - **`ActiveSupport::ExecutionContext` is restored explicitly.** It is where `Rails.error.set_context` and Rails' own controller and job entries live; `Current.set` does not bring it back. Rails has no public reader for it, so recheck this line when you upgrade Rails. -- **The locale is restored rather than assumed.** Only i18n 1.15 and later hand the caller's locale down to new threads and fibers; with an older i18n a callback starts from the default locale, like the rest of the table. +- **The locale is restored rather than assumed**, because only i18n 1.15 and later hand it to every callback by themselves. The restore is safe wherever a callback has a thread of its own, which is `:thread` scheduling, with any i18n. With i18n 1.14.8 it is only safe there: under `:inline` all fibers of the reactor thread share one locale, so a callback's `I18n.with_locale` changes the locale of every job on that worker while the callback runs, and another job that sets its own locale meanwhile changes the callback's. On a fiber-isolated host with `:inline` scheduling, keeping jobs' locales apart needs i18n 1.15 or later, or `:thread` scheduling. - **Values travel, containers do not.** The recipe rebuilds the caller's state from values. The objects themselves — `Current.user`, say — are shared with the caller, so treat them as read-only in callbacks. Do not go further and copy thread-local variables wholesale, hand the caller's ActiveRecord connection to a callback, or pass `ActiveRecord::Base.connected_to_stack` across: those are mutable and belong to one thread. An application with replicas or shards also carries the role, the shard and `prevent_writes`. Capture them in `capture`: @@ -256,7 +264,7 @@ and re-enter them innermost, in place of `with_log_tags(tags) { invocation.call `connected_to` on `ApplicationRecord` switches the models that inherit from it; an application with several connection classes captures and re-enters each of them. -What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag and error context rows of the table, the `I18n.locale` row for the i18n the bundle resolves (1.15 or later), and runs the `AgentContext` block exactly as printed above through all five kinds of callback. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. +What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag and error context rows of the table, the `I18n.locale` cases for whichever i18n the bundle resolves (CI's resolve 1.15 or later; the 1.14.7 and 1.14.8 rows were run against those releases), and runs the `AgentContext` block exactly as printed above through all five kinds of callback. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. ## Transactions and the connection pool diff --git a/spec/rails/request_state_spec.rb b/spec/rails/request_state_spec.rb index 46351749..2c9ab2fd 100644 --- a/spec/rails/request_state_spec.rb +++ b/spec/rails/request_state_spec.rb @@ -41,6 +41,28 @@ def booted_app(reloading:, isolation:) ClaudeAgentSDKRailsSpec::BootedApp.observe(reloading: reloading, isolation: isolation) end + # The I18n.locale table in docs/rails.md: what a callback off the caller's + # fiber gets by itself — the caller's :de or the default :en — depends on + # where i18n keeps its config: a fiber-local up to 1.14.7, a thread variable + # in 1.14.8 (every fiber of the thread shares it), fiber storage from 1.15 + # (new threads and fibers inherit it). + def locale_by_itself(i18n, own_thread:) + version = Gem::Version.new(i18n) + return 'de' if version >= Gem::Version.new('1.15') + return 'de' if version >= Gem::Version.new('1.14.8') && !own_thread + + 'en' + end + + # i18n 1.14.8 under :inline: the locale is shared by every fiber of the + # reactor thread, so I18n.with_locale in one job (the recipe's restore in a + # callback) leaks into another job, and the other job's I18n.locale= leaks + # back. Every other i18n keeps the two apart. + def thread_shared_locale?(i18n) + version = Gem::Version.new(i18n) + version >= Gem::Version.new('1.14.8') && version < Gem::Version.new('1.15') + end + { 'production' => false, 'development (code reloading)' => true }.each do |environment, reloading| [%i[thread thread], %i[fiber inline], %i[fiber thread]].each do |isolation, scheduling| { 'ClaudeAgentSDK.query' => 'query', 'Client.open' => 'client' }.each do |entry_point, api| @@ -72,12 +94,11 @@ def once_with(state, kind) end end - it 'hands I18n.locale down to every callback by itself when i18n keeps it in fiber storage (1.15+)' do - i18n = observed.fetch('i18n') - skip "i18n #{i18n} keeps the locale per thread or fiber" if Gem::Version.new(i18n) < Gem::Version.new('1.15') - + it 'gives every callback the I18n.locale the guide states for this i18n version' do + by_itself = locale_by_itself(observed.fetch('i18n'), own_thread: scheduling == :thread) callbacks.each do |kind| - expect(with_railtie_wrapper.fetch(kind)).to contain_exactly(include('locale' => 'de')), "in #{kind}" + locale = on_caller_fiber.include?(kind) ? 'de' : by_itself + expect(with_railtie_wrapper.fetch(kind)).to contain_exactly(include('locale' => locale)), "in #{kind}" end end @@ -114,6 +135,19 @@ def once_with(state, kind) end end + describe 'I18n.with_locale in one fiber-worker job while another runs on the same reactor thread' do + let(:observed) { booted_app(reloading: false, isolation: :fiber) } + let(:seen) { observed.fetch('two jobs switching locales') } + + it 'stays in the job that set it, unless i18n shares the locale across the thread (1.14.8)' do + if thread_shared_locale?(observed.fetch('i18n')) + expect(seen).to eq('other_job_sees' => 'de', 'own_job_sees_after' => 'fr') + else + expect(seen).to eq('other_job_sees' => 'en', 'own_job_sees_after' => 'de') + end + end + end + # Rails.logger.tagged { } on a broadcast runs its block once per tagged # logger; the recipe pushes and pops the tags instead. describe 'the documented recipe with two tagged loggers in the Rails.logger broadcast' do diff --git a/spec/rails/support/booted_app_child.rb b/spec/rails/support/booted_app_child.rb index 13281242..6a116228 100644 --- a/spec/rails/support/booted_app_child.rb +++ b/spec/rails/support/booted_app_child.rb @@ -191,6 +191,34 @@ def run_with_two_tagged_loggers(scheduling:) Rails.logger.stop_broadcasting_to(second) end + # Two fiber-worker jobs on one reactor thread: job A sets its locale with + # I18n.with_locale — as the recipe does in an :inline callback — and + # suspends; job B runs meanwhile and sets its own. Buffered queues order + # the steps, so nothing waits on a clock. No SDK involved: this is what + # i18n does with the restore. + def run_two_jobs_switching_locales + a_inside = Thread::Queue.new + b_done = Thread::Queue.new + seen = {} + Sync do |task| + job_a = task.async do + I18n.with_locale(:de) do + a_inside << true + b_done.pop + seen['own_job_sees_after'] = I18n.locale.to_s + end + end + job_b = task.async do + a_inside.pop + seen['other_job_sees'] = I18n.locale.to_s + I18n.locale = :fr + b_done << true + end + [job_a, job_b].each(&:wait) + end + seen + end + private def in_executor(&block) @@ -286,6 +314,7 @@ def answers(responses) end observed['reused wrapper'] = requests.run_reusing_a_wrapper observed['two tagged loggers'] = requests.run_with_two_tagged_loggers(scheduling: schedulings.first) +observed['two jobs switching locales'] = requests.run_two_jobs_switching_locales puts "BOOTED_APP_RESULT #{JSON.generate(observed)}" FileUtils.rm_rf(Rails.root.to_s) From ca925f918d235619e6d96176c3fc995c0c0eebf3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:49:34 +0800 Subject: [PATCH 175/194] Qualify when ActiveJob reports an exception escaping a callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Errors and Rails.error" said ActiveJob reports an exception that escapes a callback "there, once". That holds when the job runs inside the executor, as every queue worker runs it: ActiveJob wraps execution in app.reloader.wrap, which reports what escapes, with the job in the error context. A perform_now called outside any executor — a script, a console — re-raises the exception to its caller and reports nothing. Checked in a Rails 8.1.3 app on this branch's wrapper, with an exception raised in the message block of a query inside a job: perform_now outside an executor → raised, no report; perform_later (inline adapter) and ActiveJob::Base.execute, as a worker calls it → one report with the job in its context; perform_now inside a request's executor → one report from the request. The guide now says so. The error-reporting spec modelled the job layer as executor.wrap(source: 'application.active_job'), a source Rails does not use (ActiveJob reports under the reloader's default source); it now wraps the way ActiveJob does and tells the reports apart by their context. It still fails against the old executor.wrap wrapper (9 examples, 7 failures). Review round 1, non-blocking item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 2 +- spec/rails/callback_wrapper_error_reporting_spec.rb | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index 6f7b2098..9a18d8ce 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -92,7 +92,7 @@ Everywhere else — production, with no reloading — the callback runs inside t **Errors and `Rails.error`.** The wrapper reports nothing to `Rails.error` itself, which is the one difference from `executor.wrap` (that reports whatever passes through it as an unhandled error). What your error tracker sees is therefore decided by where an exception ends up: -- An exception that escapes a callback — one raised in a message block, say — comes out of `query` / `receive_response` on the thread that called the SDK. The request middleware or ActiveJob reports it there, once, with the controller or job context that thread has. +- An exception that escapes a callback — one raised in a message block, say — comes out of `query` / `receive_response` on the thread that called the SDK. It is reported there, once, with that thread's context, by whatever runs the code inside the executor: the request middleware for a controller action, ActiveJob for a job a queue worker runs (`perform_later`). A `perform_now` called outside any executor — from a script or a console — raises it to its caller and reports nothing by itself. - A failure the SDK handles is not reported: an exception in a hook or `can_use_tool` is answered to the CLI as an error response, one in an SDK MCP tool handler becomes an error result the model sees, one in an observer is swallowed, and a timed-out or cancelled callback is cancellation, not an error. To track these, report them where they happen: `rescue` inside the callback, call `Rails.error.report(e, handled: true)`, and re-raise. Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: the executor runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers are safe, including around a `break` from a message block. diff --git a/spec/rails/callback_wrapper_error_reporting_spec.rb b/spec/rails/callback_wrapper_error_reporting_spec.rb index 6514bbc6..d1e82367 100644 --- a/spec/rails/callback_wrapper_error_reporting_spec.rb +++ b/spec/rails/callback_wrapper_error_reporting_spec.rb @@ -68,17 +68,21 @@ def call_on_thread(invocation) end.value end - # The caller: a job (a request likewise) runs inside the executor, sets its - # context, and reports what escapes it under its own source. + # The caller: a job as a queue worker runs it. ActiveJob wraps the + # execution in the executor (app.reloader.wrap, so the default source), + # puts the job into the error context, and the wrap reports what escapes. + # (A request is the same, through ActionDispatch::Executor.) def as_a_job - executor.wrap(source: 'application.active_job') do + executor.wrap do Rails.error.set_context(job: 'ChatAgentJob') yield end end + # Reported once, with the job in its context: by the job's executor, not + # from the callback thread, whose report had an empty context. def caller_report - include(error: error, handled: false, source: 'application.active_job', context: include(job: 'ChatAgentJob')) + include(error: error, handled: false, context: include(job: 'ChatAgentJob')) end describe 'an exception from a wrapped callback' do From c396b8ff0f89e41c0e5ba3985f316099f98bc504 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:51:41 +0800 Subject: [PATCH 176/194] Read log tags from the tagged logger in the documented recipe The recipe read the caller's log tags through Rails.logger.formatter. On a BroadcastLogger that is not always the tagged logger's formatter: Rails 8.1 returns the first logger's, tagged or not, and on Rails 7.1 a broadcast built by hand (BroadcastLogger.new(plain, tagged)) has none. In both cases the tagged logger held the request's tags, the recipe read [], and callbacks logged without them. The recipe now takes the tags from the first logger in the broadcast whose formatter keeps tags (or from Rails.logger itself when it is not a broadcast). Restoring is unchanged: push_tags / pop_tags only reach the tagged loggers. The guide says where the tags are read from and that with no tagged logger the recipe carries none. spec/rails/request_state_spec.rb gains a broadcast with a plain logger ahead of the tagged one, under :thread and :inline: callbacks see the caller's tags, the tagged log is tagged once, the plain log is not. Against the previous recipe both examples failed on Rails 7.1 and 8.1. The recorder in the child process now reads tags from the tagged logger as well, so it works with such a broadcast. Review round 1, non-blocking item (chose reading the tagged logger over documenting an all-tagged prerequisite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 12 ++++++---- spec/rails/request_state_spec.rb | 21 +++++++++++++++++ spec/rails/support/booted_app_child.rb | 31 +++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index 9a18d8ce..c956f17b 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -196,9 +196,13 @@ module AgentContext end end + # The tags of the first tagged logger Rails.logger writes to. Not + # Rails.logger.formatter: in a broadcast that can be a plain logger's. def self.log_tags - formatter = Rails.logger.formatter if Rails.logger.respond_to?(:formatter) - formatter.respond_to?(:current_tags) ? formatter.current_tags.dup : [] + loggers = Rails.logger.respond_to?(:broadcasts) ? Rails.logger.broadcasts : [Rails.logger] + formatter = loggers.map { |logger| logger.formatter if logger.respond_to?(:formatter) } + .find { |candidate| candidate.respond_to?(:current_tags) } + formatter ? formatter.current_tags.dup : [] end # push / pop, not `Rails.logger.tagged(*tags) { ... }`: a BroadcastLogger @@ -242,7 +246,7 @@ What the recipe depends on: - **One wrapper per call, built on the caller.** `AgentContext.callback_wrapper` snapshots whoever calls it. Call it from the request or job, after its state is set and before `query` / `Client.open` — not inside their blocks, which may already run on another fiber. It cannot go into the initializer: a process-wide wrapper only ever runs at the destination and has no caller to look at. And it must not outlive the call: a wrapper is fixed for the lifetime of the session it was passed to, so one built when a long-lived `Client` connects makes every later turn run its callbacks as the **first** caller — another user's turn would read and write under the first user's account and shard. Open a session per request or job and resume it by id, as the examples below do, and the capture happens once per call. - **The restore happens inside `rails.call`.** In production the Rails wrapper enters the executor, and the executor starts every execution from a clean slate: it resets `Current` and the error context. State set around `rails.call` is wiped on the way in; state set inside it survives. - **No restore on the fiber that captured the state.** Under `:inline` scheduling a `Client`'s message block and observers run on the caller itself. The state is already there, and restoring it again would add the log tags a second time. -- **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every logger and run nothing. The two `respond_to?` checks keep the recipe working with a logger that has no tags. +- **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every tagged logger and run nothing. The tags are read from the first logger in the broadcast that keeps tags rather than from `Rails.logger.formatter`, which is the first logger's on Rails 8.1, tagged or not, and `nil` on 7.1 for a broadcast you built yourself. With no tagged logger at all the recipe carries no tags and still runs. - **`ActiveSupport::ExecutionContext` is restored explicitly.** It is where `Rails.error.set_context` and Rails' own controller and job entries live; `Current.set` does not bring it back. Rails has no public reader for it, so recheck this line when you upgrade Rails. - **The locale is restored rather than assumed**, because only i18n 1.15 and later hand it to every callback by themselves. The restore is safe wherever a callback has a thread of its own, which is `:thread` scheduling, with any i18n. With i18n 1.14.8 it is only safe there: under `:inline` all fibers of the reactor thread share one locale, so a callback's `I18n.with_locale` changes the locale of every job on that worker while the callback runs, and another job that sets its own locale meanwhile changes the callback's. On a fiber-isolated host with `:inline` scheduling, keeping jobs' locales apart needs i18n 1.15 or later, or `:thread` scheduling. - **Values travel, containers do not.** The recipe rebuilds the caller's state from values. The objects themselves — `Current.user`, say — are shared with the caller, so treat them as read-only in callbacks. Do not go further and copy thread-local variables wholesale, hand the caller's ActiveRecord connection to a callback, or pass `ActiveRecord::Base.connected_to_stack` across: those are mutable and belong to one thread. @@ -264,7 +268,7 @@ and re-enter them innermost, in place of `with_log_tags(tags) { invocation.call `connected_to` on `ApplicationRecord` switches the models that inherit from it; an application with several connection classes captures and re-enters each of them. -What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag and error context rows of the table, the `I18n.locale` cases for whichever i18n the bundle resolves (CI's resolve 1.15 or later; the 1.14.7 and 1.14.8 rows were run against those releases), and runs the `AgentContext` block exactly as printed above through all five kinds of callback. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. +What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag and error context rows of the table, the `I18n.locale` cases for whichever i18n the bundle resolves (CI's resolve 1.15 or later; the 1.14.7 and 1.14.8 rows were run against those releases), and runs the `AgentContext` block exactly as printed above through all five kinds of callback — also with two tagged loggers in the `Rails.logger` broadcast, and with a plain logger ahead of the tagged one. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. ## Transactions and the connection pool diff --git a/spec/rails/request_state_spec.rb b/spec/rails/request_state_spec.rb index 2c9ab2fd..64aa009d 100644 --- a/spec/rails/request_state_spec.rb +++ b/spec/rails/request_state_spec.rb @@ -172,4 +172,25 @@ def once_with(state, kind) end end end + + # The recipe reads the tags from the tagged logger itself, not from + # Rails.logger.formatter, which a plain logger in the broadcast can own. + describe 'the documented recipe with a plain logger before the tagged one in the Rails.logger broadcast' do + [%i[thread thread], %i[fiber inline]].each do |isolation, scheduling| + context "under callback_scheduling: :#{scheduling}" do + let(:observed) { booted_app(reloading: false, isolation: isolation).fetch('plain logger first') } + + it 'reads the tags from the tagged logger and restores them there' do + expect(observed).not_to include('error') + callbacks.each do |kind| + expect(observed.fetch(kind)).to contain_exactly(include(caller_state)), "in #{kind}" + end + lines = ['caller', *callbacks].map { |kind| "seen by #{kind}" } + expect(observed.fetch('logs')).to match( + 'plain' => match_array(lines), 'tagged' => match_array(lines.map { |line| "[req-123] #{line}" }) + ) + end + end + end + end end diff --git a/spec/rails/support/booted_app_child.rb b/spec/rails/support/booted_app_child.rb index 6a116228..575642ba 100644 --- a/spec/rails/support/booted_app_child.rb +++ b/spec/rails/support/booted_app_child.rb @@ -79,7 +79,7 @@ def record(kind) snapshot = { 'user' => Current.user, 'time_zone' => Time.zone.name, - 'log_tags' => Rails.logger.formatter.current_tags.dup, + 'log_tags' => log_tags, 'error_context' => ActiveSupport::ExecutionContext.to_h, 'locale' => I18n.locale.to_s, 'on_caller_fiber' => Fiber.current.equal?(@caller_fiber) @@ -87,6 +87,15 @@ def record(kind) @lock.synchronize { @turns.last[kind] << snapshot } end + # The tags in effect, as the tagged logger Rails.logger writes to holds + # them — read from that logger, so a plain logger in the broadcast does not + # hide them. + def log_tags + loggers = Rails.logger.respond_to?(:broadcasts) ? Rails.logger.broadcasts : [Rails.logger] + tagged = loggers.find { |logger| logger.respond_to?(:push_tags) } + tagged ? tagged.formatter.current_tags.dup : [] + end + # Observer and message block see every message; one observation each is # enough, and the result is the one every turn has. def on_message(message) @@ -191,6 +200,25 @@ def run_with_two_tagged_loggers(scheduling:) Rails.logger.stop_broadcasting_to(second) end + # The same request with Rails.logger broadcasting to a plain logger before + # the tagged one, as `ActiveSupport::BroadcastLogger.new(plain, tagged)` + # builds it. Rails.logger.formatter is then the plain logger's (Rails 8.1) + # or nil (7.1, where only #formatter= sets it). + def run_with_a_plain_logger_first(scheduling:) + plain_log = StringIO.new + tagged_log = StringIO.new + booted = Rails.logger + Rails.logger = ActiveSupport::BroadcastLogger.new( + ActiveSupport::Logger.new(plain_log), + ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(tagged_log)) + ) + seen = run(api: 'query', scheduling: scheduling, wrapper: 'recipe') + seen.merge('logs' => { 'plain' => plain_log.string.lines(chomp: true), + 'tagged' => tagged_log.string.lines(chomp: true) }) + ensure + Rails.logger = booted + end + # Two fiber-worker jobs on one reactor thread: job A sets its locale with # I18n.with_locale — as the recipe does in an :inline callback — and # suspends; job B runs meanwhile and sets its own. Buffered queues order @@ -314,6 +342,7 @@ def answers(responses) end observed['reused wrapper'] = requests.run_reusing_a_wrapper observed['two tagged loggers'] = requests.run_with_two_tagged_loggers(scheduling: schedulings.first) +observed['plain logger first'] = requests.run_with_a_plain_logger_first(scheduling: schedulings.first) observed['two jobs switching locales'] = requests.run_two_jobs_switching_locales puts "BOOTED_APP_RESULT #{JSON.generate(observed)}" From 1b11f17cb434915d69d0781489784e928dd8cd55 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 02:58:04 +0800 Subject: [PATCH 177/194] Say what :thread scheduling does not fix on i18n 1.14.8 The recipe's locale note ended with "keeping jobs' locales apart needs i18n 1.15 or later, or :thread scheduling". The second half overstates what :thread scheduling does. On i18n 1.14.8 the fiber-worker jobs of one reactor thread share a locale among themselves whether or not the SDK is involved (two interleaved fibers, no SDK: the second sees the first's with_locale, the first then sees the second's). :thread scheduling moves SDK callbacks, and with them the recipe's I18n.with_locale, onto threads of their own; it does not separate the jobs. The note now says that keeping jobs' locales apart on a fiber-isolated host takes i18n 1.15 or later, and that :thread scheduling only takes the recipe's restore off the shared thread. Documentation only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rails.md b/docs/rails.md index c956f17b..ebc72753 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -248,7 +248,7 @@ What the recipe depends on: - **No restore on the fiber that captured the state.** Under `:inline` scheduling a `Client`'s message block and observers run on the caller itself. The state is already there, and restoring it again would add the log tags a second time. - **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every tagged logger and run nothing. The tags are read from the first logger in the broadcast that keeps tags rather than from `Rails.logger.formatter`, which is the first logger's on Rails 8.1, tagged or not, and `nil` on 7.1 for a broadcast you built yourself. With no tagged logger at all the recipe carries no tags and still runs. - **`ActiveSupport::ExecutionContext` is restored explicitly.** It is where `Rails.error.set_context` and Rails' own controller and job entries live; `Current.set` does not bring it back. Rails has no public reader for it, so recheck this line when you upgrade Rails. -- **The locale is restored rather than assumed**, because only i18n 1.15 and later hand it to every callback by themselves. The restore is safe wherever a callback has a thread of its own, which is `:thread` scheduling, with any i18n. With i18n 1.14.8 it is only safe there: under `:inline` all fibers of the reactor thread share one locale, so a callback's `I18n.with_locale` changes the locale of every job on that worker while the callback runs, and another job that sets its own locale meanwhile changes the callback's. On a fiber-isolated host with `:inline` scheduling, keeping jobs' locales apart needs i18n 1.15 or later, or `:thread` scheduling. +- **The locale is restored rather than assumed**, because only i18n 1.15 and later hand it to every callback by themselves. The restore is safe wherever a callback has a thread of its own, which is `:thread` scheduling, with any i18n. With i18n 1.14.8 it is only safe there: under `:inline` all fibers of the reactor thread share one locale, so a callback's `I18n.with_locale` changes the locale of every job on that worker while the callback runs, and another job that sets its own locale meanwhile changes the callback's. On a fiber-isolated host, keeping jobs' locales apart takes i18n 1.15 or later: on 1.14.8 the jobs of one worker share a locale among themselves, with or without the SDK. `:thread` scheduling takes the recipe's restore off that shared thread; it does not separate the jobs. - **Values travel, containers do not.** The recipe rebuilds the caller's state from values. The objects themselves — `Current.user`, say — are shared with the caller, so treat them as read-only in callbacks. Do not go further and copy thread-local variables wholesale, hand the caller's ActiveRecord connection to a callback, or pass `ActiveRecord::Base.connected_to_stack` across: those are mutable and belong to one thread. An application with replicas or shards also carries the role, the shard and `prevent_writes`. Capture them in `capture`: From 5b20387ac9f8acffe4597fbc07bfb93685ed4ae9 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 03:59:35 +0800 Subject: [PATCH 178/194] Check the guide's auto-memory switches in parsed code, not text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec/rails/session_isolation_spec.rb claimed to check that every ClaudeAgentOptions the Rails guide builds turns auto-memory off, but it matched "env:" in the source text of each call. A switch that was commented out — "# env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }" — still matched, so the spec stayed green (15 examples, 0 failures) while that job's options had an empty env. The spec now reads the guide's Ruby blocks with Ripper (spec/rails/support/guide_option_sets.rb) and looks at the arguments each ClaudeAgentOptions.new is really called with: the string pairs of its env: hash literal, with a **opts argument resolved to the opts = { ... } literal of the same block. What is not code does not count — a comment, a =begin block, a string — and neither does a switch under another key, with another value, overridden by a later env:, or behind a splat that is not a literal; a call with no arguments is an option set without the switch. Ripper produces the same tree for these forms on Ruby 3.2, 3.3 and 3.4. The spec pins the safeguard as well as the guide: - for each of the six option sets, the switch is commented out in memory and exactly that set must stop counting; - the shapes above are checked one by one, as are the four forms that do count and the heading and line numbers the reader reports. Checked from outside too: commenting out each of the six switches in docs/rails.md in turn makes this spec fail on both Rails bundles (35 examples, 1 failure — the example of that section, e.g. "docs/rails.md:36: these options are built with env: nil"), and the guide's Ruby blocks still parse each time. No change to the guide or to lib/. Review round 2, item B1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- spec/rails/session_isolation_spec.rb | 114 +++++++++++------ spec/rails/support/guide_option_sets.rb | 160 ++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 35 deletions(-) create mode 100644 spec/rails/support/guide_option_sets.rb diff --git a/spec/rails/session_isolation_spec.rb b/spec/rails/session_isolation_spec.rb index 1f4b473f..2472c286 100644 --- a/spec/rails/session_isolation_spec.rb +++ b/spec/rails/session_isolation_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'rails_helper' +require_relative 'support/guide_option_sets' require 'tmpdir' require 'generators/claude_agent_sdk/install/install_generator' @@ -37,36 +38,16 @@ end end - # The text between `ClaudeAgentOptions.new(` and its closing parenthesis. - arguments_from = lambda do |code, from| - depth = 1 - index = from - until depth.zero? - raise "unbalanced ClaudeAgentOptions.new( in #{guide}" if index >= code.size - - depth += { '(' => 1, ')' => -1 }.fetch(code[index], 0) - index += 1 - end - code[from...(index - 1)] - end - - # Every ClaudeAgentOptions the guide builds, as [the heading it sits under, - # the arguments it is built from]. A `**name` argument is resolved to the - # `name = { ... }` literal in the same block, which is how the - # session-resumption model builds its options. - option_sets = [] - heading = nil - File.read(File.join(root, guide)).scan(/^(\#{2,3} [^\n]+)$|^ *```ruby\n(.*?)^ *```$/m) do |title, code| - next heading = title.sub(/\A#+ /, '') if title - - code.enum_for(:scan, /ClaudeAgentOptions\.new\(/).each do - arguments = arguments_from.call(code, Regexp.last_match.end(0)) - if (name = arguments[/\A\s*\*\*(\w+)\s*\z/, 1]) - arguments = code[/^( *)#{name} = \{\n.*?^\1\}/m] || raise("no `#{name} = { ... }` under #{heading}") - end - option_sets << [heading, arguments] - end - end + # Every ClaudeAgentOptions the guide builds, read from the parsed code of + # its Ruby blocks (support/guide_option_sets.rb) rather than from their + # text: a switch that is commented out, or that only appears in a string, + # is not there. A `**opts` argument stands for the `opts = { ... }` literal + # of the same block, which is how the session-resumption model builds its + # options. + reader = ClaudeAgentSDKRailsSpec::GuideOptionSets + guide_text = File.read(File.join(root, guide)) + option_sets = reader.in_markdown(guide_text) + carries_switch = ->(set) { set.env.to_h[variable] == '1' } # An option set in the guide that must not carry the switch goes here, # keyed by the heading it sits under, with the reason. None does. @@ -74,19 +55,82 @@ describe "#{guide}, every ClaudeAgentOptions it builds" do it 'finds an option set in every section that builds one' do - expect(option_sets.map(&:first)).to include( + expect(option_sets.map(&:heading)).to include( 'Getting started', "Carrying the caller's state into callbacks", 'ActionCable Streaming', 'Session Resumption', 'Background Jobs with Error Handling', 'HTTP MCP Servers' ) end - option_sets.each do |section, arguments| - it "turns auto-memory off in the options under \"#{section}\"" do - skip allowed_without_switch.fetch(section) if allowed_without_switch.key?(section) + option_sets.each do |set| + it "turns auto-memory off in the options under \"#{set.heading}\"" do + skip allowed_without_switch.fetch(set.heading) if allowed_without_switch.key?(set.heading) + + expect(set.env.to_h).to include(variable => '1'), + "#{guide}:#{set.line}: these options are built with env: #{set.env.inspect}" + end + end + + # The safeguard itself: comment out one switch at a time, in memory, and + # the option set it belonged to — and no other — must stop counting. + option_sets.each_with_index do |set, index| + next unless set.env_line + + it "stops counting the switch under \"#{set.heading}\" once it is commented out" do + lines = guide_text.lines + lines[set.env_line - 1] = lines[set.env_line - 1].sub('env:', '# env:') + + still_carrying = reader.in_markdown(lines.join).map(&carries_switch) + + expected = option_sets.map(&carries_switch) + expected[index] = false + expect(carries_switch.call(set)).to be(true) + expect(still_carrying).to eq(expected) + end + end + end + + describe 'reading the option sets of a guide' do + fence = '```' + in_a_guide = ->(code) { reader.in_markdown("## Section\n\n#{fence}ruby\n#{code}\n#{fence}\n") } + + { + 'commented out' => "ClaudeAgentOptions.new(\n max_turns: 1,\n # #{switch}\n)", + 'in a trailing comment' => "ClaudeAgentOptions.new(max_turns: 1) # #{switch}", + 'in a =begin / =end comment' => "ClaudeAgentOptions.new(max_turns: 1)\n=begin\nClaudeAgentOptions.new(#{switch})\n=end", + 'inside a string' => "ClaudeAgentOptions.new(append_system_prompt: \"#{switch}\")", + 'under another key' => "ClaudeAgentOptions.new(settings: { #{switch} })", + "given the value '0'" => "ClaudeAgentOptions.new(env: { '#{variable}' => '0' })", + 'overridden by a later env:' => "ClaudeAgentOptions.new(#{switch}, env: {})", + 'missing because there are no arguments' => 'ClaudeAgentOptions.new', + 'behind a splat that is not a hash literal of the block' => 'ClaudeAgentOptions.new(**defaults)' + }.each do |shape, code| + it "does not count a switch that is #{shape}" do + expect(in_a_guide.call(code).map(&carries_switch)).to eq([false]) + end + end - expect(arguments).to match(/env: \{[^}]*'#{variable}' => '1'[^}]*\}/) + { + 'among the keywords' => "ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 1, #{switch})", + 'next to other variables' => "ClaudeAgentOptions.new(env: { 'ANTHROPIC_API_KEY' => ENV.fetch('KEY'), '#{variable}' => '1' })", + 'in the hash literal a **splat stands for' => "opts = {\n #{switch} # here\n}\nClaudeAgentOptions.new(**opts)", + 'in a call without parentheses' => "ClaudeAgentSDK::ClaudeAgentOptions.new #{switch}" + }.each do |shape, code| + it "counts a switch that is #{shape}" do + expect(in_a_guide.call(code).map(&carries_switch)).to eq([true]) end end + + it 'reports the heading and the lines of the call and of its env: in the guide' do + markdown = "# Guide\n\n## Section\n\n#{fence}ruby\nopts = {\n #{switch}\n}\nClaudeAgentOptions.new(**opts)\n#{fence}\n" + + expect(reader.in_markdown(markdown).map(&:to_h)).to eq( + [{ heading: 'Section', line: 9, env: { variable => '1' }, env_line: 7 }] + ) + end + + it 'refuses a Ruby block that does not parse, naming its section' do + expect { in_a_guide.call('def (') }.to raise_error(ArgumentError, /"Section" does not parse/) + end end describe actioncable_example do diff --git a/spec/rails/support/guide_option_sets.rb b/spec/rails/support/guide_option_sets.rb new file mode 100644 index 00000000..126c8fd3 --- /dev/null +++ b/spec/rails/support/guide_option_sets.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'ripper' + +module ClaudeAgentSDKRailsSpec + # The ClaudeAgentOptions a Markdown guide builds in its fenced Ruby blocks. + # + # Read from the parsed code (Ripper), not from the text, so only what Ruby + # would run counts: an argument that is commented out is not an argument, + # and neither is one that only appears inside a string. + module GuideOptionSets + # heading: the Markdown heading the block sits under. + # line: where `ClaudeAgentOptions.new` is called, in the Markdown file. + # env: the String => String pairs of the `env:` hash the call is built + # with, or nil when it has none. + # env_line: where that `env:` key is written, in the Markdown file. + OptionSet = Struct.new(:heading, :line, :env, :env_line, keyword_init: true) + + FENCED_RUBY = /^(\#{2,3} [^\n]+)$|^ *```ruby\n(.*?)^ *```$/m + + module_function + + # @param markdown [String] + # @return [Array<OptionSet>] in document order + def in_markdown(markdown) + heading = nil + sets = [] + markdown.scan(FENCED_RUBY) do |title, code| + next heading = title.sub(/\A#+ /, '') if title + + first_line = Regexp.last_match.pre_match.count("\n") + 2 # the line after the opening fence + program = Ripper.sexp(code) || raise(ArgumentError, "the Ruby block under \"#{heading}\" does not parse") + sets.concat(option_sets(program, heading, first_line)) + end + sets + end + + def option_sets(program, heading, first_line) + constructions(program).map do |call, arguments| + key, value = env_pair(resolved_pairs(keyword_pairs(arguments), program)) + OptionSet.new(heading: heading, line: first_line + call[3][2][0] - 1, + env: value && string_pairs(value), env_line: key && (first_line + key[2][0] - 1)) + end + end + + # Every node of the tree, depth first, parents before children. + def each_node(node, &block) + return unless node.is_a?(Array) + + yield node + node.each { |child| each_node(child, &block) } + end + + # Every `ClaudeAgentOptions.new`, as [the call, its arguments or nil]: + # new(...) [:method_add_arg, [:call, receiver, _, [:@ident, "new", _]], arg_paren] + # new ... [:command_call, receiver, _, [:@ident, "new", _], arguments] + # new [:call, receiver, _, [:@ident, "new", _]] + def constructions(program) + found = [] + with_parentheses = [] + each_node(program) do |node| + case node[0] + when :method_add_arg + next unless construction?(node[1]) + + with_parentheses << node[1] + found << [node[1], node[2]] + when :command_call then found << [node, node[4]] if construction?(node) + when :call then found << [node, nil] if construction?(node) && with_parentheses.none? { |call| call.equal?(node) } + end + end + found + end + + def construction?(call) + call.is_a?(Array) && %i[call command_call].include?(call[0]) && call[3].is_a?(Array) && + call[3][1] == 'new' && constant_name(call[1]) == 'ClaudeAgentOptions' + end + + # The last constant of `A::B` or of a bare `B`. + def constant_name(node) + return unless node.is_a?(Array) + + case node[0] + when :const_path_ref then node[2][1] + when :var_ref then node[1][1] if node[1][0] == :@const + end + end + + # The call's own keyword arguments — [:assoc_new, key, value] and + # [:assoc_splat, expression] nodes — without descending into the values. + def keyword_pairs(arguments) + queue = [arguments] + until queue.empty? + node = queue.shift + next unless node.is_a?(Array) + return node[1] if node[0] == :bare_assoc_hash + + queue.concat(node) if node[0].is_a?(Array) || %i[arg_paren args_add_block].include?(node[0]) + end + [] + end + + # A `**name` among the keywords stands for the pairs of the `name = { ... }` + # literal assigned in the same block. + def resolved_pairs(pairs, program) + pairs.flat_map do |pair| + next [pair] unless pair[0] == :assoc_splat + + hash_pairs(assigned_hash(program, pair.dig(1, 1, 1))) + end + end + + def assigned_hash(program, name) + each_node(program) do |node| + next unless node[0] == :assign && node[1].is_a?(Array) && node[1][0] == :var_field + + return node[2] if node[1][1][1] == name && node[2].is_a?(Array) && node[2][0] == :hash + end + nil + end + + # [:hash, [:assoclist_from_args, pairs]], or [:hash, nil] for {}. + def hash_pairs(hash) + return [] unless hash.is_a?(Array) && hash[0] == :hash && hash[1] + + hash[1][1] + end + + # The key and the hash literal of the `env:` keyword, if there is one. Of + # two `env:` the last counts, as in Ruby; one that is not a hash literal + # cannot be read, and counts as none. + def env_pair(pairs) + pair = pairs.reverse_each.find { |node| node[0] == :assoc_new && keyword_name(node[1]) == 'env' } + pair && pair[2].is_a?(Array) && pair[2][0] == :hash ? [pair[1], pair[2]] : [nil, nil] + end + + # `env:` is [:@label, "env:", position]. + def keyword_name(key) + key[1].delete_suffix(':') if key[0] == :@label + end + + # The pairs of a hash literal whose key and value are both plain strings. + def string_pairs(hash) + hash_pairs(hash).filter_map do |pair| + key = string_value(pair[1]) + value = string_value(pair[2]) + [key, value] if pair[0] == :assoc_new && key && value + end.to_h + end + + # 'text' is [:string_literal, [:string_content, [:@tstring_content, "text", position]]]. + def string_value(node) + return unless node.is_a?(Array) && node[0] == :string_literal + + parts = node[1][1..] + parts.map { |part| part[1] }.join if parts.all? { |part| part[0] == :@tstring_content } + end + end +end From 1c2cb771f737206d8d9770d8fef30a158727c0a5 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 04:46:32 +0800 Subject: [PATCH 179/194] Fail closed when the guide spec cannot follow a **splat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spec/rails/session_isolation_spec.rb read ClaudeAgentOptions.new(**opts) as the opts = { ... } literal of the same block, whatever the block did to opts in between. With "opts[:env] = {}" put right before the call in the guide's session-resumption model, the model built its sessions with an empty env — auto-memory on — and the spec still passed (36 examples, 0 failures on both Rails bundles). The reader now follows **name in one shape only: name is a local variable, name = { ... } (a literal that splats nothing itself) is an earlier statement of the sequence the call is in, and the block mentions name nowhere else. Another mention may be a write — name[:env] = {}, name.delete(:env), name.merge!(...), name = name.merge(...), name ||= ..., helper(name) — and in a loop or a lambda it need not stand before the call to run before it. The reader does not work out which mentions are harmless: it reports the option set as not established, and the guide example of that section fails, naming the lines, e.g. docs/rails.md:440: what these options are built from is not established: **opts cannot be followed: `opts` appears again in the block (line 439) A splat that is not such a variable was read as "no pairs"; it is not followed either now, so new(env: { ... }, **overrides) no longer passes on the strength of the env: before it. The guide's own model wrote "opts[:resume] = claude_session_id if claude_session_id.present?" between the literal and the call, which is such a mention. resume: moves into the literal as claude_session_id.presence. The options built are the same: a nil keyword means "no preference" to ClaudeAgentOptions, and --resume is only passed for a value. Checked by evaluating the block before and after: same value from every reader, same to_h, same CLI arguments, for a nil, a blank and a real session ID. The spec pins it: - for every option set of the guide built from a **splat, each of name[:env] = {}, name.delete(:env), name.merge!(env: {}) and name = name.merge(env: {}) is put before the call in memory, and exactly that set must stop counting; - sixteen writes and nine other shapes that are not followed are checked one by one on a small block, with the reason reported for each, next to the shapes that are. Review round 3, item B1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 4 +- spec/rails/session_isolation_spec.rb | 106 ++++++++++++++--- spec/rails/support/guide_option_sets.rb | 151 ++++++++++++++++++++---- 3 files changed, 218 insertions(+), 43 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index ebc72753..128ea8c1 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -433,9 +433,9 @@ class ChatSession < ApplicationRecord opts = { permission_mode: 'bypassPermissions', setting_sources: [], # isolates settings files, not the CLI's auto-memory: - env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # this does (see "Per-user isolation") + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, # this does (see "Per-user isolation") + resume: claude_session_id.presence # nil for the first message: a new session } - opts[:resume] = claude_session_id if claude_session_id.present? ClaudeAgentSDK::ClaudeAgentOptions.new(**opts) end end diff --git a/spec/rails/session_isolation_spec.rb b/spec/rails/session_isolation_spec.rb index 2472c286..0da1b29f 100644 --- a/spec/rails/session_isolation_spec.rb +++ b/spec/rails/session_isolation_spec.rb @@ -43,7 +43,10 @@ # text: a switch that is commented out, or that only appears in a string, # is not there. A `**opts` argument stands for the `opts = { ... }` literal # of the same block, which is how the session-resumption model builds its - # options. + # options — and only while the block does nothing else with `opts`: one + # `opts[:env] = {}` before the call takes the switch out again, whatever + # the literal says. The reader reports such an option set as not + # established, and here that is a failure. reader = ClaudeAgentSDKRailsSpec::GuideOptionSets guide_text = File.read(File.join(root, guide)) option_sets = reader.in_markdown(guide_text) @@ -65,26 +68,46 @@ it "turns auto-memory off in the options under \"#{set.heading}\"" do skip allowed_without_switch.fetch(set.heading) if allowed_without_switch.key?(set.heading) - expect(set.env.to_h).to include(variable => '1'), - "#{guide}:#{set.line}: these options are built with env: #{set.env.inspect}" + built = if set.problem + "what these options are built from is not established: #{set.problem}" + else + "these options are built with env: #{set.env.inspect}" + end + expect(set.env.to_h).to include(variable => '1'), "#{guide}:#{set.line}: #{built}" end end - # The safeguard itself: comment out one switch at a time, in memory, and - # the option set it belonged to — and no other — must stop counting. + # The safeguard itself, in memory: take the switch away from one option set + # at a time, and that option set — and no other — must stop counting. option_sets.each_with_index do |set, index| - next unless set.env_line + next unless carries_switch.call(set) + without_this_one = option_sets.map(&carries_switch) + without_this_one[index] = false + + # By commenting it out. it "stops counting the switch under \"#{set.heading}\" once it is commented out" do lines = guide_text.lines lines[set.env_line - 1] = lines[set.env_line - 1].sub('env:', '# env:') - still_carrying = reader.in_markdown(lines.join).map(&carries_switch) + expect(reader.in_markdown(lines.join).map(&carries_switch)).to eq(without_this_one) + end - expected = option_sets.map(&carries_switch) - expected[index] = false - expect(carries_switch.call(set)).to be(true) - expect(still_carrying).to eq(expected) + # And, where the options are a `**splat`, by a write to the splatted + # variable right before the call — the literal and its switch stay as + # they are, which is why the literal alone settles nothing. + set.splats.each do |name| + ["#{name}[:env] = {}", "#{name}.delete(:env)", "#{name}.merge!(env: {})", "#{name} = #{name}.merge(env: {})"].each do |write| + it "stops counting the switch under \"#{set.heading}\" once `#{write}` comes before the call" do + lines = guide_text.lines + lines.insert(set.line - 1, "#{lines[set.line - 1][/\A\s*/]}#{write}\n") + + mutated = reader.in_markdown(lines.join) + + expect(mutated.map(&carries_switch)).to eq(without_this_one) + expect(mutated[index].problem).to eq("**#{name} cannot be followed: `#{name}` appears again in the block (line #{set.line})") + end + end end end end @@ -112,19 +135,72 @@ { 'among the keywords' => "ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 1, #{switch})", 'next to other variables' => "ClaudeAgentOptions.new(env: { 'ANTHROPIC_API_KEY' => ENV.fetch('KEY'), '#{variable}' => '1' })", + 'in a call without parentheses' => "ClaudeAgentSDK::ClaudeAgentOptions.new #{switch}", 'in the hash literal a **splat stands for' => "opts = {\n #{switch} # here\n}\nClaudeAgentOptions.new(**opts)", - 'in a call without parentheses' => "ClaudeAgentSDK::ClaudeAgentOptions.new #{switch}" + 'in the **splat literal of a method' => + "def build_options\n opts = {\n #{switch},\n resume: session_id.presence\n }\n ClaudeAgentOptions.new(**opts)\nend", + 'in the **splat literal of a call nested in a later statement' => + "opts = { #{switch} }\nClient.open(options: ClaudeAgentOptions.new(**opts)) { |client| client.query('hello') }" }.each do |shape, code| it "counts a switch that is #{shape}" do - expect(in_a_guide.call(code).map(&carries_switch)).to eq([true]) + sets = in_a_guide.call(code) + + expect(sets.map(&carries_switch)).to eq([true]) + expect(sets.map(&:problem)).to eq([nil]) + end + end + + # Fail closed for `**opts`. Its literal is what the call receives only + # while the block does nothing else with `opts`: each of these may change + # it, and the reader does not try to work out which of them do. + [ + 'opts[:env] = {}', 'opts.delete(:env)', 'opts.merge!(env: {})', 'opts = opts.merge(env: {})', + 'opts[:resume] = session_id', 'opts.store(:env, {})', 'opts.update(env: {})', 'opts.replace({})', 'opts.clear', + 'opts ||= {}', 'opts = {}', 'opts[:env] ||= {}', 'opts.compact!', 'strip_env(opts)', 'strip_env(opts:)', 'other = opts' + ].each do |write| + it "does not follow **opts once `#{write}` comes before the call" do + sets = in_a_guide.call("opts = { #{switch} }\n#{write}\nClaudeAgentOptions.new(**opts)") + + expect(sets.map(&carries_switch)).to eq([false]) + expect(sets.map(&:problem)).to eq(['**opts cannot be followed: `opts` appears again in the block (line 5)']) + end + end + + # Nor in any other shape that leaves room for doubt. The first two: what + # stands after the call in the text may still run before it. + not_before_the_call = '**opts cannot be followed: `opts = { ... }` is not a statement that always runs before the call' + not_a_local = 'a ** splat that is not a local variable cannot be followed' + { + '**opts when `opts` is used again after the call' => + ["opts = { #{switch} }\nClaudeAgentOptions.new(**opts)\nopts.clear", + '**opts cannot be followed: `opts` appears again in the block (line 6)'], + '**opts when the call sits in a lambda and `opts` is written before it runs' => + ["opts = { #{switch} }\nbuild = -> { ClaudeAgentOptions.new(**opts) }\nopts[:env] = {}\nbuild.call", + '**opts cannot be followed: `opts` appears again in the block (line 6)'], + '**opts when its literal is assigned under a modifier' => ["opts = { #{switch} } if ready\nClaudeAgentOptions.new(**opts)", not_before_the_call], + '**opts when its literal is assigned inside a condition' => + ["if ready\n opts = { #{switch} }\nend\nClaudeAgentOptions.new(**opts)", not_before_the_call], + '**opts when its literal splats another hash' => + ["opts = { #{switch}, **overrides }\nClaudeAgentOptions.new(**opts)", '**opts cannot be followed: its literal splats another hash'], + '**opts when `opts` is not assigned a hash literal' => + ["opts = defaults.merge(#{switch})\nClaudeAgentOptions.new(**opts)", '**opts cannot be followed: the block has no `opts = { ... }` literal'], + 'a splat, after the switch, of something that is not a local variable' => ["ClaudeAgentOptions.new(#{switch}, **overrides)", not_a_local], + 'a splat, before the switch, of a constant' => ["ClaudeAgentOptions.new(**DEFAULTS, #{switch})", not_a_local], + 'a splat of an expression' => ["opts = { #{switch} }\nClaudeAgentOptions.new(**opts.merge(extra))", not_a_local] + }.each do |shape, (code, problem)| + it "does not follow #{shape}" do + sets = in_a_guide.call(code) + + expect(sets.map(&carries_switch)).to eq([false]) + expect(sets.map(&:problem)).to eq([problem]) end end - it 'reports the heading and the lines of the call and of its env: in the guide' do + it 'reports the heading, the lines of the call and of its env:, and the variables it splats' do markdown = "# Guide\n\n## Section\n\n#{fence}ruby\nopts = {\n #{switch}\n}\nClaudeAgentOptions.new(**opts)\n#{fence}\n" expect(reader.in_markdown(markdown).map(&:to_h)).to eq( - [{ heading: 'Section', line: 9, env: { variable => '1' }, env_line: 7 }] + [{ heading: 'Section', line: 9, env: { variable => '1' }, env_line: 7, splats: ['opts'], problem: nil }] ) end diff --git a/spec/rails/support/guide_option_sets.rb b/spec/rails/support/guide_option_sets.rb index 126c8fd3..84203bf0 100644 --- a/spec/rails/support/guide_option_sets.rb +++ b/spec/rails/support/guide_option_sets.rb @@ -7,14 +7,18 @@ module ClaudeAgentSDKRailsSpec # # Read from the parsed code (Ripper), not from the text, so only what Ruby # would run counts: an argument that is commented out is not an argument, - # and neither is one that only appears inside a string. + # and neither is one that only appears inside a string. Where the tree does + # not settle what a call receives — a `**splat` of a hash the block goes on + # to use — the option set is reported as not established, never guessed. module GuideOptionSets # heading: the Markdown heading the block sits under. # line: where `ClaudeAgentOptions.new` is called, in the Markdown file. # env: the String => String pairs of the `env:` hash the call is built - # with, or nil when it has none. + # with, or nil when it has none — or when `problem` says why not. # env_line: where that `env:` key is written, in the Markdown file. - OptionSet = Struct.new(:heading, :line, :env, :env_line, keyword_init: true) + # splats: the local variables the call splats (`**name`), in order. + # problem: why what the call is built from is not established, or nil. + OptionSet = Struct.new(:heading, :line, :env, :env_line, :splats, :problem, keyword_init: true) FENCED_RUBY = /^(\#{2,3} [^\n]+)$|^ *```ruby\n(.*?)^ *```$/m @@ -36,14 +40,20 @@ def in_markdown(markdown) end def option_sets(program, heading, first_line) + in_markdown = ->(line) { first_line + line - 1 } constructions(program).map do |call, arguments| - key, value = env_pair(resolved_pairs(keyword_pairs(arguments), program)) - OptionSet.new(heading: heading, line: first_line + call[3][2][0] - 1, - env: value && string_pairs(value), env_line: key && (first_line + key[2][0] - 1)) + keywords = keyword_pairs(arguments) + pairs, problem, problem_line = Splat.resolve(keywords, program, call) + key, value = env_pair(pairs) + problem += " (line #{in_markdown.call(problem_line)})" if problem_line + OptionSet.new(heading: heading, line: in_markdown.call(call[3][2][0]), + splats: Splat.names(keywords), problem: problem, + env: value && string_pairs(value), env_line: key && in_markdown.call(key[2][0])) end end - # Every node of the tree, depth first, parents before children. + # Every node of the tree — and every list of nodes in it — depth first, + # parents before children. def each_node(node, &block) return unless node.is_a?(Array) @@ -101,25 +111,6 @@ def keyword_pairs(arguments) [] end - # A `**name` among the keywords stands for the pairs of the `name = { ... }` - # literal assigned in the same block. - def resolved_pairs(pairs, program) - pairs.flat_map do |pair| - next [pair] unless pair[0] == :assoc_splat - - hash_pairs(assigned_hash(program, pair.dig(1, 1, 1))) - end - end - - def assigned_hash(program, name) - each_node(program) do |node| - next unless node[0] == :assign && node[1].is_a?(Array) && node[1][0] == :var_field - - return node[2] if node[1][1][1] == name && node[2].is_a?(Array) && node[2][0] == :hash - end - nil - end - # [:hash, [:assoclist_from_args, pairs]], or [:hash, nil] for {}. def hash_pairs(hash) return [] unless hash.is_a?(Array) && hash[0] == :hash && hash[1] @@ -156,5 +147,113 @@ def string_value(node) parts = node[1][1..] parts.map { |part| part[1] }.join if parts.all? { |part| part[0] == :@tstring_content } end + + # What a `**name` among the keywords of a call stands for. + # + # A splat is followed in one shape only, the one that leaves no doubt about + # what the call receives: `name` is a local variable; `name = { ... }`, a + # literal that splats nothing itself, is a statement of the sequence the + # call is in, before it; and the block mentions `name` nowhere else. + # + # Another mention may be a write — `name[:env] = {}`, `name.delete(:env)`, + # `name.merge!(...)`, `name = name.merge(...)`, `name ||= ...`, `helper(name)` + # — and it need not stand before the call to run before it (a loop, a + # lambda). The reader does not work out which mentions are harmless: it + # fails closed on every one of them. + module Splat + module_function + + # The pairs a call is built from, every `**name` among its keywords + # replaced by the pairs of its literal. + # @return [Array] [pairs], or [[], problem, line of the block] when one + # of them cannot be followed + def resolve(pairs, program, call) + resolved = [] + pairs.each do |pair| + next resolved << pair unless pair[0] == :assoc_splat + + literal, problem, line = follow(pair[1], program, call) + return [[], problem, line] if problem + + resolved.concat(GuideOptionSets.hash_pairs(literal)) + end + [resolved] + end + + # The local variables splatted among those keywords. + def names(pairs) + pairs.filter_map { |pair| local_name(pair[1]) if pair[0] == :assoc_splat } + end + + # @return [Array] [the hash literal], or [nil, problem, line of the block] + def follow(expression, program, call) + name = local_name(expression) + return [nil, 'a ** splat that is not a local variable cannot be followed'] unless name + + cannot = "**#{name} cannot be followed: " + assignment = literal_assignment(program, name) + return [nil, "#{cannot}the block has no `#{name} = { ... }` literal"] unless assignment + + own = [expression[1], assignment[1][1]] # the splat's identifier and the assignment's + again = mentions(program, name).find { |mention| own.none? { |identifier| identifier.equal?(mention) } } + return [nil, "#{cannot}`#{name}` appears again in the block", again[2][0]] if again + + literal = assignment[2] + splats = GuideOptionSets.hash_pairs(literal).any? { |pair| pair[0] == :assoc_splat } + return [nil, "#{cannot}its literal splats another hash"] if splats + return [literal] if runs_before?(program, assignment, call) + + [nil, "#{cannot}`#{name} = { ... }` is not a statement that always runs before the call"] + end + + # `name` of [:var_ref, [:@ident, name, position]]: an identifier that is + # a local variable where it stands. One that is not is a :vcall, and a + # constant is [:var_ref, [:@const, ...]]. + def local_name(expression) + expression[1][1] if expression.is_a?(Array) && expression[0] == :var_ref && expression[1][0] == :@ident + end + + # The first [:assign, [:var_field, [:@ident, name, position]], [:hash, ...]]. + def literal_assignment(program, name) + GuideOptionSets.each_node(program) do |node| + next unless node[0] == :assign && node[1].is_a?(Array) && node[1][0] == :var_field + + return node if node[1][1][1] == name && node[2].is_a?(Array) && node[2][0] == :hash + end + nil + end + + # Every place the block names `name`, in source order: [:@ident, name, + # position], and the label of a shorthand pair — `helper(name:)` is + # [:assoc_new, [:@label, "name:", position], nil]. + def mentions(program, name) + found = [] + GuideOptionSets.each_node(program) do |node| + found << node if node[0] == :@ident && node[1] == name + found << node[1] if node[0] == :assoc_new && node[2].nil? && GuideOptionSets.keyword_name(node[1]) == name + end + found.sort_by { |mention| mention[2] } + end + + # Whether `statement` is one of a sequence of statements in which a later + # one holds `call`: only then has it run, whatever happened, by the time + # the call does. (`opts = { ... } if ready` is not: the :if_mod is the + # statement, and the call may receive `**nil`.) A sequence is an Array of + # nodes, as in [:program, statements] or [:bodystmt, statements, ...]. + def runs_before?(program, statement, call) + GuideOptionSets.each_node(program) do |sequence| + position = sequence[0].is_a?(Array) && sequence.index { |member| member.equal?(statement) } + next unless position + + return sequence.drop(position + 1).any? { |later| holds?(later, call) } + end + false + end + + def holds?(node, target) + GuideOptionSets.each_node(node) { |inner| return true if inner.equal?(target) } + false + end + end end end From bac181304049c3a9167fd71a917150ac13e0c336 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:06:00 +0800 Subject: [PATCH 180/194] Say in the Rails guide what query covers and where base64 comes from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two one-line corrections in docs/rails.md. "Getting started" said to use Client.open "for multi-turn sessions, hooks, custom tools and interrupts". Hooks and custom tools work with ClaudeAgentSDK.query too — it runs the same control protocol — so the sentence sent readers to Client for something the job right above it already supports. Client.open is now named for what only it does: multi-turn sessions and mid-session control (interrupt, model switching). The OpenTelemetry initializer requires base64, a bundled gem since Ruby 3.4: under Bundler it loads only when the bundle has it. A comment on that line says why a Rails app has nothing to add: Active Support depends on base64 from 7.1.0 on, so it is in the bundle already. (A bundle without it raises LoadError on Ruby 3.4.5; both Rails bundles of this repository load it.) No line moves, and the guide's 19 Ruby blocks still compile on Ruby 3.2, 3.3 and 3.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015LnJaiM7JNbM6AQLKPs7Gw --- docs/rails.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rails.md b/docs/rails.md index 128ea8c1..23af8641 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -46,7 +46,7 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the end ``` - The block runs on a plain thread (see the next section), so ActiveRecord calls inside it are safe. It is a thread of its own, though: the job's `Current` attributes, time zone, log tags and database role or shard are not set there — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks). For multi-turn sessions, hooks, custom tools and interrupts use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below. + The block runs on a plain thread (see the next section), so ActiveRecord calls inside it are safe. It is a thread of its own, though: the job's `Current` attributes, time zone, log tags and database role or shard are not set there — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks). For multi-turn sessions and mid-session control (interrupt, model switching) use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below. Hooks and custom tools work with `ClaudeAgentSDK.query` too. ## Thread-keyed libraries are safe inside SDK callbacks @@ -496,7 +496,7 @@ Add OpenTelemetry tracing to your Rails app with a single initializer: ```ruby # config/initializers/opentelemetry.rb -require 'base64' +require 'base64' # a bundled gem since Ruby 3.4 — no Gemfile entry needed: Active Support 7.1+ depends on it require 'opentelemetry/sdk' require 'opentelemetry/exporter/otlp' From eac02f1f7cbe032a7d7ed7d175aa93fb92ba6a6f Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:15:10 +0800 Subject: [PATCH 181/194] Cut the lite-reader fixture's windows inside a character for any path The fixture of session_lite_reader_windows_spec.rb needs both 64 KiB windows of its transcript cut inside a 3-byte character. It tried 0-2 bytes of padding at each end and raised when none worked. Every line carries cwd, a temp dir whose length differs between machines and from run to run, so a window boundary can fall deep in a line's ASCII keys and ids, which two bytes of padding cannot leave: on Linux CI (temp paths of about 40 characters) both examples failed with "no padding cuts both windows inside a character", while macOS (about 90) passed. Over project paths of 2 to 300 characters the old fixture failed for 88 lengths. The pads are now read off the unpadded bytes: for the head, the smallest pad that brings a continuation byte from behind the padding to the byte after the window; for the tail, the smallest pad that moves the window's start onto a continuation byte. The fixture asserts that both windows are then cut inside a character. Spec only. Exploration finding: B-P10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF --- spec/unit/session_lite_reader_windows_spec.rb | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/spec/unit/session_lite_reader_windows_spec.rb b/spec/unit/session_lite_reader_windows_spec.rb index cc436068..427c8b5b 100644 --- a/spec/unit/session_lite_reader_windows_spec.rb +++ b/spec/unit/session_lite_reader_windows_spec.rb @@ -16,23 +16,26 @@ let(:prompt) { '请总结这个仓库的结构' } let(:title) { '仓库结构总结' } let(:branch) { '功能/会话读取' } + let(:answer) { '好的。' } + let(:last_prompt) { '最后一个问题' } # A session of about 350 KB of CJK text: neither window is ASCII-only. - # +head_pad+ / +tail_pad+ ASCII bytes shift where the two windows are cut. + # +head_pad+ ASCII bytes in front of the first answer and +tail_pad+ after + # the last prompt shift where the two windows are cut. def transcript_bytes(head_pad, tail_pad) transcript = CLITranscript.new(session_id: session_id, cwd: cwd, git_branch: branch) transcript.queue_operations(prompt) transcript.prompt(:prompt, prompt) parent = :prompt 60.times do |turn| - answer = transcript.text("#{'x' * head_pad if turn.zero?}#{'好的。' * 500}") - transcript.assistant(:"answer_#{turn}", answer, parent: parent, message: "msg_#{turn}") + text = transcript.text("#{'x' * head_pad if turn.zero?}#{answer * 500}") + transcript.assistant(:"answer_#{turn}", text, parent: parent, message: "msg_#{turn}") transcript.prompt(:"next_#{turn}", "继续 #{turn} #{'请继续说明' * 100}", parent: :"answer_#{turn}") parent = :"next_#{turn}" end transcript.custom_title(title) transcript.tag('重要') - transcript.last_prompt("最后一个问题#{'x' * tail_pad}", leaf: parent) + transcript.last_prompt("#{last_prompt}#{'x' * tail_pad}", leaf: parent) transcript.to_jsonl end @@ -43,11 +46,41 @@ def cut_inside_a_character?(bytes) end end + # The smallest of +pads+ for which the block names a UTF-8 continuation + # byte (the second or third byte of one of the fixture's CJK characters). + def smallest_pad(pads) + pads.find { |pad| yield(pad).between?(0x80, 0xBF) } || raise('no CJK text where the window is cut') + end + + # [head pad, tail pad] that cut both windows of the fixture inside a + # character, read off its unpadded bytes. + # + # cwd is on every line and is a temp dir, whose length differs between + # machines and from run to run. It decides where the windows begin and + # end, and that can be deep in the ASCII keys and ids of a line, where a + # byte or two of padding changes nothing. So the pads are computed: + # + # * the head window ends inside a character when the byte after it is a + # continuation byte; +pad+ bytes in front of the first answer bring the + # byte from +pad+ places earlier there (it must lie behind the padding); + # * the tail window starts inside a character when its first byte is one; + # +pad+ bytes after the last prompt move its start +pad+ places on. + # Padding in front of the window moves its start and the text alike, so + # the head pad does not come into it. + def window_pads + plain = transcript_bytes(0, 0).b + window = ClaudeAgentSDK::Sessions::LITE_READ_BUF_SIZE + tail_start = plain.bytesize - window + [ + smallest_pad(0...(window - plain.index(answer.b))) { |pad| plain.getbyte(window - pad) }, + smallest_pad(0...(plain.rindex(last_prompt.b) - tail_start)) { |pad| plain.getbyte(tail_start + pad) } + ] + end + # The fixture with both of its windows cut inside a 3-byte character. def write_transcript - candidates = [0, 1, 2].product([0, 1, 2]).lazy.map { |pads| transcript_bytes(*pads) } - bytes = candidates.find { |candidate| cut_inside_a_character?(candidate) } - raise 'no padding cuts both windows inside a character' unless bytes + bytes = transcript_bytes(*window_pads) + expect(cut_inside_a_character?(bytes)).to be(true), 'the fixture does not cut both windows inside a character' path = transcript_path(session_id) FileUtils.mkdir_p(File.dirname(path)) From 2421298e855122bef392800dacbea6a79bd0481a Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:21:07 +0800 Subject: [PATCH 182/194] Rename a snake_case sandbox key only when the CLI accepts its value The previous commit on this branch sends the snake_case keys of a sandbox Hash under the names the CLI reads. That has a cost the CLI decides: it validates what it finds under a name it knows, and one value that fails its schema makes it discard the whole --settings value, the sandbox and the permissions passed next to it, and report that nowhere but in get_settings. A snake_case key is a name the CLI does not know, so until now such a value was ignored and the sandbox applied. Renamed, sandbox: { enabled: true, network: { allow_unix_sockets: true } } (a bool, which the signature of allow_unix_sockets allows and the CLI does not: it wants an Array of paths) left the session with no sandbox at all. A snake_case key is now renamed only when its value has the shape CLI 2.1.287 accepts under the wire key: true or false; an Array of Strings; for allow_mach_lookup an Array of Strings with a "*" only as the last character; an Integer for the two proxy ports; a Hash of String Arrays for ignore_violations. The kinds sit in a table next to the key tables (SandboxKeys::SHAPES), read from the schema in the CLI binary and checked against the running CLI, and a spec pins one row for every attribute that has a spelling of its own. With any other value the key is sent exactly as written, which the CLI ignores as before: the worst a snake_case key can do is what it did before this branch. Where the table is stricter than the CLI (it takes any number as a port) a key stays without effect; it is never looser. A key the caller wrote in wire spelling is not looked at and reaches the CLI as it always did, whatever its value. A known key holding nil is still left out in either spelling. One fixture of the spec changes: "the value of a field is not rewritten" put a Hash inside ignore_violations and inside allow_mach_lookup, two values the CLI rejects. It now uses values the CLI accepts; what it asserts is unchanged. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/types/option_values.rb | 72 ++++++++- spec/unit/sandbox_hash_keys_spec.rb | 163 +++++++++++++++++++- 2 files changed, 223 insertions(+), 12 deletions(-) diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index 0ba227e7..caa74d04 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -166,6 +166,11 @@ def to_h # (+deniedDomains+), with Symbol or String keys; any other key is sent as # written. The same holds for a Hash given as +sandbox:+ in place of a # SandboxSettings. + # + # A snake_case key in such a Hash is sent under the CLI's name only when + # its value has the shape the CLI accepts there (an Array of Strings for + # +denied_domains+, +true+ or +false+ for +allow_local_binding+, ...). + # With any other value it is sent as written, and the CLI ignores it. class SandboxSettings < Type include Type::OptionValue @@ -248,6 +253,34 @@ module SandboxKeys 'allow_managed_read_paths_only' => :allowManagedReadPathsOnly }.freeze + # Wire key => the kind of value CLI 2.1.287's sandbox schema accepts + # under it, for every wire key an attribute spells differently (read from + # the schema in the CLI binary and checked against the running CLI): + # + # :boolean true or false + # :strings an Array of Strings + # :mach_services an Array of Strings; a "*" only as the last character + # :integer a number (the CLI takes any; an Integer is asked for) + # :string_lists a Hash whose values are Arrays of Strings + # + # One value outside its kind makes the CLI discard the whole --settings + # value, the sandbox and the permissions next to it, without a word. A + # key in snake_case is one the CLI does not know and ignores, so it is + # renamed only when its value is of the kind listed here (see #rename). + # A kind that is too strict leaves a key without effect, as it was before + # the renaming existed; one that is too loose can cost a session its + # sandbox. + SHAPES = { + boolean: %i[failIfUnavailable autoAllowBashIfSandboxed allowUnsandboxedCommands enableWeakerNestedSandbox + enableWeakerNetworkIsolation allowManagedDomainsOnly allowAllUnixSockets allowLocalBinding + allowManagedReadPathsOnly], + strings: %i[excludedCommands allowedDomains deniedDomains allowUnixSockets + allowWrite denyWrite denyRead allowRead], + mach_services: %i[allowMachLookup], + integer: %i[httpProxyPort socksProxyPort], + string_lists: %i[ignoreViolations] + }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze + # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire # keys, at the top level and inside a Hash network / filesystem. Values # are not rewritten: ignore_violations and ripgrep hold structures of @@ -288,26 +321,49 @@ def self.filesystem(section) # A known key holding nil is left out, as #to_h leaves out a nil # attribute. The CLI rejects null under every one of these keys, and # when it does it drops the whole --settings value, sandbox included. + # + # A key in snake_case is renamed only when the CLI accepts its value + # under the wire key (SHAPES). Otherwise it is sent as written, which + # the CLI ignores, as it ignored every snake_case key before: renaming + # must not be what makes the CLI drop the settings. A key the caller + # wrote in wire spelling is not looked at; it goes out as it always did. def self.rename(hash, table) renamed = {} wire_spelled = {} hash.each do |key, value| name = key.to_s wire = table[name] || table.each_value.find { |candidate| candidate.name == name } - if wire.nil? - renamed[key] = value - elsif value.nil? - next - elsif wire.name == name + next if wire && value.nil? + + if wire && wire.name == name wire_spelled[wire] = true renamed[wire] = value - elsif !wire_spelled.key?(wire) - renamed[wire] = value + elsif wire && well_shaped?(wire, value) + renamed[wire] = value unless wire_spelled.key?(wire) + else + renamed[key] = value end end renamed end - private_class_method :rename + + # Whether the CLI accepts +value+ under +wire+. A key without a kind is + # answered false, the side that cannot cost the sandbox. + def self.well_shaped?(wire, value) + case SHAPES[wire] + when :boolean then [true, false].include?(value) + when :strings then strings?(value) + when :mach_services then strings?(value) && value.none? { |name| name.delete_suffix('*').include?('*') } + when :integer then value.is_a?(Integer) + when :string_lists then value.is_a?(Hash) && value.each_value.all? { |list| strings?(list) } + else false + end + end + + def self.strings?(value) + value.is_a?(Array) && value.all?(String) + end + private_class_method :rename, :well_shaped?, :strings? end # API-side task budget in tokens. diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb index 9d4e7cad..f4386b34 100644 --- a/spec/unit/sandbox_hash_keys_spec.rb +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -135,6 +135,59 @@ def on_the_wire(value) top_level_fields.merge(network: ['network', network], filesystem: ['filesystem', filesystem]) end + fields_of = { + 'SandboxSettings' => top_level_fields, + 'SandboxNetworkConfig' => network_fields, + 'SandboxFilesystemConfig' => filesystem_fields + }.freeze + + # What CLI 2.1.287's sandbox schema accepts under each key that has a + # snake_case spelling: attribute => kind. Read from the schema in the CLI + # binary and checked against the running CLI, key by key, with get_settings: + # + # boolean true or false + # strings an Array of Strings + # mach_services an Array of Strings; a "*" only as the last character + # integer a number (the CLI takes any; the SDK asks for an Integer) + # string_lists a Hash whose values are Arrays of Strings + # + # Spelled out here, not derived from the SDK. enabled, network, filesystem + # and ripgrep are missing on purpose: their name is their wire key. + kinds = { + 'SandboxSettings' => { + fail_if_unavailable: :boolean, auto_allow_bash_if_sandboxed: :boolean, excluded_commands: :strings, + allow_unsandboxed_commands: :boolean, ignore_violations: :string_lists, + enable_weaker_nested_sandbox: :boolean, enable_weaker_network_isolation: :boolean + }, + 'SandboxNetworkConfig' => { + allowed_domains: :strings, denied_domains: :strings, allow_managed_domains_only: :boolean, + allow_unix_sockets: :strings, allow_all_unix_sockets: :boolean, allow_local_binding: :boolean, + allow_mach_lookup: :mach_services, http_proxy_port: :integer, socks_proxy_port: :integer + }, + 'SandboxFilesystemConfig' => { + allow_write: :strings, deny_write: :strings, deny_read: :strings, allow_read: :strings, + allow_managed_read_paths_only: :boolean + } + }.freeze + + # For each kind, values the CLI rejects under such a key. Each of them makes + # it discard the whole --settings value, sandbox and permissions included. + rejected = { + boolean: ['true', 0, []], + strings: ['docker', ['docker', 1], [nil], true, { 'docker' => true }], + mach_services: ['com.apple.audio', ['com.*.helper'], ['*.helper'], ['com.apple.**'], [1]], + integer: ['8080', true, [8080]], + string_lists: [{ 'file' => '/tmp/*' }, { 'file' => [1] }, { 'file' => { 'read' => [] } }, ['/tmp/*'], 'file'] + }.freeze + + # A sandbox Hash with +fields+ at the level of the class: the top, or inside + # the section the class stands for. + at_level_of = { + 'SandboxSettings' => ->(fields) { { enabled: true }.merge(fields) }, + 'SandboxNetworkConfig' => ->(fields) { { enabled: true, network: fields } }, + 'SandboxFilesystemConfig' => ->(fields) { { enabled: true, filesystem: fields } } + }.freeze + describe 'every field of every sandbox class' do # The section the CLI must receive, whichever way it was written. wire = wire_form_of.call( @@ -211,6 +264,28 @@ def on_the_wire(value) end end end + + describe 'SHAPES' do + it 'has a kind for every attribute whose name is not its wire key, and for nothing else' do + with_a_spelling_of_their_own = fields_of.transform_values do |fields| + fields.reject { |name, (wire_key, _value)| name.to_s == wire_key }.keys + end + + expect(kinds.transform_values(&:keys)).to eq(with_a_spelling_of_their_own) + end + + it 'agrees with the kinds this file spells out' do + spelled_out = kinds.flat_map do |class_name, by_attribute| + by_attribute.map { |attribute, kind| [fields_of.fetch(class_name).fetch(attribute).first.to_sym, kind] } + end + + expect(ClaudeAgentSDK::SandboxKeys::SHAPES).to eq(spelled_out.to_h) + end + + it 'has values the CLI rejects for every kind it uses' do + expect(rejected.keys).to match_array(kinds.values.flat_map(&:values).uniq) + end + end end describe 'a key the typed classes do not model' do @@ -282,15 +357,15 @@ def on_the_wire(value) sandbox = { enabled: true, ripgrep: inner, - ignore_violations: { 'excluded_commands' => ['/tmp/*'], network: { denied_domains: ['kept'] } }, - network: { allow_unix_sockets: ['/var/run/docker.sock'], allow_mach_lookup: [{ deny_read: 'kept' }] } + ignore_violations: { 'excluded_commands' => ['/tmp/*'], denied_domains: ['kept'], network: ['localhost'] }, + network: { allow_unix_sockets: ['/var/run/docker.sock'], allow_mach_lookup: ['com.apple.coresimulator.*'] } } expect(sandbox_section(sandbox)).to eq( 'enabled' => true, 'ripgrep' => on_the_wire(inner), - 'ignoreViolations' => { 'excluded_commands' => ['/tmp/*'], 'network' => { 'denied_domains' => ['kept'] } }, - 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => [{ 'deny_read' => 'kept' }] } + 'ignoreViolations' => { 'excluded_commands' => ['/tmp/*'], 'denied_domains' => ['kept'], 'network' => ['localhost'] }, + 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => ['com.apple.coresimulator.*'] } ) end end @@ -418,6 +493,86 @@ def on_the_wire(value) end end + # A snake_case key is one the CLI does not know: it ignores it, value and + # all. Under its wire name the same value is validated, and one value that + # fails the CLI's schema makes it discard the whole --settings value (the + # sandbox, and the permissions passed next to it) without a word. So a + # snake_case key is renamed only when its value has the shape the CLI + # accepts for that key. Otherwise it goes out as written and stays inert, + # as it always was: renaming must never cost a session its sandbox. + describe 'a snake_case key whose value the CLI would reject' do + # allowUnixSockets takes an Array of socket paths; the boolean switch is + # allow_all_unix_sockets. The shipped signature of allow_unix_sockets + # allows a bool all the same, which makes `true` the value most likely to + # be written here. Sent as allowUnixSockets: true it would leave the + # session with no sandbox at all. + it 'sends allow_unix_sockets: true as written, not as allowUnixSockets' do + sandbox = { enabled: true, network: { allow_unix_sockets: true, denied_domains: ['evil.example'] } } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, 'network' => { 'allow_unix_sockets' => true, 'deniedDomains' => ['evil.example'] } + ) + end + + kinds.each do |class_name, by_attribute| + by_attribute.each do |attribute, kind| + it "is sent as written for #{attribute} (#{kind}), under a Symbol or a String key" do + sandboxes = rejected.fetch(kind).flat_map do |value| + [attribute, attribute.to_s].map { |key| at_level_of.fetch(class_name).call(key => value) } + end + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }).to eq(sandboxes.map { |sandbox| on_the_wire(sandbox) }) + end + end + end + + it 'is left as written inside a typed SandboxSettings too' do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, network: { allow_unix_sockets: true }, filesystem: { 'deny_read' => '~/.aws' } + ) + + expect(sandbox.to_h).to eq( + enabled: true, network: { allow_unix_sockets: true }, filesystem: { 'deny_read' => '~/.aws' } + ) + end + + it 'stays next to the same field written in a form the CLI accepts, and never replaces it' do + sandbox = { + excluded_commands: 'docker', excludedCommands: ['git'], + filesystem: { deny_read: ['~/.aws'], 'deny_read' => '~/.ssh' } + } + + expect(sandbox_section(sandbox)).to eq( + 'excluded_commands' => 'docker', 'excludedCommands' => ['git'], + 'filesystem' => { 'denyRead' => ['~/.aws'], 'deny_read' => '~/.ssh' } + ) + end + + it 'is still renamed for the values the CLI accepts at the edges' do + sandbox = { + excluded_commands: [], ignore_violations: {}, + network: { allowed_domains: ['*.example.com', ''], allow_mach_lookup: ['*', 'com.apple.coresimulator.*'], + http_proxy_port: 0 }, + filesystem: { deny_read: ['/work/**/*.pem'] } + } + + expect(sandbox_section(sandbox)).to eq( + 'excludedCommands' => [], 'ignoreViolations' => {}, + 'network' => { 'allowedDomains' => ['*.example.com', ''], 'allowMachLookup' => ['*', 'com.apple.coresimulator.*'], + 'httpProxyPort' => 0 }, + 'filesystem' => { 'denyRead' => ['/work/**/*.pem'] } + ) + end + + # The check is only about renaming. A key the caller wrote in the CLI's + # own spelling reaches the CLI as before, whatever its value. + it 'does not concern a key already in wire spelling' do + sandbox = { enabled: true, excludedCommands: 'docker', 'failIfUnavailable' => 'yes', network: { allowUnixSockets: true } } + + expect(sandbox_section(sandbox)).to eq(on_the_wire(sandbox)) + end + end + describe 'what the caller did not write' do it 'is not added: an enabled sandbox is sent without failIfUnavailable' do expect([{ enabled: true }, { 'enabled' => true }, ClaudeAgentSDK::SandboxSettings.new(enabled: true)] From 56e28c4f0156e1b8eab323c01d8d810a672b55b6 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:22:49 +0800 Subject: [PATCH 183/194] Count only the caller's own liveness checks in the close spec "does not return before the CLI has exited, however long that takes" lets the scripted CLI exit only after the thread that is waiting for the close has checked twice that the task running the teardown is alive. It counted every `alive?` asked of that task. async's own Task#running? goes through #alive? from 2.36 on, so a check made on the reactor's thread would have counted too and let the CLI exit before the caller had polled at all, which is the one thing the example is there to rule out. It now counts the checks made by the calling thread, as the second-caller example beside it already does. Test only. Exploration finding: A-P9 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R6FdVbAW83wS6vxQcgLinn --- spec/unit/close_from_another_thread_spec.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/spec/unit/close_from_another_thread_spec.rb b/spec/unit/close_from_another_thread_spec.rb index 17854704..410994e1 100644 --- a/spec/unit/close_from_another_thread_spec.rb +++ b/spec/unit/close_from_another_thread_spec.rb @@ -295,8 +295,14 @@ def closing_thread(signals) transport.wait_for_stdin_eof report_liveness_checks(transport.teardown_task, signals) - # Two liveness checks later — or once the caller has (wrongly) returned. - 2.times { break if signals.pop == :returned } + # Until the caller has checked twice — or has (wrongly) returned. + checks = 0 + while checks < 2 + signal = signals.pop + break if signal == :returned + + checks += 1 if signal == closer + end transport.exit_cli closer.join hold << true From 3e94b53a7953babcc8e85d6934894657b571edcc Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:24:56 +0800 Subject: [PATCH 184/194] Say what to remove when preserving a resume dir is interrupted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cut short by something that is not a StandardError (a cancellation, a signal), preserve_transcripts warned where the session transcript is and nothing else. Since #cleanup leaves the directory alone once preserve_transcripts was called, that warning is all the user gets: it has to say that copies which were not removed yet — credentials among them — may be left, and which directory to remove once the transcript is imported. It now does, and names the directory: the private one the temp dir was moved into when the move had happened (the trash is in there too), the temp dir itself when it had not. Never the temp dir's parent, which would be the system's temp directory. Whether the move happened is read from config_dir, which changes exactly then. ... Scrubbing was interrupted; the session transcript is under <dir>/projects. Copies of your credentials and settings that were not removed yet may be left under <to remove> — import the transcript into your session store, then remove <to remove>. Two examples replace the one that only checked the transcript's location: interrupted after the move (names the private directory, not the directory in it) and before the move (names the temp dir, not its parent). Exploration finding: B-P8 (round 3b follow-up) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- lib/claude_agent_sdk/session_resume.rb | 30 ++++++++++++------ spec/unit/materialized_resume_spec.rb | 44 +++++++++++++++++++++----- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 4d7ed174..ad645b5b 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -86,26 +86,38 @@ def cleanup # config_dir is the new location afterwards, the one the warning names. def preserve_transcripts @kept = true # first: whatever happens below, #cleanup must not delete it + original = @config_dir announced = false - warn "#{MIRROR_DROPPED} #{move_aside_and_scrub}" + warn "#{MIRROR_DROPPED} #{move_aside_and_scrub(original)}" announced = true rescue StandardError => e warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}" announced = true ensure # Cut short by something that is not a StandardError (a cancellation, a - # signal): the directory may have been moved, so say where it is. - unless announced - warn "#{MIRROR_DROPPED} Scrubbing was interrupted; the session transcript is under " \ - "#{File.join(@config_dir, 'projects')}." - end + # signal). #cleanup leaves the directory alone from now on, so say what + # is left to do with it. + warn "#{MIRROR_DROPPED} #{interrupted_notice(original)}" unless announced end private - # Returns the rest of the preservation warning. - def move_aside_and_scrub - original = @config_dir + # What is left to say when the scrub was cut short: where the transcript + # is, that copies which were not removed yet may be left, and which + # directory to remove once the transcript is imported. That is the private + # directory once the move happened (config_dir changed with it; the trash + # is in there too) and the temp dir itself before — never its parent, + # which would be the system's temp directory. + def interrupted_notice(original) + holding = @config_dir == original ? @config_dir : File.dirname(@config_dir) + "Scrubbing was interrupted; the session transcript is under #{File.join(@config_dir, 'projects')}. Copies of " \ + "your credentials and settings that were not removed yet may be left under #{holding} — import the " \ + "transcript into your session store, then remove #{holding}." + end + + # Returns the rest of the preservation warning. +original+ is config_dir + # as it is when preserve_transcripts starts. + def move_aside_and_scrub(original) staging, failure = move_aside(original) if failure return "#{SKIPPED} #{original} could not be moved aside (#{failure.message}). If it is still there, the " \ diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index 4b86c296..cc60b165 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -184,14 +184,42 @@ def staging expect(warning).to include("#{config_dir} could not be moved aside (Permission denied") end - # The transcript was moved: if the scrub is cut short by something that is - # not a StandardError (a cancellation, a signal), the new location must - # still be said. - it 'says where the transcript is when the scrub is interrupted' do - warning = preserve_interrupted_at(File, :unlink) - - expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) - expect(warning).to match(/transcript mirror dropped batches.*#{Regexp.escape(File.join(preserved, 'projects'))}/) + # Cut short by something that is not a StandardError (a cancellation, a + # signal), it still has to say what to do: #cleanup leaves the directory + # alone from then on. Where the transcript is, that copies which were not + # removed yet may be left, and which directory to remove once the + # transcript is imported — the private one if the directory was moved + # there, the temp dir itself if it was not. (Its parent would be $TMPDIR.) + context 'when it is interrupted' do + # The whole interrupted notice, for a transcript under +transcript_dir+ + # and +to_remove+ as the directory the user is told to remove. + def interrupted(transcript_dir, to_remove) + to_remove = Regexp.escape(to_remove) + Regexp.new( + 'transcript mirror dropped batches.* Scrubbing was interrupted; the session transcript is under ' \ + "#{Regexp.escape(File.join(transcript_dir, 'projects'))}\\. " \ + "Copies of your credentials and settings .* may be left under #{to_remove} — " \ + "import the transcript into your session store, then remove #{to_remove}\\.$" + ) + end + + it 'names the private directory to remove once the directory was moved there' do + warning = preserve_interrupted_at(File, :unlink) + + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(File.basename(staging)).to start_with('claude-preserved-resume-') + expect(warning).to match(interrupted(preserved, staging)) + expect(warning).not_to match(/remove #{Regexp.escape(preserved)}\./) + end + + it 'names the temp dir itself when the directory had not been moved yet' do + warning = preserve_interrupted_at(File, :lstat) + + expect(preserved).to eq(config_dir) + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside') + expect(warning).to match(interrupted(config_dir, config_dir)) + expect(warning).not_to match(/remove #{Regexp.escape(scratch)}\./) + end end # A teardown can run twice. Client#disconnect lets go of its From fbaf269035115ee3cd8ff309a4a4f40d24dc2159 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:25:04 +0800 Subject: [PATCH 185/194] Accept tools: as the CLI's String and Pathnames where a path goes Two forms a Ruby caller writes naturally had no branch in the command builder, and each was dropped or broke without a useful word: - tools: 'Read,Grep', the syntax of the CLI's own --tools flag. The option was matched against Array, ToolsPreset and Hash only, so no flag was sent and the session got EVERY built-in tool instead of the two it was restricted to. - settings: Pathname (Rails.root.join('config/claude.json')) is neither a String nor a Hash: no --settings was sent, and the file's permission rules were not applied. - SystemPromptFile.new(path: Pathname), a Hash system prompt with a Pathname path, and a plugin path given as a Pathname put the Pathname itself on the command line. The spawn rejects it, so connect raised CLIConnectionError "no implicit conversion of Pathname into String". tools: now takes a String and passes it to --tools as written. A Pathname (anything that answers #to_path) is converted where the builder puts a path on the command line: the system prompt file, typed or Hash, and each plugin directory, typed or Hash. settings: as a Pathname is a settings file: with no sandbox: option the path is passed through, with one the file is read and merged, both exactly as for a String path. It is never tried as inline JSON, which a String is first. The typed values keep the Pathname they were given; only the command line gets a String. Values of any other class keep doing what they did (tools: 42 still sends no flag): no new error. The signatures widen accordingly: tools: gains String, settings: gains Pathname, and SystemPromptFile#path and SdkPluginConfig#path gain Pathname (constructor, accessor and #to_h). docs/options.md follows for those rows. User-visible: a tools: String that was ignored now restricts the session to the tools it names. Exploration finding: D-P3 / C-P4 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- docs/options.md | 8 +- lib/claude_agent_sdk/command_builder.rb | 38 ++++-- sig/claude_agent_sdk/types/option_values.rbs | 12 +- sig/claude_agent_sdk/types/options.rbs | 14 +- .../tools_string_and_pathname_options_spec.rb | 129 ++++++++++++++++++ 5 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 spec/unit/tools_string_and_pathname_options_spec.rb diff --git a/docs/options.md b/docs/options.md index 509739ee..5c7d9ad0 100644 --- a/docs/options.md +++ b/docs/options.md @@ -64,7 +64,7 @@ Guides: [Structured Output](configuration.md#structured-output), [Thinking Confi |-----------------|--------------| | `nil` (the default) | `--system-prompt ""`: an empty prompt, not Claude Code's default one | | a String, `SystemPromptCustom`, or `{ type: 'custom', prompt: '...' }` | `--system-prompt <text>` | -| `SystemPromptFile`, or `{ type: 'file', path: '...' }` | `--system-prompt-file <path>` | +| `SystemPromptFile`, or `{ type: 'file', path: '...' }` (the path a String or a `Pathname`) | `--system-prompt-file <path>` | | `SystemPromptPreset`, or `{ type: 'preset', preset: 'claude_code' }` | no system prompt flag, so the CLI uses its default prompt; `append:` adds `--append-system-prompt <text>` | `exclude_dynamic_sections` on a preset is sent as `initialize.excludeDynamicSections` ([Cross-User Prompt Caching](configuration.md#cross-user-prompt-caching)), and `snapshot` on a preset or a custom prompt as `initialize.systemPromptSnapshot` ([System Prompt Snapshot](configuration.md#system-prompt-snapshot)). Both are left out when unset. @@ -79,7 +79,7 @@ Guides: [Tools Configuration](configuration.md#tools-configuration), [Skills](co | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| -| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped]` | `nil` | `--tools <comma-joined names>`. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset` sends `--tools default` | +| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped] \| String` | `nil` | `--tools <comma-joined names>`; a String is sent as written, in the CLI's own comma-separated form. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset`, or `{ type: 'preset' }`, sends `--tools default` | | `allowed_tools` | `Array[String]` | `[]` | `--allowedTools` (comma-joined; no flag when empty) | | `disallowed_tools` | `Array[String]` | `[]` | `--disallowedTools` (comma-joined; no flag when empty) | | `permission_mode` | `String` | `nil` | `--permission-mode` | @@ -100,7 +100,7 @@ Guides: [Custom Tools (SDK MCP Servers)](mcp-servers.md), [Subagent capabilities | `mcp_servers` | `Hash[String \| Symbol, mcp_server_config] \| String` | `{}` | `--mcp-config`: a Hash as `{"mcpServers": {...}}` JSON, a String (a file path or JSON) as given. An SDK server contributes only its `type` and `name`; its tools are served in your process over the control protocol | | `strict_mcp_config` | `bool` | `false`; explicit `nil` stays `nil` | `--strict-mcp-config`. See [`strict_mcp_config`](#strict_mcp_config) | | `agents` | `Hash[String \| Symbol, AgentDefinition \| Hash[Symbol \| String, untyped]]` | `nil` | `initialize.agents` | -| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir <path>`, once per plugin | +| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir <path>`, once per plugin. The path may be a String or a `Pathname` | ### `strict_mcp_config` @@ -114,7 +114,7 @@ Guides: [Bare Mode](configuration.md#bare-mode), [Verbatim Prompts](configuratio | Option | Type | Default | Sent to the CLI as | |--------|------|---------|--------------------| -| `settings` | `String \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given. With `sandbox` set as well, one merged JSON value | +| `settings` | `String \| Pathname \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given, a `Pathname` as the path of a settings file. With `sandbox` set as well, one merged JSON value | | `setting_sources` | `Array[String]` | `nil` | `--setting-sources` (comma-joined). `[]` sends `--setting-sources ""`; `nil` sends no flag | | `add_dirs` | `Array[String \| Pathname]` | `[]` | `--add-dir`, once per directory | | `bare` | `bool` | `nil` | `--bare` | diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index d08543f8..e25ef99c 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -76,7 +76,7 @@ def append_system_prompt(cmd) when String cmd.push('--system-prompt', @options.system_prompt) when SystemPromptFile - cmd.push('--system-prompt-file', @options.system_prompt.path) + cmd.push('--system-prompt-file', path_string(@options.system_prompt.path)) when SystemPromptCustom # The object form of a String prompt; snapshot travels on the # initialize request, not as a CLI flag. @@ -98,11 +98,17 @@ def hash_type(hash) (hash[:type] || hash['type']).to_s end + # A path as the String the command line takes. A Pathname (anything that + # answers #to_path) is converted; every other value is returned as it is. + def path_string(path) + path.respond_to?(:to_path) ? path.to_path : path + end + def append_hash_system_prompt(cmd, prompt_hash) case hash_type(prompt_hash) when 'file' prompt_path = prompt_hash[:path] || prompt_hash['path'] - cmd.push('--system-prompt-file', prompt_path) if prompt_path + cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path when 'custom' prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] } cmd.push('--system-prompt', custom_prompt_text(prompt)) @@ -313,16 +319,14 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo settings_is_path = false if @options.settings - if @options.settings.is_a?(String) + if @options.settings.respond_to?(:to_path) + # A Pathname names a settings file; it is never tried as inline JSON. + settings_hash, settings_is_path = settings_file(cmd, @options.settings.to_path) + elsif @options.settings.is_a?(String) begin settings_hash = JSON.parse(@options.settings) rescue JSON::ParserError - if @options.sandbox.nil? # rubocop:disable Metrics/BlockNesting -- settings-is-a-path fallback inside the JSON parse rescue - settings_is_path = true - cmd.push('--settings', @options.settings) - else - settings_hash = load_settings_file(@options.settings) - end + settings_hash, settings_is_path = settings_file(cmd, @options.settings) end elsif @options.settings.is_a?(Hash) settings_hash = @options.settings.dup @@ -340,6 +344,17 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty? end + # --settings for a settings file, as [settings_hash, settings_is_path]. + # Without a sandbox option the path itself is passed and the CLI reads + # the file. With one, the file is read here, so that the option can be + # folded into its content. + def settings_file(cmd, path) + return [load_settings_file(path), false] unless @options.sandbox.nil? + + cmd.push('--settings', path) + [{}, true] + end + # The sandbox section as the CLI reads it. A Hash stands for the # SandboxSettings with the same fields: the CLI only knows the camelCase # keys that class writes, and it ignores the others without an error, so @@ -437,6 +452,9 @@ def append_tools(cmd) when Array tools_value = @options.tools.empty? ? '' : @options.tools.join(',') cmd.push('--tools', tools_value) + when String + # The CLI's own syntax ("Read,Grep", "default", ""): passed as written. + cmd.push('--tools', @options.tools) when ToolsPreset cmd.push('--tools', 'default') when Hash @@ -531,7 +549,7 @@ def append_plugins(cmd) end next unless plugin_path - cmd.push('--plugin-dir', plugin_path) + cmd.push('--plugin-dir', path_string(plugin_path)) end end diff --git a/sig/claude_agent_sdk/types/option_values.rbs b/sig/claude_agent_sdk/types/option_values.rbs index 397618b7..5e9afb3a 100644 --- a/sig/claude_agent_sdk/types/option_values.rbs +++ b/sig/claude_agent_sdk/types/option_values.rbs @@ -81,15 +81,15 @@ module ClaudeAgentSDK # A local plugin directory (ClaudeAgentOptions#plugins entries). class SdkPluginConfig < Type - def initialize: (?path: String?) -> void + def initialize: (?path: (String | Pathname)?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void - attr_accessor path: String? + attr_accessor path: (String | Pathname)? # "local". attr_reader type: String - def to_h: () -> { type: String, path: String? } + def to_h: () -> { type: String, path: (String | Pathname)? } end class SandboxNetworkConfig < Type @@ -181,15 +181,15 @@ module ClaudeAgentSDK # A system prompt loaded from a file. class SystemPromptFile < Type - def initialize: (?path: String?) -> void + def initialize: (?path: (String | Pathname)?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void - attr_accessor path: String? + attr_accessor path: (String | Pathname)? # "file". attr_reader type: String - def to_h: () -> { type: String, path: String? } + def to_h: () -> { type: String, path: (String | Pathname)? } end # The Claude Code preset system prompt, optionally extended. diff --git a/sig/claude_agent_sdk/types/options.rbs b/sig/claude_agent_sdk/types/options.rbs index d8803e3f..fa3837fb 100644 --- a/sig/claude_agent_sdk/types/options.rbs +++ b/sig/claude_agent_sdk/types/options.rbs @@ -48,7 +48,7 @@ module ClaudeAgentSDK ?permission_prompt_tool_name: String?, ?cwd: (String | Pathname)?, ?cli_path: (String | Pathname)?, - ?settings: (String | Hash[Symbol | String, untyped])?, + ?settings: (String | Pathname | Hash[Symbol | String, untyped])?, ?add_dirs: Array[String | Pathname]?, ?env: Hash[String | Symbol, String?]?, ?extra_args: Hash[String | Symbol, untyped]?, @@ -68,7 +68,7 @@ module ClaudeAgentSDK ?plugins: Array[SdkPluginConfig | Hash[Symbol | String, untyped]]?, ?debug_stderr: (_Puts | String)?, ?betas: Array[String]?, - ?tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped])?, + ?tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)?, ?sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)?, ?thinking: thinking_config?, ?effort: (String | Symbol | Integer)?, @@ -133,8 +133,9 @@ module ClaudeAgentSDK attr_accessor cli_path: (String | Pathname)? - # A settings JSON String, a path to a settings file, or a Hash. - attr_accessor settings: (String | Hash[Symbol | String, untyped])? + # A settings JSON String, a path to a settings file (a String or a + # Pathname), or a Hash. + attr_accessor settings: (String | Pathname | Hash[Symbol | String, untyped])? attr_accessor add_dirs: Array[String | Pathname]? @@ -185,8 +186,9 @@ module ClaudeAgentSDK # Entries of SDK_BETAS. attr_accessor betas: Array[String]? - # The base tool set: tool names, or a preset. - attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped])? + # The base tool set: tool names (an Array, or the CLI's comma-separated + # String such as "Read,Grep"), or a preset. + attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)? # true / false are forwarded as-is (a bare toggle). attr_accessor sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)? diff --git a/spec/unit/tools_string_and_pathname_options_spec.rb b/spec/unit/tools_string_and_pathname_options_spec.rb new file mode 100644 index 00000000..05a24021 --- /dev/null +++ b/spec/unit/tools_string_and_pathname_options_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'pathname' +require 'tmpdir' + +# Two forms a Ruby caller writes naturally and the command builder had no +# branch for, so they were dropped without a word: +# +# tools: 'Read,Grep' the CLI's own --tools syntax. No flag was sent, so +# the session got EVERY built-in tool, not two. +# a Pathname where a path is expected. settings: Pathname sent no +# --settings at all; SystemPromptFile#path and a plugin +# path put the Pathname itself on the command line, +# which the spawn rejects ("no implicit conversion of +# Pathname into String"). +RSpec.describe 'tools as a String and paths as Pathnames' do + # The command line built for these options. + def argv(**options) + ClaudeAgentSDK::CommandBuilder.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + end + + # The value that follows +flag+ on that command line (nil without the flag). + def flag_value(cmd, flag) + index = cmd.index(flag) + index && cmd[index + 1] + end + + describe 'tools: as a String' do + it 'builds the command line the Array of the same names builds' do + expect(argv(tools: 'Read,Grep')).to eq(argv(tools: %w[Read Grep])) + end + + it 'is passed to --tools as written: it is the CLI\'s own syntax' do + values = ['Read,Grep', 'Read', 'default', ''].map { |tools| flag_value(argv(tools: tools), '--tools') } + + expect(values).to eq(['Read,Grep', 'Read', 'default', '']) + end + + it 'still sends no --tools flag for a value of another class', + rbs_incompatible: 'passes values outside the signature of tools to show they are still ignored' do + expect([42, :default, true].map { |tools| argv(tools: tools) }).to eq([argv] * 3) + end + end + + describe 'settings: as a Pathname' do + it 'builds the command line the String path builds' do + path = '/srv/app/config/claude.json' + + expect(argv(settings: Pathname.new(path))).to eq(argv(settings: path)) + expect(flag_value(argv(settings: Pathname.new(path)), '--settings')).to eq(path) + end + + it 'is read and merged with the sandbox option like the String path, relative to the CLI\'s cwd' do + Dir.mktmpdir('pathname-settings') do |dir| + File.write(File.join(dir, 'claude.json'), JSON.generate(permissions: { deny: ['WebFetch'] })) + absolute = File.join(dir, 'claude.json') + forms = [ + { settings: Pathname.new(absolute) }, + { settings: Pathname.new('claude.json'), cwd: dir }, + { settings: Pathname.new('claude.json'), cwd: Pathname.new(dir) } + ] + + merged = forms.map { |form| JSON.parse(flag_value(argv(sandbox: { enabled: true }, **form), '--settings')) } + + expect(merged).to eq([{ 'permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => { 'enabled' => true } }] * 3) + expect(argv(sandbox: { enabled: true }, settings: Pathname.new(absolute))) + .to eq(argv(sandbox: { enabled: true }, settings: absolute)) + end + end + + # A String is tried as inline JSON first and taken for a path when it does + # not parse. A Pathname says which of the two it is. + it 'is a path even when its name would parse as JSON' do + expect(flag_value(argv(settings: Pathname.new('42')), '--settings')).to eq('42') + end + end + + describe 'the path of a system prompt file, as a Pathname' do + path = '/srv/app/prompts/reviewer.md' + { + 'a SystemPromptFile' => ClaudeAgentSDK::SystemPromptFile.new(path: Pathname.new(path)), + 'a Hash' => { type: 'file', path: Pathname.new(path) }, + 'a Hash with a Symbol type and String keys' => { 'type' => :file, 'path' => Pathname.new(path) } + }.each do |form, system_prompt| + it "reaches the command line as a String for #{form}" do + cmd = argv(system_prompt: system_prompt) + + expect(cmd).to all(be_a(String)) + expect(cmd).to eq(argv(system_prompt: ClaudeAgentSDK::SystemPromptFile.new(path: path))) + expect(flag_value(cmd, '--system-prompt-file')).to eq(path) + end + end + + it 'stays the Pathname the caller gave on the typed value' do + pathname = Pathname.new(path) + prompt = ClaudeAgentSDK::SystemPromptFile.new(path: pathname) + + expect(prompt.path).to equal(pathname) + expect(prompt.to_h).to eq(type: 'file', path: pathname) + end + end + + describe 'a plugin path as a Pathname' do + path = '/srv/app/plugins/review' + { + 'an SdkPluginConfig' => ClaudeAgentSDK::SdkPluginConfig.new(path: Pathname.new(path)), + 'a Hash' => { type: 'local', path: Pathname.new(path) }, + 'a Hash with String keys' => { 'type' => 'local', 'path' => Pathname.new(path) } + }.each do |form, plugin| + it "reaches the command line as a String for #{form}" do + cmd = argv(plugins: [plugin]) + + expect(cmd).to all(be_a(String)) + expect(cmd).to eq(argv(plugins: [ClaudeAgentSDK::SdkPluginConfig.new(path: path)])) + expect(flag_value(cmd, '--plugin-dir')).to eq(path) + end + end + + it 'stays the Pathname the caller gave on the typed value' do + pathname = Pathname.new(path) + plugin = ClaudeAgentSDK::SdkPluginConfig.new(path: pathname) + + expect(plugin.path).to equal(pathname) + expect(plugin.to_h).to eq(type: 'local', path: pathname) + end + end +end From e3f1b0c73c0ccbf753c09ab68628008f68daddec Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:25:53 +0800 Subject: [PATCH 186/194] Write a typed network or filesystem inside a sandbox Hash as a Hash A sandbox: Hash may hold a typed section: sandbox: { enabled: true, network: SandboxNetworkConfig.new(denied_domains: [...]) } JSON.generate has no way to write a typed value, so the section went out as its #inspect text, "#<ClaudeAgentSDK::SandboxNetworkConfig ...>". The CLI wants an object there ("sandbox.network: Expected object, but received string") and discards the whole --settings value over it: the session ran with no sandbox at all, and without the permissions passed in the same settings. The network and filesystem of a Hash sandbox: now go through the same two functions a typed SandboxSettings uses for its own sections, so a typed config is written by its #to_h and a Hash has its keys renamed, as before. Any other value is still sent as written. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/types/option_values.rb | 16 ++++++++-------- spec/unit/sandbox_hash_keys_spec.rb | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index caa74d04..fa879d41 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -282,18 +282,18 @@ module SandboxKeys }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire - # keys, at the top level and inside a Hash network / filesystem. Values - # are not rewritten: ignore_violations and ripgrep hold structures of - # the CLI's own. + # keys, at the top level and inside its network / filesystem, which may + # each be a Hash or the typed config. Other values are not rewritten: + # ignore_violations and ripgrep hold structures of the CLI's own. def self.normalize(sandbox) normalized = rename(sandbox, TOP_LEVEL) - { network: NETWORK, filesystem: FILESYSTEM }.each do |section, table| - normalized[section] = rename(normalized[section], table) if normalized[section].is_a?(Hash) - end + normalized[:network] = network(normalized[:network]) if normalized.key?(:network) + normalized[:filesystem] = filesystem(normalized[:filesystem]) if normalized.key?(:filesystem) normalized end - # The network of a SandboxSettings as the CLI reads it. + # A network section as the CLI reads it: the typed config's #to_h, or a + # Hash with its known keys renamed. Any other value is returned as it is. def self.network(section) case section when SandboxNetworkConfig then section.to_h @@ -302,7 +302,7 @@ def self.network(section) end end - # The filesystem of a SandboxSettings as the CLI reads it. + # A filesystem section as the CLI reads it (see .network). def self.filesystem(section) case section when SandboxFilesystemConfig then section.to_h diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb index f4386b34..c60bbeb7 100644 --- a/spec/unit/sandbox_hash_keys_spec.rb +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -573,6 +573,26 @@ def on_the_wire(value) end end + # JSON.generate has no way to write a typed value: left inside a Hash it + # went out as its #inspect text ("#<ClaudeAgentSDK::SandboxNetworkConfig + # ...>"), a String where the CLI wants an object, and the CLI discarded the + # whole --settings value over it. + describe 'a typed network or filesystem inside a Hash' do + it 'is written like a Hash with the same fields' do + sandbox = { + enabled: true, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']), + 'filesystem' => ClaudeAgentSDK::SandboxFilesystemConfig.new(deny_read: ['/private/etc/ssh']) + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'network' => { 'deniedDomains' => ['evil.example'] }, + 'filesystem' => { 'denyRead' => ['/private/etc/ssh'] } + ) + end + end + describe 'what the caller did not write' do it 'is not added: an enabled sandbox is sent without failIfUnavailable' do expect([{ enabled: true }, { 'enabled' => true }, ClaudeAgentSDK::SandboxSettings.new(enabled: true)] From 1685efffbdbaec32c7418d2d39f7be791395819b Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:26:35 +0800 Subject: [PATCH 187/194] Honor a Symbol type in a plugins Hash A plugin given as a Hash carries a type, 'local' (or 'plugin', its older name). The command builder compared it with those Strings, so the Ruby spelling plugins: [{ type: :local, path: '/srv/app/plugins/review' }] raised ArgumentError "Unsupported plugin type: :local" when the command line was built. The type is now read the way system_prompt, tools and output_format read theirs, as a String whatever it was written as. A type that is not a plugin type is refused as before, and the message still shows it as the caller wrote it (:remote, "remote", nil). Exploration finding: D-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/command_builder.rb | 4 ++-- spec/unit/option_hash_symbol_type_spec.rb | 27 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index e25ef99c..e190cf82 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -541,10 +541,10 @@ def append_plugins(cmd) @options.plugins.each do |plugin| plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin - plugin_type = plugin_config[:type] || plugin_config['type'] plugin_path = plugin_config[:path] || plugin_config['path'] - unless %w[local plugin].include?(plugin_type) + unless %w[local plugin].include?(hash_type(plugin_config)) + plugin_type = plugin_config[:type] || plugin_config['type'] raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}" end next unless plugin_path diff --git a/spec/unit/option_hash_symbol_type_spec.rb b/spec/unit/option_hash_symbol_type_spec.rb index 8cc4c1e2..dcbf7c7c 100644 --- a/spec/unit/option_hash_symbol_type_spec.rb +++ b/spec/unit/option_hash_symbol_type_spec.rb @@ -149,6 +149,33 @@ def flag_value(cmd, flag) end end + # plugins: entries carry a `type` too ('local', or 'plugin', its older + # name). A Symbol there was refused: "Unsupported plugin type: :local". + describe 'a plugin Hash' do + it 'builds the same command line with a Symbol type' do + string_typed = argv(plugins: [{ type: 'local', path: '/srv/app/plugins/review' }]) + symbol_typed = [ + { type: :local, path: '/srv/app/plugins/review' }, + { 'type' => :local, 'path' => '/srv/app/plugins/review' }, + { type: :plugin, path: '/srv/app/plugins/review' } + ].map { |plugin| argv(plugins: [plugin]) } + + expect(flag_value(string_typed, '--plugin-dir')).to eq('/srv/app/plugins/review') + expect(symbol_typed).to eq([string_typed] * 3) + end + + it 'still refuses a type that is not a plugin type, naming it as written' do + messages = [{ type: 'remote', path: '/x' }, { type: :remote, path: '/x' }, { path: '/x' }].map do |plugin| + argv(plugins: [plugin]) + rescue ArgumentError => e + e.message + end + + expect(messages).to eq(['Unsupported plugin type: "remote"', 'Unsupported plugin type: :remote', + 'Unsupported plugin type: nil']) + end + end + describe 'a type the SDK does not know' do it 'still sends no system prompt flag at all' do [{ type: 'bogus', prompt: 'x' }, { type: :bogus, prompt: 'x' }, { prompt: 'x' }, { type: nil }].each do |system_prompt| From 8d749961add0d328d3f34a5900c51ffacd369618 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:29:09 +0800 Subject: [PATCH 188/194] Document the accepted spellings of Hash-form options docs/configuration.md, in the sections that show these options: - Sandbox Settings: sandbox: and its network / filesystem take a Hash, which may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; any other key is sent as written; a snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts, and is otherwise sent as written; a field holding nil is left out. - The same section now says what makes that last rule necessary, and is true of every form, the typed classes included: when one sandbox value fails the CLI's settings schema, the CLI (2.1.287) discards the whole --settings value, sandbox and permissions rules alike, and reports it nowhere. connect succeeds, nothing appears on stderr, and the session runs unsandboxed. - Structured Output, System Prompt Snapshot and Tools Configuration: next to each Hash example, `type` may be a String or a Symbol. Tools Configuration also shows the String form of tools: and the preset as a Hash. The signature comments of SandboxSettings and ClaudeAgentOptions#sandbox and the Sandbox section of the two skill trees (kept byte-identical) say the same in one paragraph. No type changes here; the Pathname and String widenings and their docs/options.md rows went in with the code. Docs only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- docs/configuration.md | 30 +++++++++++++++++++ .../claude-agent-ruby/references/options.md | 2 ++ sig/claude_agent_sdk/types/option_values.rbs | 7 +++++ sig/claude_agent_sdk/types/options.rbs | 4 ++- skills/references/options.md | 2 ++ 5 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9221e00e..51d416d4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,8 @@ ClaudeAgentSDK.query(prompt: "Create a profile for a software engineer", options end ``` +In the `output_format` Hash, `type` may be a String or a Symbol (`type: :json_schema`), and the keys Symbols or Strings. + See [examples/structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb). ## Thinking Configuration @@ -96,6 +98,8 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +In a Hash form, `type` may be a String or a Symbol (`type: :preset`). + `snapshot` is sent on the control-protocol `initialize` request (never as a CLI flag), so it applies to both `query()` and `Client`. When omitted it acts as `true`, except in bare mode (`bare: true`), where it acts as `false`. A `SystemPromptFile` has no `snapshot`. Requires Claude Code CLI 2.1.257 or later. Before 2.1.265, a session with an `append` or custom prompt recorded it only when `snapshot` was `true`. See [Modifying system prompts](https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts#change-the-prompt-of-an-existing-session) for details. @@ -177,10 +181,18 @@ Available beta features are listed in the `SDK_BETAS` constant. # Array of tool names options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ['Read', 'Edit', 'Bash']) +# The same names as one String, in the comma-separated form of the CLI's --tools flag +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: 'Read,Edit,Bash') + # Preset options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code')) + +# The preset as a Hash +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: { type: 'preset', preset: 'claude_code' }) ``` +A String is passed to the CLI as written. In the Hash form, `type` may be a String or a Symbol (`type: :preset`). + ## Skills `skills` is the single place to enable skills for the main session — it auto-allows the `Skill` tool and defaults `setting_sources` to `['user', 'project']` (when unset) so skill files are discovered: @@ -218,6 +230,24 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +`sandbox` also takes a Hash, and so do `network` and `filesystem`, inside that Hash or inside a `SandboxSettings`. A Hash may spell the fields of `SandboxSettings`, `SandboxNetworkConfig` and `SandboxFilesystemConfig` as the classes do (`denied_domains`) or as the CLI does (`deniedDomains`), with Symbol or String keys: + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + sandbox: { + enabled: true, + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['~/.ssh'] } + } +) +``` + +- Any other key is sent as written. That is how to pass a sandbox setting the classes have no attribute for (`allowAppleEvents`, `strictAllowlist` inside `network`): spell it as the CLI does. +- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, an Integer for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know. +- A field holding `nil` is left out, in either spelling, as a `nil` attribute of the typed classes is. + +The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. It reports this nowhere: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox. + `enabled: true` asks for a sandbox. It does not make one a requirement. When the sandbox cannot start on the host (missing dependencies, an unsupported platform), the CLI carries on without it. Its settings schema describes the setting that decides this, `failIfUnavailable`, as follows (CLI 2.1.287): > Exit with an error at startup if sandbox.enabled is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index 73a01911..3cfe1d7d 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -99,6 +99,8 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. + ## Agents Use `agents:` to configure sub-agent definitions passed to the CLI via the control protocol. diff --git a/sig/claude_agent_sdk/types/option_values.rbs b/sig/claude_agent_sdk/types/option_values.rbs index 5e9afb3a..52fa6e01 100644 --- a/sig/claude_agent_sdk/types/option_values.rbs +++ b/sig/claude_agent_sdk/types/option_values.rbs @@ -138,6 +138,13 @@ module ClaudeAgentSDK # Sandbox settings for isolated command execution # (ClaudeAgentOptions#sandbox). + # + # network / filesystem take the typed config or a Hash. A Hash may spell + # the fields of the typed class in snake_case or in the CLI's camelCase, + # with Symbol or String keys; other keys are sent as written. A snake_case + # key is sent under the CLI's name only when its value has the shape the + # CLI accepts for it; otherwise it is sent as written, which the CLI + # ignores. class SandboxSettings < Type def initialize: (?enabled: bool?, ?fail_if_unavailable: bool?, ?auto_allow_bash_if_sandboxed: bool?, ?excluded_commands: Array[String]?, ?allow_unsandboxed_commands: bool?, ?network: (SandboxNetworkConfig | Hash[Symbol | String, untyped])?, ?filesystem: (SandboxFilesystemConfig | Hash[Symbol | String, untyped])?, ?ignore_violations: Hash[Symbol | String, Array[String]]?, ?enable_weaker_nested_sandbox: bool?, ?enable_weaker_network_isolation: bool?, ?ripgrep: Hash[Symbol | String, untyped]?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void diff --git a/sig/claude_agent_sdk/types/options.rbs b/sig/claude_agent_sdk/types/options.rbs index fa3837fb..2a503000 100644 --- a/sig/claude_agent_sdk/types/options.rbs +++ b/sig/claude_agent_sdk/types/options.rbs @@ -190,7 +190,9 @@ module ClaudeAgentSDK # String such as "Read,Grep"), or a preset. attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)? - # true / false are forwarded as-is (a bare toggle). + # true / false are forwarded as-is (a bare toggle). A Hash may spell the + # fields of SandboxSettings, and of its network / filesystem, in + # snake_case or in the CLI's camelCase (see SandboxSettings). attr_accessor sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)? attr_accessor thinking: thinking_config? diff --git a/skills/references/options.md b/skills/references/options.md index 73a01911..3cfe1d7d 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -99,6 +99,8 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. + ## Agents Use `agents:` to configure sub-agent definitions passed to the CLI via the control protocol. From bec5d871d174e8f8ab4fc47ae5c2b8f9cb2a16c3 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 05:59:31 +0800 Subject: [PATCH 189/194] CHANGELOG entries for the October exploration fixes The [Unreleased] entries of PRs #148-#161, collected here so the fix PRs do not conflict with each other in CHANGELOG.md. Fixes that matter for safety or permissions come first under Fixed; the three "set back to nil" entries are one, and three page corrections made in other PRs are listed with the documentation entry. README: the sentence on what CI runs names Ruby 4.0 and the keyless smoke test the test-suite PR (#153) adds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2 --- CHANGELOG.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f4fe62..6717f904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`docs/options.md`**: a reference for every `ClaudeAgentOptions` attribute — its type, its default, and the CLI flag, `initialize` field or environment variable it becomes — plus what an option reads when it is left out, passed as `nil` or given a configured default, and the environment variables the SDK reads. It documents options that had no entry anywhere before (`extra_args`, `include_partial_messages`, `include_hook_events`, `task_budget`, `strict_mcp_config`) and the `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` ceiling from 1.1.0. A spec checks every row against the code. `docs/client.md` documents the `transport:` argument of `query` / `ask`. ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)) +- `Pathname` where a path goes: `settings:` (a settings file; used to send no `--settings` at all), `SystemPromptFile#path`, the `path` of a `{ type: 'file' }` system prompt Hash, and a plugin path, typed or Hash (these failed at connect with `no implicit conversion of Pathname into String`). `tools:` is signed `String` as well; the RBS signatures are widened accordingly. ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) + ### Changed - `docs/cli-installer.md` now says when `CLIInstaller::PINNED_CLI_VERSION` moves: pin-only changes ship with the next gem release rather than one release per CLI bump. It also covers how a gem patch can move your CLI and how to take a newer CLI before a release pins it. The pin-bump workflow keeps a single `[Unreleased]` entry for the pin. - `CLIInstaller::PINNED_CLI_VERSION` moves from 2.1.285 to **2.1.287**, following the CLI the Python SDK bundles. `CLIInstaller.install_pinned` installs it. See the [Claude Code changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md). +- **The Rails guide, both Rails examples and the generated initializer turn off the CLI's per-project auto-memory** (`env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }`; commented out in the initializer). Every SDK session reads that memory and a `claude_code`-preset session writes it without a permission check, so in a multi-user app one user's "remember …" would reach every other user's session. If you copied those examples, add the line; the value must be `'1'`. ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)) +- **`docs/configuration.md` has a "Session Isolation" section.** Claude Code's per-project auto-memory is on in SDK sessions: every session loads the project's `MEMORY.md` index into its context (`setting_sources: []` does not prevent it), and a session on the `claude_code` preset also writes memory files, which the CLI allows without any permission setup. Servers and multi-tenant hosts should set `env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }` (or `settings: { autoMemoryEnabled: false }`); a custom transport has to pass either one through to its CLI itself; `Client#context_usage[:memoryFiles]` shows what a session loaded. ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)) +- **A requested sandbox that is not active now produces a warning.** `sandbox: { enabled: true }` does not guarantee a sandbox: when the CLI cannot start one (a Linux container without `bubblewrap` and `socat`, for example) it prints `⚠ Sandbox disabled: …` to its stderr and runs commands unsandboxed, which a host only saw through a `stderr:` callback. The SDK now repeats that line as a Ruby warning, prefixed `[claude-agent-sdk]`, once per session, when the `sandbox:` option enabled the sandbox. The default is unchanged; set `fail_if_unavailable: true` to make the CLI exit instead. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- **`OTelObserver` now leaves a span for a session that fails before its first `InitMessage`.** A CLI that could not be found or started, or an `initialize` that failed or timed out, raised to the caller but exported no span at all (0.18.0's "crashed sessions now produce OTel traces with error status" only held once an `InitMessage` had arrived). `on_error` now emits a `claude_agent.session` span with the observer's default attributes, the prompt if one had been sent, the exception event and error status, and ends it at once. The span has no model or `session.id`. Nothing changes once a trace has started. If you count `claude_agent.session` spans, failed starts are now among them. ([#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)) +- `docs/observability.md` no longer promises "standard `gen_ai.*` semantic conventions … any OTel backend". The attributes follow the Langfuse and OpenInference conventions plus a subset of OTel `gen_ai.*`; the page now lists every attribute, event and status `OTelObserver` sets, and how the `gen_ai.*` names differ from the OTel GenAI semantic conventions — notably that `gen_ai.usage.input_tokens` leaves cache tokens out, and that the inclusive count is `llm.token_count.prompt` on the session span. No attribute changed. ([#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)) +- **`docs/rails.md` no longer says callbacks inherit the request's state.** A callback runs on a thread or fiber Rails never set up: `Current` attributes, `Time.zone`, log tags, the error context and the `connected_to` role / shard read as their defaults there (a write under `connected_to(shard:)` lands in the default shard), with or without `Railtie.callback_wrapper`; `I18n.locale` depends on the i18n version. The guide now has the table, a per-call recipe that carries the state across, the rule for composing your own wrapper with the Rails one, a section on transactions and the connection pool (a callback is outside the caller's transaction and uses its own connection), and a note that in development an agent run holds the reload lock. ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)) +- `docs/configuration.md` says what the CLI does with an invalid sandbox value: it discards the whole `--settings` value, sandbox and `permissions` rules alike. The SDK does not surface this — `connect` succeeds and nothing appears on stderr; only the CLI's `get_settings` control response lists the error. That holds for every form, the typed classes included. ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- Disk listings (`list_sessions`, `get_session_info`) scan each transcript's head and tail windows as bytes; with non-ASCII text in a window this takes a fraction of the CPU time it did. Results are unchanged. ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)) +- Development only: the gemspec's development dependency on Bundler is `>= 2.0` (was `~> 2.0`), so `bundle install` works in a checkout on Ruby 4.0, which ships Bundler 4. CI now runs the suite and RuboCop on Ruby 4.0, and a keyless smoke runs the pinned CLI on PRs that touch the installer, the transport, `Query` or the message parser; the API-key integration job shows as skipped instead of passed when the key is absent. Runtime dependencies are unchanged. ([#153](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/153)) + +### Fixed +- **A hook callback's Hash return value now means what the equivalent typed output means, whatever its key style.** Only five top-level keys were renamed before, and only as Symbols, so `{ hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' } }` reached the CLI under keys it does not read: the decision was ignored and the tool ran. A String-keyed Hash (e.g. after `JSON.parse`) and a `SyncHookJSONOutput` holding a snake_case Hash failed the same way. The fields the typed output classes model are now accepted in snake_case or camelCase, as Symbols or Strings, at the top level and inside `hook_specific_output`; any other key is sent as written, and the keys inside `updated_input`, the tool outputs and a `PermissionRequest` `decision` are not renamed. **If a hook of yours returns snake_case keys, they now take effect.** When one Hash spells a field both ways the CLI's spelling is sent, and a Symbol and a String spelling the same key are written once (json 3.x used to fail the hook with `detected duplicate key`). ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- **A `sandbox:` Hash written in Ruby spelling is now applied.** The typed sandbox classes take snake_case attributes and write the camelCase keys the CLI reads, but a Hash was forwarded as written: `sandbox: { enabled: true, network: { denied_domains: ['evil.example'] }, filesystem: { deny_read: ['~/.aws'] } }` reached the CLI under keys it does not know, and the CLI ignored them without an error — the session ran with `network: {}` and `filesystem: {}`, no deny rule. `SandboxSettings.new(network: { denied_domains: [...] })` lost its rules the same way. The fields of `SandboxSettings`, `SandboxNetworkConfig` and `SandboxFilesystemConfig` are now accepted in snake_case or camelCase, as Symbols or Strings, at the top level and inside `network` / `filesystem`; any other key is sent as written. **If a sandbox Hash of yours has snake_case keys, they now take effect.** A snake_case key is renamed only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); with any other value it is sent as written and stays without effect, as before — so the rename can never make the CLI discard your settings. When one Hash spells a field both ways the camelCase one is sent; a field holding `nil` is left out (the CLI rejects `null` there); a Symbol and a String spelling the same key are written once. A typed `SandboxNetworkConfig` / `SandboxFilesystemConfig` inside a `sandbox:` Hash is written as the Hash it stands for (it reached the CLI as its `#inspect` text, which made the CLI drop the sandbox). ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- **"Always allow" answers from `can_use_tool` now stick for whole-tool rules.** `PermissionUpdate#to_h` wrote `"ruleContent": null` for a rule without content and omitted a nil `destination`; the CLI accepts neither and drops the **whole** `updatedPermissions` array when one entry is malformed, so `PermissionResultAllow.new(updated_permissions: context.suggestions)` silently persisted nothing for the suggestions the CLI builds for `WebSearch` or MCP tools, and the callback was asked again on every call. `ruleContent` is now omitted when nil, and an update that has a `type` but no `destination` is sent to `'session'` (this run only; no settings file is written). ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- **`tools: 'Read,Grep'` is no longer ignored.** A String had no branch in the command builder, so no `--tools` flag was sent and the session got every built-in tool. It is now passed to `--tools` as written (the CLI's own comma-separated form). **If you pass `tools:` as a String, the session is now restricted to the tools it names.** ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- **`system_prompt`, `tools`, `output_format` and `plugins` Hashes accept a Symbol `type`**, like `thinking` and the MCP server configs already did. `tools: { type: :preset, preset: :claude_code }` sent the Hash's JSON text as a tool name and the session started with **no tools**; `system_prompt: { type: :custom, prompt: '…' }` (and `:file`, `:preset`) sent no flag, so the prompt and its options were dropped; `output_format: { type: :json_schema, schema: {…} }` made the CLI exit; `plugins: [{ type: :local, … }]` raised `Unsupported plugin type`. An `output_format` Hash with `type` and `schema` under different key styles silently turned structured output off; `schema` is now found under either. A Symbol-typed Hash means what the String-typed one means, including its check: `{ type: :custom }` without a `:prompt` String raises the same `ArgumentError`. ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- **Directory-scoped session APIs could read and modify another project's sessions.** For project paths over 200 characters, `list_sessions(directory: B)`, `get_session_messages` and `rename_session` / `tag_session` / `delete_session` / `fork_session` fell back to ANY project directory sharing the first 200 characters, so they could return or change the sessions of a sibling path A. In a directory found by that fallback, only sessions whose own transcript records the requested path as its top-level `cwd` are listed, read or modified (worktrees whose paths share such a directory are each listed). ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)) +- **A relative CLI path could run a different binary than the one the SDK checked.** The version probe runs in the process's working directory but the CLI is spawned in `cwd:`, so `cli_path: 'bin/claude'`, or a `claude` found through a relative `PATH` entry (`bin`, `.`, an empty entry), was checked in one directory and executed in the other — whatever file sat at that path inside the directory the agent was pointed at. The transport now settles one absolute path and uses it for both: a relative `cli_path` is resolved against the process's working directory (a leading `~` is expanded), and a bare name is looked up on `PATH` by the SDK, with relative `PATH` entries resolved against the process's working directory. Discovery no longer runs `which`: the SDK searches the process's `PATH` for `claude` itself, so a `which` found through a relative `PATH` entry can neither run nor redirect the lookup (a host without a `which` program now finds `claude` on `PATH`). If you relied on a relative `cli_path` resolving inside `cwd:`, pass an absolute path. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- **`#inspect` no longer prints credentials held outside `env` and `headers`.** `ClaudeAgentOptions#inspect` (and `#to_s`, `pp`, string interpolation) now filters `settings` and `extra_args` the way it filters `env`: a Hash keeps its keys with `"[FILTERED]"` values, and a String `settings` (JSON or a file path) prints as `"[FILTERED]"`. `mcp_servers` given as a String (JSON or a file path) prints as `"[FILTERED]"` too. `McpStdioServerConfig#args` is filtered. The `url` of `McpHttpServerConfig` / `McpSSEServerConfig` prints as scheme, host and port only (`"https://mcp.example.com/[FILTERED]"`), since a token can sit in its userinfo, path or query. Raw Hash server configs are filtered the same way (`type`, `command` and the redacted `url` are shown; `args`, `env`, `headers` and any other key print as `"[FILTERED]"`): inside `mcp_servers`, where they used to rely on the nesting limit, and in `McpServerStatus#config`, where the CLI echoes each server's `headers`, `url` and `args` back in `mcp_status`. Display only: the objects are not modified and nothing sent to the CLI changes. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **A materialized resume directory preserved after a mirror failure now holds only `projects/`.** The cleanup removed four known files and left whatever else the CLI had written — including `backups/.claude.json.backup.<ts>`, a full copy of the seeded `.claude.json`, which can hold MCP header secrets. Every entry except `projects/` is now deleted. To do that safely the SDK first moves the directory into a fresh private one next to it (`claude-preserved-resume-*`), so **the preserved transcript is no longer at the temp dir's old path: the warning names the new one.** If what it moved is not the directory it created (the path had been replaced by a symlink or another directory), or it cannot be moved, nothing is deleted and the warning says the scrub was skipped and why; symlinks are never followed, permissions are repaired only on the directory that was checked, an entry that cannot be removed is named in the warning, and an interrupted scrub says which directory to remove once the transcript is imported. Calling `disconnect` again after a teardown that was interrupted while it preserved the directory no longer deletes it. ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **`ClaudeAgentSDK::Railtie.callback_wrapper` no longer reports to `Rails.error`.** In production it ran callbacks inside `executor.wrap`, which reports whatever passes through it as an unhandled error: a message-block exception was reported from the callback thread with an empty context (and then skipped by the request / job layer as already reported), exceptions the SDK handles itself (hook, `can_use_tool`, SDK MCP tool, observer) were reported although the run continued, and — on Rails 7.2.3, 8.0.2 and later — so were the SDK's own cancellations under `callback_scheduling: :inline`. The wrapper now enters the executor with `run!` / `complete!` (same hooks, no report), so an exception that escapes a callback is reported once, by the layer that called the SDK, with its controller / job context. If you relied on those reports to see hook or tool failures, report them inside the callback (`Rails.error.report`). ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)) +- **A callback that fails with `NotImplementedError`, `LoadError`, `SystemStackError` or `SecurityError` is answered like any other failing callback.** These are not `StandardError`s, so nothing rescued them when a hook, a `can_use_tool` callback, an SDK MCP tool, resource or prompt handler, or the `callback_wrapper` raised one: the CLI never got a response for that request, and the exception stopped the whole Async reactor, taking every other session and task on it down (`query()` raised the raw exception). Now hooks and `can_use_tool` get the error response, a tool handler's failure reaches the model as an `isError: true` result, and a resource or prompt handler gets the JSON-RPC `-32603` error, exactly as for a `RuntimeError`; the session carries on. `SdkMcpServer#call_tool` called directly returns the `isError` result for these exceptions instead of raising. `exit`, `Interrupt`, signals and cancellation behave as before, and `NoMemoryError` is still fatal. ([#150](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/150)) +- **A callback failure whose message is not valid UTF-8 is answered.** When a hook or `can_use_tool` callback raised an exception whose message held invalid bytes (a multibyte character cut by `byteslice`, binary subprocess or HTTP output), building the error response raised `JSON::GeneratorError` and the CLI was never answered. On the process-exit path the same error replaced the `SystemExit` the SDK promises to re-raise: under `:inline` scheduling `exit` did not end the process. An `exit` or signal exception whose message was in an encoding that is not ASCII-compatible (UTF-16) failed the same way, with `Encoding::CompatibilityError`. The error text is now normalized first — invalid bytes become U+FFFD, text in another valid encoding is transcoded — and a response that still cannot be serialized is replaced by a fixed message, so the request is always answered and the original exception is the one re-raised. ([#150](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/150)) +- **`query()` with a streamed (Enumerator) prompt no longer closes stdin right after writing a message.** With hooks, `can_use_tool` or SDK MCP servers, stdin could close before that message's turn had produced a frame, so the turn's hook, permission and SDK MCP requests failed with "Stream closed". It happened when the CLI's `idle` for the previous run and the next message became ready in the same reactor pass: `Query#end_run` stopped the between-turns ceiling task (which yields) before it marked the run ended, and the message joined the run that was about to end. The run is now marked ended first. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A `Client` control method called from a fiber on another thread's reactor no longer breaks the session.** `interrupt`, `set_model`, `set_permission_mode`, `mcp_status`, … called through `Sync { client.interrupt }` on a request thread while the session runs on a background reactor, or from a `Sync` block inside a `:thread`-mode callback: on async 2.10–2.28 the response raised `FiberError: fiber called across threads` in the session's read loop, which ended the session and left the caller waiting for the control-request timeout (20 minutes by default); on async ≥ 2.29 the response could be missed, with the same wait. Such callers now wait the way plain threads already did. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **`receive_response` / `receive_messages` no longer hang after the CLI is gone.** After the CLI exited or crashed while a `Client` was connected, the first call raised the stream error (or returned the last turn), the second returned without messages, and every later call hung forever with no error and no timeout (from plain Ruby after the reactor had finished, async 2.10 raised `NoMethodError` instead). Every call after the end of the stream now returns at once; the stream error is still raised once. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A deadline around a `Client` call is no longer swallowed while an observer runs.** With observers configured, a deadline around `Client#receive_response`, `#receive_messages`, `#query` or `#disconnect` (`task.with_timeout`, or `Timeout.timeout` inside a reactor) was silently dropped when it expired while an observer callback was still running: nothing was raised and the turn carried on, calling the message block after the deadline. Only what the observer (or its `callback_wrapper`) raises is contained now. With `callback_scheduling: :inline` the observer runs on the caller's own fiber, where such a deadline cannot be told from the observer's own failure, and it is still contained. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **`Client#disconnect`, and the cleanup of a failing `Client#connect`, always tear the session down.** When the call was interrupted while an observer's `on_close` / `on_error` was running — the caller's task stopped (`Async::Stop`), or an inline hook's cooperative timeout fired — the CLI process, the session's read loop and the materialized resume directory (with its credentials copy) were left behind. The teardown now always runs, and the interruption propagates after it. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A `Client#disconnect` from another thread completes the CLI's graceful shutdown.** From an application thread, or from a tool handler, hook or `can_use_tool` callback under the default `callback_scheduling: :thread`, the close was handed to a transient task on the session's reactor; when the session had nothing else left to do the reactor wound down and stopped that task mid-teardown, so the CLI got SIGTERM instead of stdin EOF and its 5-second grace period (the time it has to finish its last session write), and `disconnect` returned before the child was reaped. The close now runs on a task the reactor waits for: such a `disconnect` takes as long as the teardown, like one made on the reactor. ([#161](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/161)) +- **A callback's thread hop no longer returns before the callback has finished.** When an exception (`Async::Stop`, a timeout) had been raised into the waiting fiber just as the previous hop's thread ended, the stale wakeup resumed the next hop, which returned `nil` as its result; with a `timeout:` (session-store adapter calls) the same wakeup was reported as `timed out` although no time had passed. `FiberBoundary.invoke` now waits until the thread has really finished or the deadline has really passed. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- An error raised by `ClaudeAgentSDK.query` / `.ask` from synchronous code (CLI not found, `ProcessError`, `ResultError`, an exception in your own block) was also logged by Async as `Task may have ended with unhandled exception.`, with the message and a backtrace, even when your code rescued it. The error is now only raised. Inside a reactor the same applied to failures before the first suspension, such as a transport that cannot connect. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A `stderr:` callback that raised `NotImplementedError`, `LoadError` or `SystemStackError` could hang the session.** Only `StandardError` was contained, so those ended the thread that reads the CLI's stderr; once the CLI had written another 64 KiB of stderr it blocked for good. They are now handled like any other callback error: dropped, and the callback is called again for the next line. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- **Store-backed resume now authenticates on macOS with a custom `CLAUDE_CONFIG_DIR`.** There the CLI keeps its OAuth credentials in the Keychain under `Claude Code-credentials-<first 8 hex of SHA-256(config dir)>` and writes no `.credentials.json`; the SDK only bridged the default config dir's entry, so the resumed subprocess reported "Not logged in" (and the failed attempt was mirrored into the store). The Keychain is now consulted for a custom config dir too — only when that directory has no `.credentials.json`, and never under `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`. The seeded copy still has the refresh token removed and mode `0600`. ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **A mirror flush cancelled while it waited behind an in-flight append no longer loses its batch.** The batcher detached the pending entries before queueing on its lock, so a `#flush` whose task was stopped there (a timeout around the caller, `Task#stop`) dropped them: they never reached the store and no `MirrorErrorMessage` was produced. The buffer is now detached inside the lock, so those entries go out with the next flush; a frame that arrives while the final flush is still waiting is delivered rather than counted as dropped (Python [#1289](https://github.com/anthropics/claude-agent-sdk-python/pull/1289)). The mirror's retry backoff uses `Kernel#sleep` instead of the deprecated `Async::Task#sleep`. ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **`get_session_messages` and `get_subagent_messages` (disk and `session_store:`) no longer drop the results of parallel tool calls.** The CLI parents each `tool_result` on the entry holding its own `tool_use`, so with several tool calls in one assistant turn only the last-written result lay on the path the readers walk; the others were silently missing, leaving `tool_use` blocks without a `tool_result`. They are now returned, after the turn's `tool_use` messages and before the result the conversation continued from; the chain's own result wins, so a call answered again after a rewind is not returned twice. Affected sessions return more messages than before, so `offset:`/`limit:` pages over them shift. ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)) +- **Session reads, listings and mutations — other fixes** ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)): + - `get_session_messages` (disk and store) no longer returns `[]` for a session whose last entry is an unanswered meta injection (a slash-command or skill body, a stop-hook message, a system reminder written just before the session was closed). + - The message readers no longer raise `Encoding::CompatibilityError` under a non-UTF-8 locale (`LANG=C` / `LC_ALL=C`), nor on a transcript whose last line was cut inside a multibyte character or that holds a raw non-UTF-8 byte (a torn line is skipped; invalid bytes come back as U+FFFD); `import_session_to_store` no longer stops half-way on such a line. Under `LC_ALL=C` a non-ASCII `CLAUDE_CONFIG_DIR`, `directory: Dir.pwd` and a non-ASCII worktree path no longer raise or hide every worktree. + - `list_sessions(directory:)` no longer leaves out the directory's own sessions when it is a subdirectory of a repository with several git worktrees; worktree paths git reports in decomposed Unicode form are NFC-normalized. + - Project paths containing a character outside the BMP (an emoji, a CJK Extension B ideograph) map to the project directory the CLI creates (two hyphens per such character). **`project_key_for_directory` changes for affected paths**: store entries written under the old key by `import_session_to_store` or the store mutations no longer match (the transcript mirror never used that key). + - `rename_session`, `tag_session`, `fork_session` and `delete_session` no longer raise `Errno::ENOENT` when `directory:` names a removed project directory (a deleted worktree); a missing path is resolved as far as it exists, following symlinks whose target is gone and applying a `..` after a link to the link's target, as `os.path.realpath` does. + - `list_sessions` no longer returns `[]` when the config directory path contains `[` or `{`. + - Disk listings find a first prompt beyond the first 64 KiB of the transcript (a bounded scan of up to 1 MiB) and take `created_at` only from a top-level `timestamp`, as the store path does; `created_at` is no longer 1 ms low for about one ISO timestamp in eight. `docs/sessions.md` lists what the disk path still does not see. + - `fork_session` without `title:` names the fork after the title the listing shows for the source, on disk and with `session_store:` alike. + - `get_session_info(session_store:)` reports the adapter's `mtime` as `last_modified`, as `list_sessions(session_store:)` does; the store subagent readers skip non-String `list_subkeys` values; `rename_session` / `tag_session` raise `ArgumentError` for a title or tag that is not a usable String; `list_subagents` on disk returns each id once. + - An optional `SessionStore` method that raises `NotImplementedError` when called (a stub behind a delegating wrapper, an adapter declining at run time) is treated as not implemented — the documented fallback is taken and `run_session_store_conformance` skips its contracts — while a required `load` that raises still fails the resume. + - `docs/sessions.md`: rename / tag / fork are safe while the session's CLI runs; delete only sessions whose CLI has exited. +- **`CLIInstaller.install` no longer deadlocks when two fibers of one `Async` reactor install into the same directory.** The second fiber blocked the reactor thread in `flock` while the first, which held the lock, was waiting on the network inside the critical section, so neither returned; with the default `version: 'stable'` this happened even when nothing needed installing. A waiting installer now polls the lock (every 50 ms) instead of blocking its thread. Threads and separate processes were never affected, and mutual exclusion is unchanged. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`CLIInstaller.install` / `install_pinned` no longer fail in a read-only install directory when the requested version is already installed.** In an image built as root and run as another user, or on a read-only root filesystem, a boot-time `install_pinned` raised `CLIInstallError` (`EACCES`) because the lock file could not be opened, although the binary was intact. When the lock file cannot be opened (`EACCES`, `EROFS`, `EPERM`), a concrete version that is installed and matches its recorded checksum and platform is now returned without the lock. A dist-tag (`'stable'` / `'latest'`), a different version or a damaged binary still raises there. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`CLIInstaller` honors `HTTPS_PROXY` / `https_proxy`.** `Net::HTTP` only looks at `http_proxy` unless it is told otherwise, even for a TLS connection, so an environment that exported only `HTTPS_PROXY` was bypassed, and where direct egress is blocked the install timed out with no mention of the proxy. The proxy for each request (redirect hops included) is now resolved from the `https` URL and passed explicitly, with its credentials; `NO_PROXY` / `no_proxy` apply as before, and an environment that only sets `http_proxy` keeps working. `ALL_PROXY` is not read, and only `http://` proxy URLs are used. `docs/cli-installer.md` has a new "Proxies and custom CAs" section (`HTTPS_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`). ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`CLIInstaller` fsyncs what it publishes.** The downloaded binary and `VERSION` are flushed to disk before they are renamed into place, and the directory after it (best-effort). Previously a power loss shortly after an install could leave an empty or partial executable `claude` under the published name, which discovery kept selecting until the next `install`. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- `CLIInstaller.install` with no `dir:` names the default directory (`vendor/claude`) in its error when the process's working directory no longer exists, instead of an empty path. `docs/cli-installer.md` states the locked sequence in the order the code uses (record `VERSION`, then place the binary), and that a failed first install never publishes a binary — it can leave the directory, its lock file and, after a failed final rename, a recorded `VERSION`. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **Shorthand tool schemas with `Array` / `Hash` parameters are advertised correctly.** `{ order_id: Integer, tags: Array }` told the model `tags` was a string, so it sent `"fragile,gift"` and the handler got a String; a model that did send an array had the call rejected. `Array` / `:array` now map to `array` and `Hash` / `:object` to `object`, in `tools/list` and in `tools/call` argument validation alike. Other shorthand values are unchanged. `docs/mcp-servers.md` lists the shorthand types. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **A tool handler that returns a non-Array `:content` gets a clear error.** `{ content: 'text' }`, a single block Hash, or a present `false` or `nil`, was forwarded unchanged; the CLI rejected it and told the model the server "returned a malformed result", so the text never arrived. It is now an in-band `isError` result naming the tool: `Tool 'x' must return :content as an Array of content blocks (got String)`. A result without a `:content` key keeps the existing message. Return the String itself, or `content: [{ type: 'text', text: ... }]`. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`create_tool(annotations: { maxResultSizeChars: … }, meta: { … })` keeps the size hint.** An explicit `meta:` replaced the `_meta['anthropic/maxResultSizeChars']` entry derived from the annotation, so large results were truncated to a preview again. The two are now merged; a size key set in `meta:` wins. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`OTelObserver` no longer counts an API response's token usage once per content block.** The CLI sends one `AssistantMessage` per content block, and each repeats the response's `message_id` and `usage`. The observer put that usage on every `claude_agent.generation` span, so a response with a thinking block and a tool call reported its input, cache-read and cache-creation tokens twice (N times for N blocks). The `gen_ai.usage.*` token attributes are now set on the first generation span of each `message_id` only. Span count, names and parentage are unchanged, and the session span was already correct. **Sums of `gen_ai.usage.*` over generation spans drop by the duplication factor.** `gen_ai.usage.output_tokens` on a generation span is still the snapshot taken when the response started; `docs/observability.md` now says so, and that the session span carries the authoritative totals. ([#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)) +- `examples/advanced_hooks_example.rb` and `examples/lifecycle_hooks_example.rb` returned a `*HookSpecificOutput#to_h` as the whole hook output, which leaves `permissionDecision`, `additionalContext`, `retry` and `watchPaths` at the top level where the CLI does not read them — the example's "deny" did not deny. They now wrap the typed output in `SyncHookJSONOutput`. If you copied that pattern, wrap yours the same way (or return `{ hook_specific_output: … }`). ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- `agents:` accepts a Hash for an agent, as its signature says: `agents: { reviewer: { description: '…', prompt: '…' } }` used to raise `NoMethodError` at connect. The Hash is read like `AgentDefinition.new(hash)`, so a misspelled key raises the same `ArgumentError`. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- `allowed_tools`, `disallowed_tools`, `add_dirs`, `extra_args`, `env` and `load_timeout_ms` can be set back to `nil` after construction (`options.dup_with(allowed_tools: nil)`, `options.extra_args = nil`), as their signatures say. The command builder, the transport and store-backed resume used to raise an anonymous `NoMethodError` at connect; they now read `nil` as the default. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149), [#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156), [#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- The `sandbox:` option now replaces a `sandbox` section already present in `settings:` instead of being added beside it. Settings given as a JSON String, a file path or a String-keyed Hash produced a `--settings` value with the key twice: under json 3.x connect raised a raw `JSON::GeneratorError: detected duplicate key "sandbox"`, which made the documented `sandbox: false` override unusable; under json 2.x it only worked as long as the CLI kept the last duplicate. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- Hooks registered for one event under both a String and a Symbol key (`{ 'PreToolUse' => [a], PreToolUse: [b] }`) are all registered, in the order written. The second key used to replace the first silently, so `a` never ran. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- **`#[]` returns `nil` for a writer's name instead of raising.** `msg[:session_id=]`, `msg['sessionId=']` and `options[:env=]` called the setter without an argument (`ArgumentError: wrong number of arguments`); they now read as `nil`, like any other name that is not an attribute. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **`ClaudeAgentOptions` reports `'[]'` as an unknown option.** `ClaudeAgentOptions.new('[]' => 1)` raised `wrong number of arguments (given 1, expected 2)`; it now raises the usual `unknown ClaudeAgentOptions option: "[]"`. The keys `'='`, `'!'` and `'=='` used to resolve to `#==`, `#!=` and `#===` and were dropped without an error; they now raise the same `ArgumentError`. Setters you define on your own `ClaudeAgentOptions` subclass are still accepted. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **`Type#inspect` no longer raises on a value it cannot render.** A `BasicObject` attribute raised `NoMethodError` (`nil?`), and a String, Hash or Array subclass whose `#length` / `#size` raises took `#inspect` down with it — inside a logger or `puts`. Such a value now shows as `#<ClassName>` (`#<?>` for a `BasicObject`) and the rest of the object still prints. `Interrupt`, `SystemExit` and other non-`StandardError` exceptions are not rescued. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **`cli_path:` given as a `Pathname` failed at connect.** The signature has always allowed it, but the spawn raised `no implicit conversion of Pathname into String`. The transport now converts it once, before the version check and the spawn. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- No more `Async::Task#sleep is deprecated` warnings under `ruby -w` from the transport: while it waited for the CLI to exit, or for the version check, it printed one every 50 ms. It now uses `Kernel#sleep`, which parks only the calling fiber on a reactor. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- The Postgres reference adapter (`examples/session_stores/`) stores entries in a `json` column instead of `jsonb`, which rejects the JSON escape for U+0000: one transcript entry with a NUL character (binary tool output) failed the whole `INSERT` and the mirror dropped that batch. An existing table is not changed by `create_schema`; the README gives the migration (`ALTER TABLE … ALTER COLUMN entry TYPE json USING entry::json`). ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **`.yardopts` is packaged in the gem.** rubydoc.info builds the API reference from the gem's own files; without it YARD ran with its defaults, so the reference listed every `@api private` internal (`Query`, `FiberBoundary`, `MessageParser`, …) next to the public API and did not render `CHANGELOG.md` or `UPGRADING-1.0.md`. Takes effect on rubydoc.info from this release on. ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)) +- **Documentation that contradicted the code** ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)): + - `docs/types.md` lists every message and content-block class the SDK can yield (`ToolProgressMessage`, `AuthStatusMessage`, `ToolUseSummaryMessage`, `PromptSuggestionMessage`, `ServerToolUseBlock` and `ServerToolResultBlock` were missing), with a table of the system and progress messages and what `#data` holds; the `SandboxIgnoreViolations` row (removed in 0.12.0) is gone. `docs/errors.md` documents `CLIInstallError`, and that a control request the CLI rejects raises a plain `StandardError`, not a `ClaudeSDKError`. + - Hooks, custom tools and permission callbacks do not require `Client` (`docs/client.md`, the README and the bundled skill said they did); `query()` and `ask` have run all three for many releases. + - The custom-transport contract is stated once: `connect`, `write`, `read_messages`, `end_input` and `close` are required, `ready?` is optional. `docs/client.md` listed six methods and the bundled skill four (a transport written from the skill's list made `query()` hang). The YARD of `ClaudeAgentSDK::Transport` no longer calls it an internal API. + - README: links are absolute GitHub URLs (relative ones were 404 on rubydoc.info, and the banner was missing from the installed gem); the CLI install snippet uses `CLIInstaller.install_pinned` instead of the literal `2.1.220`; a new Authentication section says how the CLI authenticates and what a missing login looks like; the comparison table's Python column is corrected. + - The bundled skill's configure examples no longer put `ENV.fetch('ANTHROPIC_API_KEY')` into the default options (the CLI inherits the environment anyway, and the line made every boot without the key raise `KeyError`); the skill and `docs/options.md` say that a `CLAUDE_CLI_PATH` naming a missing file is skipped and discovery continues. + - Four clarifications, each checked against Claude Code 2.1.287: `sandbox: { enabled: true }` does not make the sandbox a requirement (set `fail_if_unavailable: true`; otherwise the CLI warns on stderr and runs unsandboxed); `setting_sources: []` does not stop the MCP servers of the logged-in claude.ai account (`strict_mcp_config: true` does); `Client#query(prompt, session_id:)` does not start a separate conversation; an explicit `tools:` list must include `'Skill'` for `skills:` to work. Sandbox settings travel inside `--settings`, not a `--sandbox` flag. + - The `Client` examples in the guides and the `Client` YARD use `Client.open` (the `connect` … `disconnect` shape they showed leaves the session open when the block raises). + - `.claude-plugin/marketplace.json` no longer declares a version for the plugin (it said `0.13.1`); `SECURITY.md` states the supported versions for 1.x; `examples/message_types_example.rb` handles all 28 typed message classes. + - In the pages other PRs of this batch own: the documented Gemfile lines for the OpenTelemetry setup list the `base64` gem, which is no longer a default gem on Ruby 3.4 (`docs/observability.md`, [#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)); `docs/cli-installer.md` says an unusable `CLAUDE_CLI_PATH` is skipped without a warning and discovery continues, instead of "no discovery at all" ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)); `docs/rails.md` says hooks and custom tools work with `query` too ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)). ## [1.1.0] - 2026-09-30 diff --git a/README.md b/README.md index 7307cdb0..5591e7b7 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ RUN_INTEGRATION=1 bundle exec rspec # also run the real-CLI integration suite ( BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/.rspec # Rails integration specs ``` -CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4 on Linux, the suite on macOS, and the Rails specs against Rails 7.1 and 8, validates the RBS signatures and runs the suite under RBS runtime type checking; a weekly job runs the integration suite against the pinned CLI. The gem ships RBS signatures for its public API in `sig/`, which Steep and other RBS tools pick up through `rbs collection`. See [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout. +CI runs the suite and RuboCop on Ruby 3.2, 3.3, 3.4 and 4.0 on Linux, the suite on macOS, and the Rails specs against Rails 7.1 and 8, validates the RBS signatures and runs the suite under RBS runtime type checking. Weekly, and on PRs that touch the installer, the transport, `Query` or the message parser, it runs a keyless smoke test against the pinned CLI, and the integration suite as well when the repository has an API key. The gem ships RBS signatures for its public API in `sig/`, which Steep and other RBS tools pick up through `rbs collection`. See [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout. ## Contributing From 690b5cb082f40fd62151364d86cdf5c2c9376669 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 06:03:54 +0800 Subject: [PATCH 190/194] Let rmdir decide when a scrubbed directory is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A directory moved into the trash can still receive an entry from something that holds it open. The scrub allowed for that by listing the directory again after taking its entries out — but it stopped at the first empty listing and then called rmdir once. An entry that arrived right after an empty listing was therefore never looked for: rmdir failed with ENOTEMPTY, the entry was reported as not removed and the trash stayed behind. Which case a run hits depends on the order the filesystem lists entries in. The spec that covers a late arrival wrote its file after the first listing of a trash directory: on APFS that was a directory with entries, on ext4 the empty sessions/, so the example passed on macOS and failed on Linux CI. rmdir is now what decides. Each pass lists the directory, takes its entries out and tries rmdir; ENOTEMPTY (EEXIST on some systems) gives it another pass, EMPTYING_PASSES in all, and the last refusal is reported as before. A directory nothing writes into costs one listing less than it did. Specs: the examples that wrap Dir.children now run with the names sorted, in reverse order and shuffled (with the run's seed), so their result no longer depends on the machine. A late arrival is checked after a listing that had entries and after one that was empty; rmdir answering EEXIST is treated the same; a directory that keeps receiving entries is given up on after exactly EMPTYING_PASSES passes and named in the warning. The two swap examples that used to force backups/ to the front now read off the listing what the scrub must have left untouched. Exploration finding: B-P8 (Linux CI failure on PR #159) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw --- lib/claude_agent_sdk/session_resume.rb | 33 ++-- spec/unit/materialized_resume_spec.rb | 229 +++++++++++++++++-------- 2 files changed, 178 insertions(+), 84 deletions(-) diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index ad645b5b..325bd371 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -893,8 +893,8 @@ class Changed < StandardError; end OWNER_RWX = 0o700 - # Passes over a directory that keeps receiving entries, before rmdir - # is left to report it. + # How many times a directory that keeps receiving entries is emptied + # before rmdir's "not empty" is left to report it. EMPTYING_PASSES = 3 def self.owner_rwx?(stat) @@ -946,22 +946,31 @@ def remove(dir, name) next File.unlink(path) unless stat.directory? self.class.make_accessible(path, [stat.dev, stat.ino]) unless self.class.owner_rwx?(stat) - pending.concat(take_entries_of(path)) - Dir.rmdir(path) + pending.concat(empty_and_remove(path)) end end private - # Empty the directory at +path+ into the trash; returns where its - # entries are now. - def take_entries_of(path) + # Empty the directory at +path+ into the trash and remove it; returns + # where its entries are now. + # + # Something that still holds the directory open can write into it + # meanwhile, and a listing cannot tell: the entry may arrive right after + # it, and the listing may well have been empty. rmdir is what notices + # (ENOTEMPTY; EEXIST on some systems). The directory then gets another + # pass, EMPTYING_PASSES in all; the last refusal is the caller's to + # report. + def empty_and_remove(path) taken = [] - EMPTYING_PASSES.times do - children = Dir.children(path) - break if children.empty? - - children.each { |child| taken << take(path, child) } + passes = 0 + begin + passes += 1 + Dir.children(path).each { |child| taken << take(path, child) } + Dir.rmdir(path) + rescue Errno::ENOTEMPTY, Errno::EEXIST + retry if passes < EMPTYING_PASSES + raise end taken end diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb index cc60b165..7f2f6c99 100644 --- a/spec/unit/materialized_resume_spec.rb +++ b/spec/unit/materialized_resume_spec.rb @@ -144,6 +144,28 @@ def staging let(:skipped) { /transcript mirror dropped batches.*Scrubbing was skipped and nothing was deleted/ } + # Dir.children answers in the filesystem's order, and that order differs + # from one system to the next: an example that passed on APFS failed on + # ext4. Every example that wraps Dir.children runs with the names in each + # of these orders, so that none owes its result to the machine it runs on. + listing_orders = { + 'sorted' => :sort.to_proc, + 'in reverse order' => ->(names) { names.sort.reverse }, + 'shuffled' => ->(names) { names.sort.shuffle(random: Random.new(RSpec.configuration.seed)) } + }.freeze + + # Makes Dir.children answer with the names in +order+. A block, if given, is + # called with the path and those names before they are returned. + def list_children(order) + allow(Dir).to receive(:children).and_wrap_original do |original, path| + order.call(original.call(path)).tap { |names| yield path, names if block_given? } + end + end + + def in_trash?(path) + File.basename(File.dirname(path)).start_with?('scrub-') + end + it 'moves the directory into a private one next to it, keeps projects/ and deletes every other entry' do warning = preserve @@ -340,24 +362,81 @@ def interrupted(transcript_dir, to_remove) end # Something that still holds a directory open can write into it after it - # was moved into the trash. - it 'takes out what arrives in a directory while it is being emptied' do - arrived = false - allow(Dir).to receive(:children).and_wrap_original do |original, path| - names = original.call(path) - if !arrived && File.basename(File.dirname(path)).start_with?('scrub-') - arrived = true - File.write(File.join(path, 'late.json'), claude_json) + # was moved into the trash. A listing cannot tell: the entry may arrive + # right after it, and the listing may have been empty (on ext4 the empty + # sessions/ was the first directory listed; on APFS it never was). rmdir + # is what notices, and the directory is then emptied again. + listing_orders.each do |how, order| + context "when an entry arrives in a directory that is being emptied (entries listed #{how})" do + # Writes one file into the first directory of the trash whose listing + # the block accepts, right after that listing. + def late_arrival(order) + { arrived: false }.tap do |late| + list_children(order) do |path, names| + next if late[:arrived] || !in_trash?(path) || !yield(names) + + late[:arrived] = true + File.write(File.join(path, 'late.json'), claude_json) + end + end end - names - end - warning = preserve + def expect_nothing_left(warning) + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(warning).not_to match(/Scrubbing/) + end - expect(arrived).to be(true) - expect(Dir.children(preserved)).to eq(['projects']) - expect(Dir.children(staging)).to eq(['claude-resume-under-test']) - expect(warning).not_to match(/Scrubbing/) + it 'takes it out too, after a listing that had entries' do + late = late_arrival(order, &:any?) + + warning = preserve + + expect(late[:arrived]).to be(true) + expect_nothing_left(warning) + end + + it 'takes it out too, after a listing that was empty' do + late = late_arrival(order, &:empty?) + + warning = preserve + + expect(late[:arrived]).to be(true) + expect_nothing_left(warning) + end + + it 'does the same when rmdir answers EEXIST for a directory that is not empty, as some systems do' do + allow(Dir).to receive(:rmdir).and_wrap_original do |original, path| + raise Errno::EEXIST, path unless Dir.empty?(path) + + original.call(path) + end + late = late_arrival(order, &:empty?) + + warning = preserve + + expect(late[:arrived]).to be(true) + expect_nothing_left(warning) + end + + it 'gives up after EMPTYING_PASSES passes when entries keep arriving, and says what is left' do + kept_busy = nil + arrivals = 0 + list_children(order) do |path, _names| + # The ceiling is for a scrub that would never give up. + next unless in_trash?(path) && (kept_busy ||= path) == path && arrivals < 10 + + File.write(File.join(path, "late-#{arrivals += 1}.json"), claude_json) + end + + warning = preserve + + expect(arrivals).to eq(ClaudeAgentSDK::SessionResume::Scrubber::EMPTYING_PASSES) + expect(warning).to match(/Scrubbing failed: could not remove \S+ \(Directory not empty/) + expect(Dir.children(staging)).to contain_exactly('claude-resume-under-test', a_string_starting_with('scrub-')) + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + end + end end # Teardown runs on the reactor, and a fiber's stack is small. A scrub that @@ -564,17 +643,6 @@ def deep_chain(levels) .merge('.claude.json.backup.1790881328533' => 'synthetic')) end - # Dir.children answers in the filesystem's order. Putting backups/ first - # lets an example see that the scrub stopped at it, in what it never got to. - def scrub_backups_first - allow(Dir).to receive(:children).and_wrap_original do |original, path| - names = original.call(path) - next names unless File.basename(path) == 'claude-resume-under-test' - - names.partition { |name| name == 'backups' }.flatten - end - end - it 'deletes nothing when the directory becomes a symlink right before it is moved' do swapped = false allow(File).to receive(:rename).and_wrap_original do |original, from, to| @@ -640,58 +708,75 @@ def scrub_backups_first # A read-only backups/ has to be made writable. Between the look at it # and the chmod it is swapped for a symlink to a directory elsewhere: - # that directory must keep its mode and its files. - it 'leaves an outside directory alone when backups/ is swapped between the lstat and the chmod' do - File.chmod(0o500, backups) - File.chmod(0o555, outside) - scrub_backups_first - swapped = false - allow(File).to receive(:lstat).and_wrap_original do |original, path| - stat = original.call(path) - if !swapped && File.basename(path) == 'backups' && stat.directory? && stat.mode.nobits?(0o200) - swapped = true - File.rename(path, "#{path}-aside") - File.symlink(outside, path) + # that directory must keep its mode and its files, and the scrub stops. + listing_orders.each do |how, order| + context "with a read-only backups/ (entries listed #{how})" do + # The names the scrub got for the directory itself, in the order it + # walked them: that it stopped at backups/ shows in what came after. + let(:listed) { [] } + + before do + File.chmod(0o500, backups) + File.chmod(0o555, outside) + list_children(order) do |path, names| + listed.replace(names) if listed.empty? && File.basename(path) == 'claude-resume-under-test' + end end - stat - end - warning = preserve + # Gone: what was listed before backups/. Still there: what was listed + # after it, projects/, the symlink the swap put at backups and the real + # directory under the name the swap gave it. + def expect_the_scrub_stopped_at_backups + untouched = listed.drop(listed.index('backups') + 1) + expect(Dir.children(preserved)).to match_array((untouched + %w[projects backups backups-aside]).uniq) + end - expect(swapped).to be(true) - expect(mode_of(outside)).to eq('555') - expect(tree(outside)).to eq('not-ours.txt' => 'keep me') - expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) - expect(warning).to match(/Preserving the session transcript.* Scrubbing failed: could not remove .*backups/) - expect(warning).to match(/backups became a symlink/) - # It stopped there: backups/ came first, nothing after it was touched. - expect(Dir.children(preserved)).to include(*(secrets.keys.map { |key| key.split('/').first }.uniq - ['backups'])) - end + it 'leaves an outside directory alone when backups/ is swapped between the lstat and the chmod' do + swapped = false + allow(File).to receive(:lstat).and_wrap_original do |original, path| + stat = original.call(path) + if !swapped && File.basename(path) == 'backups' && stat.directory? && stat.mode.nobits?(0o200) + swapped = true + File.rename(path, "#{path}-aside") + File.symlink(outside, path) + end + stat + end + + warning = preserve + + expect(swapped).to be(true) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript.* Scrubbing failed: could not remove .*backups/) + expect(warning).to match(/backups became a symlink/) + expect_the_scrub_stopped_at_backups + end - it 'stops, leaving an outside directory alone, when backups/ is swapped right after the chmod' do - File.chmod(0o500, backups) - File.chmod(0o555, outside) - scrub_backups_first - swapped = false - allow(File).to receive(:open).and_wrap_original do |original, *args, **options, &block| - result = original.call(*args, **options, &block) - if !swapped && File.basename(args.first.to_s) == 'backups' - swapped = true - File.rename(args.first, "#{args.first}-aside") - File.symlink(outside, args.first) + it 'stops, leaving an outside directory alone, when backups/ is swapped right after the chmod' do + swapped = false + allow(File).to receive(:open).and_wrap_original do |original, *args, **options, &block| + result = original.call(*args, **options, &block) + if !swapped && File.basename(args.first.to_s) == 'backups' + swapped = true + File.rename(args.first, "#{args.first}-aside") + File.symlink(outside, args.first) + end + result + end + + warning = preserve + + expect(swapped).to be(true) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/Scrubbing failed: could not remove .*backups/) + expect(warning).to match(/backups was replaced while it was being made writable/) + expect_the_scrub_stopped_at_backups end - result end - - warning = preserve - - expect(swapped).to be(true) - expect(mode_of(outside)).to eq('555') - expect(tree(outside)).to eq('not-ours.txt' => 'keep me') - expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) - expect(warning).to match(/Scrubbing failed: could not remove .*backups/) - expect(warning).to match(/backups was replaced while it was being made writable/) - expect(Dir.children(preserved)).to include(*(secrets.keys.map { |key| key.split('/').first }.uniq - ['backups'])) end end end From c50c5ed5b1a9a67c96db3d6752ccd009f03b1328 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 06:07:45 +0800 Subject: [PATCH 191/194] Rename a snake_case proxy port only when it is a port number A snake_case sandbox key is renamed only when the CLI accepts its value under the wire key. For the two proxy ports the check was "an Integer", and that is not what the CLI accepts: an Integer too large for it to read as a number (2**1024 and up, 10**400 for one) reaches it as a non-finite value, fails its schema ("Expected number, but received null"), and the CLI discards the whole --settings value over it. So sandbox: { enabled: true, network: { http_proxy_port: 10**400 } } ran with no sandbox, and without the permissions passed in the same settings, where the key was simply ignored before it was renamed. That is the one thing the rename must never do. The kind for http_proxy_port and socks_proxy_port is now a port: an Integer from 0 to 65535 (and is called :port, no longer :integer). A value outside that range is sent as written and has no effect, as in 1.1.0. The bound is tighter than what the CLI takes, which is any number it can read; a bound that is too tight leaves a key inert, and one that is too loose can cost the sandbox. A key written in wire spelling is still not looked at. The spec gets the three out-of-range values (10**400, 65536, -1) for both attributes under Symbol and String keys, the two ends of the range (0 and 65535) renamed, and httpProxyPort: 10**400 still sent as it is. The comment on the kinds table also said the CLI discards the settings "without a word". It lists the error in its get_settings response; the SDK is what is not told. Found by review. Exploration finding: C-P2 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- lib/claude_agent_sdk/types/option_values.rb | 22 +++++---- spec/unit/sandbox_hash_keys_spec.rb | 50 +++++++++++++++------ 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index fa879d41..af67f7f3 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -260,16 +260,20 @@ module SandboxKeys # :boolean true or false # :strings an Array of Strings # :mach_services an Array of Strings; a "*" only as the last character - # :integer a number (the CLI takes any; an Integer is asked for) + # :port an Integer from 0 to 65535 # :string_lists a Hash whose values are Arrays of Strings # # One value outside its kind makes the CLI discard the whole --settings - # value, the sandbox and the permissions next to it, without a word. A - # key in snake_case is one the CLI does not know and ignores, so it is - # renamed only when its value is of the kind listed here (see #rename). - # A kind that is too strict leaves a key without effect, as it was before - # the renaming existed; one that is too loose can cost a session its - # sandbox. + # value, the sandbox and the permissions next to it. It lists the error + # in its get_settings response only, so the SDK is not told. A key in + # snake_case is one the CLI does not know and ignores, so it is renamed + # only when its value is of the kind listed here (see #rename). A kind + # that is too strict leaves a key without effect, as it was before the + # renaming existed; one that is too loose can cost a session its sandbox. + # + # :port is the strict side of what the CLI does. It takes any number it + # can read as a port, but an Integer too large for that (2**1024 and up, + # say 10**400) reaches it as a non-finite value and fails its schema. SHAPES = { boolean: %i[failIfUnavailable autoAllowBashIfSandboxed allowUnsandboxedCommands enableWeakerNestedSandbox enableWeakerNetworkIsolation allowManagedDomainsOnly allowAllUnixSockets allowLocalBinding @@ -277,7 +281,7 @@ module SandboxKeys strings: %i[excludedCommands allowedDomains deniedDomains allowUnixSockets allowWrite denyWrite denyRead allowRead], mach_services: %i[allowMachLookup], - integer: %i[httpProxyPort socksProxyPort], + port: %i[httpProxyPort socksProxyPort], string_lists: %i[ignoreViolations] }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze @@ -354,7 +358,7 @@ def self.well_shaped?(wire, value) when :boolean then [true, false].include?(value) when :strings then strings?(value) when :mach_services then strings?(value) && value.none? { |name| name.delete_suffix('*').include?('*') } - when :integer then value.is_a?(Integer) + when :port then value.is_a?(Integer) && value.between?(0, 65_535) when :string_lists then value.is_a?(Hash) && value.each_value.all? { |list| strings?(list) } else false end diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb index c60bbeb7..cb234b20 100644 --- a/spec/unit/sandbox_hash_keys_spec.rb +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -148,7 +148,8 @@ def on_the_wire(value) # boolean true or false # strings an Array of Strings # mach_services an Array of Strings; a "*" only as the last character - # integer a number (the CLI takes any; the SDK asks for an Integer) + # port a number (the CLI takes any it can read; the SDK asks for + # what a port is, an Integer from 0 to 65535) # string_lists a Hash whose values are Arrays of Strings # # Spelled out here, not derived from the SDK. enabled, network, filesystem @@ -162,7 +163,7 @@ def on_the_wire(value) 'SandboxNetworkConfig' => { allowed_domains: :strings, denied_domains: :strings, allow_managed_domains_only: :boolean, allow_unix_sockets: :strings, allow_all_unix_sockets: :boolean, allow_local_binding: :boolean, - allow_mach_lookup: :mach_services, http_proxy_port: :integer, socks_proxy_port: :integer + allow_mach_lookup: :mach_services, http_proxy_port: :port, socks_proxy_port: :port }, 'SandboxFilesystemConfig' => { allow_write: :strings, deny_write: :strings, deny_read: :strings, allow_read: :strings, @@ -170,13 +171,18 @@ def on_the_wire(value) } }.freeze - # For each kind, values the CLI rejects under such a key. Each of them makes - # it discard the whole --settings value, sandbox and permissions included. - rejected = { + # For each kind, values outside it: a snake_case key holding one is not + # renamed. The CLI rejects them under the wire key, and one rejected value + # makes it discard the whole --settings value, sandbox and permissions + # included. That goes for 10**400 too: it is an Integer, and a number the + # CLI cannot read (it fails the schema as a non-finite value). 65_536 and -1 + # are the exception, numbers the CLI takes: they are outside the kind + # because a port is 0..65535, and a bound that is too tight costs nothing. + ill_shaped = { boolean: ['true', 0, []], strings: ['docker', ['docker', 1], [nil], true, { 'docker' => true }], mach_services: ['com.apple.audio', ['com.*.helper'], ['*.helper'], ['com.apple.**'], [1]], - integer: ['8080', true, [8080]], + port: ['8080', true, [8080], 10**400, 65_536, -1], string_lists: [{ 'file' => '/tmp/*' }, { 'file' => [1] }, { 'file' => { 'read' => [] } }, ['/tmp/*'], 'file'] }.freeze @@ -282,8 +288,8 @@ def on_the_wire(value) expect(ClaudeAgentSDK::SandboxKeys::SHAPES).to eq(spelled_out.to_h) end - it 'has values the CLI rejects for every kind it uses' do - expect(rejected.keys).to match_array(kinds.values.flat_map(&:values).uniq) + it 'has values outside every kind it uses' do + expect(ill_shaped.keys).to match_array(kinds.values.flat_map(&:values).uniq) end end end @@ -496,7 +502,7 @@ def on_the_wire(value) # A snake_case key is one the CLI does not know: it ignores it, value and # all. Under its wire name the same value is validated, and one value that # fails the CLI's schema makes it discard the whole --settings value (the - # sandbox, and the permissions passed next to it) without a word. So a + # sandbox, and the permissions passed next to it); the SDK is not told. So a # snake_case key is renamed only when its value has the shape the CLI # accepts for that key. Otherwise it goes out as written and stays inert, # as it always was: renaming must never cost a session its sandbox. @@ -517,7 +523,7 @@ def on_the_wire(value) kinds.each do |class_name, by_attribute| by_attribute.each do |attribute, kind| it "is sent as written for #{attribute} (#{kind}), under a Symbol or a String key" do - sandboxes = rejected.fetch(kind).flat_map do |value| + sandboxes = ill_shaped.fetch(kind).flat_map do |value| [attribute, attribute.to_s].map { |key| at_level_of.fetch(class_name).call(key => value) } end @@ -552,22 +558,40 @@ def on_the_wire(value) sandbox = { excluded_commands: [], ignore_violations: {}, network: { allowed_domains: ['*.example.com', ''], allow_mach_lookup: ['*', 'com.apple.coresimulator.*'], - http_proxy_port: 0 }, + http_proxy_port: 0, socks_proxy_port: 65_535 }, filesystem: { deny_read: ['/work/**/*.pem'] } } expect(sandbox_section(sandbox)).to eq( 'excludedCommands' => [], 'ignoreViolations' => {}, 'network' => { 'allowedDomains' => ['*.example.com', ''], 'allowMachLookup' => ['*', 'com.apple.coresimulator.*'], - 'httpProxyPort' => 0 }, + 'httpProxyPort' => 0, 'socksProxyPort' => 65_535 }, 'filesystem' => { 'denyRead' => ['/work/**/*.pem'] } ) end + # A proxy port is renamed for what a port can be, whichever of the two + # attributes it is and whichever class its key has. Beyond that range the + # CLI still takes a number it can read, but not one it cannot: an Integer + # such as 10**400 reaches it as a non-finite value, and it discards the + # whole --settings value over that (the examples for the port kind above). + %i[http_proxy_port socks_proxy_port].each do |attribute| + it "is renamed for #{attribute} at 0 and at 65535, under a Symbol or a String key" do + wire_key = network_fields.fetch(attribute).first + sections = [0, 65_535].flat_map do |port| + [attribute, attribute.to_s].map { |key| sandbox_section({ enabled: true, network: { key => port } }) } + end + + expect(sections) + .to eq([0, 0, 65_535, 65_535].map { |port| { 'enabled' => true, 'network' => { wire_key => port } } }) + end + end + # The check is only about renaming. A key the caller wrote in the CLI's # own spelling reaches the CLI as before, whatever its value. it 'does not concern a key already in wire spelling' do - sandbox = { enabled: true, excludedCommands: 'docker', 'failIfUnavailable' => 'yes', network: { allowUnixSockets: true } } + sandbox = { enabled: true, excludedCommands: 'docker', 'failIfUnavailable' => 'yes', + network: { allowUnixSockets: true, httpProxyPort: 10**400, 'socksProxyPort' => -1 } } expect(sandbox_section(sandbox)).to eq(on_the_wire(sandbox)) end From 8c80bd355ad99ac8384e511522cbb52a387e725a Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 06:08:23 +0800 Subject: [PATCH 192/194] Say where the CLI reports a rejected sandbox value docs/configuration.md ("Sandbox Settings") and the Sandbox paragraph of the two skill trees said that the CLI, when it discards the --settings value over one invalid sandbox value, reports this "nowhere". It does report it: in the errors of its get_settings control response. What is true, and what the text now says, is that the SDK does not surface that error: connect succeeds, nothing appears on stderr, and the session runs unsandboxed. The same passages said a snake_case key is renamed for "an Integer" as a proxy port. The check is now for a port number, an Integer from 0 to 65535, and the text says so. The two skill trees stay byte-identical. Docs only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9UuLogZdvUXMxTuufTemS --- docs/configuration.md | 4 ++-- .../skills/claude-agent-ruby/references/options.md | 2 +- skills/references/options.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 51d416d4..7bec245a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -243,10 +243,10 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ``` - Any other key is sent as written. That is how to pass a sandbox setting the classes have no attribute for (`allowAppleEvents`, `strictAllowlist` inside `network`): spell it as the CLI does. -- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, an Integer for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know. +- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, a port number (an Integer from 0 to 65535) for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know. - A field holding `nil` is left out, in either spelling, as a `nil` attribute of the typed classes is. -The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. It reports this nowhere: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox. +The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. The CLI reports the failure in the `errors` of its `get_settings` control response, and the SDK does not surface that: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox. `enabled: true` asks for a sandbox. It does not make one a requirement. When the sandbox cannot start on the host (missing dependencies, an unsupported platform), the CLI carries on without it. Its settings schema describes the setting that decides this, `failIfUnavailable`, as follows (CLI 2.1.287): diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index 3cfe1d7d..99d1b468 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -99,7 +99,7 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. -A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema. It reports that in the `errors` of its `get_settings` control response, which the SDK does not surface: `connect` succeeds and nothing appears on stderr. ## Agents diff --git a/skills/references/options.md b/skills/references/options.md index 3cfe1d7d..99d1b468 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -99,7 +99,7 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. -A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, an Integer port); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema, and reports it nowhere. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema. It reports that in the `errors` of its `get_settings` control response, which the SDK does not surface: `connect` succeeds and nothing appears on stderr. ## Agents From 40a621409d30378c690e6fa1da1b3b71ab848e02 Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 06:25:09 +0800 Subject: [PATCH 193/194] Rename a spec helper that shadowed RSpec's receive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client_receive_after_stream_end_spec.rb defined a helper named receive in its example group. That hides RSpec's own receive matcher for every example in the group, so any before hook that stubs with allow(...).to receive(...) calls the helper instead — as a suite-wide stub added by the store-backed resume PR (#159) does, which made all eight examples fail with "wrong number of arguments" once both PRs are in. The helper is receive_within_deadline now; what it does is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2 --- spec/unit/client_receive_after_stream_end_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/client_receive_after_stream_end_spec.rb b/spec/unit/client_receive_after_stream_end_spec.rb index b09593d6..330e57c1 100644 --- a/spec/unit/client_receive_after_stream_end_spec.rb +++ b/spec/unit/client_receive_after_stream_end_spec.rb @@ -107,7 +107,7 @@ def with_session(on_user_message) # What one receive call did: the message classes it yielded, or :parked # when it was still waiting at the bound. - def receive(task, client, method) + def receive_within_deadline(task, client, method) seen = [] task.with_timeout(5) { client.public_send(method) { |message| seen << message.class } } seen @@ -134,7 +134,7 @@ def receive(task, client, method) describe "##{method}" do it 'ends at once on every call after a clean end of stream' do with_session(cli_exits_after_the_turn) do |client, task| - calls = Array.new(3) { receive(task, client, method) } + calls = Array.new(3) { receive_within_deadline(task, client, method) } expect(calls).to eq([[ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], [], []]) end @@ -148,7 +148,7 @@ def receive(task, client, method) end.to raise_error(ClaudeAgentSDK::ProcessError, /exit code 1/) expect(seen_before_the_crash).to eq([ClaudeAgentSDK::AssistantMessage]) - expect(Array.new(2) { receive(task, client, method) }).to eq([[], []]) + expect(Array.new(2) { receive_within_deadline(task, client, method) }).to eq([[], []]) end end end From 400ed0c3f73fb7efcecd924ea7b205ff9e26bd8a Mon Sep 17 00:00:00 2001 From: ya-luotao <luotao@hey.com> Date: Sat, 3 Oct 2026 06:26:00 +0800 Subject: [PATCH 194/194] Fit materialize_resume_session under the method-length limit The method was one line over Metrics/MethodLength (30) on this branch and carried a disable for it. The sessions PR (#157) shortens the same method by two lines, so once both are merged the disable is redundant and RuboCop fails on Lint/RedundantCopDisableDirective. The two early returns are one condition now, which brings the method under the limit on its own and lets the disable go, with or without #157. Also: the directory identity is read inside the begin block, so a failure while reading it removes the new temp dir like any other failure after mkdtemp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2 --- lib/claude_agent_sdk/session_resume.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 325bd371..92ce8a5c 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -264,10 +264,9 @@ def build_mirror_batcher(store:, env:, on_error:, eager: false, callback_wrapper # session id is not a valid UUID) — the caller then falls through to the # normal spawn path. Raises SessionStoreError (#cause: the adapter's own # exception or the timeout) if a store call fails or times out. - def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- materialization sequence kept in order + def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order store = options.session_store - return nil if store.nil? - return nil if options.resume.nil? && !options.continue_conversation + return nil if store.nil? || (options.resume.nil? && !options.continue_conversation) # load_timeout_ms reads nil once a caller set it back to nil; the # constructor default applies then. 0 is a valid (immediate) timeout. @@ -296,10 +295,10 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize, Metri session_id, lines = resolved tmp_base = Dir.mktmpdir('claude-resume-') - # Taken now: a teardown that deletes inside the directory checks that - # the path still leads to this one (MaterializedResume#preserve_transcripts). - root_identity = directory_identity(tmp_base) begin + # Taken now: a teardown that deletes inside the directory checks that + # the path still leads to this one (MaterializedResume#preserve_transcripts). + root_identity = directory_identity(tmp_base) project_dir = File.join(tmp_base, 'projects', project_key) FileUtils.mkdir_p(project_dir) write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), lines)