Skip to content

[do not merge] CI check: all exploration fix PRs merged together - #162

Closed
ya-luotao wants to merge 210 commits into
mainfrom
ci/combined-exploration-fixes
Closed

ya-luotao wants to merge 210 commits into
mainfrom
ci/combined-exploration-fixes

Conversation

@ya-luotao

Copy link
Copy Markdown
Collaborator

Do not merge. A throwaway PR to run CI (Linux, Ruby 3.2–4.0, macOS, Rails, RBS) on all fourteen fix PRs (#148–#161)
plus the CHANGELOG branch merged onto main in the planned merge order — two of the PRs had Linux-only failures on
their own, so the merged tree gets the same check before any of them is merged. It will be closed and its branch
deleted as soon as CI has run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CNgMt2RjeBLyRP3CWFWNy2

ya-luotao and others added 30 commits October 3, 2026 00:37
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bcNsh7XyWSTw75nG98VvN
`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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
On macOS the CLI keeps OAuth credentials in the Keychain. With a custom
CLAUDE_CONFIG_DIR it stores them under
"Claude Code-credentials-<first 8 hex of SHA-256(config dir)>" 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtTUAobtpvUZpVjb4C43VS
`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 `#<ClassName>`, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
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.<ts>, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018TunHCEUfBYcKbMTdjR8Xw
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZSPHnAumXtaZ6z6DLPVda
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nuqe8YhTz78Qm7d4JVy2tP
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X8uVHR4p6nUax9ngpLZtJ
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvFY2o1ARvShoLnEypJGpF
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRaNU3CNmsGrJ9GM2hhRPC
ya-luotao and others added 26 commits October 3, 2026 05:24
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
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
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
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
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
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
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
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
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
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
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
@ya-luotao

Copy link
Copy Markdown
Collaborator Author

CI check done: all 14 checks passed on the merged tree (real-CLI job skipped without a key). Closing as planned.

@ya-luotao ya-luotao closed this Oct 3, 2026
@ya-luotao
ya-luotao deleted the ci/combined-exploration-fixes branch October 3, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant