From 8f84595fec205b5888a39101712ddfcce108db5f Mon Sep 17 00:00:00 2001 From: Sanchit Monga Date: Fri, 9 Oct 2026 16:37:43 -0700 Subject: [PATCH 1/2] fix: macOS bottle runs without Homebrew; claude-code keeps wally's endpoint The macOS bottle linked openssl@3, brotli and zstd by /opt/homebrew path and shipped an empty lib/, so a Mac without Homebrew failed at launch (and wally update rolled back on its own post-install check). Bundle those libraries and point the binary at them through @rpath. wally claude-code copied the reader's settings.json verbatim into its own config dir. Claude Code applies the `env` block of settings over the process environment, so a pinned ANTHROPIC_BASE_URL in the reader's settings silently replaced wally's endpoint. Drop the gateway variables from the copy. Co-Authored-By: Claude Sonnet 5.5 --- scripts/build/bundle-macos-dylibs.sh | 62 ++++++++++++++++++++++++++++ scripts/build/package-wally.sh | 3 ++ src/commands/cmd_editors.rs | 60 ++++++++++++++++++++++++--- 3 files changed, 120 insertions(+), 5 deletions(-) create mode 100755 scripts/build/bundle-macos-dylibs.sh diff --git a/scripts/build/bundle-macos-dylibs.sh b/scripts/build/bundle-macos-dylibs.sh new file mode 100755 index 00000000..2791dd81 --- /dev/null +++ b/scripts/build/bundle-macos-dylibs.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# bundle-macos-dylibs.sh +# +# Make a macOS bottle self-contained. The Swift MLX host links the kit's server +# component against Homebrew's openssl@3, brotli and zstd by absolute path +# (/opt/homebrew/opt/...). A machine without Homebrew then fails at launch with +# `dyld: Library not loaded: /opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib`, +# including during `wally update`'s own post-install check. +# +# Copies every such library (and what they pull in) into /lib, points the +# binary at it through @rpath, and leaves signing to package-wally.sh. +# +# $WALLY_DYLIB_FALLBACK_DIRS optional ':'-separated dirs searched for a +# library whose recorded path does not exist +# (used to re-pack a bottle off a Homebrew host). +set -euo pipefail + +STAGE="${1:?usage: bundle-macos-dylibs.sh }" +BIN="${STAGE}/bin/wally" +LIB="${STAGE}/lib" +mkdir -p "${LIB}" + +is_brew() { [[ "$1" == /opt/homebrew/* || "$1" == /usr/local/opt/* || "$1" == /usr/local/Cellar/* ]]; } + +# Dependencies of $1 that live outside the system. +brew_deps() { otool -L "$1" | awk 'NR > 1 { print $1 }' | while read -r d; do is_brew "$d" && echo "$d"; done; } + +resolve() { + local ref="$1" found + if [[ -e "${ref}" ]]; then echo "${ref}"; return; fi + IFS=: read -ra dirs <<<"${WALLY_DYLIB_FALLBACK_DIRS:-}" + for d in "${dirs[@]}"; do + found="$(find "${d}" -name "$(basename "${ref}")" 2>/dev/null | head -1)" + [[ -n "${found}" ]] && { echo "${found}"; return; } + done + echo "error: cannot find ${ref} to bundle" >&2 + return 1 +} + +queue=("${BIN}") +while ((${#queue[@]})); do + target="${queue[0]}"; queue=("${queue[@]:1}") + while read -r ref; do + [[ -n "${ref}" ]] || continue + name="$(basename "${ref}")" + if [[ ! -f "${LIB}/${name}" ]]; then + cp -L "$(resolve "${ref}")" "${LIB}/${name}" + chmod u+w "${LIB}/${name}" + install_name_tool -id "@rpath/${name}" "${LIB}/${name}" + queue+=("${LIB}/${name}") + fi + install_name_tool -change "${ref}" "@rpath/${name}" "${target}" + done < <(brew_deps "${target}") +done + +# Anything still pointing at Homebrew would reintroduce the failure. +for f in "${BIN}" "${LIB}"/*.dylib; do + if left="$(brew_deps "${f}")" && [[ -n "${left}" ]]; then + echo "error: ${f} still links ${left}" >&2 + exit 1 + fi +done diff --git a/scripts/build/package-wally.sh b/scripts/build/package-wally.sh index 73942b44..48d3ab51 100755 --- a/scripts/build/package-wally.sh +++ b/scripts/build/package-wally.sh @@ -157,6 +157,9 @@ fi case "${PLATFORM}" in macos-*) + # Ship the Homebrew openssl@3/brotli/zstd the binary links, so a Mac + # without Homebrew can launch it. + "${SCRIPT_DIR}/bundle-macos-dylibs.sh" "${STAGE}" if [[ -d "${STAGE}/lib" ]] && compgen -G "${STAGE}/lib/*.dylib" >/dev/null; then install_name_tool -add_rpath "@loader_path/../lib" "${STAGE}/bin/wally" 2>/dev/null || true for lib in "${STAGE}/lib/"*.dylib; do diff --git a/src/commands/cmd_editors.rs b/src/commands/cmd_editors.rs index 453b5f47..d6dad6c5 100644 --- a/src/commands/cmd_editors.rs +++ b/src/commands/cmd_editors.rs @@ -192,6 +192,37 @@ fn copy_recursive_overwrite(src: &Path, dst: &Path) { } } +/// Variables wally sets for the Claude Code it launches. +const GATEWAY_ENV: &[&str] = &[ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", +]; + +/// `settings.json` with the gateway variables dropped from its `env` block. +/// +/// Claude Code applies `env` from settings over the process environment, so a +/// reader whose own settings pin `ANTHROPIC_BASE_URL` (another gateway, a +/// proxy) would see wally's endpoint silently ignored: the launched Claude Code +/// talks to their gateway, not the model wally resolved. Text that is not a JSON +/// object is returned unchanged. +fn without_gateway_env(text: &str) -> String { + let Ok(mut doc) = serde_json::from_str::(text) else { + return text.to_string(); + }; + let Some(env) = doc.get_mut("env").and_then(serde_json::Value::as_object_mut) else { + return text.to_string(); + }; + let before = env.len(); + for name in GATEWAY_ENV { + env.remove(*name); + } + if env.len() == before { + return text.to_string(); + } + serde_json::to_string_pretty(&doc).unwrap_or_else(|_| text.to_string()) +} + /// A wally-owned config directory for the Claude Code we launch, seeded from the /// reader's real `~/.claude` so their settings, agents, rules, skills and memory /// come along, but WITHOUT the login: a separate dir has no claude.ai session to @@ -253,11 +284,13 @@ fn prepare_claude_config_dir() -> Option { // A cheap refresh every run so edits to the real settings and memory flow // through without re-copying the heavy trees. if let Some(og_dir) = &og_dir { - for file in ["settings.json", "CLAUDE.md"] { - let src = og_dir.join(file); - if src.exists() { - let _ = fs::copy(&src, ours.join(file)); - } + let settings = og_dir.join("settings.json"); + if let Ok(text) = fs::read_to_string(&settings) { + let _ = fs::write(ours.join("settings.json"), without_gateway_env(&text)); + } + let memory = og_dir.join("CLAUDE.md"); + if memory.exists() { + let _ = fs::copy(&memory, ours.join("CLAUDE.md")); } } if let Some(og_json) = &og_json { @@ -920,6 +953,23 @@ mod tests { assert_eq!(fs::read_to_string(&path).unwrap(), "{}"); } + #[test] + fn without_gateway_env_drops_only_the_gateway_variables() { + let out = without_gateway_env( + r#"{"env":{"ANTHROPIC_BASE_URL":"https://other","ANTHROPIC_AUTH_TOKEN":"t","KEEP":"1"},"model":"sonnet"}"#, + ); + let doc: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!(doc["env"], serde_json::json!({"KEEP": "1"})); + assert_eq!(doc["model"], "sonnet"); + } + + #[test] + fn without_gateway_env_leaves_other_text_alone() { + for text in ["not json", "{}", r#"{"env":{"KEEP":"1"}}"#, "[1]"] { + assert_eq!(without_gateway_env(text), text); + } + } + // With no usable HOME or XDG state dir, `prepare_claude_config_dir` used // to build the root-level "/claude" and export it, so Claude Code // couldn't create its config and wally silently lost the setup. It must From 6e6324765aebb4b8e8dae48c7ce3afb274464aeb Mon Sep 17 00:00:00 2001 From: Sanchit Monga Date: Fri, 9 Oct 2026 16:43:34 -0700 Subject: [PATCH 2/2] style: cargo fmt Co-Authored-By: Claude Sonnet 5.5 --- src/commands/cmd_editors.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/cmd_editors.rs b/src/commands/cmd_editors.rs index d6dad6c5..cf2fb969 100644 --- a/src/commands/cmd_editors.rs +++ b/src/commands/cmd_editors.rs @@ -210,7 +210,10 @@ fn without_gateway_env(text: &str) -> String { let Ok(mut doc) = serde_json::from_str::(text) else { return text.to_string(); }; - let Some(env) = doc.get_mut("env").and_then(serde_json::Value::as_object_mut) else { + let Some(env) = doc + .get_mut("env") + .and_then(serde_json::Value::as_object_mut) + else { return text.to_string(); }; let before = env.len();