Files
claude-desktop-debian/scripts/doctor.sh

1020 lines
32 KiB
Bash
Raw Normal View History

#===============================================================================
# Doctor Diagnostics
#
# Sourced by: scripts/launcher-common.sh (which is in turn sourced by the
# per-package launcher scripts — deb, rpm, AppImage, Nix).
#
# Provides: run_doctor (the `claude-desktop --doctor` entry point) plus its
# internal helpers. Self-contained — no dependencies on launcher-common.sh
# state or functions.
#
# To add a new check: define an internal function `_check_<name>`, call it
# from run_doctor in the appropriate section, use _pass / _fail / _warn /
# _info to print results. _fail increments _doctor_failures (local to
# run_doctor) which becomes the exit status.
#===============================================================================
# Color helpers (disabled when stdout is not a terminal)
_doctor_colors() {
if [[ -t 1 ]]; then
_green='\033[0;32m'
_red='\033[0;31m'
_yellow='\033[0;33m'
_bold='\033[1m'
_reset='\033[0m'
else
_green='' _red='' _yellow='' _bold='' _reset=''
fi
}
# Return the distro ID from /etc/os-release
_cowork_distro_id() {
local id='unknown'
if [[ -f /etc/os-release ]]; then
local line
while IFS= read -r line; do
if [[ $line == ID=* ]]; then
id="${line#ID=}"
id="${id//\"/}"
break
fi
done < /etc/os-release
fi
printf '%s' "$id"
}
# Return a distro-specific install command for a cowork tool
# Usage: _cowork_pkg_hint <distro_id> <tool_name>
_cowork_pkg_hint() {
local distro="$1"
local tool="$2"
local pkg_cmd
# Determine package manager command
case "$distro" in
debian|ubuntu) pkg_cmd='sudo apt install' ;;
fedora) pkg_cmd='sudo dnf install' ;;
arch) pkg_cmd='sudo pacman -S' ;;
*)
printf '%s' "Install $tool using your package manager"
return
;;
esac
# Map tool name to distro-specific package(s)
local pkg
case "$tool" in
qemu)
case "$distro" in
debian|ubuntu) pkg='qemu-system-x86 qemu-utils' ;;
fedora) pkg='qemu-kvm qemu-img' ;;
arch) pkg='qemu-full' ;;
esac
;;
doctor: detect IBus/GTK misconfigurations that break input (#572) * doctor: detect IBus/GTK misconfigurations that break input (#550) Adds _doctor_check_im_modules helper covering the four input-method failure modes from #545: - ibus-gtk3 package missing while GTK_IM_MODULE=ibus - GTK immodules cache stale (active module not listed by gtk-query-immodules-3.0 --update-cache fixes it) - XWayland session routing IBus through XIM (lossy for some IMEs; informational note pointing at CLAUDE_USE_WAYLAND=1 for native Wayland IME) - CLAUDE_GTK_IM_MODULE override visibility (informational, so users can verify the resolved value) Each check is gated so it only fires when relevant — e.g. the package check is skipped when GTK_IM_MODULE isn't ibus, the cache check is skipped when gtk-query-immodules-3.0 isn't installed, and the package check returns silently on distros without dpkg/rpm/pacman to avoid false negatives. Adds tests/doctor.bats with 17 cases covering each gating branch and the _cowork_pkg_hint mapping for ibus-gtk3 (Arch maps to plain ibus since it bundles the GTK3 immodule). Hoists _distro_id resolution to the top of run_doctor so the IM check and the existing Cowork section share one /etc/os-release read. Closes #550. Refs #545, #549. Co-Authored-By: Claude <claude@anthropic.com> * doctor: simplify IM-check helper and DRY out doctor.bats setup Mechanical clean-up of the #550 diff after self-review: scripts/doctor.sh - tighten the _doctor_check_im_modules docblock: drop the "each check is gated" paragraph (self-evident in the code) and inline the XWayland/XIM rationale into the failure-mode bullet - drop the inline section comments that just restated the next block's purpose; keep the rc=1/rc=2 comment because the value distinction is the load-bearing detail - replace the `local _pkg_rc=0; ... || _pkg_rc=$?; if ((_pkg_rc == 1))` dance with a `case $?` on the direct call tests/doctor.bats - hoist the `command -v gtk-query-immodules-3.0 → not-found` shim into a `_skip_gtk_query` helper (it was duplicated across 11 of the 17 cases) - default `_pkg_installed() { return 2; }` in setup so per-test stubs only appear when the test cares about rc=0 or rc=1 - drop dead `_skip_gtk_query` calls from cases where the function returns earlier (no IM selected, package warn fires) so the shim is only present where it actually changes behaviour No behaviour change — all 17 doctor.bats cases still pass, plus the 68 launcher-common.bats cases. Shellcheck is unchanged from baseline. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:08:36 -04:00
ibus-gtk3)
# Arch ships the GTK3 immodule as part of the main ibus
# package; Debian/Ubuntu and Fedora split it out.
case "$distro" in
arch) pkg='ibus' ;;
*) pkg='ibus-gtk3' ;;
esac
;;
*) pkg="$tool" ;;
esac
printf '%s' "$pkg_cmd $pkg"
}
doctor: detect IBus/GTK misconfigurations that break input (#572) * doctor: detect IBus/GTK misconfigurations that break input (#550) Adds _doctor_check_im_modules helper covering the four input-method failure modes from #545: - ibus-gtk3 package missing while GTK_IM_MODULE=ibus - GTK immodules cache stale (active module not listed by gtk-query-immodules-3.0 --update-cache fixes it) - XWayland session routing IBus through XIM (lossy for some IMEs; informational note pointing at CLAUDE_USE_WAYLAND=1 for native Wayland IME) - CLAUDE_GTK_IM_MODULE override visibility (informational, so users can verify the resolved value) Each check is gated so it only fires when relevant — e.g. the package check is skipped when GTK_IM_MODULE isn't ibus, the cache check is skipped when gtk-query-immodules-3.0 isn't installed, and the package check returns silently on distros without dpkg/rpm/pacman to avoid false negatives. Adds tests/doctor.bats with 17 cases covering each gating branch and the _cowork_pkg_hint mapping for ibus-gtk3 (Arch maps to plain ibus since it bundles the GTK3 immodule). Hoists _distro_id resolution to the top of run_doctor so the IM check and the existing Cowork section share one /etc/os-release read. Closes #550. Refs #545, #549. Co-Authored-By: Claude <claude@anthropic.com> * doctor: simplify IM-check helper and DRY out doctor.bats setup Mechanical clean-up of the #550 diff after self-review: scripts/doctor.sh - tighten the _doctor_check_im_modules docblock: drop the "each check is gated" paragraph (self-evident in the code) and inline the XWayland/XIM rationale into the failure-mode bullet - drop the inline section comments that just restated the next block's purpose; keep the rc=1/rc=2 comment because the value distinction is the load-bearing detail - replace the `local _pkg_rc=0; ... || _pkg_rc=$?; if ((_pkg_rc == 1))` dance with a `case $?` on the direct call tests/doctor.bats - hoist the `command -v gtk-query-immodules-3.0 → not-found` shim into a `_skip_gtk_query` helper (it was duplicated across 11 of the 17 cases) - default `_pkg_installed() { return 2; }` in setup so per-test stubs only appear when the test cares about rc=0 or rc=1 - drop dead `_skip_gtk_query` calls from cases where the function returns earlier (no IM selected, package warn fires) so the shim is only present where it actually changes behaviour No behaviour change — all 17 doctor.bats cases still pass, plus the 68 launcher-common.bats cases. Shellcheck is unchanged from baseline. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:08:36 -04:00
# Return 0 if the named package is installed, 1 otherwise. Returns 2
# (treated as "unknown") when no recognized package manager is
# available — callers should not warn in that case to avoid false
# positives on unsupported distros.
_pkg_installed() {
local distro="$1"
local pkg="$2"
case "$distro" in
debian|ubuntu)
command -v dpkg-query &>/dev/null || return 2
dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null \
| grep -q 'install ok installed'
;;
fedora)
command -v rpm &>/dev/null || return 2
rpm -q "$pkg" &>/dev/null
;;
arch)
command -v pacman &>/dev/null || return 2
pacman -Q "$pkg" &>/dev/null
;;
*) return 2 ;;
esac
}
# Diagnose IBus / GTK input-method misconfigurations that break
# keyboard input in the chat (#550). Surfaces:
# - CLAUDE_GTK_IM_MODULE override visibility (informational)
# - XWayland-with-IBus routing note: on a Wayland session Electron
# defaults to XWayland (preserves global hotkeys), which forces
# the IBus path through XIM — a known weak link for some IMEs.
# - ibus-gtk3 package missing when GTK_IM_MODULE=ibus
# - GTK immodules cache stale: active module not listed by
# gtk-query-immodules-3.0 (--update-cache fixes it)
#
# Usage: _doctor_check_im_modules <distro_id>
_doctor_check_im_modules() {
local distro="$1"
local active_im="${CLAUDE_GTK_IM_MODULE:-${GTK_IM_MODULE:-}}"
if [[ -n ${CLAUDE_GTK_IM_MODULE:-} ]]; then
_info "CLAUDE_GTK_IM_MODULE=$CLAUDE_GTK_IM_MODULE" \
"(overrides GTK_IM_MODULE for Electron)"
fi
if [[ ${XDG_SESSION_TYPE:-} == 'wayland' \
&& -z ${CLAUDE_USE_WAYLAND:-} ]]; then
_info \
'IME note: Wayland session, Electron via XWayland —' \
'IBus path goes through XIM (lossy for some IMEs).'
_info \
'Tip: CLAUDE_USE_WAYLAND=1 enables native Wayland IME' \
'(loses global hotkeys).'
fi
# Nothing further to check without an active IM module.
[[ -n $active_im ]] || return 0
# ibus-gtk3 package check — only when the active module is ibus.
# rc=1 means definitely missing (warn); rc=2 means unsupported
# distro / no package manager (skip silently to avoid false
# negatives). On warn, return early — `apt install` refreshes
# the immodules cache, so the cache check below would be noise.
if [[ $active_im == 'ibus' ]]; then
_pkg_installed "$distro" ibus-gtk3
case $? in
1)
_warn \
"GTK_IM_MODULE=ibus but ibus-gtk3 is not installed"
_info "Fix: $(_cowork_pkg_hint "$distro" ibus-gtk3)"
return 0
;;
esac
fi
# GTK immodules cache check. gtk-query-immodules-3.0 ships with
# libgtk-3-bin (Debian/Ubuntu) / gtk3 (Fedora/Arch); absence
# means GTK 3 isn't in use — skip silently rather than warn.
command -v gtk-query-immodules-3.0 &>/dev/null || return 0
if ! gtk-query-immodules-3.0 2>/dev/null \
| grep -q "\"$active_im\""; then
_warn \
"GTK immodules: '$active_im' not listed by" \
"gtk-query-immodules-3.0 (cache may be stale)"
_info \
'Fix: sudo gtk-query-immodules-3.0 --update-cache'
fi
}
# Read the version string from the version file beside an Electron binary.
# Prints the raw version string, or nothing if unavailable.
_electron_version() {
local version_file
version_file="$(dirname "$1")/version"
[[ -r $version_file ]] && printf '%s' "$(< "$version_file")"
}
_pass() { echo -e "${_green}[PASS]${_reset} $*"; }
_fail() {
echo -e "${_red}[FAIL]${_reset} $*"
_doctor_failures=$((_doctor_failures + 1))
}
_warn() { echo -e "${_yellow}[WARN]${_reset} $*"; }
_info() { echo -e " $*"; }
# Warn about an unrecognized COWORK_VM_BACKEND value. The daemon
# (cowork-vm-service.js) ignores invalid values and falls through to
# auto-detect — see #442 for the daemon-side wart. Called from both
# COWORK_VM_BACKEND case statements below so the warning fires once
# at the severity-gating site and once at the user-facing summary.
_warn_unknown_backend() {
_warn "Unknown COWORK_VM_BACKEND: '${COWORK_VM_BACKEND}'"
_info 'Valid values: kvm, bwrap, host'
}
fix: detect virtiofsd at off-PATH install locations (#447) (#453) * fix: detect virtiofsd at off-PATH install locations (#447) Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common) and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default $PATH, so doctor.sh's `command -v virtiofsd` always returned a false negative — users would install the package and still see "virtiofsd: not found" (reported most recently by @zabka in #445, originally flagged by @jarrodcolburn). Adds a _find_virtiofsd helper that searches PATH first, then the known off-PATH install locations: - /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL) - /usr/lib/qemu/virtiofsd (legacy Debian) - /usr/lib/virtiofsd (Arch/CachyOS/Manjaro) Splits virtiofsd out of the KVM tools loop into a dedicated three-branch check: [PASS] virtiofsd: found — on PATH [PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused) [WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm (+ info lines about 9p fallback + symlink Fix) [INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged) The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd by PATH name and will silently fall back to virtio-9p (lower performance) if the binary is only reachable off-PATH — so the user knows a symlink is needed to actually get virtiofs performance. Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the helper (PATH hit / fallback hit / ordered fallback / total miss / non-executable skip / default-list regression guard for the Arch path). All 45 tests pass. Does not touch cowork-vm-service.js — teaching KvmBackend to probe these same paths would give Ubuntu KVM users real virtiofs performance without a symlink, but that's a separate change. Fixes #447 Co-Authored-By: Claude <claude@anthropic.com> * style: collapse unnecessary line continuations in virtiofsd check Simplifier pass — the five backslash-continued `_warn` / `_info` invocations in the new virtiofsd three-severity block were all under 63 chars after collapsing, well within the project's 80-char guideline. The continuations were visual noise, not wrap-driven. Behavior byte-identical. All 45 BATS tests still pass. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
# Locate the virtiofsd binary. Distros install it at different
# off-PATH locations:
# - Debian/Ubuntu: /usr/libexec/virtiofsd (qemu-system-common)
# - Fedora/RHEL: /usr/libexec/virtiofsd
# - Older Debian: /usr/lib/qemu/virtiofsd
# - Arch/Manjaro: /usr/lib/virtiofsd
#
# `command -v virtiofsd` alone produces a false negative on any of
# the above. Search PATH first, then the well-known fallback paths.
#
# Prints the discovered path on stdout; returns 0 on hit, 1 on miss.
fix(kvm): probe virtiofsd fallback paths in KvmBackend (#447) (#454) Follow-up to #453: the daemon still spawns virtiofsd via PATH lookup (`spawnProcess('virtiofsd', ...)`), so on stock Debian/Ubuntu (`/usr/libexec/virtiofsd`) and Arch/CachyOS/Manjaro (`/usr/lib/virtiofsd`) the spawn ENOENTs and KvmBackend silently falls through to virtio-9p — users who opted into `COWORK_VM_BACKEND=kvm` and installed virtiofsd get 9p performance without knowing. Mirror doctor.sh's `_find_virtiofsd` in JS: probe `COWORK_VM_VIRTIOFSD_BIN` override, then `which`, then the same fallback list. Pass the resolved absolute path as argv[0] so the spawn bypasses PATH entirely. Also: - Add a `spawnFailed` flag the socket-wait loop checks for early exit when the async 'error' event fires (e.g. binary removed between probe and exec) — prevents a 5s stall before 9p fallback. - Guard `this.virtiofsdProcess.kill()` against the race where the error handler has already zeroed it. - Rename doctor.sh's test hook `_COWORK_DOCTOR_VFSD_PATHS` → `_COWORK_VFSD_PATHS` so doctor and daemon share the same env var for lock-step test parity (shipped 24h ago in #453, zero external users). Verified on CachyOS via a node harness covering 8 scenarios: PATH hit, fallback hit, fallback ordering, total miss, non-executable rejection, explicit override wins over PATH, override non-executable → null, override missing → null (no fall-through). All 45 BATS tests still pass after the env-var rename. Not verifiable locally: Ubuntu `/usr/libexec/virtiofsd` hit (needs an Ubuntu VM with `qemu-system-common`). Logic is symmetric to the Arch case that is verified. Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:52:53 -05:00
# Fallback paths are overridable via _COWORK_VFSD_PATHS
fix: detect virtiofsd at off-PATH install locations (#447) (#453) * fix: detect virtiofsd at off-PATH install locations (#447) Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common) and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default $PATH, so doctor.sh's `command -v virtiofsd` always returned a false negative — users would install the package and still see "virtiofsd: not found" (reported most recently by @zabka in #445, originally flagged by @jarrodcolburn). Adds a _find_virtiofsd helper that searches PATH first, then the known off-PATH install locations: - /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL) - /usr/lib/qemu/virtiofsd (legacy Debian) - /usr/lib/virtiofsd (Arch/CachyOS/Manjaro) Splits virtiofsd out of the KVM tools loop into a dedicated three-branch check: [PASS] virtiofsd: found — on PATH [PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused) [WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm (+ info lines about 9p fallback + symlink Fix) [INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged) The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd by PATH name and will silently fall back to virtio-9p (lower performance) if the binary is only reachable off-PATH — so the user knows a symlink is needed to actually get virtiofs performance. Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the helper (PATH hit / fallback hit / ordered fallback / total miss / non-executable skip / default-list regression guard for the Arch path). All 45 tests pass. Does not touch cowork-vm-service.js — teaching KvmBackend to probe these same paths would give Ubuntu KVM users real virtiofs performance without a symlink, but that's a separate change. Fixes #447 Co-Authored-By: Claude <claude@anthropic.com> * style: collapse unnecessary line continuations in virtiofsd check Simplifier pass — the five backslash-continued `_warn` / `_info` invocations in the new virtiofsd three-severity block were all under 63 chars after collapsing, well within the project's 80-char guideline. The continuations were visual noise, not wrap-driven. Behavior byte-identical. All 45 BATS tests still pass. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
# (colon-separated) so tests can point at a stub directory. The
# namespaced prefix signals "internal test hook — not a user knob".
fix(kvm): probe virtiofsd fallback paths in KvmBackend (#447) (#454) Follow-up to #453: the daemon still spawns virtiofsd via PATH lookup (`spawnProcess('virtiofsd', ...)`), so on stock Debian/Ubuntu (`/usr/libexec/virtiofsd`) and Arch/CachyOS/Manjaro (`/usr/lib/virtiofsd`) the spawn ENOENTs and KvmBackend silently falls through to virtio-9p — users who opted into `COWORK_VM_BACKEND=kvm` and installed virtiofsd get 9p performance without knowing. Mirror doctor.sh's `_find_virtiofsd` in JS: probe `COWORK_VM_VIRTIOFSD_BIN` override, then `which`, then the same fallback list. Pass the resolved absolute path as argv[0] so the spawn bypasses PATH entirely. Also: - Add a `spawnFailed` flag the socket-wait loop checks for early exit when the async 'error' event fires (e.g. binary removed between probe and exec) — prevents a 5s stall before 9p fallback. - Guard `this.virtiofsdProcess.kill()` against the race where the error handler has already zeroed it. - Rename doctor.sh's test hook `_COWORK_DOCTOR_VFSD_PATHS` → `_COWORK_VFSD_PATHS` so doctor and daemon share the same env var for lock-step test parity (shipped 24h ago in #453, zero external users). Verified on CachyOS via a node harness covering 8 scenarios: PATH hit, fallback hit, fallback ordering, total miss, non-executable rejection, explicit override wins over PATH, override non-executable → null, override missing → null (no fall-through). All 45 BATS tests still pass after the env-var rename. Not verifiable locally: Ubuntu `/usr/libexec/virtiofsd` hit (needs an Ubuntu VM with `qemu-system-common`). Logic is symmetric to the Arch case that is verified. Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:52:53 -05:00
# Shared with the VM daemon (cowork-vm-service.js) so doctor's
# diagnosis and the daemon's actual probe stay in lock-step.
fix: detect virtiofsd at off-PATH install locations (#447) (#453) * fix: detect virtiofsd at off-PATH install locations (#447) Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common) and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default $PATH, so doctor.sh's `command -v virtiofsd` always returned a false negative — users would install the package and still see "virtiofsd: not found" (reported most recently by @zabka in #445, originally flagged by @jarrodcolburn). Adds a _find_virtiofsd helper that searches PATH first, then the known off-PATH install locations: - /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL) - /usr/lib/qemu/virtiofsd (legacy Debian) - /usr/lib/virtiofsd (Arch/CachyOS/Manjaro) Splits virtiofsd out of the KVM tools loop into a dedicated three-branch check: [PASS] virtiofsd: found — on PATH [PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused) [WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm (+ info lines about 9p fallback + symlink Fix) [INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged) The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd by PATH name and will silently fall back to virtio-9p (lower performance) if the binary is only reachable off-PATH — so the user knows a symlink is needed to actually get virtiofs performance. Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the helper (PATH hit / fallback hit / ordered fallback / total miss / non-executable skip / default-list regression guard for the Arch path). All 45 tests pass. Does not touch cowork-vm-service.js — teaching KvmBackend to probe these same paths would give Ubuntu KVM users real virtiofs performance without a symlink, but that's a separate change. Fixes #447 Co-Authored-By: Claude <claude@anthropic.com> * style: collapse unnecessary line continuations in virtiofsd check Simplifier pass — the five backslash-continued `_warn` / `_info` invocations in the new virtiofsd three-severity block were all under 63 chars after collapsing, well within the project's 80-char guideline. The continuations were visual noise, not wrap-driven. Behavior byte-identical. All 45 BATS tests still pass. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
_find_virtiofsd() {
local bin
bin=$(command -v virtiofsd 2>/dev/null)
if [[ -n $bin ]]; then
printf '%s' "$bin"
return 0
fi
fix(kvm): probe virtiofsd fallback paths in KvmBackend (#447) (#454) Follow-up to #453: the daemon still spawns virtiofsd via PATH lookup (`spawnProcess('virtiofsd', ...)`), so on stock Debian/Ubuntu (`/usr/libexec/virtiofsd`) and Arch/CachyOS/Manjaro (`/usr/lib/virtiofsd`) the spawn ENOENTs and KvmBackend silently falls through to virtio-9p — users who opted into `COWORK_VM_BACKEND=kvm` and installed virtiofsd get 9p performance without knowing. Mirror doctor.sh's `_find_virtiofsd` in JS: probe `COWORK_VM_VIRTIOFSD_BIN` override, then `which`, then the same fallback list. Pass the resolved absolute path as argv[0] so the spawn bypasses PATH entirely. Also: - Add a `spawnFailed` flag the socket-wait loop checks for early exit when the async 'error' event fires (e.g. binary removed between probe and exec) — prevents a 5s stall before 9p fallback. - Guard `this.virtiofsdProcess.kill()` against the race where the error handler has already zeroed it. - Rename doctor.sh's test hook `_COWORK_DOCTOR_VFSD_PATHS` → `_COWORK_VFSD_PATHS` so doctor and daemon share the same env var for lock-step test parity (shipped 24h ago in #453, zero external users). Verified on CachyOS via a node harness covering 8 scenarios: PATH hit, fallback hit, fallback ordering, total miss, non-executable rejection, explicit override wins over PATH, override non-executable → null, override missing → null (no fall-through). All 45 BATS tests still pass after the env-var rename. Not verifiable locally: Ubuntu `/usr/libexec/virtiofsd` hit (needs an Ubuntu VM with `qemu-system-common`). Logic is symmetric to the Arch case that is verified. Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:52:53 -05:00
local fallback_paths="${_COWORK_VFSD_PATHS:-}"
fix: detect virtiofsd at off-PATH install locations (#447) (#453) * fix: detect virtiofsd at off-PATH install locations (#447) Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common) and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default $PATH, so doctor.sh's `command -v virtiofsd` always returned a false negative — users would install the package and still see "virtiofsd: not found" (reported most recently by @zabka in #445, originally flagged by @jarrodcolburn). Adds a _find_virtiofsd helper that searches PATH first, then the known off-PATH install locations: - /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL) - /usr/lib/qemu/virtiofsd (legacy Debian) - /usr/lib/virtiofsd (Arch/CachyOS/Manjaro) Splits virtiofsd out of the KVM tools loop into a dedicated three-branch check: [PASS] virtiofsd: found — on PATH [PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused) [WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm (+ info lines about 9p fallback + symlink Fix) [INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged) The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd by PATH name and will silently fall back to virtio-9p (lower performance) if the binary is only reachable off-PATH — so the user knows a symlink is needed to actually get virtiofs performance. Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the helper (PATH hit / fallback hit / ordered fallback / total miss / non-executable skip / default-list regression guard for the Arch path). All 45 tests pass. Does not touch cowork-vm-service.js — teaching KvmBackend to probe these same paths would give Ubuntu KVM users real virtiofs performance without a symlink, but that's a separate change. Fixes #447 Co-Authored-By: Claude <claude@anthropic.com> * style: collapse unnecessary line continuations in virtiofsd check Simplifier pass — the five backslash-continued `_warn` / `_info` invocations in the new virtiofsd three-severity block were all under 63 chars after collapsing, well within the project's 80-char guideline. The continuations were visual noise, not wrap-driven. Behavior byte-identical. All 45 BATS tests still pass. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
if [[ -z $fallback_paths ]]; then
fallback_paths='/usr/libexec/virtiofsd'
fallback_paths+=':/usr/lib/qemu/virtiofsd'
fallback_paths+=':/usr/lib/virtiofsd'
fi
local fallback
local IFS=:
for fallback in $fallback_paths; do
if [[ -x $fallback ]]; then
printf '%s' "$fallback"
return 0
fi
done
return 1
}
# Check custom bwrap mount configuration and report findings
_doctor_check_bwrap_mounts() {
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
local config_file="$config_dir/claude_desktop_linux_config.json"
[[ -f $config_file ]] || return 0
local parser=''
if command -v python3 &>/dev/null; then
parser='python3'
elif command -v node &>/dev/null; then
parser='node'
else
return 0
fi
local mounts_json=''
if [[ $parser == 'python3' ]]; then
mounts_json=$(python3 - "$config_file" 2>/dev/null <<'PYEOF'
import json, sys
try:
with open(sys.argv[1]) as f:
cfg = json.load(f)
mounts = cfg.get('preferences', {}).get('coworkBwrapMounts', {})
if mounts:
print(json.dumps(mounts))
except Exception:
pass
PYEOF
)
else
mounts_json=$(node - "$config_file" 2>/dev/null <<'JSEOF'
try {
const fs = require('fs');
const cfg = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
const m = (cfg.preferences || {}).coworkBwrapMounts || {};
if (Object.keys(m).length > 0)
process.stdout.write(JSON.stringify(m));
} catch (_) {}
JSEOF
)
fi
if [[ -z $mounts_json ]]; then
_info 'Bwrap mounts: default (no custom configuration)'
return 0
fi
_info 'Bwrap custom mount configuration detected:'
local parsed_output=''
if [[ $parser == 'python3' ]]; then
parsed_output=$(python3 - "$mounts_json" 2>/dev/null <<'PYEOF'
import json, sys
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
def fmt(p):
if isinstance(p, str):
return p
if isinstance(p, dict) and isinstance(p.get('src'), str) \
and isinstance(p.get('dst'), str):
return p['src'] + ' -> ' + p['dst']
return None
m = json.loads(sys.argv[1])
for p in m.get('additionalROBinds', []):
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
s = fmt(p)
if s is not None:
print(s)
print('---')
for p in m.get('additionalBinds', []):
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
s = fmt(p)
if s is not None:
print(s)
print('---')
for p in m.get('disabledDefaultBinds', []):
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
if isinstance(p, str):
print(p)
PYEOF
)
else
parsed_output=$(node - "$mounts_json" 2>/dev/null <<'JSEOF'
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
function fmt(p) {
if (typeof p === 'string') return p;
if (p && typeof p === 'object'
&& typeof p.src === 'string' && typeof p.dst === 'string') {
return p.src + ' -> ' + p.dst;
}
return null;
}
const m = JSON.parse(process.argv[1]);
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
(m.additionalROBinds || []).forEach(p => {
const s = fmt(p);
if (s !== null) console.log(s);
});
console.log('---');
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
(m.additionalBinds || []).forEach(p => {
const s = fmt(p);
if (s !== null) console.log(s);
});
console.log('---');
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
(m.disabledDefaultBinds || []).forEach(p => {
if (typeof p === 'string') console.log(p);
});
JSEOF
)
fi
local ro_binds='' rw_binds='' disabled_binds=''
local section=0
while IFS= read -r line; do
if [[ $line == '---' ]]; then
((section++))
continue
fi
case $section in
0) ro_binds+="${line}"$'\n' ;;
1) rw_binds+="${line}"$'\n' ;;
2) disabled_binds+="${line}"$'\n' ;;
esac
done <<< "$parsed_output"
ro_binds=${ro_binds%$'\n'}
rw_binds=${rw_binds%$'\n'}
disabled_binds=${disabled_binds%$'\n'}
if [[ -n $ro_binds ]]; then
_info ' Read-only mounts:'
while IFS= read -r bind_path; do
_info " - $bind_path"
done <<< "$ro_binds"
fi
if [[ -n $rw_binds ]]; then
_info ' Read-write mounts:'
while IFS= read -r bind_path; do
_info " - $bind_path"
done <<< "$rw_binds"
fi
feat(bwrap): support {src, dst} mount form in coworkBwrapMounts (#531) * feat(bwrap): support {src, dst} mount form for distinct host/sandbox paths Extends coworkBwrapMounts (#339) so additionalROBinds and additionalBinds accept entries of the form { src, dst } in addition to the existing string form. This unlocks the persistent /tmp use case: the default --tmpfs /tmp gets wiped between Bash tool calls because of --die-with-parent, and the old string-only API (--bind p p) had no way to map a host directory under $HOME onto /tmp inside the sandbox without exposing the host /tmp itself. Validation: - src: same checks as the string form (absolute, not in FORBIDDEN_MOUNT_PATHS, $HOME constraint when RW) - dst: absolute and non-forbidden only — the $HOME constraint is intentionally skipped since the whole point of the form is to map outside $HOME (e.g. /tmp) - malformed objects are filtered out with a warning, matching the existing string-validation behavior Doctor (--doctor) renders the object form as "src -> dst" in both the Python and Node parser branches. 100% backwards compatible: the string form is preserved unchanged. The 36 existing tests pass; 13 new tests cover accept/reject paths, mixed string+object configs, the persistent-/tmp recipe end-to-end, and the doctor rendering (58/58 total). Closes #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * docs(configuration): document {src, dst} mount form Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> * chore(bwrap): address PR #531 review feedback - doctor: warn when an additional mount's dst lands on a default RO mount (/usr, /etc, /bin, /sbin, /lib, /lib64, or subpaths). bwrap honors the later mount, so the user's bind silently replaces the default — a config footgun, not an escape, but worth surfacing (RayCharlizard issue 1) - docs(configuration): note the shadowing implication under "Distinct host/sandbox paths" (RayCharlizard issue 2) - test(bwrap-config): pin the reject contract for dst under a forbidden path (e.g. /proc/self), beyond the existing exact-match case (RayCharlizard issue 3) - bwrap-config: harmonize the rejected-mount warning text — the string-form path now reads "rejected mount" like the object-form variants (RayCharlizard issue 4) Tests: 61/61 passing (3 new: 1 reject-subpath + 2 doctor shadow positive/negative). Refs #530 --- Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-30 16:34:20 +02:00
# Warn when an additional mount's dst lands on a default RO mount.
# bwrap honors the later mount, so this silently replaces a system
# path inside the sandbox. Only the {src, dst} form can trigger this
# (string form mounts src=dst, and additionalBinds requires src under
# $HOME, which never overlaps the default RO set).
local shadow_input=''
[[ -n $ro_binds ]] && shadow_input+="${ro_binds}"$'\n'
[[ -n $rw_binds ]] && shadow_input+="${rw_binds}"$'\n'
shadow_input=${shadow_input%$'\n'}
local shadow_line shadow_dst
if [[ -n $shadow_input ]]; then
while IFS= read -r shadow_line; do
[[ $shadow_line == *' -> '* ]] || continue
shadow_dst=${shadow_line##* -> }
# Long alternation pattern (STYLEGUIDE 80-col exception)
case $shadow_dst in
/usr|/usr/*|/etc|/etc/*|/bin|/bin/*|/sbin|/sbin/*|/lib|/lib/*|/lib64|/lib64/*)
_warn \
"Mount dst '${shadow_dst}' shadows a default sandbox mount" \
'(may break system tools inside the sandbox)'
;;
esac
done <<< "$shadow_input"
fi
local critical_warned=false
if [[ -n $disabled_binds ]]; then
while IFS= read -r bind_path; do
case "$bind_path" in
/usr|/etc)
_warn \
"Disabled default mount: $bind_path" \
'(may break system tools!)'
critical_warned=true
;;
*)
_info " Disabled default mount: $bind_path"
;;
esac
done <<< "$disabled_binds"
if [[ $critical_warned == true ]]; then
_info \
' Disabling /usr or /etc may cause commands' \
'to fail inside the sandbox.'
_info \
' Restart the daemon after config changes:' \
'pkill -f cowork-vm-service'
fi
fi
if [[ $critical_warned != true ]]; then
_info \
' Note: Restart daemon for config changes:' \
'pkill -f cowork-vm-service'
fi
}
# Surface a warning when systemd-coredump shows N+ recent Electron
# crashes. The most common cause on Linux is the GPU process FATAL
# exhaustion tracked in #583 — workaround for affected users is the
# upstream Settings → disable hardware acceleration toggle, or
# CLAUDE_DISABLE_GPU=1 in the environment for headless persistence.
#
# Arguments: $1 = electron path (e.g.,
# /usr/lib/claude-desktop/node_modules/electron/dist/electron)
# Used to filter results to claude-desktop's electron when possible;
# falls back to all-electron crashes when the path doesn't match
# (e.g., AppImage mount paths are transient).
_doctor_check_recent_crashes() {
local electron_path="${1:-}"
command -v coredumpctl &>/dev/null || return 0
# `coredumpctl list electron` filters by COMM=electron. If the
# exact electron_path matches any entry's EXE column, prefer that
# tighter count; otherwise fall back to all-electron entries.
local listing total_count path_count
listing=$(coredumpctl list electron \
--since='7 days ago' --no-pager 2>/dev/null) || return 0
[[ -n $listing ]] || return 0
# Drop the header line; count remaining entries.
total_count=$(awk 'NR>1 && NF>0' <<< "$listing" | wc -l)
((total_count == 0)) && return 0
if [[ -n $electron_path ]]; then
path_count=$(awk -v p="$electron_path" \
'NR>1 && index($0, p)' <<< "$listing" | wc -l)
else
path_count=0
fi
# Use the path-matched count when available; else the unfiltered
# count with a footnote so the user knows it may include other
# Electron apps (Slack, VSCode, etc.).
local count footnote=''
if ((path_count > 0)); then
count=$path_count
else
count=$total_count
footnote=' (some entries may be from other Electron apps)'
fi
if ((count >= 3)); then
_warn "Recent Electron crashes: $count in last 7 days$footnote"
_info \
'Most common cause: Chromium GPU process FATAL (#583).' \
'Try one of:'
_info ' Settings → toggle hardware acceleration off → restart'
_info ' or set CLAUDE_DISABLE_GPU=1 in the environment'
_info \
'Tracking:' \
'https://github.com/aaddrick/claude-desktop-debian/issues/583'
elif ((count > 0)); then
_info "Recent Electron crashes: $count in last 7 days$footnote"
fi
}
# Run all diagnostic checks and print results
# Arguments: $1 = electron path (optional, for package-specific checks)
run_doctor() {
local electron_path="${1:-}"
local _doctor_failures=0
_doctor_colors
doctor: detect IBus/GTK misconfigurations that break input (#572) * doctor: detect IBus/GTK misconfigurations that break input (#550) Adds _doctor_check_im_modules helper covering the four input-method failure modes from #545: - ibus-gtk3 package missing while GTK_IM_MODULE=ibus - GTK immodules cache stale (active module not listed by gtk-query-immodules-3.0 --update-cache fixes it) - XWayland session routing IBus through XIM (lossy for some IMEs; informational note pointing at CLAUDE_USE_WAYLAND=1 for native Wayland IME) - CLAUDE_GTK_IM_MODULE override visibility (informational, so users can verify the resolved value) Each check is gated so it only fires when relevant — e.g. the package check is skipped when GTK_IM_MODULE isn't ibus, the cache check is skipped when gtk-query-immodules-3.0 isn't installed, and the package check returns silently on distros without dpkg/rpm/pacman to avoid false negatives. Adds tests/doctor.bats with 17 cases covering each gating branch and the _cowork_pkg_hint mapping for ibus-gtk3 (Arch maps to plain ibus since it bundles the GTK3 immodule). Hoists _distro_id resolution to the top of run_doctor so the IM check and the existing Cowork section share one /etc/os-release read. Closes #550. Refs #545, #549. Co-Authored-By: Claude <claude@anthropic.com> * doctor: simplify IM-check helper and DRY out doctor.bats setup Mechanical clean-up of the #550 diff after self-review: scripts/doctor.sh - tighten the _doctor_check_im_modules docblock: drop the "each check is gated" paragraph (self-evident in the code) and inline the XWayland/XIM rationale into the failure-mode bullet - drop the inline section comments that just restated the next block's purpose; keep the rc=1/rc=2 comment because the value distinction is the load-bearing detail - replace the `local _pkg_rc=0; ... || _pkg_rc=$?; if ((_pkg_rc == 1))` dance with a `case $?` on the direct call tests/doctor.bats - hoist the `command -v gtk-query-immodules-3.0 → not-found` shim into a `_skip_gtk_query` helper (it was duplicated across 11 of the 17 cases) - default `_pkg_installed() { return 2; }` in setup so per-test stubs only appear when the test cares about rc=0 or rc=1 - drop dead `_skip_gtk_query` calls from cases where the function returns earlier (no IM selected, package warn fires) so the shim is only present where it actually changes behaviour No behaviour change — all 17 doctor.bats cases still pass, plus the 68 launcher-common.bats cases. Shellcheck is unchanged from baseline. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:08:36 -04:00
# Distro ID is shared between the IM-module check (#550) and the
# Cowork Mode section further down. Resolve once.
local _distro_id
_distro_id=$(_cowork_distro_id)
echo -e "${_bold}Claude Desktop Diagnostics${_reset}"
echo '================================'
echo
# -- Installed package version --
if command -v dpkg-query &>/dev/null; then
local pkg_version
pkg_version=$(dpkg-query -W -f='${Version}' \
claude-desktop 2>/dev/null) || true
if [[ -n $pkg_version ]]; then
_pass "Installed version: $pkg_version"
else
_warn 'claude-desktop not found via dpkg (AppImage?)'
fi
fi
# -- Display server --
if [[ -n "${WAYLAND_DISPLAY:-}" ]]; then
_pass "Display server: Wayland (WAYLAND_DISPLAY=$WAYLAND_DISPLAY)"
local desktop="${XDG_CURRENT_DESKTOP:-unknown}"
_info "Desktop: $desktop"
if [[ "${CLAUDE_USE_WAYLAND:-}" == '1' ]]; then
_info 'Mode: native Wayland (CLAUDE_USE_WAYLAND=1)'
else
_info 'Mode: X11 via XWayland (default, for global hotkey support)'
_info 'Tip: Set CLAUDE_USE_WAYLAND=1 for native Wayland'
_info ' (disables global hotkeys)'
fi
elif [[ -n "${DISPLAY:-}" ]]; then
_pass "Display server: X11 (DISPLAY=$DISPLAY)"
else
_fail "No display server detected" \
"(DISPLAY and WAYLAND_DISPLAY are unset)"
_info 'Fix: Run from within an X11 or Wayland session, not a TTY'
fi
doctor: detect IBus/GTK misconfigurations that break input (#572) * doctor: detect IBus/GTK misconfigurations that break input (#550) Adds _doctor_check_im_modules helper covering the four input-method failure modes from #545: - ibus-gtk3 package missing while GTK_IM_MODULE=ibus - GTK immodules cache stale (active module not listed by gtk-query-immodules-3.0 --update-cache fixes it) - XWayland session routing IBus through XIM (lossy for some IMEs; informational note pointing at CLAUDE_USE_WAYLAND=1 for native Wayland IME) - CLAUDE_GTK_IM_MODULE override visibility (informational, so users can verify the resolved value) Each check is gated so it only fires when relevant — e.g. the package check is skipped when GTK_IM_MODULE isn't ibus, the cache check is skipped when gtk-query-immodules-3.0 isn't installed, and the package check returns silently on distros without dpkg/rpm/pacman to avoid false negatives. Adds tests/doctor.bats with 17 cases covering each gating branch and the _cowork_pkg_hint mapping for ibus-gtk3 (Arch maps to plain ibus since it bundles the GTK3 immodule). Hoists _distro_id resolution to the top of run_doctor so the IM check and the existing Cowork section share one /etc/os-release read. Closes #550. Refs #545, #549. Co-Authored-By: Claude <claude@anthropic.com> * doctor: simplify IM-check helper and DRY out doctor.bats setup Mechanical clean-up of the #550 diff after self-review: scripts/doctor.sh - tighten the _doctor_check_im_modules docblock: drop the "each check is gated" paragraph (self-evident in the code) and inline the XWayland/XIM rationale into the failure-mode bullet - drop the inline section comments that just restated the next block's purpose; keep the rc=1/rc=2 comment because the value distinction is the load-bearing detail - replace the `local _pkg_rc=0; ... || _pkg_rc=$?; if ((_pkg_rc == 1))` dance with a `case $?` on the direct call tests/doctor.bats - hoist the `command -v gtk-query-immodules-3.0 → not-found` shim into a `_skip_gtk_query` helper (it was duplicated across 11 of the 17 cases) - default `_pkg_installed() { return 2; }` in setup so per-test stubs only appear when the test cares about rc=0 or rc=1 - drop dead `_skip_gtk_query` calls from cases where the function returns earlier (no IM selected, package warn fires) so the shim is only present where it actually changes behaviour No behaviour change — all 17 doctor.bats cases still pass, plus the 68 launcher-common.bats cases. Shellcheck is unchanged from baseline. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-05-05 07:08:36 -04:00
# -- Input method (IBus / GTK) --
_doctor_check_im_modules "$_distro_id"
# -- Menu bar mode --
local menu_bar_mode="${CLAUDE_MENU_BAR:-}"
if [[ -n $menu_bar_mode ]]; then
local resolved_mode="${menu_bar_mode,,}"
# Resolve boolean-style aliases
case "$resolved_mode" in
1|true|yes|on) resolved_mode='visible' ;;
0|false|no|off) resolved_mode='hidden' ;;
esac
case "$resolved_mode" in
auto|visible|hidden)
_pass "Menu bar mode: $resolved_mode" \
"(CLAUDE_MENU_BAR=$menu_bar_mode)"
;;
*)
_warn "Unknown CLAUDE_MENU_BAR: '$menu_bar_mode'"
_info 'Will fall back to auto'
_info 'Valid values: auto, visible, hidden' \
'(or 0/1/true/false/yes/no/on/off)'
;;
esac
else
_info 'Menu bar mode: auto (default, Alt toggles visibility)'
fi
feat(linux): hybrid titlebar mode for clickable in-app topbar (#538) * feat(linux): hybrid titlebar mode for clickable in-app topbar Default `CLAUDE_TITLEBAR_STYLE` is now `hybrid`: native OS frame plus a BrowserView preload shim that convinces claude.ai's bundle to render its in-app topbar (hamburger / sidebar / search / nav / Cowork ghost). Stacked layout instead of Windows's combined bar, but every button is clickable. Why not the upstream `frame:false` + WCO config: investigation (see docs/learnings/linux-topbar-shim.md) ruled out `titleBarOverlay`, `titleBarStyle:'hidden'`, and the `.draggable` CSS class as the source of the topbar click-eating drag region. The remaining cause is a Chromium-level implicit drag region for `frame:false` windows that exists on both X11 and Wayland and has no Electron-API knob. With `frame:true` the OS handles dragging and Chromium pushes no drag-region map, so the buttons receive mouse events normally. Modes: - `hybrid` (default) — system frame + shim, topbar visible and clickable - `native` — system frame, no shim, no in-app topbar - `hidden` — frameless + WCO config, matches Windows/macOS upstream; topbar visible but not clickable on Linux. Kept for Wayland comparison and future investigation Tests: tests/launcher-common.bats grew 16 cases covering `_resolve_titlebar_style`, `build_electron_args` flag selection per mode, and `setup_electron_env` env-var wiring per mode. `claude-desktop --doctor` now reports the resolved mode and warns when `hidden` is set. Co-Authored-By: Claude <claude@anthropic.com> * docs(learnings): add hybrid-mode screenshot Visual reference of the stacked layout: DE-drawn titlebar on top with native window controls, claude.ai's in-app topbar (hamburger / search / back-forward) immediately below it. Co-Authored-By: Claude <claude@anthropic.com> * docs(learnings): fix codespell hit (Pre-emptive → Preemptive) Codespell flags hyphenated "Pre-emptive" as a misspelling of "Preemptive". Drops the hyphen to clear the spellcheck CI gate on PR #538. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-05-01 02:47:16 -04:00
# -- Titlebar style --
local titlebar_style="${CLAUDE_TITLEBAR_STYLE:-}"
if [[ -n $titlebar_style ]]; then
local resolved_style="${titlebar_style,,}"
case "$resolved_style" in
hybrid|native)
_pass "Titlebar style: $resolved_style" \
"(CLAUDE_TITLEBAR_STYLE=$titlebar_style)"
;;
hidden)
_warn "Titlebar style: hidden — topbar clicks unresponsive on Linux (both X11 and Wayland)"
_info 'Use hybrid (default) or native for clickable buttons'
;;
*)
_warn "Unknown CLAUDE_TITLEBAR_STYLE: '$titlebar_style'"
_info 'Will fall back to hybrid'
_info 'Valid values: hybrid, native, hidden'
;;
esac
else
_info 'Titlebar style: hybrid (default, native frame + in-app topbar)'
fi
# -- Electron binary --
# Version is read from the file next to the binary rather than
# launching Electron, which can hang (see #371).
if [[ -n $electron_path && -x $electron_path ]]; then
local ver
ver=$(_electron_version "$electron_path")
if [[ $ver =~ ^v?[0-9]+\.[0-9]+ ]]; then
_pass "Electron: v${ver#v} ($electron_path)"
else
_pass "Electron: found at $electron_path"
fi
elif [[ -n $electron_path ]]; then
_fail "Electron binary not found at $electron_path"
_info 'Fix: Reinstall claude-desktop package'
elif command -v electron &>/dev/null; then
local ver
ver=$(_electron_version "$(command -v electron)")
_pass "Electron: ${ver:+v${ver#v} }(system)"
else
_fail 'Electron binary not found'
_info 'Fix: Reinstall claude-desktop package'
fi
# -- Chrome sandbox permissions --
local sandbox_paths=(
'/usr/lib/claude-desktop/node_modules/electron/dist/chrome-sandbox'
)
# Also check relative to the provided electron path
if [[ -n $electron_path ]]; then
local electron_dir
electron_dir=$(dirname "$electron_path")
sandbox_paths+=("$electron_dir/chrome-sandbox")
fi
local sandbox_checked=false
for sandbox_path in "${sandbox_paths[@]}"; do
if [[ -f $sandbox_path ]]; then
sandbox_checked=true
local sandbox_perms sandbox_owner
sandbox_perms=$(stat -c '%a' "$sandbox_path" 2>/dev/null) || true
sandbox_owner=$(stat -c '%U' "$sandbox_path" 2>/dev/null) || true
if [[ $sandbox_perms == '4755' && $sandbox_owner == 'root' ]]; then
_pass "Chrome sandbox: permissions OK ($sandbox_path)"
else
_fail "Chrome sandbox: perms=${sandbox_perms:-?},\
owner=${sandbox_owner:-?}"
_info "Fix: sudo chown root:root $sandbox_path"
_info " sudo chmod 4755 $sandbox_path"
fi
break
fi
done
if [[ $sandbox_checked == false ]]; then
_warn 'Chrome sandbox not found (expected for AppImage)'
fi
# -- SingletonLock --
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
local lock_file="$config_dir/SingletonLock"
if [[ -L $lock_file ]]; then
local lock_target lock_pid
lock_target="$(readlink "$lock_file" 2>/dev/null)" || true
lock_pid="${lock_target##*-}"
if [[ $lock_pid =~ ^[0-9]+$ ]] && kill -0 "$lock_pid" 2>/dev/null; then
_pass "SingletonLock: held by running process (PID $lock_pid)"
else
_warn "SingletonLock: stale lock found" \
"(PID $lock_pid is not running)"
_info "Fix: rm '$lock_file'"
fi
else
_pass 'SingletonLock: no lock file (OK)'
fi
# -- MCP config --
local mcp_config="$config_dir/claude_desktop_config.json"
if [[ -f $mcp_config ]]; then
if command -v python3 &>/dev/null; then
if python3 -c \
"import json,sys; json.load(open(sys.argv[1]))" \
"$mcp_config" 2>/dev/null; then
_pass "MCP config: valid JSON ($mcp_config)"
# Check if any MCP servers are configured
local server_count
server_count=$(python3 -c "
import json,sys
with open(sys.argv[1]) as f:
cfg = json.load(f)
servers = cfg.get('mcpServers', {})
print(len(servers))
" "$mcp_config" 2>/dev/null) || server_count='0'
_info "MCP servers configured: $server_count"
else
_fail "MCP config: invalid JSON"
_info "Fix: Check $mcp_config for syntax errors"
_info "Tip: python3 -m json.tool '$mcp_config' to see the error"
fi
elif command -v node &>/dev/null; then
if node -e \
"JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" \
"$mcp_config" 2>/dev/null; then
_pass "MCP config: valid JSON ($mcp_config)"
else
_fail "MCP config: invalid JSON"
_info "Fix: Check $mcp_config for syntax errors"
fi
else
_warn "MCP config: exists but cannot validate" \
"(no python3 or node available)"
fi
else
_info "MCP config: not found at $mcp_config (OK if not using MCP)"
fi
# -- Node.js (needed by MCP servers) --
if command -v node &>/dev/null; then
local node_version
node_version=$(node --version 2>/dev/null) || true
local node_major="${node_version#v}"
node_major="${node_major%%.*}"
if ((node_major >= 20)); then
_pass "Node.js: $node_version"
elif ((node_major >= 1)); then
_warn "Node.js: $node_version (v20+ recommended for MCP servers)"
_info 'Fix: Update Node.js to v20 or later'
fi
_info "Path: $(command -v node)"
else
_warn 'Node.js: not found (required for MCP servers)'
_info 'Fix: Install Node.js v20+ from https://nodejs.org'
fi
# -- Desktop integration --
local desktop_file='/usr/share/applications/claude-desktop.desktop'
if [[ -f $desktop_file ]]; then
_pass "Desktop entry: $desktop_file"
else
_warn 'Desktop entry not found (expected for AppImage installs)'
fi
# -- Disk space --
local config_disk_avail
config_disk_avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
| tail -1 | tr -d ' M') || true
if [[ -n $config_disk_avail ]]; then
if ((config_disk_avail < 100)); then
_fail "Disk space: ${config_disk_avail}MB free on config partition"
_info 'Fix: Free up disk space'
elif ((config_disk_avail < 500)); then
_warn "Disk space: ${config_disk_avail}MB free" \
"on config partition (low)"
else
_pass "Disk space: ${config_disk_avail}MB free"
fi
fi
# -- Cowork Mode --
echo
echo -e "${_bold}Cowork Mode${_reset}"
echo '----------------'
# Determine whether bwrap is the active backend (for severity
# of bwrap-related diagnostics). Auto-detect prefers bwrap, so
# bwrap is active unless the user has overridden to KVM or host.
local _bwrap_active=true
case "${COWORK_VM_BACKEND,,}" in
kvm|host) _bwrap_active=false ;;
''|bwrap) ;;
*)
# Unknown values: warn but leave _bwrap_active=true.
# The daemon falls through to auto-detect, which
# prefers bwrap — keep severity semantics aligned
# with that runtime behavior. See #442.
_warn_unknown_backend
;;
esac
# Bubblewrap (default backend)
if command -v bwrap &>/dev/null; then
_pass 'bubblewrap: found'
# Probe the sandbox. User namespaces must be available for
# bwrap to create its sandbox; Ubuntu 24.04+ blocks them via
# AppArmor by default (issue #351).
local _bwrap_probe_err='' _bwrap_probe_rc=0
_bwrap_probe_err=$(bwrap --ro-bind / / true 2>&1 >/dev/null) \
|| _bwrap_probe_rc=$?
if ((_bwrap_probe_rc == 0)); then
_pass 'bubblewrap: sandbox probe succeeded'
else
local _bwrap_issue=_warn
$_bwrap_active && _bwrap_issue=_fail
"$_bwrap_issue" \
"bubblewrap: sandbox probe failed" \
"(rc=$_bwrap_probe_rc)"
if [[ -n $_bwrap_probe_err ]]; then
_info " stderr: $_bwrap_probe_err"
fi
# Detect the Ubuntu 24.04 AppArmor userns block
# specifically, and hint the remediation.
local _userns_re='(user[[:space:]_-]?namespace|apparmor|[Oo]peration not permitted|CLONE_NEW|CAP_SYS_ADMIN)'
if [[ $_bwrap_probe_err =~ $_userns_re ]]; then
_info \
' Likely cause: unprivileged user namespaces' \
'are blocked.'
_info \
' Common on Ubuntu 24.04+ where AppArmor sets' \
'apparmor_restrict_unprivileged_userns=1'
_info \
' by default. See docs/TROUBLESHOOTING.md' \
'"Cowork on Ubuntu 24.04"'
_info ' for the AppArmor profile fix.'
fi
fi
else
_warn 'bubblewrap: not found'
_info \
"Fix: $(_cowork_pkg_hint "$_distro_id" bubblewrap)"
fi
# Warn on missing KVM deps only when explicitly requested;
# otherwise just inform since bwrap is the default.
local _kvm_active=false
[[ ${COWORK_VM_BACKEND-} == [Kk][Vv][Mm] ]] && _kvm_active=true
local _kvm_issue=_info
$_kvm_active && _kvm_issue=_warn
# KVM backend (opt-in via COWORK_VM_BACKEND=kvm)
if [[ -e /dev/kvm ]]; then
if [[ -r /dev/kvm && -w /dev/kvm ]]; then
_pass 'KVM: accessible'
else
"$_kvm_issue" 'KVM: /dev/kvm exists but not accessible'
if $_kvm_active; then
_info "Fix: sudo usermod -aG kvm $USER"
_info '(Log out and back in after running this)'
fi
fi
else
"$_kvm_issue" 'KVM: not available'
if $_kvm_active; then
_info \
'Fix: Install qemu-kvm and ensure KVM is enabled in BIOS'
fi
fi
# vsock module
if [[ -e /dev/vhost-vsock ]]; then
_pass 'vsock: module loaded'
else
"$_kvm_issue" 'vsock: /dev/vhost-vsock not found'
if $_kvm_active; then
_info 'Fix: sudo modprobe vhost_vsock'
fi
fi
fix: detect virtiofsd at off-PATH install locations (#447) (#453) * fix: detect virtiofsd at off-PATH install locations (#447) Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common) and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default $PATH, so doctor.sh's `command -v virtiofsd` always returned a false negative — users would install the package and still see "virtiofsd: not found" (reported most recently by @zabka in #445, originally flagged by @jarrodcolburn). Adds a _find_virtiofsd helper that searches PATH first, then the known off-PATH install locations: - /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL) - /usr/lib/qemu/virtiofsd (legacy Debian) - /usr/lib/virtiofsd (Arch/CachyOS/Manjaro) Splits virtiofsd out of the KVM tools loop into a dedicated three-branch check: [PASS] virtiofsd: found — on PATH [PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused) [WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm (+ info lines about 9p fallback + symlink Fix) [INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged) The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd by PATH name and will silently fall back to virtio-9p (lower performance) if the binary is only reachable off-PATH — so the user knows a symlink is needed to actually get virtiofs performance. Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the helper (PATH hit / fallback hit / ordered fallback / total miss / non-executable skip / default-list regression guard for the Arch path). All 45 tests pass. Does not touch cowork-vm-service.js — teaching KvmBackend to probe these same paths would give Ubuntu KVM users real virtiofs performance without a symlink, but that's a separate change. Fixes #447 Co-Authored-By: Claude <claude@anthropic.com> * style: collapse unnecessary line continuations in virtiofsd check Simplifier pass — the five backslash-continued `_warn` / `_info` invocations in the new virtiofsd three-severity block were all under 63 chars after collapsing, well within the project's 80-char guideline. The continuations were visual noise, not wrap-driven. Behavior byte-identical. All 45 BATS tests still pass. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
# KVM tools: QEMU, socat. virtiofsd is handled separately below
# because Debian/Ubuntu install it off-PATH.
local _tool_label _tool_bin _tool_pkg
for _tool_label in \
'QEMU:qemu-system-x86_64:qemu' \
fix: detect virtiofsd at off-PATH install locations (#447) (#453) * fix: detect virtiofsd at off-PATH install locations (#447) Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common) and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default $PATH, so doctor.sh's `command -v virtiofsd` always returned a false negative — users would install the package and still see "virtiofsd: not found" (reported most recently by @zabka in #445, originally flagged by @jarrodcolburn). Adds a _find_virtiofsd helper that searches PATH first, then the known off-PATH install locations: - /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL) - /usr/lib/qemu/virtiofsd (legacy Debian) - /usr/lib/virtiofsd (Arch/CachyOS/Manjaro) Splits virtiofsd out of the KVM tools loop into a dedicated three-branch check: [PASS] virtiofsd: found — on PATH [PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused) [WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm (+ info lines about 9p fallback + symlink Fix) [INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged) The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd by PATH name and will silently fall back to virtio-9p (lower performance) if the binary is only reachable off-PATH — so the user knows a symlink is needed to actually get virtiofs performance. Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the helper (PATH hit / fallback hit / ordered fallback / total miss / non-executable skip / default-list regression guard for the Arch path). All 45 tests pass. Does not touch cowork-vm-service.js — teaching KvmBackend to probe these same paths would give Ubuntu KVM users real virtiofs performance without a symlink, but that's a separate change. Fixes #447 Co-Authored-By: Claude <claude@anthropic.com> * style: collapse unnecessary line continuations in virtiofsd check Simplifier pass — the five backslash-continued `_warn` / `_info` invocations in the new virtiofsd three-severity block were all under 63 chars after collapsing, well within the project's 80-char guideline. The continuations were visual noise, not wrap-driven. Behavior byte-identical. All 45 BATS tests still pass. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
'socat:socat:socat'
do
_tool_bin="${_tool_label#*:}"
_tool_pkg="${_tool_bin#*:}"
_tool_bin="${_tool_bin%%:*}"
_tool_label="${_tool_label%%:*}"
if command -v "$_tool_bin" &>/dev/null; then
_pass "$_tool_label: found"
else
"$_kvm_issue" "$_tool_label: not found"
if $_kvm_active; then
_info \
"Fix: $(_cowork_pkg_hint "$_distro_id" "$_tool_pkg")"
fi
fi
done
fix: detect virtiofsd at off-PATH install locations (#447) (#453) * fix: detect virtiofsd at off-PATH install locations (#447) Ubuntu ships virtiofsd at /usr/libexec/virtiofsd (from qemu-system-common) and Arch/CachyOS/Manjaro at /usr/lib/virtiofsd. Neither is on the default $PATH, so doctor.sh's `command -v virtiofsd` always returned a false negative — users would install the package and still see "virtiofsd: not found" (reported most recently by @zabka in #445, originally flagged by @jarrodcolburn). Adds a _find_virtiofsd helper that searches PATH first, then the known off-PATH install locations: - /usr/libexec/virtiofsd (Debian/Ubuntu/Fedora/RHEL) - /usr/lib/qemu/virtiofsd (legacy Debian) - /usr/lib/virtiofsd (Arch/CachyOS/Manjaro) Splits virtiofsd out of the KVM tools loop into a dedicated three-branch check: [PASS] virtiofsd: found — on PATH [PASS] virtiofsd: found at <path> (not on PATH) — off-PATH, bwrap default (virtiofsd unused) [WARN] virtiofsd: found at <path> but not on PATH — off-PATH, COWORK_VM_BACKEND=kvm (+ info lines about 9p fallback + symlink Fix) [INFO]/[WARN] virtiofsd: not found — missing (severity ladder unchanged) The WARN-on-KVM-active branch surfaces that KvmBackend spawns virtiofsd by PATH name and will silently fall back to virtio-9p (lower performance) if the binary is only reachable off-PATH — so the user knows a symlink is needed to actually get virtiofs performance. Tests: 6 new BATS cases in tests/cowork-bwrap-config.bats exercise the helper (PATH hit / fallback hit / ordered fallback / total miss / non-executable skip / default-list regression guard for the Arch path). All 45 tests pass. Does not touch cowork-vm-service.js — teaching KvmBackend to probe these same paths would give Ubuntu KVM users real virtiofs performance without a symlink, but that's a separate change. Fixes #447 Co-Authored-By: Claude <claude@anthropic.com> * style: collapse unnecessary line continuations in virtiofsd check Simplifier pass — the five backslash-continued `_warn` / `_info` invocations in the new virtiofsd three-severity block were all under 63 chars after collapsing, well within the project's 80-char guideline. The continuations were visual noise, not wrap-driven. Behavior byte-identical. All 45 BATS tests still pass. Co-Authored-By: Claude <claude@anthropic.com> --------- Co-authored-by: Claude <claude@anthropic.com>
2026-04-20 15:34:49 -05:00
# virtiofsd: ships off-PATH on several distros (see _find_virtiofsd
# above). Probe known locations so we don't report "not found" when
# the package is actually installed. KvmBackend spawns by PATH name
# and silently falls back to virtio-9p (lower perf) if the spawn
# fails — so when KVM is the active backend and virtiofsd is only
# reachable off-PATH, surface a [WARN] so the user knows they need
# a symlink to actually get virtiofs performance. On the bwrap
# default path virtiofsd is unused, so [PASS] is fine.
local _vfsd_path _vfsd_on_path
_vfsd_on_path=$(command -v virtiofsd 2>/dev/null)
_vfsd_path=$(_find_virtiofsd)
if [[ -n $_vfsd_path ]]; then
if [[ $_vfsd_path == "$_vfsd_on_path" ]]; then
_pass 'virtiofsd: found'
elif $_kvm_active; then
_warn "virtiofsd: found at $_vfsd_path but not on PATH"
_info 'KvmBackend spawns by PATH name and will fall back'
_info 'to virtio-9p (lower performance) without a symlink.'
_info "Fix: sudo ln -s $_vfsd_path /usr/local/bin/virtiofsd"
else
_pass "virtiofsd: found at $_vfsd_path (not on PATH)"
fi
else
"$_kvm_issue" 'virtiofsd: not found'
if $_kvm_active; then
_info "Fix: $(_cowork_pkg_hint "$_distro_id" virtiofsd)"
fi
fi
# VM image
local vm_image
vm_image="${HOME}/.local/share/claude-desktop/vm/rootfs.qcow2"
if [[ -f $vm_image ]]; then
local vm_size
vm_size=$(du -h "$vm_image" 2>/dev/null \
| cut -f1) || vm_size='unknown size'
_pass "VM image: $vm_size"
else
_info 'VM image: not downloaded yet'
fi
# Determine active backend (matches daemon's detectBackend())
local cowork_backend='none (host-direct, no isolation)'
if [[ -n ${COWORK_VM_BACKEND-} ]]; then
case ${COWORK_VM_BACKEND,,} in
kvm) cowork_backend='KVM (full VM isolation, via override)' ;;
bwrap) cowork_backend='bubblewrap (namespace sandbox, via override)' ;;
host) cowork_backend='host-direct (no isolation, via override)' ;;
*)
_warn_unknown_backend
cowork_backend="auto-detect (invalid override '${COWORK_VM_BACKEND}' — see warning above)"
;;
esac
elif command -v bwrap &>/dev/null; then
# bwrap is installed: if the probe succeeds, use it;
# otherwise fall to host (matching daemon behavior, so we
# don't silently imply KVM will be chosen when bwrap is
# blocked — see #351).
if bwrap --ro-bind / / true &>/dev/null; then
cowork_backend='bubblewrap (namespace sandbox)'
else
cowork_backend='host-direct (bwrap probe failed — see above)'
fi
elif [[ -e /dev/kvm ]] \
&& [[ -r /dev/kvm && -w /dev/kvm ]] \
&& command -v qemu-system-x86_64 &>/dev/null \
&& [[ -e /dev/vhost-vsock ]]; then
cowork_backend='KVM (full VM isolation)'
fi
_info "Cowork isolation: $cowork_backend"
# Custom bwrap mount configuration
_doctor_check_bwrap_mounts
# -- Orphaned cowork daemon --
# Uses the same live-UI detection as cleanup_orphaned_cowork_daemon
# above: a live UI is an Electron main process on app.asar that is
# not a Chromium helper (--type=...), not the cowork daemon itself,
# and not stopped/zombie. Counting any `claude-desktop`-matching
# process (as the old check did) would include the launcher's own
# bash and stuck launcher bashes from previous crashes, producing
# false negatives where a real orphan is misreported as "parent
# alive".
local _cowork_pids
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \
|| true
if [[ -n $_cowork_pids ]]; then
local _daemon_orphaned=true _pid _cmdline _state
for _pid in $(pgrep -f 'app\.asar' 2>/dev/null); do
[[ $_pid == "$$" || $_pid == "$PPID" ]] && continue
_cmdline=$(tr '\0' ' ' \
< "/proc/$_pid/cmdline" 2>/dev/null) || continue
[[ $_cmdline == *cowork-vm-service* ]] && continue
[[ $_cmdline == *--type=* ]] && continue
_state=$(awk '/^State:/ {print $2; exit}' \
"/proc/$_pid/status" 2>/dev/null) || continue
[[ $_state == T || $_state == t || $_state == Z ]] \
&& continue
_daemon_orphaned=false
break
done
if [[ $_daemon_orphaned == true ]]; then
_warn "Cowork daemon: orphaned (PIDs: $_cowork_pids)"
_info 'Fix: Restart Claude Desktop' \
'(daemon will be cleaned up automatically)'
else
_pass 'Cowork daemon: running (parent alive)'
fi
fi
# -- Recent crashes --
# Surfaces the GPU process FATAL pattern (#583) before users
# notice the in-app "Claude crashed repeatedly" prompt.
_doctor_check_recent_crashes "$electron_path"
# -- Log file --
local log_path
log_path="${XDG_CACHE_HOME:-$HOME/.cache}"
log_path="$log_path/claude-desktop-debian/launcher.log"
if [[ -f $log_path ]]; then
local log_size
log_size=$(stat -c '%s' "$log_path" 2>/dev/null) || log_size=0
local log_size_kb=$((log_size / 1024))
if ((log_size_kb > 10240)); then
_warn "Log file: ${log_size_kb}KB" \
"(consider clearing: rm '$log_path')"
else
_pass "Log file: ${log_size_kb}KB ($log_path)"
fi
else
_info 'Log file: not yet created (OK)'
fi
# -- Summary --
echo
if ((_doctor_failures == 0)); then
echo -e "${_green}${_bold}All checks passed.${_reset}"
else
echo -e "${_red}${_bold}${_doctor_failures} check(s) failed.${_reset}"
echo 'See above for fixes.'
fi
return "$_doctor_failures"
}