run.sh shows a window (zenity form, terminal prompts without zenity) for a base URL, token, auth header and optional model list. Left empty, Claude Desktop starts normally with the claude.ai sign-in. Otherwise it runs in its third-party gateway mode: run.sh writes the managed config (inferenceProvider "gateway" and friends) and copies it into the created container as root, because Claude Desktop ignores /etc/claude-desktop/managed-settings.json unless root owns it. The host copy is deleted right away; nothing is kept between starts. Claude Desktop only allows plain http to loopback, so for an http:// gateway launch-apps.sh runs socat on 127.0.0.1:18080 and forwards to it. localhost is rewritten to host.docker.internal for servers on the host. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
17 KiB
Bash
Executable File
384 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Start the Claude Desktop container, forwarding the host's X server.
|
|
# Before launching, verify that a window from the container can actually be
|
|
# drawn on the host display. Asks (in a folder picker) which host folder to
|
|
# share with Claude; it is mounted read-write at /workspace in the container.
|
|
# Also starts the REA MCP server in a second, isolated container and stops it
|
|
# when Claude Desktop's container ends.
|
|
#
|
|
# Usage: ./run.sh [--build] [--build-only] [--check-only] [--workspace DIR]
|
|
# --build rebuild the image even if it exists
|
|
# --build-only (re)build the image and exit
|
|
# --check-only run the display check and exit without starting Claude
|
|
# --workspace DIR share DIR without asking (also: WORKSPACE=DIR)
|
|
|
|
set -euo pipefail
|
|
|
|
IMAGE="${IMAGE:-claude-re-setup:latest}"
|
|
CONTAINER="${CONTAINER:-claude-desktop}"
|
|
REA_CONTAINER="${REA_CONTAINER:-claude-re-mcp}"
|
|
# Internal network (no route out) shared by Claude Desktop and REA.
|
|
NETWORK="${NETWORK:-claude-re-setup-internal}"
|
|
RUNTIME="${RUNTIME:-docker}"
|
|
WORKSPACE="${WORKSPACE:-}"
|
|
|
|
script_dir="$(dirname "$(readlink -f "$0")")"
|
|
state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/claude-re-setup"
|
|
last_workspace_file="$state_dir/last_workspace"
|
|
|
|
build=0
|
|
check_only=0
|
|
build_only=0
|
|
while (( $# )); do
|
|
case "$1" in
|
|
--build) build=1 ;;
|
|
--build-only) build=1 build_only=1 ;;
|
|
--check-only) check_only=1 ;;
|
|
--workspace) [[ $# -ge 2 ]] || { echo "--workspace needs a directory" >&2; exit 2; }
|
|
WORKSPACE="$2"; shift ;;
|
|
--workspace=*) WORKSPACE="${1#*=}" ;;
|
|
-h|--help) sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
# Started from the app menu (install.sh) there is no terminal to read
|
|
# errors in, so they are also shown in a dialog.
|
|
die() {
|
|
echo "error: $*" >&2
|
|
if [[ ! -t 2 ]] && command -v zenity >/dev/null; then
|
|
zenity --error --no-wrap --title="Claude RE workstation" --text="$*" 2>/dev/null || true
|
|
fi
|
|
exit 1
|
|
}
|
|
info() { echo "==> $*"; }
|
|
|
|
command -v "$RUNTIME" >/dev/null || die "'$RUNTIME' not found (set RUNTIME=podman to use podman)"
|
|
|
|
# --- Image build -------------------------------------------------------------
|
|
ensure_image() {
|
|
if (( build )) || ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
|
|
info "Building $IMAGE"
|
|
"$RUNTIME" build \
|
|
--build-arg USER_UID="$(id -u)" \
|
|
--build-arg USER_GID="$(id -g)" \
|
|
-t "$IMAGE" "$script_dir"
|
|
fi
|
|
}
|
|
if (( build_only )); then
|
|
ensure_image
|
|
info "Built $IMAGE"
|
|
exit 0
|
|
fi
|
|
|
|
command -v xauth >/dev/null || die "xauth not found on the host (dnf install xorg-x11-xauth)"
|
|
|
|
# --- Display -----------------------------------------------------------------
|
|
# On Wayland sessions this is XWayland, which GNOME/KDE start on demand.
|
|
[[ -n "${DISPLAY:-}" ]] || die "DISPLAY is not set; run this from a graphical session"
|
|
|
|
display_num="${DISPLAY#*:}"
|
|
display_num="${display_num%%.*}"
|
|
[[ -S "/tmp/.X11-unix/X${display_num}" ]] \
|
|
|| die "X socket /tmp/.X11-unix/X${display_num} not found for DISPLAY=$DISPLAY"
|
|
|
|
# Hand the container a copy of this display's cookie, rewritten to match any
|
|
# hostname (family ffff). This avoids opening the server up with 'xhost +'.
|
|
xauth_dir="${XDG_RUNTIME_DIR:-/tmp}/claude-re-setup"
|
|
xauth_file="$xauth_dir/Xauthority"
|
|
mkdir -p "$xauth_dir"
|
|
chmod 700 "$xauth_dir"
|
|
: > "$xauth_file"
|
|
xauth nlist "$DISPLAY" | sed -e 's/^..../ffff/' | xauth -f "$xauth_file" nmerge - \
|
|
|| die "could not export the X cookie for $DISPLAY"
|
|
[[ -s "$xauth_file" ]] || die "no X cookie found for $DISPLAY (is XAUTHORITY set?)"
|
|
# The container user has the same UID as us, so 0600 is enough.
|
|
chmod 600 "$xauth_file"
|
|
|
|
# --- Image -------------------------------------------------------------------
|
|
ensure_image
|
|
|
|
# label=disable: the X socket and cookie are host files that SELinux would
|
|
# otherwise block, and relabeling /tmp/.X11-unix with :z would break the host.
|
|
run_args=(
|
|
--rm
|
|
--init
|
|
--security-opt label=disable
|
|
--shm-size 1g
|
|
-e DISPLAY=":${display_num}"
|
|
-e XAUTHORITY=/tmp/.Xauthority
|
|
-v /tmp/.X11-unix:/tmp/.X11-unix:ro
|
|
-v "$xauth_file:/tmp/.Xauthority:ro"
|
|
)
|
|
|
|
# --- Display check -----------------------------------------------------------
|
|
# 1. xdpyinfo proves the container can connect and authenticate to the server.
|
|
# 2. xmessage opens a real window; xwininfo confirms the server mapped it.
|
|
info "Checking that the container can draw on DISPLAY=$DISPLAY"
|
|
check_script='
|
|
set -e
|
|
xdpyinfo >/dev/null 2>&1 || { echo "FAIL: cannot connect to X server at $DISPLAY"; exit 1; }
|
|
echo "ok: connected to X server ($(xdpyinfo | sed -n "s/^vendor string: *//p"))"
|
|
|
|
title="claude-re-setup-x11-check"
|
|
xmessage -title "$title" -center -timeout 3 "Display check from the container: OK" &
|
|
for _ in $(seq 1 30); do
|
|
if xwininfo -name "$title" 2>/dev/null | grep -q "Map State: IsViewable"; then
|
|
echo "ok: test window is mapped and viewable"
|
|
wait || true
|
|
exit 0
|
|
fi
|
|
sleep 0.1
|
|
done
|
|
kill %1 2>/dev/null || true
|
|
echo "FAIL: test window was created but never became viewable"
|
|
exit 1
|
|
'
|
|
if ! "$RUNTIME" run "${run_args[@]}" "$IMAGE" bash -c "$check_script"; then
|
|
die "display check failed; see the output above"
|
|
fi
|
|
|
|
(( check_only )) && { info "Display check passed"; exit 0; }
|
|
|
|
# --- Claude Desktop ----------------------------------------------------------
|
|
for c in "$CONTAINER" "$REA_CONTAINER"; do
|
|
if "$RUNTIME" container inspect "$c" >/dev/null 2>&1; then
|
|
die "container '$c' already exists; stop it with: $RUNTIME stop $c"
|
|
fi
|
|
done
|
|
|
|
# --- Model provider ----------------------------------------------------------
|
|
# Asked on every start. Left empty, Claude Desktop starts normally (claude.ai
|
|
# sign-in, Anthropic's models). Given a base URL, Claude Desktop runs in its
|
|
# third-party "gateway" mode: Chat, Code and Cowork send their requests to
|
|
# that URL, which must speak the Anthropic Messages API (/v1/messages), e.g.
|
|
# a self-hosted model behind LiteLLM. The settings go into Claude Desktop's
|
|
# managed configuration file (/etc/claude-desktop/managed-settings.json on
|
|
# Linux). Claude Desktop ignores that file unless root owns it and only root
|
|
# can write it, so it is copied into the created container (as root, 0644)
|
|
# just before it starts, rather than mounted; the copy on this side, in a
|
|
# private runtime folder, is deleted right after. Nothing is remembered
|
|
# between starts, the token included.
|
|
managed_settings_dir="$xauth_dir/managed-settings"
|
|
rm -rf "$managed_settings_dir"
|
|
gateway_url=""
|
|
gateway_token=""
|
|
gateway_scheme="bearer"
|
|
gateway_models=""
|
|
|
|
# JSON string literal for $1 (refuses control characters).
|
|
json_str() {
|
|
[[ "$1" != *[[:cntrl:]]* ]] || die "control characters are not allowed in: $1"
|
|
local s="${1//\\/\\\\}"
|
|
printf '"%s"' "${s//\"/\\\"}"
|
|
}
|
|
|
|
provider_text="Leave the base URL empty to use Anthropic's models (sign in to claude.ai as usual).
|
|
|
|
To use another model, enter the base URL of a server that speaks the Anthropic Messages API, without /v1 (e.g. http://host.docker.internal:4000 for a LiteLLM proxy on this machine). With http:// the token is sent unencrypted. Models: comma-separated IDs, the first is the default; leave empty to ask the server's /v1/models."
|
|
if command -v zenity >/dev/null; then
|
|
info "Choose the model provider in the dialog"
|
|
form="$(zenity --forms --title="Claude RE workstation: model provider" \
|
|
--text="$provider_text" --separator=$'\x1f' \
|
|
--add-entry="Base URL (ANTHROPIC_BASE_URL)" \
|
|
--add-password="Token" \
|
|
--add-combo="Send token as" --combo-values="Authorization: Bearer|x-api-key" \
|
|
--add-entry="Models (optional)" 2>/dev/null)" \
|
|
|| die "model provider dialog cancelled; not starting Claude"
|
|
IFS=$'\x1f' read -r gateway_url gateway_token scheme_choice gateway_models <<< "$form"
|
|
[[ "${scheme_choice:-}" == "x-api-key" ]] && gateway_scheme="x-api-key"
|
|
elif [[ -t 0 ]]; then
|
|
echo "$provider_text"
|
|
read -r -p "Base URL (empty = Anthropic): " gateway_url
|
|
if [[ -n "$gateway_url" ]]; then
|
|
read -r -s -p "Token: " gateway_token; echo
|
|
read -r -p "Models (optional): " gateway_models
|
|
fi
|
|
fi
|
|
|
|
# Trim spaces and a trailing slash or /v1 (Claude Desktop appends /v1/messages).
|
|
gateway_url="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's#/*$##' -e 's#/v1$##' <<< "$gateway_url")"
|
|
gateway_token="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<< "$gateway_token")"
|
|
if [[ -n "$gateway_url" ]]; then
|
|
[[ "$gateway_url" =~ ^https?://[^/[:space:]]+ ]] \
|
|
|| die "the base URL must start with http:// or https://: $gateway_url"
|
|
# localhost in the container is the container itself; a server on this
|
|
# machine is reached through host.docker.internal (added to the container
|
|
# below). It has to listen on an address Docker's bridge can reach, not
|
|
# only 127.0.0.1.
|
|
if [[ "$gateway_url" =~ ^(https?://)(localhost|127\.0\.0\.1|\[::1\])(:|/|$)(.*)$ ]]; then
|
|
gateway_url="${BASH_REMATCH[1]}host.docker.internal${BASH_REMATCH[3]}${BASH_REMATCH[4]}"
|
|
info "Using $gateway_url (localhost inside the container is the container itself)"
|
|
fi
|
|
gateway_shown="$gateway_url"
|
|
# Claude Desktop accepts plain http only for loopback addresses. For an
|
|
# http:// server elsewhere, socat in the container listens on
|
|
# 127.0.0.1:18080 and forwards to it (started by launch-apps.sh), and
|
|
# Claude Desktop is pointed there. The token still crosses the network
|
|
# unencrypted, as it would with any plain-HTTP server.
|
|
if [[ "$gateway_url" =~ ^http://([^/:]+)(:([0-9]+))?(/.*)?$ ]]; then
|
|
fwd_target="${BASH_REMATCH[1]}:${BASH_REMATCH[3]:-80}"
|
|
run_args+=(-e "CLAUDE_RE_GATEWAY_FORWARD=$fwd_target")
|
|
gateway_url="http://127.0.0.1:18080${BASH_REMATCH[4]}"
|
|
info "Plain HTTP: forwarding 127.0.0.1:18080 in the container to $fwd_target (token not encrypted)"
|
|
elif [[ "$gateway_url" == http://* ]]; then
|
|
die "unsupported http:// address (use a hostname or IPv4 address, or https://): $gateway_url"
|
|
fi
|
|
# The key is required in gateway mode; local servers usually ignore it.
|
|
[[ -n "$gateway_token" ]] || gateway_token="none"
|
|
|
|
models_json=""
|
|
IFS=',' read -r -a model_list <<< "$gateway_models"
|
|
for m in "${model_list[@]}"; do
|
|
m="$(sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<< "$m")"
|
|
[[ -n "$m" ]] && models_json+="${models_json:+, }$(json_str "$m")"
|
|
done
|
|
|
|
# Removed again if the start fails before it is copied in.
|
|
trap 'rm -rf "$managed_settings_dir"' EXIT
|
|
mkdir -m 700 "$managed_settings_dir"
|
|
( umask 077
|
|
{
|
|
echo "{"
|
|
echo " \"inferenceProvider\": \"gateway\","
|
|
echo " \"inferenceGatewayBaseUrl\": $(json_str "$gateway_url"),"
|
|
echo " \"inferenceGatewayApiKey\": $(json_str "$gateway_token"),"
|
|
echo " \"inferenceGatewayAuthScheme\": \"$gateway_scheme\","
|
|
[[ -n "$models_json" ]] && echo " \"inferenceModels\": [$models_json],"
|
|
echo " \"disableDeploymentModeChooser\": true"
|
|
echo "}"
|
|
} > "$managed_settings_dir/managed-settings.json" )
|
|
info "Model provider: gateway at $gateway_shown"
|
|
else
|
|
info "Model provider: Anthropic (claude.ai sign-in)"
|
|
fi
|
|
unset gateway_token
|
|
# For self-hosted servers on this machine (see above).
|
|
run_args+=(--add-host host.docker.internal:host-gateway)
|
|
|
|
# --- Workspace ---------------------------------------------------------------
|
|
# The folder Claude can read and write. Chosen in the desktop's own folder
|
|
# picker (zenity on GNOME, kdialog on KDE), else asked in the terminal.
|
|
# Default: the last one used, else ./workspace next to this script.
|
|
ws_title="Choose the folder Claude can access (mounted at /workspace)"
|
|
|
|
# Prints the reason a folder can't be shared, or nothing if it's fine.
|
|
workspace_problem() {
|
|
case "$1" in
|
|
/|"$HOME") echo "Sharing $1 would give Claude far more than intended; pick a dedicated folder." ;;
|
|
*) [[ -w "$1" ]] || echo "$1 is not writable by $(id -un)." ;;
|
|
esac
|
|
}
|
|
|
|
# Shows a folder picker starting in $1; prints the choice, fails on cancel.
|
|
gui_pick() {
|
|
if command -v zenity >/dev/null; then
|
|
zenity --file-selection --directory --title="$ws_title" --filename="$1/" 2>/dev/null
|
|
elif command -v kdialog >/dev/null; then
|
|
kdialog --title "$ws_title" --getexistingdirectory "$1" 2>/dev/null
|
|
else
|
|
return 2
|
|
fi
|
|
}
|
|
gui_error() {
|
|
if command -v zenity >/dev/null; then zenity --error --no-wrap --text="$1" 2>/dev/null
|
|
else kdialog --error "$1" 2>/dev/null; fi
|
|
}
|
|
|
|
if [[ -z "$WORKSPACE" ]]; then
|
|
default_ws="$script_dir/workspace"
|
|
[[ -r "$last_workspace_file" ]] && default_ws="$(<"$last_workspace_file")"
|
|
# The picker needs an existing folder to open in. ./workspace is
|
|
# git-ignored, so creating it is harmless; a stale last choice falls back.
|
|
[[ -d "$default_ws" ]] || { default_ws="$script_dir/workspace"; mkdir -p "$default_ws"; }
|
|
|
|
if command -v zenity >/dev/null || command -v kdialog >/dev/null; then
|
|
info "Choose the folder to share in the dialog"
|
|
while :; do
|
|
WORKSPACE="$(gui_pick "$default_ws")" || die "no folder chosen; not starting Claude"
|
|
problem="$(workspace_problem "$(readlink -f "$WORKSPACE")")"
|
|
[[ -z "$problem" ]] && break
|
|
gui_error "$problem"
|
|
done
|
|
elif [[ -t 0 ]]; then
|
|
echo "Claude will get read-write access to one folder, mounted at /workspace."
|
|
read -r -e -p "Folder to share [$default_ws]: " WORKSPACE
|
|
fi
|
|
WORKSPACE="${WORKSPACE:-$default_ws}"
|
|
fi
|
|
# Expand a leading ~ the way the shell would have.
|
|
[[ "$WORKSPACE" == "~" || "$WORKSPACE" == "~/"* ]] && WORKSPACE="$HOME${WORKSPACE#\~}"
|
|
|
|
if [[ ! -d "$WORKSPACE" ]]; then
|
|
[[ -e "$WORKSPACE" ]] && die "$WORKSPACE exists but is not a directory"
|
|
if [[ -t 0 ]]; then
|
|
read -r -p "$WORKSPACE does not exist. Create it? [Y/n] " answer
|
|
[[ "${answer:-y}" =~ ^[Yy] ]] || die "no folder to share"
|
|
fi
|
|
mkdir -p "$WORKSPACE" || die "could not create $WORKSPACE"
|
|
fi
|
|
WORKSPACE="$(readlink -f "$WORKSPACE")"
|
|
|
|
problem="$(workspace_problem "$WORKSPACE")"
|
|
[[ -z "$problem" ]] || die "$problem"
|
|
|
|
mkdir -p "$state_dir"
|
|
printf '%s\n' "$WORKSPACE" > "$last_workspace_file"
|
|
|
|
# --- REA MCP server ----------------------------------------------------------
|
|
# REA (Reverse Engineer Anything) gets its own container from the same image,
|
|
# because some of its tools run the target: no display, no capabilities, only
|
|
# the workspace mounted, and only the internal network, which has no route
|
|
# out. It serves MCP over HTTP as "rea:8765"; Claude Desktop reaches it
|
|
# through mcp-remote (see claude-desktop/claude_desktop_config.json).
|
|
if ! "$RUNTIME" network inspect "$NETWORK" >/dev/null 2>&1; then
|
|
info "Creating internal network $NETWORK"
|
|
"$RUNTIME" network create --internal "$NETWORK" >/dev/null
|
|
fi
|
|
|
|
ws_mount=(-v "$WORKSPACE:/workspace")
|
|
info "Starting the REA MCP server in container '$REA_CONTAINER'"
|
|
"$RUNTIME" run -d --rm --init \
|
|
--name "$REA_CONTAINER" \
|
|
--network "$NETWORK" --network-alias rea \
|
|
--cap-drop ALL --security-opt no-new-privileges \
|
|
--security-opt label=disable \
|
|
"${ws_mount[@]}" \
|
|
"$IMAGE" rea mcp --http --host 0.0.0.0 --port 8765 >/dev/null
|
|
stop_rea() { "$RUNTIME" stop "$REA_CONTAINER" >/dev/null 2>&1 || true; }
|
|
|
|
# --- Claude Desktop ----------------------------------------------------------
|
|
# No config volume: the container is removed on stop (--rm), so the sign-in
|
|
# is discarded and Claude asks for a login on every start.
|
|
# No :z on the workspace mount: label=disable already lets the container use
|
|
# it, and relabeling an arbitrary host folder could break other apps.
|
|
# Created first, so it is on the internal network (as well as the default one,
|
|
# for sign-in) before it starts.
|
|
info "Starting Claude Desktop and Ghidra in container '$CONTAINER' (workspace: $WORKSPACE -> /workspace)"
|
|
# Copies the provider settings (if any) into the created container, owned
|
|
# by root and readable by Claude Desktop, then deletes them on this side.
|
|
copy_managed_settings() {
|
|
[[ -d "$managed_settings_dir" ]] || return 0
|
|
tar -C "$managed_settings_dir" --owner=0 --group=0 --numeric-owner --mode=0644 \
|
|
-cf - managed-settings.json \
|
|
| "$RUNTIME" cp - "$CONTAINER:/etc/claude-desktop/" \
|
|
&& rm -rf "$managed_settings_dir"
|
|
}
|
|
if ! { "$RUNTIME" create "${run_args[@]}" --name "$CONTAINER" "${ws_mount[@]}" "$IMAGE" >/dev/null \
|
|
&& "$RUNTIME" network connect "$NETWORK" "$CONTAINER" \
|
|
&& copy_managed_settings \
|
|
&& "$RUNTIME" start "$CONTAINER" >/dev/null; }; then
|
|
"$RUNTIME" rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
|
stop_rea
|
|
die "could not start Claude Desktop"
|
|
fi
|
|
trap - EXIT
|
|
|
|
# Stop REA once Claude Desktop's container is gone, in the background and
|
|
# detached from this terminal.
|
|
setsid nohup bash -c '"$1" wait "$2" >/dev/null 2>&1; "$1" stop "$3" >/dev/null 2>&1' \
|
|
_ "$RUNTIME" "$CONTAINER" "$REA_CONTAINER" </dev/null >/dev/null 2>&1 &
|
|
|
|
info "Running. Logs: $RUNTIME logs -f $CONTAINER (REA: $REA_CONTAINER) Stop: $RUNTIME stop $CONTAINER"
|