cmux/Resources/bin/open
Lawrence Chen fdc38a3326
Add external URL bypass rules for embedded browser opens (#768)
* Add external URL bypass rules for embedded browser opens

* Align open-wrapper external regex handling with app-side matcher
2026-03-02 17:50:34 -08:00

466 lines
11 KiB
Bash
Executable file

#!/usr/bin/env bash
# cmux open wrapper - routes HTTP(S) URLs to cmux's in-app browser
#
# When running inside a cmux terminal (CMUX_SOCKET_PATH is set), this wrapper
# intercepts `open https://...` invocations and opens them in cmux's built-in
# browser within the same workspace. All other arguments pass through to
# /usr/bin/open unchanged.
SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}"
DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}"
PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}"
if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then
SYSTEM_OPEN_BIN="/usr/bin/open"
fi
if [[ ! -x "$DEFAULTS_BIN" ]]; then
DEFAULTS_BIN="/usr/bin/defaults"
fi
if [[ -n "$PYTHON3_BIN" ]]; then
if [[ ! -x "$PYTHON3_BIN" ]]; then
PYTHON3_BIN=""
fi
elif command -v python3 >/dev/null 2>&1; then
PYTHON3_BIN="$(command -v python3)"
fi
settings_domain="${CMUX_BUNDLE_ID:-}"
whitelist_raw=""
whitelist_patterns=()
system_open() {
exec "$SYSTEM_OPEN_BIN" "$@"
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
to_lower_ascii() {
# Bash 3.2-compatible lowercase conversion.
LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
normalize_boolean() {
to_lower_ascii "$(trim "$1")"
}
is_false_setting() {
local normalized
normalized="$(normalize_boolean "$1")"
case "$normalized" in
0|false|no|off)
return 0
;;
esac
return 1
}
canonicalize_idn_host() {
local value="$1"
[[ -z "$PYTHON3_BIN" ]] && {
printf '%s' "$value"
return 0
}
local canonicalized
canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true
import sys
host = sys.argv[1].strip().rstrip(".")
if not host:
raise SystemExit(1)
labels = host.split(".")
if any(not label for label in labels):
raise SystemExit(1)
try:
canonical = ".".join(label.encode("idna").decode("ascii") for label in labels)
except Exception:
raise SystemExit(1)
sys.stdout.write(canonical.lower())
PY
)"
if [[ -n "$canonicalized" ]]; then
printf '%s' "$canonicalized"
return 0
fi
printf '%s' "$value"
}
is_http_url() {
local value="$1"
case "$value" in
[Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*)
return 0
;;
esac
return 1
}
is_file_url() {
local value="$1"
case "$value" in
[Ff][Ii][Ll][Ee]://*)
return 0
;;
esac
return 1
}
has_uri_scheme() {
local value="$1"
[[ "$value" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]]
}
is_html_extension() {
local value
value="$(to_lower_ascii "$(trim "$1")")"
case "$value" in
*.html|*.htm)
return 0
;;
esac
return 1
}
is_explicit_local_path() {
local value="$1"
case "$value" in
/*|./*|../*|~|~/*)
return 0
;;
esac
return 1
}
file_url_points_to_html() {
local value="$1"
if [[ -n "$PYTHON3_BIN" ]]; then
"$PYTHON3_BIN" - "$value" <<'PY' >/dev/null 2>&1
import sys
from urllib.parse import unquote, urlsplit
value = sys.argv[1].strip()
if not value:
raise SystemExit(1)
parts = urlsplit(value)
path = unquote(parts.path or "")
lower = path.lower()
if lower.endswith(".html") or lower.endswith(".htm"):
raise SystemExit(0)
raise SystemExit(1)
PY
return $?
fi
local without_fragment="${value%%\#*}"
local without_query="${without_fragment%%\?*}"
local remainder path_part
case "$without_query" in
[Ff][Ii][Ll][Ee]://*)
remainder="${without_query#*://}"
;;
*)
return 1
;;
esac
if [[ "$remainder" == /* ]]; then
path_part="$remainder"
elif [[ "$remainder" == */* ]]; then
path_part="/${remainder#*/}"
else
return 1
fi
is_html_extension "$path_part"
}
path_to_file_url_without_python() {
local raw="$1"
local expanded="$raw"
case "$expanded" in
"~")
expanded="$HOME"
;;
"~/"*)
expanded="$HOME/${expanded#~/}"
;;
esac
local absolute
if [[ "$expanded" == /* ]]; then
absolute="$expanded"
else
absolute="$(pwd)/$expanded"
fi
local directory="$absolute"
local basename=""
if [[ "$absolute" == */* ]]; then
directory="${absolute%/*}"
basename="${absolute##*/}"
fi
local resolved_directory
if resolved_directory="$(cd "$directory" 2>/dev/null && pwd -P)"; then
absolute="$resolved_directory"
if [[ -n "$basename" ]]; then
absolute="$absolute/$basename"
fi
fi
local encoded=""
local length=${#absolute}
local index char hex
local LC_ALL=C
for ((index = 0; index < length; index++)); do
char="${absolute:index:1}"
case "$char" in
[a-zA-Z0-9.~_-]|/)
encoded+="$char"
;;
*)
printf -v hex '%02X' "'$char"
encoded+="%$hex"
;;
esac
done
printf 'file://%s\n' "$encoded"
}
path_to_file_url() {
local raw="$1"
if [[ -n "$PYTHON3_BIN" ]]; then
local converted
if converted="$("$PYTHON3_BIN" - "$raw" <<'PY' 2>/dev/null
import pathlib
import sys
raw = sys.argv[1]
if not raw:
raise SystemExit(1)
path = pathlib.Path(raw).expanduser()
if path.is_absolute():
resolved = path.resolve(strict=False)
else:
resolved = (pathlib.Path.cwd() / path).resolve(strict=False)
sys.stdout.write(resolved.as_uri())
PY
)"; then
printf '%s\n' "$converted"
return 0
fi
fi
path_to_file_url_without_python "$raw"
}
normalize_host() {
local value
value="$(trim "$1")"
value="$(to_lower_ascii "$value")"
[[ -z "$value" ]] && return 1
if [[ "$value" == *"://"* ]]; then
value="${value#*://}"
fi
value="${value%%/*}"
value="${value%%\?*}"
value="${value%%\#*}"
if [[ "$value" == *"@"* ]]; then
value="${value##*@}"
fi
if [[ "$value" == \[* ]]; then
value="${value#\[}"
value="${value%%\]*}"
elif [[ "$value" == *:* ]]; then
local colons="${value//[^:]}"
if [[ ${#colons} -eq 1 ]] && [[ "$value" =~ :[0-9]+$ ]]; then
value="${value%:*}"
fi
fi
while [[ "$value" == .* ]]; do
value="${value#.}"
done
while [[ "$value" == *. ]]; do
value="${value%.}"
done
[[ -z "$value" ]] && return 1
value="$(canonicalize_idn_host "$value")"
printf '%s' "$value"
}
normalize_whitelist_pattern() {
local value
value="$(trim "$1")"
value="$(to_lower_ascii "$value")"
[[ -z "$value" ]] && return 1
if [[ "$value" == \*.* ]]; then
local suffix
suffix="$(normalize_host "${value#*.}")" || return 1
printf '*.%s' "$suffix"
return 0
fi
normalize_host "$value"
}
host_matches_pattern() {
local host="$1"
local pattern="$2"
if [[ "$pattern" == \*.* ]]; then
local suffix="${pattern#*.}"
[[ "$host" == "$suffix" ]] && return 0
[[ "$host" == *".$suffix" ]] && return 0
return 1
fi
[[ "$host" == "$pattern" ]]
}
host_matches_whitelist() {
local url="$1"
if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then
return 0
fi
local host
host="$(normalize_host "$url")" || return 1
for pattern in "${whitelist_patterns[@]}"; do
if host_matches_pattern "$host" "$pattern"; then
return 0
fi
done
return 1
}
load_whitelist_patterns() {
local raw="$1"
local line
while IFS= read -r line || [[ -n "$line" ]]; do
local normalized
normalized="$(normalize_whitelist_pattern "$line")" || continue
whitelist_patterns+=("$normalized")
done <<< "$raw"
}
# Pass through immediately if not in a cmux terminal.
if [[ -z "$CMUX_SOCKET_PATH" ]]; then
system_open "$@"
fi
# No arguments → pass through.
if [[ $# -eq 0 ]]; then
system_open "$@"
fi
# Scan for flags that indicate explicit user intent → pass through.
# Also collect non-flag arguments and route eligible browser targets to cmux.
passthrough=false
cmux_targets=()
passthrough_args=()
for arg in "$@"; do
case "$arg" in
-a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr)
passthrough=true
break
;;
-*)
# Unknown flag → be conservative, pass through
passthrough=true
break
;;
*)
if is_http_url "$arg"; then
cmux_targets+=("$arg")
elif is_file_url "$arg"; then
if file_url_points_to_html "$arg"; then
cmux_targets+=("$arg")
else
passthrough_args+=("$arg")
fi
elif has_uri_scheme "$arg"; then
passthrough_args+=("$arg")
elif is_html_extension "$arg"; then
if is_explicit_local_path "$arg" || [[ -e "$arg" ]]; then
if local_file_url="$(path_to_file_url "$arg")"; then
cmux_targets+=("$local_file_url")
else
passthrough_args+=("$arg")
fi
else
passthrough_args+=("$arg")
fi
else
passthrough_args+=("$arg")
fi
;;
esac
done
if [[ "$passthrough" == true ]] || [[ ${#cmux_targets[@]} -eq 0 ]]; then
system_open "$@"
fi
# Respect the same settings used for terminal link clicks.
if [[ -n "$settings_domain" ]]; then
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserInterceptTerminalOpenCommandInCmuxBrowser 2>/dev/null || true)"
if [[ -z "$open_in_cmux" ]]; then
# Backward compatibility for installs that predate the dedicated open-wrapper toggle.
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)"
fi
if is_false_setting "$open_in_cmux"; then
system_open "$@"
fi
whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)"
if [[ -n "$whitelist_raw" ]]; then
load_whitelist_patterns "$whitelist_raw"
fi
fi
# Find cmux CLI (same directory as this script).
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
CMUX_CLI="$SELF_DIR/cmux"
if [[ ! -x "$CMUX_CLI" ]]; then
system_open "$@"
fi
# Open each URL in cmux's in-app browser; track failures individually.
# External-open pattern rules are evaluated in-app (NSRegularExpression) so
# terminal link clicks and intercepted `open` commands share one regex dialect.
failed_urls=()
for url in "${cmux_targets[@]}"; do
if is_http_url "$url" && ! host_matches_whitelist "$url"; then
failed_urls+=("$url")
continue
fi
CMUX_RESPECT_EXTERNAL_OPEN_RULES=1 "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
done
# Fall back to system open for unmatched args and URLs that failed.
if [[ ${#passthrough_args[@]} -gt 0 ]] || [[ ${#failed_urls[@]} -gt 0 ]]; then
system_open "${passthrough_args[@]}" "${failed_urls[@]}"
fi