feat: make bubblewrap the default cowork isolation backend

Swap auto-detection order from KVM → bwrap → host to
bwrap → KVM → host. KVM remains available via
COWORK_VM_BACKEND=kvm.

- detectBackend(): check bwrap before KVM
- --doctor: bwrap checked first; KVM deps shown as info
  (not warnings) unless COWORK_VM_BACKEND=kvm is set
- Fix header comment inaccuracy about rootfs.qcow2 check
- Update README and handover docs to reflect new default

Fixes #326

Co-Authored-By: Claude <claude@anthropic.com>
This commit is contained in:
aaddrick
2026-03-21 18:33:00 -04:00
parent dee729b873
commit 3ada749410
4 changed files with 70 additions and 45 deletions

View File

@@ -11,11 +11,11 @@ This project provides build scripts to run Claude Desktop natively on Linux syst
>
> | Backend | Isolation | Requirements |
> |---------|-----------|-------------|
> | **KVM** (preferred) | Full VM via QEMU/KVM | `/dev/kvm`, `qemu-system-x86_64`, `/dev/vhost-vsock`, `socat`, `virtiofsd` |
> | **bubblewrap** (fallback) | Namespace sandbox | `bwrap` installed and functional |
> | **bubblewrap** (default) | Namespace sandbox | `bwrap` installed and functional |
> | **KVM** (opt-in) | Full VM via QEMU/KVM | `/dev/kvm`, `qemu-system-x86_64`, `/dev/vhost-vsock`, `socat`, `virtiofsd` |
> | **host** (last resort) | None — runs directly on host | No additional requirements |
>
> The best available backend is auto-detected at startup. Run `claude-desktop --doctor` to check which backend will be used and which dependencies are missing.
> The best available backend is auto-detected at startup. Run `claude-desktop --doctor` to check which backend will be used and which dependencies are missing. For full VM-level isolation matching the upstream Windows (Hyper-V) behavior, set `COWORK_VM_BACKEND=kvm`.
>
> **Note:** The bubblewrap backend mounts your home directory as read-only (only the project working directory is writable). The host backend provides no isolation — use it only if you understand the security implications.

View File

@@ -16,8 +16,8 @@ cowork-vm-service (Node.js daemon)
→ delegates to this.backend (auto-detected or COWORK_VM_BACKEND override)
Backend selection (priority order):
1. KvmBackend — QEMU/KVM + vsock + virtiofs (full VM isolation)
2. BwrapBackend — bubblewrap namespace sandbox (lightweight isolation)
1. BwrapBackend — bubblewrap namespace sandbox (default)
2. KvmBackend QEMU/KVM + vsock + virtiofs (opt-in, full VM isolation)
3. HostBackend — direct on host, no isolation (fallback)
KvmBackend path:
@@ -173,15 +173,15 @@ The `detectBackend()` function selects the active backend at daemon startup. The
### Auto-detection order:
1. **KVM** — Requires ALL of:
1. **Bwrap** (default) — Requires:
- `bwrap` in PATH
- `bwrap --ro-bind / / true` succeeds (functional test)
2. **KVM** (opt-in via `COWORK_VM_BACKEND=kvm`) — Requires ALL of:
- `/dev/kvm` readable and writable
- `qemu-system-x86_64` in PATH
- `/dev/vhost-vsock` readable
- `~/.local/share/claude-desktop/vm/rootfs.qcow2` exists
2. **Bwrap** — Requires:
- `bwrap` in PATH
- `bwrap --ro-bind / / true` succeeds (functional test)
- Rootfs image checked at `startVM()` time, not during detection
3. **Host** — Always available (fallback)

View File

@@ -12,9 +12,9 @@
* - KvmBackend: QEMU/KVM virtual machine with vsock communication
*
* Backend selection (auto-detected or overridden via COWORK_VM_BACKEND env):
* 1. kvm — if /dev/kvm, qemu-system-x86_64, /dev/vhost-vsock, and
* rootfs.qcow2 are all available
* 2. bwrap — if bwrap is installed and functional
* 1. bwrap — if bwrap is installed and functional (default)
* 2. kvm — if /dev/kvm, qemu-system-x86_64, and /dev/vhost-vsock
* are available (rootfs checked at startVM time)
* 3. host — fallback, no isolation
*
* Protocol:
@@ -1806,7 +1806,18 @@ function detectBackend(emitEvent) {
}
}
// Auto-detect: try KVM first, then bwrap, then host.
// Auto-detect: try bwrap first, then KVM, then host.
try {
execFileSync('which', ['bwrap'], { stdio: 'pipe' });
execFileSync('bwrap', ['--ro-bind', '/', '/', 'true'], {
stdio: 'pipe', timeout: 5000
});
log('Backend: bwrap');
return new BwrapBackend(emitEvent);
} catch (e) {
log(`bwrap not available: ${e.message}`);
}
// Note: rootfs is NOT checked here — the app downloads it to
// bundlePath which isn't known until startVM(). The rootfs
// check happens at startVM time instead.
@@ -1820,17 +1831,6 @@ function detectBackend(emitEvent) {
log(`KVM not available: ${e.message}`);
}
try {
execFileSync('which', ['bwrap'], { stdio: 'pipe' });
execFileSync('bwrap', ['--ro-bind', '/', '/', 'true'], {
stdio: 'pipe', timeout: 5000
});
log('Backend: bwrap');
return new BwrapBackend(emitEvent);
} catch (e) {
log(`bwrap not available: ${e.message}`);
}
log('Backend: host (no isolation)');
return new HostBackend(emitEvent);
}

View File

@@ -468,35 +468,59 @@ print(len(servers))
local _distro_id
_distro_id=$(_cowork_distro_id)
# KVM access
# Bubblewrap (default backend)
if command -v bwrap &>/dev/null; then
_pass 'bubblewrap: found'
else
_warn 'bubblewrap: not found'
_info \
"Fix: $(_cowork_pkg_hint "$_distro_id" bubblewrap)"
fi
# Use _warn for missing KVM deps only when KVM is explicitly
# requested; otherwise use _info since bwrap is the default.
local _kvm_flag
_kvm_flag="${COWORK_VM_BACKEND:-}"
local _kvm_issue
if [[ ${_kvm_flag,,} == kvm ]]; then
_kvm_issue=_warn
else
_kvm_issue=_info
fi
# 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
_warn 'KVM: /dev/kvm exists but not accessible'
"$_kvm_issue" 'KVM: /dev/kvm exists but not accessible'
_info "Fix: sudo usermod -aG kvm $USER"
_info '(Log out and back in after running this)'
fi
else
_warn 'KVM: not available'
_info 'Fix: Install qemu-kvm and ensure KVM is enabled in BIOS'
"$_kvm_issue" 'KVM: not available'
if [[ ${_kvm_flag,,} == kvm ]]; 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
_warn 'vsock: /dev/vhost-vsock not found'
_info 'Fix: sudo modprobe vhost_vsock'
"$_kvm_issue" 'vsock: /dev/vhost-vsock not found'
if [[ ${_kvm_flag,,} == kvm ]]; then
_info 'Fix: sudo modprobe vhost_vsock'
fi
fi
# Check required tools: label, binary, pkg-hint name
# KVM tools: QEMU, socat, virtiofsd
local _tool_label _tool_bin _tool_pkg
for _tool_label in \
'QEMU:qemu-system-x86_64:qemu' \
'socat:socat:socat' \
'virtiofsd:virtiofsd:virtiofsd' \
'bubblewrap:bwrap:bubblewrap'
'virtiofsd:virtiofsd:virtiofsd'
do
_tool_bin="${_tool_label#*:}"
_tool_pkg="${_tool_bin#*:}"
@@ -506,9 +530,11 @@ print(len(servers))
if command -v "$_tool_bin" &>/dev/null; then
_pass "$_tool_label: found"
else
_warn "$_tool_label: not found"
_info \
"Fix: $(_cowork_pkg_hint "$_distro_id" "$_tool_pkg")"
"$_kvm_issue" "$_tool_label: not found"
if [[ ${_kvm_flag,,} == kvm ]]; then
_info \
"Fix: $(_cowork_pkg_hint "$_distro_id" "$_tool_pkg")"
fi
fi
done
@@ -526,15 +552,14 @@ print(len(servers))
# Determine active backend (matches daemon's detectBackend())
local cowork_backend='none (host-direct, no isolation)'
if [[ -e /dev/kvm ]] \
&& [[ -r /dev/kvm && -w /dev/kvm ]] \
&& command -v qemu-system-x86_64 &>/dev/null \
&& [[ -e /dev/vhost-vsock ]] \
&& [[ -f $vm_image ]]; then
cowork_backend='KVM (full VM isolation)'
elif command -v bwrap &>/dev/null \
if command -v bwrap &>/dev/null \
&& bwrap --ro-bind / / true &>/dev/null; then
cowork_backend='bubblewrap (namespace sandbox)'
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"