From 4baf0208e12458b12c7f83e61d262acf329e8bc3 Mon Sep 17 00:00:00 2001 From: Linus Henke Date: Mon, 2 Nov 2026 12:38:38 +0100 Subject: [PATCH] Add Ghidra 12.2 and start it alongside Claude Desktop Install Ghidra 12.2 from the official GitHub release into /opt/ghidra, verified against the SHA-256 in the release notes, with Ubuntu's openjdk-25-jdk (Ghidra 12.2 requires Java 25). Version, build date and hash are build args. The entrypoint, renamed from launch-claude.sh to launch-apps.sh, now starts both apps and stops both, ending the container, when either is closed: Ghidra when its process exits (closing the project window), Claude Desktop when none of its windows is open or minimized. Claude is stopped via its main process so it can shut down its own helpers. CLAUDE.md now records that Claude Desktop and Ghidra share one container, at the user's request. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++--- Dockerfile | 26 +++++++++++++++--- launch-apps.sh | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ launch-claude.sh | 46 -------------------------------- run.sh | 2 +- 5 files changed, 95 insertions(+), 53 deletions(-) create mode 100755 launch-apps.sh delete mode 100755 launch-claude.sh diff --git a/CLAUDE.md b/CLAUDE.md index 3dd0afd..8586cff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ The goal is that `docker compose up` gives you a working setup where Claude Desk ## Status -The repo is at an early stage (no code yet). The layout below is the **intended** structure. Update this file when the real structure differs. +Early stage. What exists: a single image (`Dockerfile`, `launch-apps.sh`) with Claude Desktop and Ghidra, started by `run.sh`; no compose setup yet. The layout below is the **intended** structure. Update this file when the real structure differs. ## Intended layout @@ -37,7 +37,7 @@ The repo is at an early stage (no code yet). The layout below is the **intended* ## Architecture -- **One service per container.** Claude Desktop, Ghidra and the RE MCP server each get their own image, connected on one compose network. +- **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 build args), runs on Ubuntu's `openjdk-25-jdk`, lives in `/opt/ghidra` and starts together with Claude Desktop. Further services such as the RE MCP server may still get their own containers. - **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.** GhidraMCP has two parts: @@ -45,7 +45,7 @@ The repo is at an early stage (no code yet). The layout below is the **intended* - a Python MCP bridge (`bridge_mcp_ghidra.py`) that speaks MCP to Claude and forwards calls to that HTTP server. The bridge is configured with the Ghidra server URL (e.g. `http://ghidra:8080/`). The plugin must be enabled in the CodeBrowser and a program must be open, or the bridge has nothing to talk to. -- **Container lifetime follows the window.** Closing Claude Desktop's window only hides the app (GNOME shows no tray icon to bring it back). The image's entrypoint `launch-claude.sh` watches the app's X windows: when none is open or minimized for ~3 s, it quits the app and the container exits. `run.sh` uses `--init` so signals and zombie processes are handled. +- **Container lifetime follows the apps.** The entrypoint `launch-apps.sh` starts Claude Desktop and Ghidra and stops both, ending the container, when either is closed. Ghidra counts as closed when its process exits (closing the project window quits it; CodeBrowser or Help windows don't). Claude Desktop's window only hides the app on close (GNOME shows no tray icon), so its X windows are watched: none open or minimized for ~3 s counts as closed. `run.sh` uses `--init` so signals and zombie processes are handled. - **MCP transport between containers.** Claude Desktop starts local MCP servers as stdio subprocesses. Because the servers live in other containers, pick one approach and apply it consistently: - *Preferred:* run the MCP servers with an HTTP/SSE transport in their own containers. Bridge them into Claude Desktop's stdio config with a small proxy (e.g. `mcp-remote` or `mcp-proxy`) installed in the Claude Desktop image. - *Alternative:* install the bridges inside the Claude Desktop image and point them at the other containers over the network. This works well for the GhidraMCP bridge, which is just Python + HTTP. diff --git a/Dockerfile b/Dockerfile index 07158b3..068a4e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,25 @@ RUN apt-get update \ fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* +# Ghidra from the official GitHub release, checked against the SHA-256 in the +# release notes: https://github.com/NationalSecurityAgency/ghidra/releases +# Ghidra 12.2 needs JDK 25 (application.java.min=25). The full JDK, not the +# headless one, because Ghidra's GUI needs the AWT/X11 libraries. +ARG GHIDRA_VERSION=12.2 +ARG GHIDRA_BUILD_DATE=20261016 +ARG GHIDRA_SHA256=2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67 +RUN apt-get update \ + && apt-get install -y --no-install-recommends openjdk-25-jdk unzip \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSLo /tmp/ghidra.zip \ + "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_${GHIDRA_VERSION}_build/ghidra_${GHIDRA_VERSION}_PUBLIC_${GHIDRA_BUILD_DATE}.zip" \ + && echo "${GHIDRA_SHA256} /tmp/ghidra.zip" | sha256sum -c - \ + && unzip -q /tmp/ghidra.zip -d /opt \ + && rm /tmp/ghidra.zip \ + && ln -s "/opt/ghidra_${GHIDRA_VERSION}_PUBLIC" /opt/ghidra \ + && ln -s /opt/ghidra/ghidraRun /usr/local/bin/ghidra +ENV GHIDRA_INSTALL_DIR=/opt/ghidra + # Ubuntu 24.04 ships a default "ubuntu" user on UID 1000; replace it. RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \ && groupadd -g "${USER_GID}" "${USER_NAME}" \ @@ -40,16 +59,17 @@ RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \ && install -d -o "${USER_UID}" -g "${USER_GID}" /workspace \ && ln -s /workspace "/home/${USER_NAME}/workspace" -COPY --chmod=755 launch-claude.sh /usr/local/bin/launch-claude +COPY --chmod=755 launch-apps.sh /usr/local/bin/launch-apps USER ${USER_NAME} WORKDIR /home/${USER_NAME} -# launch-claude quits the app (and so the container) when its window closes. +# launch-apps starts Claude Desktop and Ghidra and stops both (and so the +# container) when either one is closed. # No keyring: tell Electron not to look for one. Session data lives only in # the container's filesystem, which is discarded on exit (run with --rm), so # the user signs in on every start. # --no-sandbox: Docker's default seccomp profile blocks the namespaces # Chromium's sandbox needs, so the zygote dies on start. The container's own # seccomp/namespaces stay in place as the boundary to the host instead. -CMD ["launch-claude", "--password-store=basic", "--no-sandbox"] +CMD ["launch-apps", "--password-store=basic", "--no-sandbox"] diff --git a/launch-apps.sh b/launch-apps.sh new file mode 100755 index 0000000..279c6d5 --- /dev/null +++ b/launch-apps.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Container entrypoint: start Claude Desktop and Ghidra, and stop both (so the +# container exits) as soon as either one is closed. +# +# - Ghidra exits when its project window is closed, so its process ending is +# the signal. Closing other Ghidra windows (CodeBrowser, Help) doesn't count. +# - Closing Claude Desktop's window only hides the app (it keeps running for +# the tray icon, which GNOME doesn't show). So its X windows are watched and +# it counts as closed once none of them is open or minimized. +# +# Arguments are passed to Claude Desktop. + +set -u +# Job control puts each app in its own process group, so stopping an app also +# stops everything it started (Ghidra's launch script runs java as a child). +set -m + +claude-desktop "$@" & +claude=$! +"${GHIDRA_INSTALL_DIR:-/opt/ghidra}/support/launch.sh" fg jdk Ghidra "" "" ghidra.GhidraRun & +ghidra=$! + +stop_all() { + trap - TERM INT HUP + echo "launch-apps: $1, stopping" >&2 + # Claude's main process shuts down its own helpers; killing its whole + # group would take out the GPU process first and trigger restart loops. + # Ghidra's group is needed to reach java behind the launch script. + kill -TERM "$claude" 2>/dev/null + kill -TERM -- "-$ghidra" 2>/dev/null + # Give both a few seconds to shut down cleanly before the container ends. + for _ in {1..10}; do + kill -0 "$claude" 2>/dev/null || kill -0 -- "-$ghidra" 2>/dev/null || break + sleep 0.5 + done + exit 0 +} +trap 'stop_all "received stop signal"' TERM INT HUP + +host="$(hostname)" + +# True if any of this container's Claude windows is Normal (shown) or Iconic +# (minimized). A closed window is Withdrawn or loses WM_STATE altogether. +# WM_CLIENT_MACHINE filters out Claude windows from other containers or apps +# on the same X server. +claude_window_open() { + local w props + for w in $(xwininfo -root -tree 2>/dev/null \ + | awk '/\("com\.anthropic\.claude" "com\.anthropic\.Claude"\)/ {print $1}'); do + props="$(xprop -id "$w" WM_CLIENT_MACHINE WM_STATE 2>/dev/null)" || continue + [[ "$props" == *"WM_CLIENT_MACHINE(STRING) = \"$host\""* ]] || continue + [[ "$props" =~ "window state: "(Normal|Iconic) ]] && return 0 + done + return 1 +} + +seen=0 # only arm the window check after Claude's window has appeared +misses=0 # require a few consecutive misses to ride out brief remaps +while :; do + sleep 1 & wait $! + kill -0 "$ghidra" 2>/dev/null || stop_all "Ghidra exited" + kill -0 "$claude" 2>/dev/null || stop_all "Claude Desktop exited" + if claude_window_open; then + seen=1 misses=0 + elif (( seen )) && (( ++misses >= 3 )); then + stop_all "Claude Desktop window closed" + fi +done diff --git a/launch-claude.sh b/launch-claude.sh deleted file mode 100755 index af8001e..0000000 --- a/launch-claude.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# Container entrypoint: run Claude Desktop and stop when its window is closed. -# -# Closing the window only hides the app (it keeps running for the tray icon, -# which GNOME doesn't show), so the container would keep running invisibly. -# This watches the app's X windows and quits the app once none of them is -# open or minimized. The container then exits with it. - -set -u - -claude-desktop "$@" & -app=$! - -stop_app() { kill -TERM "$app" 2>/dev/null; wait "$app"; exit $?; } -trap stop_app TERM INT HUP - -host="$(hostname)" - -# True if any of this container's Claude windows is Normal (shown) or Iconic -# (minimized). A closed window is Withdrawn or loses WM_STATE altogether. -# WM_CLIENT_MACHINE filters out Claude windows from other containers or apps -# on the same X server. -window_open() { - local w props - for w in $(xwininfo -root -tree 2>/dev/null \ - | awk '/\("com\.anthropic\.claude" "com\.anthropic\.Claude"\)/ {print $1}'); do - props="$(xprop -id "$w" WM_CLIENT_MACHINE WM_STATE 2>/dev/null)" || continue - [[ "$props" == *"WM_CLIENT_MACHINE(STRING) = \"$host\""* ]] || continue - [[ "$props" =~ "window state: "(Normal|Iconic) ]] && return 0 - done - return 1 -} - -seen=0 # only arm the check after the first window has appeared -misses=0 # require a few consecutive misses to ride out brief remaps -while kill -0 "$app" 2>/dev/null; do - sleep 1 & wait $! - if window_open; then - seen=1 misses=0 - elif (( seen )) && (( ++misses >= 3 )); then - echo "launch-claude: window closed, quitting Claude Desktop" >&2 - stop_app - fi -done - -wait "$app" diff --git a/run.sh b/run.sh index 333d493..2aab484 100755 --- a/run.sh +++ b/run.sh @@ -192,7 +192,7 @@ printf '%s\n' "$WORKSPACE" > "$last_workspace_file" # 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. -info "Starting Claude Desktop in container '$CONTAINER' (workspace: $WORKSPACE -> /workspace)" +info "Starting Claude Desktop and Ghidra in container '$CONTAINER' (workspace: $WORKSPACE -> /workspace)" "$RUNTIME" run -d "${run_args[@]}" \ --name "$CONTAINER" \ -v "$WORKSPACE:/workspace" \