From 16dbf25c9bbfce02b12f924cb132488dc509b8c5 Mon Sep 17 00:00:00 2001 From: Linus Henke Date: Mon, 2 Nov 2026 12:22:38 +0100 Subject: [PATCH] Share a chosen workspace folder and stop with the window run.sh asks which host folder Claude may access, using the desktop's folder picker (zenity, or kdialog on KDE; terminal prompt otherwise), and mounts it read-write at /workspace. It opens at the last folder used, refuses / and $HOME, and can be skipped with --workspace DIR. The image creates /workspace and a ~/workspace symlink to it. Closing Claude Desktop's window only hides the app, which left the container running invisibly. The new entrypoint launch-claude.sh watches the app's X windows and quits it once none is open or minimized, so the container exits with it. run.sh adds --init for signal handling and reaping. Also add .gitignore for workspace/ and .env. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + CLAUDE.md | 3 +- Dockerfile | 9 +++- launch-claude.sh | 46 +++++++++++++++++++++ run.sh | 104 ++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 .gitignore create mode 100755 launch-claude.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db85450 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +workspace/ +.env diff --git a/CLAUDE.md b/CLAUDE.md index 2cbb2b0..3dd0afd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,13 +38,14 @@ 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. -- **Shared workspace volume.** Every container mounts `./workspace` at the same path (e.g. `/workspace`), so a file path means the same thing to Claude, Ghidra and the RE MCP. +- **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: - a Ghidra plugin that runs an HTTP server inside Ghidra (default port `8080`), and - 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. - **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 10e8d2e..07158b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,15 +36,20 @@ RUN apt-get update \ # 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}" \ - && useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash "${USER_NAME}" + && useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash "${USER_NAME}" \ + && 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 USER ${USER_NAME} WORKDIR /home/${USER_NAME} +# launch-claude quits the app (and so the container) when its window closes. # 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 ["claude-desktop", "--password-store=basic", "--no-sandbox"] +CMD ["launch-claude", "--password-store=basic", "--no-sandbox"] diff --git a/launch-claude.sh b/launch-claude.sh new file mode 100755 index 0000000..af8001e --- /dev/null +++ b/launch-claude.sh @@ -0,0 +1,46 @@ +#!/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 9b7aaef..333d493 100755 --- a/run.sh +++ b/run.sh @@ -1,27 +1,38 @@ #!/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. +# 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. # -# Usage: ./run.sh [--build] [--check-only] -# --build rebuild the image even if it exists -# --check-only run the display check and exit without starting Claude +# Usage: ./run.sh [--build] [--check-only] [--workspace DIR] +# --build rebuild the image even if it exists +# --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}" 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 -for arg in "$@"; do - case "$arg" in +while (( $# )); do + case "$1" in --build) build=1 ;; --check-only) check_only=1 ;; - -h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "Unknown argument: $arg" >&2; exit 2 ;; + --workspace) [[ $# -ge 2 ]] || { echo "--workspace needs a directory" >&2; exit 2; } + WORKSPACE="$2"; shift ;; + --workspace=*) WORKSPACE="${1#*=}" ;; + -h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; esac + shift done die() { echo "error: $*" >&2; exit 1; } @@ -58,13 +69,14 @@ if (( build )) || ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then "$RUNTIME" build \ --build-arg USER_UID="$(id -u)" \ --build-arg USER_GID="$(id -g)" \ - -t "$IMAGE" "$(dirname "$(readlink -f "$0")")" + -t "$IMAGE" "$script_dir" fi # 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}" @@ -107,10 +119,82 @@ if "$RUNTIME" container inspect "$CONTAINER" >/dev/null 2>&1; then die "container '$CONTAINER' already exists; stop it with: $RUNTIME stop $CONTAINER" fi +# --- 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" + # 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. -info "Starting Claude Desktop in container '$CONTAINER'" +# 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)" "$RUNTIME" run -d "${run_args[@]}" \ --name "$CONTAINER" \ + -v "$WORKSPACE:/workspace" \ "$IMAGE" >/dev/null info "Running. Logs: $RUNTIME logs -f $CONTAINER Stop: $RUNTIME stop $CONTAINER"