Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions scripts/build/bundle-macos-dylibs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# bundle-macos-dylibs.sh <stage-dir>
#
# 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 <stage>/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 <stage-dir>}"
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; }
Comment thread
Siddhesh2377 marked this conversation as resolved.

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
3 changes: 3 additions & 0 deletions scripts/build/package-wally.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 58 additions & 5 deletions src/commands/cmd_editors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,40 @@ 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::<serde_json::Value>(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
Expand Down Expand Up @@ -253,11 +287,13 @@ fn prepare_claude_config_dir() -> Option<String> {
// 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 {
Expand Down Expand Up @@ -920,6 +956,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
Expand Down
Loading