diff --git a/CLAUDE.md b/CLAUDE.md
index 3a71a3b..d8446ed 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,6 +38,11 @@ Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with C
## Architecture
- **Claude Desktop and Ghidra share one container** (the root `Dockerfile`, started by `run.sh`), at the user's request. Ghidra is the official GitHub release (version, build date and SHA-256 are global build args, downloaded once in the `ghidra-dist` stage and copied into the final image and into build stages that compile extensions against it), is pinned to **12.1.4** because that's what the GhidraMCP release is built for (Ghidra only loads extensions built for its exact version), runs on Ubuntu's `openjdk-21-jdk`, lives in `/opt/ghidra` and starts together with Claude Desktop. The **Developer** plugin package is enabled in both the project window and the CodeBrowser. A bare `` only adds the package's RELEASED plugins, so the STABLE ones are listed with ``. CodeBrowser: the build inserts `ghidra/codebrowser-developer.xml` into `defaultTools/CodeBrowser.tool` inside `Public_Release.jar`. Project window: it has no default to patch, so `ghidra/FrontEndTool.xml` is copied into the user settings, along with `ghidra/preferences` (`USER_AGREEMENT=ACCEPT`, because a pre-created settings dir otherwise makes Ghidra ask for its user agreement). To check a config really loaded, close Ghidra cleanly and see whether the plugins survive in the file it rewrites; Ghidra drops what it didn't load.
+- **Model provider window.** `run.sh` asks on every start (zenity form; terminal prompts without zenity) for a base URL, token, auth header and optional model list. Empty URL: normal claude.ai sign-in. Otherwise it writes Claude Desktop's third-party managed config (`inferenceProvider: "gateway"`, `inferenceGatewayBaseUrl`, `inferenceGatewayApiKey`, `inferenceGatewayAuthScheme`, `inferenceModels`, `disableDeploymentModeChooser`; docs: https://claude.com/docs/third-party/claude-desktop) and Chat, Code and Cowork use that Anthropic-Messages-compatible endpoint, with no claude.ai account. Verified against Claude Desktop 4.4212.0:
+ - On Linux the file is `/etc/claude-desktop/managed-settings.json`, and Claude Desktop **ignores it unless root owns it and it isn't group/world-writable**. A bind mount from the host shows the user's UID, so `run.sh` creates the container, copies the file in with `docker cp` from a tar stream (owner 0, mode 0644), then starts it; the host copy (runtime dir, 0700) is deleted right away and on failure. The image provides `/etc/claude-desktop`.
+ - Claude Desktop rejects `http://` base URLs except loopback. For `http://host[:port]` the container gets `CLAUDE_RE_GATEWAY_FORWARD=host:port`, `launch-apps.sh` runs `socat` on `127.0.0.1:18080` to it, and the config points at `http://127.0.0.1:18080` (the token then travels unencrypted, as with any plain-HTTP server). `https://` URLs are used as given.
+ - `localhost`/`127.0.0.1` in the URL is rewritten to `host.docker.internal` (`--add-host host.docker.internal:host-gateway`); a trailing `/` or `/v1` is stripped because Claude Desktop appends `/v1/messages`. An empty token becomes `none` (the key is required in gateway mode).
+ - Check what Claude Desktop made of it in `~/.config/Claude/logs/main.log` (`[managedConfig]`, `[custom-3p]`).
- **Shared workspace volume.** Every container mounts the workspace at `/workspace`, so a file path means the same thing to Claude, Ghidra and the RE MCP. `run.sh` asks for the host folder on each start in the desktop's folder picker (`zenity`, or `kdialog` on KDE; terminal prompt if neither exists), opening at the last folder used, else `./workspace`. Skip it with `--workspace DIR` or `WORKSPACE=DIR`. It refuses `/` and `$HOME`. The image symlinks `~/workspace` to `/workspace` for Claude Desktop's file picker.
- **GUI forwarding.** Claude Desktop and Ghidra are GUI apps. Default to X11 forwarding (`/tmp/.X11-unix` + `DISPLAY`). Support Wayland through XWayland. Keep a noVNC/VNC fallback in mind for headless hosts. Never solve display access with a blanket `xhost +`. Scope it (`xhost +local:docker` or `+SI:localuser:...`).
- **GhidraMCP wiring.** LaurieWired's GhidraMCP (https://github.com/LaurieWired/GhidraMCP), release 2.0, installed from the official release zip (SHA-256 checked). It has two parts:
@@ -56,7 +61,7 @@ Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with C
- **Pin versions.** Ghidra version, GhidraMCP release, JDK version, Claude Desktop build and RE MCP commit go in `.env` / build args, not `latest`. The GhidraMCP release must match the Ghidra version it was built for.
- **Run as non-root** inside containers. Map the host UID/GID (via `.env`) so files in `workspace/` stay owned by the user.
- **Persist state in named volumes or `workspace/`**: Ghidra user settings and projects. Rebuilding an image must not lose analysis.
-- **Claude Desktop sign-in is not persisted.** No keyring is installed and `~/.config/Claude` is not mounted, so the user logs in on every start. Don't add a keyring or config volume back without being asked.
+- **Claude Desktop sign-in is not persisted.** No keyring is installed and `~/.config/Claude` is not mounted, so the user logs in on every start. Don't add a keyring or config volume back without being asked. The same goes for the model-provider token (below): it's asked for on every start and never stored on the host.
- **No secrets in the repo.** Credentials, session data and `workspace/` contents are git-ignored. Ship `.env.example`, never `.env`.
- **Untrusted binaries.** Samples under analysis may be malicious. Never execute them on the host or in the Claude Desktop container. Containers that might run samples (dynamic analysis) should have no host mounts beyond `workspace/` and should allow network isolation (`network_mode: none` or an internal-only network).
- **Linux only.** Target recent Docker Engine + Compose v2 (`docker compose`, not `docker-compose`). Make the setup work under both Docker and rootless Podman where practical. On SELinux hosts such as Fedora, bind mounts need `:z`/`:Z`.
diff --git a/Dockerfile b/Dockerfile
index bf653ad..0ec604c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -177,6 +177,7 @@ RUN apt-get update \
claude-desktop \
x11-utils \
xauth \
+ socat \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
@@ -393,6 +394,11 @@ RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \
COPY --chown=${USER_UID}:${USER_GID} ghidra/FrontEndTool.xml ghidra/preferences \
/home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC/
+# Claude Desktop's managed configuration directory. run.sh copies a
+# managed-settings.json in (root-owned, as Claude Desktop requires) when a
+# third-party model provider is chosen at start.
+RUN install -d -m 755 /etc/claude-desktop
+
# Claude Desktop's MCP servers. Only this file is put in ~/.config/Claude;
# sign-in data is still created at runtime and discarded with the container.
COPY --chown=${USER_UID}:${USER_GID} claude-desktop/claude_desktop_config.json \
diff --git a/README.md b/README.md
index 4d1550a..037162b 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,21 @@ Docker and an X server (or XWayland) is needed on the host.
docker stop claude-desktop # stop everything
```
-The shared folder is mounted at `/workspace` in both containers. Sign in to
-Claude Desktop on every start; the sign-in is not kept. Closing Claude
+On every start a window asks for the model provider:
+
+- **Base URL empty:** Anthropic's models; sign in to claude.ai as usual.
+- **Base URL set:** Claude Desktop runs in its third-party "gateway" mode, with
+ no claude.ai account: Chat, Code and Cowork send their requests to that URL
+ with the token. The server must speak the Anthropic Messages API
+ (`/v1/messages`), e.g. a self-hosted model behind LiteLLM. Models are
+ optional (comma-separated, first = default); without them Claude Desktop
+ asks the server's `/v1/models`. A server on this machine is reachable as
+ `http://host.docker.internal:PORT` (`localhost` is rewritten to that) and must
+ listen on an address Docker can reach. With `http://` the token is sent
+ unencrypted.
+
+The shared folder is mounted at `/workspace` in both containers. Neither the
+sign-in nor the token is kept between starts. Closing Claude
Desktop or Ghidra's project window stops everything.
The launcher runs `run.sh` from this checkout, so re-run `./install.sh` after
diff --git a/launch-apps.sh b/launch-apps.sh
index 279c6d5..c9806d9 100755
--- a/launch-apps.sh
+++ b/launch-apps.sh
@@ -15,6 +15,12 @@ set -u
# stops everything it started (Ghidra's launch script runs java as a child).
set -m
+# Plain-HTTP model gateway chosen in run.sh: Claude Desktop only allows http
+# to loopback, so it talks to 127.0.0.1:18080 and socat forwards that.
+if [[ -n "${CLAUDE_RE_GATEWAY_FORWARD:-}" ]]; then
+ socat TCP-LISTEN:18080,bind=127.0.0.1,reuseaddr,fork "TCP:${CLAUDE_RE_GATEWAY_FORWARD}" &
+fi
+
claude-desktop "$@" &
claude=$!
"${GHIDRA_INSTALL_DIR:-/opt/ghidra}/support/launch.sh" fg jdk Ghidra "" "" ghidra.GhidraRun &
diff --git a/run.sh b/run.sh
index 24a6575..b20652c 100755
--- a/run.sh
+++ b/run.sh
@@ -148,6 +148,115 @@ for c in "$CONTAINER" "$REA_CONTAINER"; do
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.
@@ -247,13 +356,24 @@ stop_rea() { "$RUNTIME" stop "$REA_CONTAINER" >/dev/null 2>&1 || true; }
# 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.