Ask for the model provider on every start

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>
This commit is contained in:
2026-11-02 18:54:41 +01:00
co-authored by Claude Opus 5.5
parent 9b921eeae5
commit 0315d5816e
5 changed files with 153 additions and 3 deletions
+6 -1
View File
@@ -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 `<PACKAGE NAME="Developer" />` only adds the package's RELEASED plugins, so the STABLE ones are listed with `<INCLUDE CLASS=...>`. 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`.
+6
View File
@@ -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 \
+15 -2
View File
@@ -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
+6
View File
@@ -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 &
+120
View File
@@ -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.