Skip to content

fix(cli): replace shell-based update with native Rust implementation#7148

Merged
jamadeo merged 2 commits intoblock:mainfrom
debugmcpdev:fix/native-rust-update-windows
Feb 18, 2026
Merged

fix(cli): replace shell-based update with native Rust implementation#7148
jamadeo merged 2 commits intoblock:mainfrom
debugmcpdev:fix/native-rust-update-windows

Conversation

@debugmcpdev
Copy link
Contributor

Summary

Replaces the shell-based goose update implementation (which shells out to curl + bash) with a native Rust implementation that works on Windows, macOS, and Linux.

Problem

goose update has been broken on native Windows since ~v1.9.0. The current implementation downloads download_cli.sh and executes it via bash - neither curl nor bash exist on native Windows. This affects all Windows users who try to update via the CLI.

See: #7146, #5006

Solution

Complete rewrite of crates/goose-cli/src/commands/update.rs (~37 lines -> ~460 lines) to use native Rust libraries instead of shell commands:

Step Before (broken) After (native)
Download curl reqwest (blocking + rustls)
Extract bash + tar zip crate (Windows) / tar + bzip2 (Unix)
Replace binary mv via bash std::fs::rename with rename-away strategy on Windows
DLL handling N/A Copies companion DLLs on Windows (libgcc, libstdc++, libwinpthread)

Key design decisions

  • Compile-time platform detection via #[cfg] attributes - no runtime OS sniffing
  • Rename-away binary replacement on Windows (goose.exe -> goose.exe.old, then new -> goose.exe) because Windows locks running executables
  • Handles goose-package/ subdirectory in archives (matches existing release structure)
  • Preserves existing API - update(canary, reconfigure) signature unchanged, just made async
  • disable-update feature flag still works

Files Changed

File Change
Cargo.lock Updated for new dependencies
crates/goose-cli/Cargo.toml Added reqwest (blocking, rustls-tls-native-roots), zip 7.4 (deflate), bzip2 0.5
crates/goose-cli/src/commands/update.rs Complete rewrite - native Rust download, extract, replace
crates/goose-cli/src/cli.rs One line: added .await to update() call
crates/goose-cli/src/session/editor.rs Added #[cfg(unix)] to 2 test functions (bonus fix for pre-existing Windows test compilation)

Testing

Unit tests (10 new, all pass)

  • test_asset_name_valid - correct platform asset selection
  • test_binary_name - correct binary name per OS
  • test_find_binary_* (4 tests) - binary discovery in various archive layouts
  • test_extract_zip_with_package_dir - zip extraction with goose-package subdir
  • test_replace_binary_* (3 tests) - binary replacement including Windows rename-away

End-to-end (manual, Windows)

  • goose update - successfully downloaded 50MB stable release, extracted, replaced binary + 3 DLLs
  • goose update --canary - successfully downloaded 89MB canary release
  • goose --version confirms updated version after each test

Regression

  • All 118 other goose-cli lib tests pass (0 regressions)

Closes

Replace the curl+bash update mechanism with a pure Rust implementation
that works on Windows, macOS, and Linux without requiring external tools.

The previous implementation downloaded and executed download_cli.sh via
curl and bash, which fails on native Windows where neither is available.
This has been broken since v1.9.0 (~4 months).

The new implementation:
- Uses reqwest to download the release archive directly
- Extracts .zip on Windows, .tar.bz2 on macOS/Linux
- Handles the goose-package/ subdirectory in archives
- Uses rename-away strategy on Windows (running exe can be renamed)
- Copies companion DLLs on Windows GNU builds
- Includes 10 unit tests covering all platform-specific logic

Also fixes editor.rs tests that failed to compile on Windows due to
unconditional use of std::os::unix.

Closes block#7146

Signed-off-by: JF <john.franklin@gmail.com>
@DOsinga
Copy link
Collaborator

DOsinga commented Feb 12, 2026

fixes: #7146

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Replaces the goose update command’s shell-based installer (curl/bash) with a native Rust implementation to support Windows/macOS/Linux updates reliably.

Changes:

  • Rewrote goose update to download release assets via reqwest, extract archives, and replace the running binary (with Windows-specific rename-away behavior + DLL copying).
  • Updated CLI dispatch to await the now-async updater.
  • Adjusted two editor tests to compile only on Unix.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
crates/goose-cli/src/commands/update.rs Native Rust updater: download, extract (zip/tar.bz2), locate binary, replace in-place, Windows DLL handling, and new unit tests.
crates/goose-cli/src/cli.rs Awaits the async update() command.
crates/goose-cli/Cargo.toml Adds dependencies needed for native download/extraction (reqwest/zip/bzip2).
crates/goose-cli/src/session/editor.rs Gates Unix-only symlink tests behind #[cfg(unix)].
Cargo.lock Lockfile updates for newly added dependencies.

#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
"goose-x86_64-pc-windows-gnu.zip"
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asset_name() has no fallback for unsupported target triples, which can produce a confusing compile error on new/unsupported platforms; consider adding an explicit #[cfg(not(any(...)))] compile_error!(...) (or returning a clear error) so the failure mode is intentional and readable.

Suggested change
}
}
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "aarch64"),
all(target_os = "windows", target_arch = "x86_64"),
)))]
{
compile_error!(
"goose update is not supported on this target; no prebuilt binary asset is available"
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +64
// --- Download -----------------------------------------------------------
let response = reqwest::get(&url)
.await
.context("Failed to download release archive")?;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The update download uses reqwest::get without any explicit timeout, so goose update can hang indefinitely on stalled networks; consider using a reqwest::Client with a reasonable request/overall timeout (and reuse it for the body download).

Copilot uses AI. Check for mistakes.
Comment on lines +144 to +148
let decoder = BzDecoder::new(data);
let mut archive = tar::Archive::new(decoder);
archive
.unpack(dest)
.context("Failed to extract tar.bz2 archive")?;
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tar::Archive::unpack can write files with paths from the archive as-is; to avoid potential path traversal (writing outside dest) when extracting remote archives, consider iterating entries and using Entry::unpack_in(dest) (and rejecting absolute/parent components).

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +169
if package_dir.is_dir() {
let p = package_dir.join(binary_name);
if p.exists() {
return Some(p);
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find_binary() uses Path::exists() which can match directories; using is_file() (and possibly metadata checks) avoids returning a non-file path that will later fail to copy/execute.

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +283
if let Ok(entries) = fs::read_dir(source_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext.eq_ignore_ascii_case("dll") {
let file_name = path.file_name().unwrap();
let dest = dest_dir.join(file_name);
// Remove existing DLL first (it may be locked by another process)
if dest.exists() {
let _ = fs::remove_file(&dest);
}
fs::copy(&path, &dest).with_context(|| {
format!("Failed to copy {} to {}", path.display(), dest.display())
})?;
println!(" Copied {}", file_name.to_string_lossy());
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copy_dlls() silently ignores read_dir failures via if let Ok(entries) = ..., which can report a successful update while skipping required DLL copies; consider propagating the error with context so update fails loudly when DLL enumeration isn’t possible.

Suggested change
if let Ok(entries) = fs::read_dir(source_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext.eq_ignore_ascii_case("dll") {
let file_name = path.file_name().unwrap();
let dest = dest_dir.join(file_name);
// Remove existing DLL first (it may be locked by another process)
if dest.exists() {
let _ = fs::remove_file(&dest);
}
fs::copy(&path, &dest).with_context(|| {
format!("Failed to copy {} to {}", path.display(), dest.display())
})?;
println!(" Copied {}", file_name.to_string_lossy());
}
let entries = fs::read_dir(source_dir)
.with_context(|| format!("Failed to read directory {}", source_dir.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext.eq_ignore_ascii_case("dll") {
let file_name = path.file_name().unwrap();
let dest = dest_dir.join(file_name);
// Remove existing DLL first (it may be locked by another process)
if dest.exists() {
let _ = fs::remove_file(&dest);
}
fs::copy(&path, &dest).with_context(|| {
format!("Failed to copy {} to {}", path.display(), dest.display())
})?;
println!(" Copied {}", file_name.to_string_lossy());

Copilot uses AI. Check for mistakes.
base64 = { workspace = true }
regex = "1.11.1"
tar = "0.4"
reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"] }
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reqwest is enabled with the blocking feature but the new updater uses the async API (reqwest::get(...).await); dropping the blocking feature would reduce dependency surface/compile time unless it’s needed elsewhere in goose-cli.

Suggested change
reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots"] }
reqwest = { workspace = true, features = ["rustls-tls-native-roots"] }

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

@jamadeo jamadeo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, thanks for this. Tested this out on mac + windows

@jamadeo jamadeo added this pull request to the merge queue Feb 18, 2026
Merged via the queue into block:main with commit c932766 Feb 18, 2026
20 checks passed
michaelneale added a commit that referenced this pull request Feb 19, 2026
* main: (54 commits)
  docs: add monitoring subagent activity section (#7323)
  docs: document Desktop UI recipe editing for model/provider and extensions (#7327)
  docs: add CLAUDE_THINKING_BUDGET and CLAUDE_THINKING_ENABLED environm… (#7330)
  fix: display 'Code Mode' instead of 'code_execution' in CLI (#7321)
  docs: add Permission Policy documentation for MCP Apps (#7325)
  update RPI plan prompt (#7326)
  docs: add CLI syntax highlighting theme customization (#7324)
  fix(cli): replace shell-based update with native Rust implementation (#7148)
  docs: rename Code Execution extension to Code Mode extension (#7316)
  docs: remove ALPHA_FEATURES flag from documentation (#7315)
  docs: escape variable syntax in recipes (#7314)
  docs: update OTel environment variable and config guides (#7221)
  docs: system proxy settings (#7311)
  docs: add Summon extension tutorial and update Skills references (#7310)
  docs: agent session id (#7289)
  fix(gemini-cli): restore streaming lost in #7247 (#7291)
  Update more instructions (#7305)
  feat: add Moonshot and Kimi Code declarative providers (#7304)
  fix(cli): handle Reasoning content and fix streaming thinking display (#7296)
  feat: add GOOSE_SUBAGENT_MODEL and GOOSE_SUBAGENT_PROVIDER config options (#7277)
  ...
katzdave added a commit that referenced this pull request Feb 19, 2026
* 'main' of github.com:block/goose: (40 commits)
  Remove trailing space from links (#7156)
  fix: detect low balance and prompt for top up (#7166)
  feat(apps): add support for MCP apps to sample (#7039)
  Typescript SDK for ACP extension methods (#7319)
  chore: upgrade to rmcp 0.16.0 (#7274)
  docs: add monitoring subagent activity section (#7323)
  docs: document Desktop UI recipe editing for model/provider and extensions (#7327)
  docs: add CLAUDE_THINKING_BUDGET and CLAUDE_THINKING_ENABLED environm… (#7330)
  fix: display 'Code Mode' instead of 'code_execution' in CLI (#7321)
  docs: add Permission Policy documentation for MCP Apps (#7325)
  update RPI plan prompt (#7326)
  docs: add CLI syntax highlighting theme customization (#7324)
  fix(cli): replace shell-based update with native Rust implementation (#7148)
  docs: rename Code Execution extension to Code Mode extension (#7316)
  docs: remove ALPHA_FEATURES flag from documentation (#7315)
  docs: escape variable syntax in recipes (#7314)
  docs: update OTel environment variable and config guides (#7221)
  docs: system proxy settings (#7311)
  docs: add Summon extension tutorial and update Skills references (#7310)
  docs: agent session id (#7289)
  ...
jh-block added a commit that referenced this pull request Feb 19, 2026
* origin/main:
  fix(ci): deflake smoke tests for Google models (#7344)
  feat: add Cerebras provider support (#7339)
  fix: skip whitespace-only text blocks in Anthropic message (#7343)
  fix(goose-acp): heap allocations (#7322)
  Remove trailing space from links (#7156)
  fix: detect low balance and prompt for top up (#7166)
  feat(apps): add support for MCP apps to sample (#7039)
  Typescript SDK for ACP extension methods (#7319)
  chore: upgrade to rmcp 0.16.0 (#7274)
  docs: add monitoring subagent activity section (#7323)
  docs: document Desktop UI recipe editing for model/provider and extensions (#7327)
  docs: add CLAUDE_THINKING_BUDGET and CLAUDE_THINKING_ENABLED environm… (#7330)
  fix: display 'Code Mode' instead of 'code_execution' in CLI (#7321)
  docs: add Permission Policy documentation for MCP Apps (#7325)
  update RPI plan prompt (#7326)
  docs: add CLI syntax highlighting theme customization (#7324)
  fix(cli): replace shell-based update with native Rust implementation (#7148)
  docs: rename Code Execution extension to Code Mode extension (#7316)
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.

4 participants

Comments