From d9af541b0f1606a3aef4d489c31b65a741875f9f Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Tue, 27 Oct 2026 15:45:27 +0800 Subject: [PATCH] CLIInstaller: write the downloaded CLI in binary mode on every platform File::BINARY is 0 outside Windows, so the download was opened in text mode. With Encoding.default_internal set (Rails sets it to UTF-8), each write transcoded the binary chunks and raised Encoding::UndefinedConversionError on the first byte that is not valid UTF-8, so `bin/rails claude_agent_sdk:install_cli` always failed. Open the file with binmode: true instead. Fixes #177 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016wuLhHzFb7oEoM5Qw39CBj --- CHANGELOG.md | 3 +++ lib/claude_agent_sdk/cli_installer.rb | 7 ++++++- spec/unit/cli_installer_spec.rb | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 823c22e5..b56ef380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **`bin/rails claude_agent_sdk:install_cli` (and `CLIInstaller.install` whenever `Encoding.default_internal` is set) no longer fails with `Encoding::UndefinedConversionError`** (issue #177). The download was opened with `File::BINARY`, which is 0 outside Windows, so the file was in text mode; Rails sets `default_internal` to UTF-8, and each write then transcoded the binary chunks and raised on the first byte that is not valid UTF-8. The file is now opened with `binmode: true`. + ## [1.4.1] - 2026-10-25 ### Changed diff --git a/lib/claude_agent_sdk/cli_installer.rb b/lib/claude_agent_sdk/cli_installer.rb index 5f859552..706d8a85 100644 --- a/lib/claude_agent_sdk/cli_installer.rb +++ b/lib/claude_agent_sdk/cli_installer.rb @@ -178,10 +178,15 @@ def fetch_text(url, limit:) # 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. + # + # binmode, not File::BINARY: that flag is 0 outside Windows, so the + # file would be in text mode, and with Encoding.default_internal set + # (Rails sets it to UTF-8) every write would transcode the binary + # chunks and raise on the first byte that is not valid UTF-8 (#177). def download_to(url, path, max_bytes: nil) with_response(url) do |response| written = 0 - File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::BINARY, 0o600) do |file| + File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600, binmode: true) do |file| response.read_body do |chunk| written += chunk.bytesize if over?(written, max_bytes) diff --git a/spec/unit/cli_installer_spec.rb b/spec/unit/cli_installer_spec.rb index 1f94172f..27a145b1 100644 --- a/spec/unit/cli_installer_spec.rb +++ b/spec/unit/cli_installer_spec.rb @@ -481,6 +481,26 @@ def stub_response(*chunks) expect(File.binread(target)).to eq('abcdef') end + # Issue #177: Rails sets default_internal to UTF-8, and a text-mode file + # transcodes each write. File::BINARY is 0 outside Windows. + it 'writes the bytes unchanged when Encoding.default_internal is set' do + payload = "\xB7\x00\xFF\xCF".b + stub_response(payload[0, 2], payload[2, 2]) + target = File.join(tmp_dir, 'binary') + saved = Encoding.default_internal + verbose = $VERBOSE + $VERBOSE = nil # the setter warns under -W + + begin + Encoding.default_internal = Encoding::UTF_8 + expect(http.download_to('https://example.com/claude', target)).to eq(target) + ensure + Encoding.default_internal = saved + $VERBOSE = verbose + end + expect(File.binread(target)).to eq(payload) + end + it 'aborts mid-stream once max_bytes is exceeded' do stub_response('a' * 4, 'b' * 4) target = File.join(tmp_dir, 'too-big')