diff --git a/CHANGELOG.md b/CHANGELOG.md index 823c22e..b56ef38 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 5f85955..706d8a8 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 1f94172..27a145b 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')