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" \