fix: diagnose AppArmor userns block on bwrap probe (#351) (#434)

* fix: diagnose AppArmor userns block on bwrap probe (#351)

Ubuntu 24.04+ ships apparmor_restrict_unprivileged_userns=1 by
default, which blocks the user namespace bwrap needs to start. The
daemon's probe then fails, auto-detect silently falls through to
KVM, and KVM hangs waiting for a rootfs the user hasn't set up —
leaving Cowork stuck in a retry loop with no clear error.

- Classify the probe failure (classifyBwrapProbeError) so the daemon
  can distinguish AppArmor/userns blocks from generic failures and
  log a pointer to the TROUBLESHOOTING.md remediation.
- Stop falling through to KVM when bwrap is installed but blocked;
  drop to host-direct instead so users see a working (if unsandboxed)
  Cowork and the reason bwrap didn't engage. Users who actually want
  KVM can still set COWORK_VM_BACKEND=kvm.
- Mirror the probe + diagnosis in `--doctor` so misconfigured systems
  get the same actionable output without waiting for a daemon log.
- Document the AppArmor profile workaround in TROUBLESHOOTING.md.
- Credit @hfyeh for the diagnosis and profile snippet.

Co-Authored-By: Claude <claude@anthropic.com>

* refactor: simplify PR #434 per cdd-code-simplifier

Drop redundant `-n` guard around the COWORK_VM_BACKEND case in
`--doctor`: the `${VAR,,}` expansion is already safe on an unset var
(no `set -u` in this script) and the `kvm|host` arms simply don't
match an empty string.

Co-Authored-By: Claude <claude@anthropic.com>

---------

Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
Travis
2026-04-19 01:12:13 -05:00
committed by GitHub
parent 9e577cc3d5
commit 3c843244b3
5 changed files with 300 additions and 16 deletions

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bats
#
# cowork-backend-detection.bats
# Tests for classifyBwrapProbeError — diagnoses why the bwrap sandbox
# probe failed so the daemon can emit actionable errors instead of
# silently falling through to a broken KVM backend (issue #351).
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
NODE_PREAMBLE='
const {
classifyBwrapProbeError,
} = require("'"${SCRIPT_DIR}"'/../scripts/cowork-vm-service.js");
function assert(condition, msg) {
if (!condition) {
process.stderr.write("ASSERTION FAILED: " + msg + "\n");
process.exit(1);
}
}
function assertEqual(actual, expected, msg) {
assert(actual === expected,
msg + " expected=" + JSON.stringify(expected) +
" actual=" + JSON.stringify(actual));
}
function mkErr(stderr, message) {
return {
message: message || "Command failed",
stderr: Buffer.from(stderr || ""),
stdout: Buffer.from(""),
};
}
'
# =============================================================================
# classifyBwrapProbeError — AppArmor / userns denials (the #351 case)
# =============================================================================
@test "classifyBwrapProbeError: bwrap EPERM on user namespace" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: Creating new user namespace: Operation not permitted');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'EPERM on userns should classify as userns');
assert(r.stderr.includes('user namespace'), 'stderr is preserved');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: AppArmor denial message" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: setting up uid map: Permission denied');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'uid map denial should classify as userns');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: explicit apparmor keyword" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('denied by AppArmor policy');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'apparmor keyword should classify as userns');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: CLONE_NEWUSER keyword in kernel log" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: unshare: CLONE_NEWUSER failed: EPERM');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'CLONE_NEW* should classify as userns');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: CAP_SYS_ADMIN hint" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('need CAP_SYS_ADMIN to create user namespace');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'userns', 'CAP_SYS_ADMIN hint should classify as userns');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# classifyBwrapProbeError — non-userns failures
# =============================================================================
@test "classifyBwrapProbeError: unrelated bwrap failure" {
run node -e "${NODE_PREAMBLE}
const e = mkErr('bwrap: No such file or directory: /does-not-exist');
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'unknown', 'unrelated errors should classify as unknown');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: spawn ENOENT has no stderr" {
run node -e "${NODE_PREAMBLE}
const e = { message: 'spawn bwrap ENOENT', code: 'ENOENT' };
const r = classifyBwrapProbeError(e);
assertEqual(r.kind, 'unknown', 'ENOENT without userns text is unknown');
assertEqual(r.stderr, '', 'missing stderr normalized to empty string');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: empty error object" {
run node -e "${NODE_PREAMBLE}
const r = classifyBwrapProbeError({});
assertEqual(r.kind, 'unknown', 'empty error is unknown, not a crash');
assertEqual(r.stderr, '', 'missing stderr normalized to empty string');
"
[[ "$status" -eq 0 ]]
}
@test "classifyBwrapProbeError: null-safe" {
run node -e "${NODE_PREAMBLE}
const r = classifyBwrapProbeError(null);
assertEqual(r.kind, 'unknown', 'null error does not crash');
"
[[ "$status" -eq 0 ]]
}