3e076d4dd9
Deploy Worker / deploy (push) Failing after 1s
Shellcheck / Check shell scripts (push) Failing after 1s
CI / Build Packages (amd64 - appimage) (push) Has been skipped
CI / Build Packages (amd64 - deb) (push) Has been skipped
CI / Build Packages (amd64 - rpm) (push) Has been skipped
CI / Build Packages (arm64 - appimage) (push) Has been skipped
CI / Build Packages (arm64 - deb) (push) Has been skipped
CI / Test Flags Parsing (push) Failing after 1s
BATS Tests / BATS unit tests (push) Failing after 1s
CI / Build Packages (arm64 - rpm) (push) Has been skipped
CI / Test Build Artifacts (amd64) (push) Has been skipped
CI / Test Build Artifacts (arm64) (push) Has been skipped
CI / Update APT Repository (push) Has been cancelled
CI / Mirror Official .deb to Release (push) Has been cancelled
CI / Update DNF Repository (push) Has been cancelled
CI / Update AUR Package (push) Has been cancelled
CI / Create Release (push) Has been cancelled
1730 lines
59 KiB
Bash
1730 lines
59 KiB
Bash
# shellcheck shell=bash
|
|
#===============================================================================
|
|
# 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-unofficial --doctor` entry
|
|
# point) plus its
|
|
# internal helpers. Self-contained except for the WM_CLASS constant defined
|
|
# at the top of launcher-common.sh (substituted at build time), which the
|
|
# live-UI fingerprint in the orphaned-daemon check reads at runtime.
|
|
#
|
|
# 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
|
|
;;
|
|
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"
|
|
}
|
|
|
|
# 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 " $*"; }
|
|
|
|
# Locate the virtiofsd binary anywhere a distro puts it. 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.
|
|
#
|
|
# NOTE: this is deliberately BROADER than the official client's own
|
|
# probe, which checks only /usr/libexec and /usr/bin plus its bundled
|
|
# copy (see _check_cowork_virtiofsd). A hit here does NOT mean the
|
|
# client will find it — _check_cowork_virtiofsd uses this helper only
|
|
# to explain a "found somewhere the client won't look" mismatch.
|
|
#
|
|
# Prints the discovered path on stdout; returns 0 on hit, 1 on miss.
|
|
# Fallback paths are overridable via _COWORK_VFSD_PATHS
|
|
# (colon-separated) so tests can point at a stub directory. The
|
|
# namespaced prefix signals "internal test hook — not a user knob".
|
|
# Shared with the VM daemon (cowork-vm-service.js) so doctor's
|
|
# diagnosis and the daemon's actual probe stay in lock-step.
|
|
_find_virtiofsd() {
|
|
local bin
|
|
bin=$(command -v virtiofsd 2>/dev/null)
|
|
if [[ -n $bin ]]; then
|
|
printf '%s' "$bin"
|
|
return 0
|
|
fi
|
|
|
|
local fallback_paths="${_COWORK_VFSD_PATHS:-}"
|
|
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
|
|
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', []):
|
|
s = fmt(p)
|
|
if s is not None:
|
|
print(s)
|
|
print('---')
|
|
for p in m.get('additionalBinds', []):
|
|
s = fmt(p)
|
|
if s is not None:
|
|
print(s)
|
|
print('---')
|
|
for p in m.get('disabledDefaultBinds', []):
|
|
if isinstance(p, str):
|
|
print(p)
|
|
PYEOF
|
|
)
|
|
else
|
|
parsed_output=$(node - "$mounts_json" 2>/dev/null <<'JSEOF'
|
|
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]);
|
|
(m.additionalROBinds || []).forEach(p => {
|
|
const s = fmt(p);
|
|
if (s !== null) console.log(s);
|
|
});
|
|
console.log('---');
|
|
(m.additionalBinds || []).forEach(p => {
|
|
const s = fmt(p);
|
|
if (s !== null) console.log(s);
|
|
});
|
|
console.log('---');
|
|
(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
|
|
|
|
# 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
|
|
}
|
|
|
|
# Diagnose short-filename-limit filesystems that break cowork session
|
|
# initialization. Claude Code creates a per-session directory under
|
|
# ~/.claude/projects/ whose name is the sanitized host CWD — for cowork
|
|
# sessions that flattens to ~180 chars (the host CWD is the deeply
|
|
# nested outputs dir under ~/.config/Claude/local-agent-mode-sessions/
|
|
# <accountId>/<orgId>/local_<uuid>/outputs). On filesystems with a
|
|
# short NAME_MAX — eCryptfs caps at 143 due to filename-encryption
|
|
# overhead — that mkdir fails with ENAMETOOLONG and the session never
|
|
# starts. Standard fs (ext4/btrfs/xfs/zfs) cap at 255 and are fine. See
|
|
# #590.
|
|
_doctor_check_filename_limit() {
|
|
# Walk up from ~/.claude/projects to the first dir that exists so
|
|
# getconf has something to query on a fresh install where the tree
|
|
# hasn't been created yet. $HOME is the floor — stop there rather
|
|
# than crossing into /.
|
|
local probe_dir="$HOME/.claude/projects"
|
|
while [[ ! -d $probe_dir ]]; do
|
|
probe_dir=$(dirname "$probe_dir")
|
|
[[ $probe_dir == "$HOME" || $probe_dir == / ]] && break
|
|
done
|
|
[[ -d $probe_dir ]] || return 0
|
|
|
|
local name_max
|
|
name_max=$(getconf NAME_MAX "$probe_dir" 2>/dev/null) || return 0
|
|
[[ $name_max =~ ^[0-9]+$ ]] || return 0
|
|
# Force base 10 so a leading zero can't trip octal arithmetic.
|
|
name_max=$((10#$name_max))
|
|
|
|
((name_max >= 200)) && return 0
|
|
|
|
_warn "Filename limit: NAME_MAX=$name_max on $probe_dir (< 200)"
|
|
_info \
|
|
'Cowork sessions create project-dir names up to ~180 chars' \
|
|
'under ~/.claude/projects/; short limits cause ENAMETOOLONG'
|
|
_info 'when Claude Code initializes a session inside cowork (#590).'
|
|
|
|
local fs_type
|
|
fs_type=$(df --output=fstype "$probe_dir" 2>/dev/null \
|
|
| awk 'NR==2 {print $1}')
|
|
if [[ $fs_type == 'ecryptfs' ]]; then
|
|
_info \
|
|
'Detected eCryptfs (legacy Ubuntu/Mint encrypted home,' \
|
|
'NAME_MAX=143 due to filename-encryption overhead).'
|
|
_info \
|
|
'Workaround: move ~/.config/Claude onto a separate' \
|
|
'LUKS-encrypted ext4 volume (NAME_MAX=255) and symlink it'
|
|
_info \
|
|
'back. See docs/troubleshooting.md "Cowork: ENAMETOOLONG' \
|
|
'on encrypted home (eCryptfs)" for the worked steps.'
|
|
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-unofficial/claude-desktop)
|
|
# 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.
|
|
# Assumes `coredumpctl list electron`'s COMM=electron filter
|
|
# excludes `-- Reboot --` separator rows from the listing (true
|
|
# on systemd as of writing). The path-matched branch below uses
|
|
# index($0, p) so it's unaffected even if that ever changes;
|
|
# revisit this total-count branch if a future systemd version
|
|
# starts leaking reboot markers into per-COMM listings.
|
|
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
|
|
|
|
# Threshold tuned against the #583 repro (~10 crashes over 7 days
|
|
# on the affected laptop); a noisy session typically clears 3 in a
|
|
# week, so 3 is the floor for "worth surfacing the workaround".
|
|
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
|
|
}
|
|
|
|
# Report how the Chromium password-store backend is selected.
|
|
#
|
|
# Since the v3.0.0 rebase the launcher no longer probes for a keyring:
|
|
# the official build's os_crypt autodetection owns that decision (and
|
|
# deliberately declines weak persistence on some sessions). The only
|
|
# knob is CLAUDE_PASSWORD_STORE, the documented escape hatch. There is
|
|
# nothing to probe, so this is informational only — no PASS/FAIL.
|
|
_doctor_check_password_store() {
|
|
if [[ -n ${CLAUDE_PASSWORD_STORE:-} ]]; then
|
|
_info "Password store: forced to $CLAUDE_PASSWORD_STORE" \
|
|
'(overrides upstream autodetection)'
|
|
else
|
|
_info 'Password store: upstream os_crypt autodetect (default)'
|
|
fi
|
|
}
|
|
|
|
# Return whether a session secret backend usable by Chromium's
|
|
# os_crypt (Secret Service or KWallet) is reachable — running or
|
|
# D-Bus-activatable. Prints the matched bus name on stdout.
|
|
# Exit 0 = reachable, 1 = provably absent, 2 = unprobeable (no
|
|
# session bus or no D-Bus tooling). Overridable via
|
|
# _DOCTOR_SECRET_BACKEND=present|absent for tests (same
|
|
# internal-hook convention as _DOCTOR_KVM_DEV).
|
|
_secret_backend_reachable() {
|
|
case "${_DOCTOR_SECRET_BACKEND:-}" in
|
|
present)
|
|
echo 'org.freedesktop.secrets'
|
|
return 0
|
|
;;
|
|
absent)
|
|
return 1
|
|
;;
|
|
esac
|
|
|
|
local bus_sock="${XDG_RUNTIME_DIR:-/nonexistent}/bus"
|
|
if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} && ! -S $bus_sock ]]; then
|
|
return 2
|
|
fi
|
|
|
|
local names=''
|
|
if command -v busctl &>/dev/null; then
|
|
# The default listing includes activatable names, so a
|
|
# keyring daemon that starts on demand still counts.
|
|
names=$(busctl --user --no-pager --no-legend list \
|
|
2>/dev/null)
|
|
elif command -v gdbus &>/dev/null; then
|
|
local method
|
|
for method in ListNames ListActivatableNames; do
|
|
names+=$(gdbus call --session \
|
|
--dest org.freedesktop.DBus \
|
|
--object-path /org/freedesktop/DBus \
|
|
--method "org.freedesktop.DBus.$method" \
|
|
2>/dev/null)
|
|
done
|
|
else
|
|
return 2
|
|
fi
|
|
[[ -n $names ]] || return 2
|
|
|
|
local name
|
|
for name in org.freedesktop.secrets \
|
|
org.kde.kwalletd6 org.kde.kwalletd5; do
|
|
if [[ $names == *"$name"* ]]; then
|
|
echo "$name"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Advisory follow-on to the password-store report: when no secret
|
|
# backend is reachable, the official build's os_crypt autodetect
|
|
# falls back to the plaintext 'basic' backend and the login token
|
|
# persists unencrypted at rest under ~/.config/Claude. Login still
|
|
# works (live-verified on keyring-less wlroots/i3 sessions) — this
|
|
# is a data-at-rest advisory, never a FAIL.
|
|
_doctor_check_keyring_persistence() {
|
|
local forced="${CLAUDE_PASSWORD_STORE:-}"
|
|
if [[ -n $forced && $forced != 'basic' ]]; then
|
|
# A real backend was forced; nothing to probe.
|
|
return 0
|
|
fi
|
|
if [[ $forced == 'basic' ]]; then
|
|
_warn 'Keyring: CLAUDE_PASSWORD_STORE=basic —' \
|
|
'login token stored unencrypted at rest'
|
|
return 0
|
|
fi
|
|
|
|
local backend rc
|
|
backend=$(_secret_backend_reachable)
|
|
rc=$?
|
|
case $rc in
|
|
0)
|
|
_pass "Keyring: $backend reachable for" \
|
|
'credential encryption'
|
|
;;
|
|
1)
|
|
_warn 'Keyring: no Secret Service or KWallet' \
|
|
'on the session bus'
|
|
_info 'Login still works, but the token' \
|
|
"persists via Chromium's plaintext" \
|
|
"'basic' backend"
|
|
_info '(unencrypted at rest under' \
|
|
"${XDG_CONFIG_HOME:-$HOME/.config}/Claude)."
|
|
_info 'Fix: install/enable a keyring' \
|
|
'(gnome-keyring or kwalletd), or ignore' \
|
|
'if acceptable for this machine.'
|
|
;;
|
|
*)
|
|
_info 'Keyring: unable to probe the session bus' \
|
|
'(no busctl/gdbus or no session bus)'
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Report free space on the partition holding the Claude config dir.
|
|
# Arguments: $1 = config directory to check.
|
|
#
|
|
# Skips when df is unavailable or yields a non-numeric value, leaving
|
|
# an _info line so the summary never claims a pass over an unrun
|
|
# check: better a visible skip than a green PASS reporting space we
|
|
# could not read.
|
|
_doctor_check_disk_space() {
|
|
local config_dir="$1"
|
|
local avail
|
|
avail=$(df -BM --output=avail "$config_dir" 2>/dev/null \
|
|
| tail -1 | tr -d ' M') || true
|
|
if [[ ! $avail =~ ^[0-9]+$ ]]; then
|
|
_info 'Disk space: unable to read (df)'
|
|
return 0
|
|
fi
|
|
# Force base 10: a leading zero ("0099") would otherwise make
|
|
# (( )) parse the value as octal and error out, falling through
|
|
# to the PASS branch.
|
|
avail=$((10#$avail))
|
|
if ((avail < 100)); then
|
|
_fail "Disk space: ${avail}MB free on config partition"
|
|
_info 'Fix: Free up disk space'
|
|
elif ((avail < 500)); then
|
|
_warn "Disk space: ${avail}MB free" \
|
|
"on config partition (low)"
|
|
else
|
|
_pass "Disk space: ${avail}MB free"
|
|
fi
|
|
}
|
|
|
|
# Check the Chromium single-instance SingletonLock under the Claude
|
|
# config dir. Electron writes it as a 'hostname-PID' symlink; a stale
|
|
# one (dead PID) is self-healed — Chromium unlinks the orphan and
|
|
# continues. The case that actually blocks startup is a non-symlink
|
|
# regular file (possible after an unclean update): ReadLink returns
|
|
# empty, the lock parse fails, and the symlink() retry hits EEXIST,
|
|
# so the app quits on the next cold launch. That case must not be
|
|
# reported as "no lock file", which was a silent false PASS.
|
|
#
|
|
# Usage: _doctor_check_singleton_lock [config_dir]
|
|
_doctor_check_singleton_lock() {
|
|
local config_dir="${1:-${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
|
|
elif [[ -e $lock_file ]]; then
|
|
# WARN, not _fail, for consistency with the stale-symlink
|
|
# precedent above — even though this case provably blocks the
|
|
# next cold launch.
|
|
_warn 'SingletonLock: present but not a symlink (unexpected)'
|
|
_info "Fix: rm '$lock_file'"
|
|
else
|
|
_pass 'SingletonLock: no lock file (OK)'
|
|
fi
|
|
}
|
|
|
|
# Report the installed claude-desktop version from the package manager
|
|
# that actually owns the install (#711). On dual-DB hosts (e.g. a
|
|
# Fedora box with dpkg installed for deb work) a stale dpkg record
|
|
# must not shadow the live rpm install, so rpm ownership of the real
|
|
# Electron binary is probed first: `rpm -qf <path>` succeeds only when
|
|
# rpm installed the file, which a stale dpkg record can never claim.
|
|
# dpkg is consulted only when rpm does not own the path.
|
|
#
|
|
# AppImage and Nix installs (no package owns the path) keep the
|
|
# existing not-found warn; hosts with no package tools stay silent.
|
|
#
|
|
# Usage: _doctor_check_pkg_version <electron_path>
|
|
_doctor_check_pkg_version() {
|
|
local electron_path="${1:-}"
|
|
local probe_path="$electron_path"
|
|
local pkg_version=''
|
|
|
|
if [[ -z $probe_path ]]; then
|
|
# Official layout: bare ELF at the package root (no
|
|
# node_modules/electron/dist tree anymore). Prefer our
|
|
# renamed install (claude-desktop-unofficial, Phase 3);
|
|
# fall back to Anthropic's official install path.
|
|
probe_path='/usr/lib/claude-desktop-unofficial/claude-desktop'
|
|
if [[ ! -e $probe_path ]]; then
|
|
probe_path='/usr/lib/claude-desktop/claude-desktop'
|
|
fi
|
|
fi
|
|
|
|
# rpm branch: query the file, not the package name, so the answer
|
|
# comes from the database that owns the actual install.
|
|
if command -v rpm &>/dev/null; then
|
|
pkg_version=$(rpm -qf --qf '%{VERSION}-%{RELEASE}' \
|
|
"$probe_path" 2>/dev/null) || pkg_version=''
|
|
if [[ -n $pkg_version ]]; then
|
|
# Record for _check_official_drift (run_doctor scopes the
|
|
# global; see the drift check for how it is consumed).
|
|
_installed_pkg_version="$pkg_version"
|
|
_pass "Installed version: $pkg_version"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# dpkg branch: only consulted when rpm does not own the install.
|
|
# Our deb is claude-desktop-unofficial since the Phase 3 rename;
|
|
# plain claude-desktop is Anthropic's official package on dpkg
|
|
# hosts and is not ours to report here.
|
|
# Gate on install status, not just version: dpkg-query still
|
|
# prints a version for a removed-but-not-purged package (rc /
|
|
# config-files state), so trusting the version alone PASSes for
|
|
# software no longer installed — the #711 false-PASS.
|
|
if command -v dpkg-query &>/dev/null; then
|
|
local dpkg_status
|
|
dpkg_status=$(dpkg-query -W \
|
|
-f='${db:Status-Status} ${Version}' \
|
|
claude-desktop-unofficial 2>/dev/null) \
|
|
|| dpkg_status=''
|
|
if [[ $dpkg_status == 'installed '* ]]; then
|
|
pkg_version="${dpkg_status#installed }"
|
|
_installed_pkg_version="$pkg_version"
|
|
_pass "Installed version: $pkg_version"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
# Neither manager knows the install — AppImage or Nix. Only warn
|
|
# when a package tool exists; with none there is nothing to say.
|
|
if command -v rpm &>/dev/null \
|
|
|| command -v dpkg-query &>/dev/null; then
|
|
_warn 'claude-desktop-unofficial not found via dpkg/rpm' \
|
|
'(AppImage?)'
|
|
fi
|
|
}
|
|
|
|
# Best-effort drift check against Anthropic's official APT pool.
|
|
#
|
|
# doctor.sh is installed WITHOUT official-deb.sh, so the resolver is
|
|
# embedded here rather than sourced. It reuses the same RS='' Packages
|
|
# stanza parse as resolve_official_deb in scripts/setup/official-deb.sh
|
|
# (kept in lock-step by hand). Network-optional: a missing curl, an
|
|
# unsupported arch, or an unreachable pool is an _info skip, never a
|
|
# failure.
|
|
#
|
|
# Reads the installed upstream version from the _installed_pkg_version
|
|
# global recorded by _doctor_check_pkg_version — the part before the
|
|
# first '-', since our packages append '-<wrapper>' to upstream's
|
|
# dotted version.
|
|
_check_official_drift() {
|
|
if ! command -v curl &>/dev/null; then
|
|
_info 'Version drift: skipped (curl not available)'
|
|
return 0
|
|
fi
|
|
|
|
local arch
|
|
case "$(uname -m)" in
|
|
x86_64) arch='amd64' ;;
|
|
aarch64) arch='arm64' ;;
|
|
*)
|
|
_info 'Version drift: skipped (unsupported architecture)'
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
local base='https://downloads.claude.ai/claude-desktop/apt/stable'
|
|
local index_url="$base/dists/stable/main/binary-${arch}/Packages"
|
|
|
|
local newest
|
|
newest=$(curl -fsS --max-time 8 "$index_url" 2>/dev/null | awk -v RS='' '
|
|
/^Package: claude-desktop\n/ || $1 == "Package:" {
|
|
v = ""
|
|
n = split($0, lines, "\n")
|
|
for (i = 1; i <= n; i++) {
|
|
if (lines[i] ~ /^Version: /) v = substr(lines[i], 10)
|
|
}
|
|
if (v != "") print v
|
|
}' | sort -V | tail -1)
|
|
|
|
if [[ -z $newest ]]; then
|
|
_info 'Version drift: skipped (offline or pool unreachable)'
|
|
return 0
|
|
fi
|
|
|
|
local installed="${_installed_pkg_version:-}"
|
|
installed="${installed%%-*}"
|
|
|
|
if [[ -z $installed ]]; then
|
|
_info "Version drift: newest official pool version is $newest" \
|
|
'(installed version unknown — AppImage?)'
|
|
return 0
|
|
fi
|
|
|
|
if [[ $installed == "$newest" ]]; then
|
|
_pass "Version: in sync with the official pool ($newest)"
|
|
else
|
|
_warn "Version drift: official pool has $newest," \
|
|
"this install packages $installed"
|
|
_info 'Fix: upgrade via your package manager or download the' \
|
|
'newest release'
|
|
fi
|
|
}
|
|
|
|
# Classify a dpkg package named claude-desktop. Since the Phase 3
|
|
# rename our package is claude-desktop-unofficial, so that name now
|
|
# identifies either Anthropic's official package (fine — just note the
|
|
# shared profile dir) or a leftover pre-rename (< 1.16000) install of
|
|
# this project (warn, with the migration hint). deb-family only;
|
|
# silent when dpkg-query is absent or nothing detectable is installed.
|
|
# The sources.list.d directory is overridable via _DOCTOR_APT_SOURCES_DIR
|
|
# so tests can point at a fixture tree.
|
|
_check_name_collision() {
|
|
command -v dpkg-query &>/dev/null || return 0
|
|
|
|
local sources_list='/etc/apt/sources.list'
|
|
local sources_dir="${_DOCTOR_APT_SOURCES_DIR:-/etc/apt/sources.list.d}"
|
|
local pattern='downloads\.claude\.ai/claude-desktop/apt'
|
|
|
|
# (a) Is Anthropic's official repo configured in APT's source lists?
|
|
# grep -qs stays quiet on missing/unreadable files.
|
|
local repo_found=false
|
|
if grep -qs "$pattern" "$sources_list" 2>/dev/null; then
|
|
repo_found=true
|
|
elif [[ -d $sources_dir ]]; then
|
|
local f
|
|
for f in "$sources_dir"/*; do
|
|
[[ -f $f ]] || continue
|
|
if grep -qs "$pattern" "$f" 2>/dev/null; then
|
|
repo_found=true
|
|
break
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# (b) Is a package named claude-desktop installed, and whose is it?
|
|
# Gate on install status first: dpkg-query still answers
|
|
# ${Maintainer}/${Version} for a removed-but-not-purged record
|
|
# (config-files / rc state), which the transitional claude-desktop
|
|
# dummy makes common post-rename — classifying that as an install
|
|
# would warn/info about software no longer present (#711 family).
|
|
local dpkg_status
|
|
dpkg_status=$(dpkg-query -W -f='${db:Status-Status}' \
|
|
claude-desktop 2>/dev/null) || dpkg_status=''
|
|
[[ $dpkg_status == 'installed' ]] || return 0
|
|
|
|
local maintainer version
|
|
maintainer=$(dpkg-query -W -f='${Maintainer}' claude-desktop \
|
|
2>/dev/null) || maintainer=''
|
|
version=$(dpkg-query -W -f='${Version}' claude-desktop \
|
|
2>/dev/null) || version=''
|
|
[[ -n $maintainer || -n $version ]] || return 0
|
|
|
|
# Anthropic's official package alongside ours: not a conflict —
|
|
# both apps share ~/.config/Claude and its SingletonLock, so only
|
|
# one can run at a time.
|
|
if [[ $maintainer == *Anthropic* ]]; then
|
|
_info "Anthropic's official claude-desktop package is also" \
|
|
'installed. Both apps share ~/.config/Claude and its' \
|
|
'SingletonLock, so only one can run at a time.'
|
|
return 0
|
|
fi
|
|
|
|
# Pre-rename install of this project (< 1.16000, before the
|
|
# claude-desktop-unofficial rename) still lingering in dpkg.
|
|
if command -v dpkg &>/dev/null && [[ -n $version ]] \
|
|
&& dpkg --compare-versions "$version" lt 1.16000; then
|
|
_warn "Installed claude-desktop ($version) is this project's" \
|
|
'pre-rename package'
|
|
_info 'Migrate: sudo apt install claude-desktop-unofficial' \
|
|
'(Conflicts/Replaces removes the old package)'
|
|
return 0
|
|
fi
|
|
|
|
# Not legacy and not Anthropic by maintainer — but with their apt
|
|
# source configured, an installed claude-desktop is theirs.
|
|
if $repo_found; then
|
|
_info "Anthropic's official claude-desktop package is also" \
|
|
'installed. Both apps share ~/.config/Claude and its' \
|
|
'SingletonLock, so only one can run at a time.'
|
|
fi
|
|
}
|
|
|
|
# Warn about 2.x environment knobs that the v3.0.0 rebase onto the
|
|
# official build no longer honors (the patches that read them were
|
|
# deleted in Phase 2). Silent when none are set.
|
|
_check_legacy_env() {
|
|
local var
|
|
for var in \
|
|
CLAUDE_TITLEBAR_STYLE \
|
|
CLAUDE_MENU_BAR \
|
|
CLAUDE_KEEP_AWAKE
|
|
do
|
|
if [[ -n ${!var:-} ]]; then
|
|
_warn "$var is set but no longer honored since the v3.0.0" \
|
|
'rebase onto the official build'
|
|
fi
|
|
done
|
|
|
|
# LD-2: close-to-tray left with frame-fix-wrapper.js, but the
|
|
# official build supersedes it natively — deliberately a no-op, not
|
|
# a regression, so it gets its own pointer instead of the generic
|
|
# warning above.
|
|
if [[ -n ${CLAUDE_QUIT_ON_CLOSE:-} ]]; then
|
|
_warn 'CLAUDE_QUIT_ON_CLOSE is set but no longer honored since' \
|
|
'the v3.0.0 rebase onto the official build'
|
|
_info 'Close behavior is now a native setting: Settings >' \
|
|
'General > System Tray (on = close to tray, off = quit)'
|
|
fi
|
|
}
|
|
|
|
# Cowork isolation on the official client is KVM-only. Report whether
|
|
# /dev/kvm is present and read-write. The device path is overridable via
|
|
# _DOCTOR_KVM_DEV for tests (same internal-hook convention as
|
|
# _COWORK_VFSD_PATHS). Cowork absence is never a failure — the app works
|
|
# fine without it.
|
|
_check_kvm() {
|
|
local dev="${_DOCTOR_KVM_DEV:-/dev/kvm}"
|
|
if [[ ! -e $dev ]]; then
|
|
_warn 'KVM: /dev/kvm not present — Cowork requires KVM'
|
|
_info 'Enable hardware virtualization (VT-x/AMD-V) in your' \
|
|
'BIOS/UEFI, then: sudo modprobe kvm'
|
|
_cowork_incomplete=true
|
|
return 0
|
|
fi
|
|
if [[ -r $dev && -w $dev ]]; then
|
|
_pass 'KVM: /dev/kvm present and accessible'
|
|
else
|
|
_warn 'KVM: /dev/kvm present but not read-write'
|
|
_info "Fix: sudo usermod -aG kvm $USER"
|
|
_info '(Log out and back in for the group change to take effect)'
|
|
_cowork_incomplete=true
|
|
fi
|
|
}
|
|
|
|
# Cowork's guest<->host control channel rides vhost-vsock. The device
|
|
# path is overridable via _DOCTOR_VSOCK_DEV for tests.
|
|
_check_vhost_vsock() {
|
|
local dev="${_DOCTOR_VSOCK_DEV:-/dev/vhost-vsock}"
|
|
if [[ -e $dev ]]; then
|
|
_pass 'vsock: /dev/vhost-vsock present'
|
|
else
|
|
_warn 'vsock: /dev/vhost-vsock not found'
|
|
_info 'Fix: sudo modprobe vhost_vsock'
|
|
_info 'Persist across reboots: echo vhost_vsock |' \
|
|
'sudo tee /etc/modules-load.d/vhost_vsock.conf'
|
|
_cowork_incomplete=true
|
|
fi
|
|
}
|
|
|
|
# Check virtiofsd the way the client actually resolves it (#771/#772):
|
|
# two hardcoded system paths (read-access, like the client's R_OK
|
|
# probe), then the bundled resources/virtiofsd — which our
|
|
# virtiofsd-probe asar patch un-gates (upstream limits it to Ubuntu
|
|
# 22.x). A binary anywhere else (Arch's /usr/lib/virtiofsd, Debian's
|
|
# /usr/lib/qemu/virtiofsd, PATH) is invisible to the client, so it
|
|
# must NOT pass — that false PASS is exactly the doctor-vs-app
|
|
# disagreement reported in #771.
|
|
#
|
|
# Client-probed paths are overridable via _COWORK_VFSD_CLIENT_PATHS
|
|
# (colon-list) for tests, mirroring _COWORK_VFSD_PATHS.
|
|
#
|
|
# Usage: _check_cowork_virtiofsd <distro_id> <resources_dir>
|
|
_check_cowork_virtiofsd() {
|
|
local distro="$1"
|
|
local resources_dir="$2"
|
|
|
|
local client_paths="${_COWORK_VFSD_CLIENT_PATHS:-}"
|
|
if [[ -z $client_paths ]]; then
|
|
client_paths='/usr/libexec/virtiofsd:/usr/bin/virtiofsd'
|
|
fi
|
|
|
|
local -a client_list
|
|
IFS=: read -r -a client_list <<< "$client_paths"
|
|
local probed vfsd=''
|
|
for probed in "${client_list[@]}"; do
|
|
if [[ -r $probed ]]; then
|
|
vfsd="$probed"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ -n $vfsd ]]; then
|
|
_pass "virtiofsd: $vfsd (client-probed path)"
|
|
return
|
|
fi
|
|
|
|
local bundled="${resources_dir:+$resources_dir/virtiofsd}"
|
|
if [[ -n $bundled && -x $bundled ]]; then
|
|
_pass "virtiofsd: bundled copy ($bundled)"
|
|
return
|
|
fi
|
|
|
|
# Present somewhere the client won't look?
|
|
local elsewhere
|
|
if elsewhere=$(_find_virtiofsd); then
|
|
_warn "virtiofsd: found at $elsewhere, but the client only" \
|
|
'probes /usr/libexec/virtiofsd and /usr/bin/virtiofsd'
|
|
_info "Fix: sudo ln -s $elsewhere /usr/bin/virtiofsd"
|
|
else
|
|
_warn 'virtiofsd: not found'
|
|
_info "Fix: $(_cowork_pkg_hint "$distro" virtiofsd)"
|
|
fi
|
|
_cowork_incomplete=true
|
|
}
|
|
|
|
# Check the QEMU/KVM userspace stack Cowork drives: the arch-matched
|
|
# qemu-system binary on PATH, firmware at the paths the official client
|
|
# hardcodes, and virtiofsd at the paths the client probes (see
|
|
# _check_cowork_virtiofsd).
|
|
#
|
|
# Firmware: the official probe list is hardcoded with no env override
|
|
# (audit fact — docs/learnings/official-deb-rebase-verification.md).
|
|
# Firmware present at a Fedora/Arch edk2 location does NOT count, so we
|
|
# check only the official paths and explain the mismatch on a miss. The
|
|
# probe list is overridable via _DOCTOR_OVMF_PATHS (colon-list) for
|
|
# tests.
|
|
#
|
|
# Usage: _check_cowork_stack <distro_id> <resources_dir>
|
|
_check_cowork_stack() {
|
|
local distro="$1"
|
|
local resources_dir="${2:-}"
|
|
local qemu_bin fw_default
|
|
case "$(uname -m)" in
|
|
aarch64)
|
|
qemu_bin='qemu-system-aarch64'
|
|
fw_default='/usr/share/AAVMF/AAVMF_CODE.fd'
|
|
;;
|
|
*)
|
|
qemu_bin='qemu-system-x86_64'
|
|
fw_default='/usr/share/OVMF/OVMF_CODE_4M.fd'
|
|
fw_default+=':/usr/share/OVMF/OVMF_CODE.fd'
|
|
;;
|
|
esac
|
|
|
|
# QEMU binary — the client spawns it by PATH name.
|
|
if command -v "$qemu_bin" &>/dev/null; then
|
|
_pass "QEMU: $qemu_bin found"
|
|
else
|
|
_warn "QEMU: $qemu_bin not found on PATH"
|
|
_info "Fix: $(_cowork_pkg_hint "$distro" qemu)"
|
|
_cowork_incomplete=true
|
|
fi
|
|
|
|
# Firmware at the officially probed paths ONLY.
|
|
local fw_paths="${_DOCTOR_OVMF_PATHS:-$fw_default}"
|
|
local -a fw_list
|
|
IFS=: read -r -a fw_list <<< "$fw_paths"
|
|
local fw fw_found=''
|
|
for fw in "${fw_list[@]}"; do
|
|
if [[ -f $fw ]]; then
|
|
fw_found="$fw"
|
|
break
|
|
fi
|
|
done
|
|
if [[ -n $fw_found ]]; then
|
|
_pass "Firmware: $fw_found"
|
|
else
|
|
_warn 'Firmware: none of the official probe paths exist' \
|
|
"($fw_paths)"
|
|
_info 'The official client hardcodes this probe list with no' \
|
|
'env override, so firmware installed elsewhere'
|
|
_info '(Fedora/Arch edk2 layouts) is not found — add a compat' \
|
|
'symlink at the probed path.'
|
|
_cowork_incomplete=true
|
|
fi
|
|
|
|
# virtiofsd — client-probed paths + bundled copy only.
|
|
_check_cowork_virtiofsd "$distro" "$resources_dir"
|
|
}
|
|
|
|
# Cowork cloud tasks need a hardware-backed device key so the
|
|
# remote-tools bridge can attest this machine; @ant/claude-native has
|
|
# no Linux implementation of that key yet (upstream gap, #780), so
|
|
# ant-device-registry.json can only ever hold a "none:<ts>" row on
|
|
# Linux — never the "pk1:<fp>:<rowpk>" row that marks a registered
|
|
# device. This is diagnostic only: it explains why new cloud tasks
|
|
# show "Not linked to a computer" and is INFO-only, since it is
|
|
# upstream-owned and pre-existing HostLoop/on-device sessions are
|
|
# unaffected. It must never _warn/_fail or flip _cowork_incomplete.
|
|
#
|
|
# The registry path is overridable via _DOCTOR_DEVICE_REGISTRY for
|
|
# tests (same internal-hook convention as _DOCTOR_KVM_DEV). A missing
|
|
# file means Cowork was never used on this profile, so stay silent.
|
|
#
|
|
# Usage: _check_device_registry <config_dir>
|
|
_check_device_registry() {
|
|
local config_dir="$1"
|
|
local registry
|
|
registry="${_DOCTOR_DEVICE_REGISTRY:-$config_dir/ant-device-registry.json}"
|
|
|
|
[[ -f $registry ]] || return 0
|
|
|
|
if grep -q '"pk1:' "$registry" 2>/dev/null; then
|
|
_info 'Device registry: registered (hardware-backed key present)'
|
|
return 0
|
|
fi
|
|
|
|
if grep -q '"none:' "$registry" 2>/dev/null; then
|
|
_info 'Device registry: not registered — Linux has no' \
|
|
'hardware-backed device key yet (upstream gap, #780)'
|
|
_info ' New Cowork cloud tasks show "Not linked to a' \
|
|
'computer"; pre-existing HostLoop/on-device sessions' \
|
|
'are unaffected.'
|
|
fi
|
|
}
|
|
|
|
# True when the given node binary provides fs.statfsSync — the
|
|
# capability the bwrap daemon requires (getSessionsDiskInfo reports real
|
|
# session-disk space). It landed in Node 18.15 / 16.19, so probe the
|
|
# call directly rather than compare a major: Node 18.0-18.14 has major
|
|
# 18 but not the call. Shared by the launcher (setup_cowork_bwrap_env)
|
|
# and this doctor check so both agree with the daemon's own startup
|
|
# guard (nodeHasRequiredFeatures in cowork-vm-service.js).
|
|
cowork_node_has_features() {
|
|
local node_bin="$1"
|
|
[[ -n $node_bin && -x $node_bin ]] || return 1
|
|
"$node_bin" -e \
|
|
'process.exit(typeof require("fs").statfsSync==="function"?0:1)' \
|
|
2>/dev/null
|
|
}
|
|
|
|
# The bwrap fallback daemon needs a system Node runtime and its own
|
|
# shipped script: the official Electron binary has the RunAsNode fuse
|
|
# off, so it can't run the daemon, and the launcher hands a resolved
|
|
# node path to the patched spawn via COWORK_NODE_PATH. Verify both are
|
|
# present and the node is new enough. Consistent with the "Cowork
|
|
# absence is never a _fail" doctrine, gaps here _warn (and mark the
|
|
# section incomplete) rather than flipping the doctor exit code — the
|
|
# user opted into an optional feature. Runs only under
|
|
# COWORK_VM_BACKEND=bwrap.
|
|
#
|
|
# Usage: _doctor_check_bwrap_node <resources_dir>
|
|
_doctor_check_bwrap_node() {
|
|
local resources_dir="$1"
|
|
|
|
local node_bin="${COWORK_NODE_PATH:-}"
|
|
if [[ -z $node_bin ]]; then
|
|
node_bin=$(command -v node 2>/dev/null) \
|
|
|| node_bin=$(command -v nodejs 2>/dev/null) || node_bin=''
|
|
fi
|
|
if [[ -n $node_bin && -x $node_bin ]]; then
|
|
local nv
|
|
nv=$("$node_bin" --version 2>/dev/null) || nv=''
|
|
if cowork_node_has_features "$node_bin"; then
|
|
_pass "bwrap daemon runtime: node $nv ($node_bin)"
|
|
else
|
|
_warn "bwrap daemon runtime: node ${nv:-?} lacks" \
|
|
"fs.statfsSync (needs Node >= 18.15) ($node_bin)"
|
|
_info 'Fix: install a newer Node.js (>= 18.15), or point' \
|
|
'COWORK_NODE_PATH at one'
|
|
_cowork_incomplete=true
|
|
fi
|
|
else
|
|
_warn 'bwrap daemon runtime: no system node/nodejs on PATH'
|
|
_info 'Fix: install Node.js' \
|
|
'(>= 18.15, e.g. sudo apt install nodejs), or set' \
|
|
'COWORK_NODE_PATH to a node binary'
|
|
_cowork_incomplete=true
|
|
fi
|
|
|
|
# The daemon ships beside app.asar (process.resourcesPath).
|
|
if [[ -n $resources_dir ]]; then
|
|
if [[ -f "$resources_dir/cowork-vm-service.js" ]]; then
|
|
_pass 'bwrap daemon: cowork-vm-service.js present'
|
|
else
|
|
_warn 'bwrap daemon: cowork-vm-service.js missing from' \
|
|
"$resources_dir"
|
|
_info 'Fix: reinstall the claude-desktop-unofficial package' \
|
|
'(the bwrap patch may not have been active at build time)'
|
|
_cowork_incomplete=true
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Bwrap sandbox diagnostics for the opt-in COWORK_VM_BACKEND=bwrap path
|
|
# (patch_cowork_bwrap). Runs solely when the user sets that flag.
|
|
#
|
|
# Usage: _doctor_check_bwrap_fallback <distro_id>
|
|
_doctor_check_bwrap_fallback() {
|
|
local distro="$1"
|
|
|
|
if command -v bwrap &>/dev/null; then
|
|
_pass 'bubblewrap: found'
|
|
|
|
# User namespaces must be available for bwrap to create its
|
|
# sandbox; Ubuntu 24.04+ blocks them via AppArmor (issue #351).
|
|
local _err='' _rc=0
|
|
_err=$(bwrap --ro-bind / / true 2>&1 >/dev/null) || _rc=$?
|
|
if ((_rc == 0)); then
|
|
_pass 'bubblewrap: sandbox probe succeeded'
|
|
else
|
|
_warn "bubblewrap: sandbox probe failed (rc=$_rc)"
|
|
[[ -n $_err ]] && _info " stderr: $_err"
|
|
local _re='(user[[:space:]_-]?namespace|apparmor|[Oo]peration not permitted|CLONE_NEW|CAP_SYS_ADMIN)'
|
|
if [[ $_err =~ $_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" for the AppArmor profile fix.'
|
|
fi
|
|
fi
|
|
else
|
|
_warn 'bubblewrap: not found'
|
|
_info "Fix: $(_cowork_pkg_hint "$distro" bubblewrap)"
|
|
fi
|
|
|
|
_doctor_check_bwrap_mounts
|
|
}
|
|
|
|
# User-namespace sandbox check (Ubuntu 24.04+ AppArmor). Ubuntu 24.04+
|
|
# sets apparmor_restrict_unprivileged_userns=1, which blocks the user
|
|
# namespaces Chromium's sandbox needs and crashes the app on launch
|
|
# (credentials.cc FATAL, exit 133). A scoped AppArmor profile permits
|
|
# them for Claude only. Only reports when the restriction is actually
|
|
# in force — on other distros the knob is absent and this stays silent.
|
|
#
|
|
# Path hooks (test-only; defaults are the real system locations, so
|
|
# production behavior is unchanged):
|
|
# _DOCTOR_USERNS_PATH the apparmor_restrict_unprivileged_userns knob
|
|
# _DOCTOR_DEB_ELECTRON the deb-installed Electron the profile pins
|
|
# _DOCTOR_AA_PROFILE the on-disk profile path
|
|
# _DOCTOR_AA_LOADED the kernel's loaded-profile set
|
|
#
|
|
# Usage: _doctor_check_userns_apparmor
|
|
_doctor_check_userns_apparmor() {
|
|
local _userns_path="${_DOCTOR_USERNS_PATH:-}"
|
|
[[ -n $_userns_path ]] \
|
|
|| _userns_path='/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
|
|
local _userns_val=''
|
|
[[ -r $_userns_path ]] && _userns_val=$(<"$_userns_path")
|
|
# Gate on the deb's installed Electron, not $electron_path (the
|
|
# invoking build's binary): the profile pins this exact path, so only
|
|
# a deb install is confined by it. AppImage always runs --no-sandbox
|
|
# and Nix binaries live in the store — neither can hit the crash.
|
|
local _deb_electron="${_DOCTOR_DEB_ELECTRON:-}"
|
|
[[ -n $_deb_electron ]] \
|
|
|| _deb_electron='/usr/lib/claude-desktop-unofficial/claude-desktop'
|
|
if [[ $_userns_val == 1 && -e $_deb_electron ]]; then
|
|
# Profile name must match deb.sh's /etc/apparmor.d/$package_name
|
|
# (PACKAGE_NAME in build.sh — claude-desktop-unofficial since
|
|
# the Phase 3 rename; plain claude-desktop is the official
|
|
# package's profile, registered by Anthropic's own postinst).
|
|
local _aa_profile="${_DOCTOR_AA_PROFILE:-}"
|
|
[[ -n $_aa_profile ]] \
|
|
|| _aa_profile='/etc/apparmor.d/claude-desktop-unofficial'
|
|
local _aa_loaded="${_DOCTOR_AA_LOADED:-}"
|
|
[[ -n $_aa_loaded ]] \
|
|
|| _aa_loaded='/sys/kernel/security/apparmor/profiles'
|
|
# securityfs marks this file world-readable (0444), but the kernel
|
|
# still denies the actual read without CAP_MAC_ADMIN — so a -r test
|
|
# passes for non-root yet the read returns nothing. Attempt the read
|
|
# and judge by whether we actually got data, not by the mode bits.
|
|
local _loaded_set=''
|
|
_loaded_set=$(cat "$_aa_loaded" 2>/dev/null)
|
|
if [[ -n $_loaded_set ]]; then
|
|
# Authoritative: we actually read the kernel's loaded profile
|
|
# set (needs root), so report the real load state — not
|
|
# mere presence on disk.
|
|
if printf '%s\n' "$_loaded_set" \
|
|
| grep -q '^claude-desktop-unofficial '; then
|
|
_pass 'User namespaces: restricted, AppArmor profile loaded'
|
|
else
|
|
_warn 'User namespaces: restricted by AppArmor,' \
|
|
'Claude profile not loaded'
|
|
if [[ -e $_aa_profile ]]; then
|
|
_info ' Profile is on disk but not loaded. Load it:'
|
|
_info " sudo apparmor_parser -r $_aa_profile"
|
|
else
|
|
_info ' No profile found. See docs/troubleshooting.md'
|
|
_info ' "Claude Desktop crashes immediately on launch".'
|
|
fi
|
|
fi
|
|
elif [[ -e $_aa_profile ]]; then
|
|
# The loaded set was unreadable: non-root (the kernel needs
|
|
# CAP_MAC_ADMIN despite the 0444 mode), or securityfs is
|
|
# unmounted (common in containers). Report presence on disk
|
|
# only — never a definitive PASS.
|
|
if (( EUID == 0 )); then
|
|
_info 'User namespaces: AppArmor profile present on disk' \
|
|
'(securityfs unavailable; cannot confirm it is loaded)'
|
|
else
|
|
_info 'User namespaces: AppArmor profile present on disk' \
|
|
'(re-run with sudo to confirm it is loaded)'
|
|
fi
|
|
else
|
|
_warn 'User namespaces: restricted by AppArmor,' \
|
|
'no Claude profile found'
|
|
_info ' Unprivileged user namespaces are blocked, which'
|
|
_info ' crashes the app on launch in X11 sessions'
|
|
_info ' (credentials.cc FATAL). Wayland sessions run with'
|
|
_info ' --no-sandbox and are unaffected.'
|
|
_info ' See docs/troubleshooting.md "Claude Desktop crashes'
|
|
_info ' immediately on launch" for the profile to install.'
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Report the Electron binary and its version. The version is read from
|
|
# the file next to the binary rather than launching Electron, which can
|
|
# hang (see #371). Falls back to a system `electron` on PATH; fails when
|
|
# a provided path is missing or no binary is found at all.
|
|
#
|
|
# Usage: _doctor_check_electron_binary [electron_path]
|
|
_doctor_check_electron_binary() {
|
|
local electron_path="${1:-}"
|
|
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 the claude-desktop-unofficial 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 the claude-desktop-unofficial package'
|
|
fi
|
|
}
|
|
|
|
# Check the chrome-sandbox helper's setuid permissions (must be 4755,
|
|
# owned by root). Official layout: chrome-sandbox sits at the package
|
|
# root beside the ELF (no node_modules/electron/dist tree anymore).
|
|
# Looks at the deb install path and, when an electron path is provided,
|
|
# the chrome-sandbox beside it; the first existing file wins. Warns
|
|
# when none is found (expected for AppImage).
|
|
#
|
|
# _DOCTOR_DEB_SANDBOX overrides the hardcoded deb path (test hook; the
|
|
# default is the real install location, so production is unaffected).
|
|
#
|
|
# Usage: _doctor_check_chrome_sandbox [electron_path]
|
|
_doctor_check_chrome_sandbox() {
|
|
local electron_path="${1:-}"
|
|
local _deb_sandbox="${_DOCTOR_DEB_SANDBOX:-}"
|
|
[[ -n $_deb_sandbox ]] \
|
|
|| _deb_sandbox='/usr/lib/claude-desktop-unofficial/chrome-sandbox'
|
|
local sandbox_paths=("$_deb_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 sandbox_path
|
|
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
|
|
}
|
|
|
|
# Report the active display server (Wayland/X11) and, on Wayland, the
|
|
# desktop and whether Electron runs natively (CLAUDE_USE_WAYLAND=1) or
|
|
# via XWayland (default, preserves global hotkeys). Fails when neither
|
|
# DISPLAY nor WAYLAND_DISPLAY is set (TTY / broken session).
|
|
#
|
|
# Usage: _doctor_check_display_server
|
|
_doctor_check_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
|
|
}
|
|
|
|
# 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
|
|
# Recorded by _doctor_check_pkg_version, consumed by
|
|
# _check_official_drift (dynamic scope makes the helper's assignment
|
|
# land on this local).
|
|
local _installed_pkg_version=''
|
|
# Flipped true by any Cowork stack check that isn't green, so the
|
|
# section summary can report readiness without recomputing.
|
|
local _cowork_incomplete=false
|
|
|
|
# Doctor must see the same environment a launch would: the per-user
|
|
# config file can carry the launcher vars this run inspects
|
|
# (COWORK_VM_BACKEND=bwrap is the exact #772 persona). Guarded — a
|
|
# standalone `source doctor.sh` (doctor.bats) has no
|
|
# launcher-common.sh in scope. log_message no-ops here because the
|
|
# doctor path never runs setup_logging.
|
|
declare -F load_launcher_config > /dev/null && load_launcher_config
|
|
_doctor_colors
|
|
|
|
# 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 --
|
|
_doctor_check_pkg_version "$electron_path"
|
|
|
|
# -- Version drift vs. the official pool (best-effort, network) --
|
|
_check_official_drift
|
|
|
|
# -- Package-name collision with Anthropic's APT repo --
|
|
_check_name_collision
|
|
|
|
# -- Display server --
|
|
_doctor_check_display_server
|
|
|
|
# -- Input method (IBus / GTK) --
|
|
_doctor_check_im_modules "$_distro_id"
|
|
|
|
# -- Legacy 2.x env knobs (no longer honored post-rebase) --
|
|
_check_legacy_env
|
|
|
|
# -- Electron binary --
|
|
_doctor_check_electron_binary "$electron_path"
|
|
|
|
# -- Chrome sandbox permissions --
|
|
_doctor_check_chrome_sandbox "$electron_path"
|
|
|
|
# -- User-namespace sandbox (Ubuntu 24.04+ AppArmor) --
|
|
_doctor_check_userns_apparmor
|
|
|
|
# -- SingletonLock --
|
|
local config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/Claude"
|
|
_doctor_check_singleton_lock "$config_dir"
|
|
|
|
# -- Password store --
|
|
_doctor_check_password_store
|
|
_doctor_check_keyring_persistence
|
|
|
|
# -- 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
|
|
desktop_file='/usr/share/applications/claude-desktop-unofficial.desktop'
|
|
if [[ -f $desktop_file ]]; then
|
|
_pass "Desktop entry: $desktop_file"
|
|
else
|
|
_warn 'Desktop entry not found (expected for AppImage installs)'
|
|
fi
|
|
|
|
# -- Disk space --
|
|
_doctor_check_disk_space "$config_dir"
|
|
|
|
# -- Cowork Mode --
|
|
echo
|
|
echo -e "${_bold}Cowork Mode${_reset}"
|
|
echo '----------------'
|
|
|
|
# The official Linux client runs Cowork as coworkd + QEMU/KVM; there
|
|
# is no bwrap backend. Report the KVM stack honestly. Cowork absence
|
|
# is never a _fail — the app works fine without it.
|
|
_check_kvm
|
|
_check_vhost_vsock
|
|
# Resources dir sits next to the Electron ELF in the official
|
|
# co-located layout (deb/rpm/AppImage/Nix all preserve it); the
|
|
# bundled virtiofsd lives there.
|
|
local _resources_dir=''
|
|
[[ -n $electron_path ]] &&
|
|
_resources_dir="$(dirname "$electron_path")/resources"
|
|
_check_cowork_stack "$_distro_id" "$_resources_dir"
|
|
|
|
# One-line readiness summary (the checks above flip
|
|
# _cowork_incomplete on any non-green result).
|
|
if [[ $_cowork_incomplete == true ]]; then
|
|
_info 'Cowork: unavailable until the KVM stack is complete' \
|
|
'(see above)'
|
|
else
|
|
_info 'Cowork isolation: KVM (official)'
|
|
fi
|
|
|
|
# Device registration (upstream gap, #780) — diagnostic only, never
|
|
# feeds _cowork_incomplete.
|
|
_check_device_registry "$config_dir"
|
|
|
|
# Bwrap fallback (opt-in, patch_cowork_bwrap). Set
|
|
# COWORK_VM_BACKEND=bwrap to route Cowork through the bundled Node
|
|
# daemon + bubblewrap instead of the KVM microVM — for hosts that
|
|
# can't do KVM/vhost-vsock (ChromeOS Crostini, #772). These
|
|
# diagnostics run only when that flag is set. Any other non-empty
|
|
# value is a 2.x knob the official client ignores.
|
|
local _cvb="${COWORK_VM_BACKEND:-}"
|
|
if [[ -n $_cvb ]]; then
|
|
if [[ ${_cvb,,} == 'bwrap' ]]; then
|
|
echo
|
|
_info 'COWORK_VM_BACKEND=bwrap: Cowork routes through the' \
|
|
'bubblewrap fallback daemon (opt-in).'
|
|
_doctor_check_bwrap_node "$_resources_dir"
|
|
_doctor_check_bwrap_fallback "$_distro_id"
|
|
else
|
|
_info "COWORK_VM_BACKEND=$_cvb: not read by the official" \
|
|
'client (2.x knob)'
|
|
fi
|
|
fi
|
|
|
|
# Short NAME_MAX on the host's ~/.claude tree (eCryptfs etc.)
|
|
# blocks cowork session init with ENAMETOOLONG — see #590.
|
|
_doctor_check_filename_limit
|
|
|
|
# -- Orphaned cowork-vm-service daemon --
|
|
# cowork-vm-service.js is the bwrap fallback daemon (opt-in
|
|
# COWORK_VM_BACKEND=bwrap, patch_cowork_bwrap); it was also OUR 2.x
|
|
# VM daemon. Either way, a daemon whose parent UI is gone is
|
|
# orphaned — holding a stale socket — so we reap it. When the UI is
|
|
# alive the daemon is healthy (expected on the flagged path). Live-UI
|
|
# detection matches cleanup_orphaned_cowork_daemon:
|
|
# _claude_desktop_ui_is_alive in launcher-common.sh fingerprints the
|
|
# --class=$WM_CLASS flag (since #700 the launchers no longer pass
|
|
# app.asar in argv), excluding Chromium helpers (--type=...), cowork
|
|
# helpers, our own launcher bash, and stopped/zombie processes.
|
|
local _cowork_pids
|
|
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) || true
|
|
if [[ -n $_cowork_pids ]]; then
|
|
if ! _claude_desktop_ui_is_alive; then
|
|
_warn "Cowork bwrap daemon: orphaned" \
|
|
"(PIDs: $_cowork_pids)"
|
|
_info 'Fix: Restart Claude Desktop' \
|
|
'(daemon will be cleaned up automatically)'
|
|
else
|
|
_pass 'Cowork bwrap 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"
|
|
}
|