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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
workspace/
|
||||||
|
.env
|
||||||
@@ -38,13 +38,14 @@ The repo is at an early stage (no code yet). The layout below is the **intended*
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **One service per container.** Claude Desktop, Ghidra and the RE MCP server each get their own image, connected on one compose network.
|
- **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:...`).
|
- **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:
|
- **GhidraMCP wiring.** GhidraMCP has two parts:
|
||||||
- a Ghidra plugin that runs an HTTP server inside Ghidra (default port `8080`), and
|
- 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.
|
- 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.
|
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:
|
- **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.
|
- *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.
|
- *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.
|
||||||
|
|||||||
+7
-2
@@ -36,15 +36,20 @@ RUN apt-get update \
|
|||||||
# Ubuntu 24.04 ships a default "ubuntu" user on UID 1000; replace it.
|
# 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 \
|
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \
|
||||||
&& groupadd -g "${USER_GID}" "${USER_NAME}" \
|
&& 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}
|
USER ${USER_NAME}
|
||||||
WORKDIR /home/${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
|
# 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 container's filesystem, which is discarded on exit (run with --rm), so
|
||||||
# the user signs in on every start.
|
# the user signs in on every start.
|
||||||
# --no-sandbox: Docker's default seccomp profile blocks the namespaces
|
# --no-sandbox: Docker's default seccomp profile blocks the namespaces
|
||||||
# Chromium's sandbox needs, so the zygote dies on start. The container's own
|
# 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.
|
# 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"]
|
||||||
|
|||||||
Executable
+46
@@ -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"
|
||||||
@@ -1,27 +1,38 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Start the Claude Desktop container, forwarding the host's X server.
|
# Start the Claude Desktop container, forwarding the host's X server.
|
||||||
# Before launching, verify that a window from the container can actually be
|
# 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]
|
# Usage: ./run.sh [--build] [--check-only] [--workspace DIR]
|
||||||
# --build rebuild the image even if it exists
|
# --build rebuild the image even if it exists
|
||||||
# --check-only run the display check and exit without starting Claude
|
# --check-only run the display check and exit without starting Claude
|
||||||
|
# --workspace DIR share DIR without asking (also: WORKSPACE=DIR)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IMAGE="${IMAGE:-claude-re-setup:latest}"
|
IMAGE="${IMAGE:-claude-re-setup:latest}"
|
||||||
CONTAINER="${CONTAINER:-claude-desktop}"
|
CONTAINER="${CONTAINER:-claude-desktop}"
|
||||||
RUNTIME="${RUNTIME:-docker}"
|
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
|
build=0
|
||||||
check_only=0
|
check_only=0
|
||||||
for arg in "$@"; do
|
while (( $# )); do
|
||||||
case "$arg" in
|
case "$1" in
|
||||||
--build) build=1 ;;
|
--build) build=1 ;;
|
||||||
--check-only) check_only=1 ;;
|
--check-only) check_only=1 ;;
|
||||||
-h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
--workspace) [[ $# -ge 2 ]] || { echo "--workspace needs a directory" >&2; exit 2; }
|
||||||
*) echo "Unknown argument: $arg" >&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
|
esac
|
||||||
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
die() { echo "error: $*" >&2; exit 1; }
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
@@ -58,13 +69,14 @@ if (( build )) || ! "$RUNTIME" image inspect "$IMAGE" >/dev/null 2>&1; then
|
|||||||
"$RUNTIME" build \
|
"$RUNTIME" build \
|
||||||
--build-arg USER_UID="$(id -u)" \
|
--build-arg USER_UID="$(id -u)" \
|
||||||
--build-arg USER_GID="$(id -g)" \
|
--build-arg USER_GID="$(id -g)" \
|
||||||
-t "$IMAGE" "$(dirname "$(readlink -f "$0")")"
|
-t "$IMAGE" "$script_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# label=disable: the X socket and cookie are host files that SELinux would
|
# 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.
|
# otherwise block, and relabeling /tmp/.X11-unix with :z would break the host.
|
||||||
run_args=(
|
run_args=(
|
||||||
--rm
|
--rm
|
||||||
|
--init
|
||||||
--security-opt label=disable
|
--security-opt label=disable
|
||||||
--shm-size 1g
|
--shm-size 1g
|
||||||
-e DISPLAY=":${display_num}"
|
-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"
|
die "container '$CONTAINER' already exists; stop it with: $RUNTIME stop $CONTAINER"
|
||||||
fi
|
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
|
# 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.
|
# 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[@]}" \
|
"$RUNTIME" run -d "${run_args[@]}" \
|
||||||
--name "$CONTAINER" \
|
--name "$CONTAINER" \
|
||||||
|
-v "$WORKSPACE:/workspace" \
|
||||||
"$IMAGE" >/dev/null
|
"$IMAGE" >/dev/null
|
||||||
info "Running. Logs: $RUNTIME logs -f $CONTAINER Stop: $RUNTIME stop $CONTAINER"
|
info "Running. Logs: $RUNTIME logs -f $CONTAINER Stop: $RUNTIME stop $CONTAINER"
|
||||||
|
|||||||
Reference in New Issue
Block a user