Merge pull request #340 from cbonnissent/feature/339-configurable-bwrap-mounts

All 8 review items addressed. 39 BATS tests. Verified by community tester (pmolodo).
This commit is contained in:
Aaddrick
2026-04-12 15:15:37 -04:00
committed by GitHub
4 changed files with 1123 additions and 62 deletions

View File

@@ -1,56 +1,107 @@
[< Back to README](../README.md)
# Configuration
## MCP Configuration
Model Context Protocol settings are stored in:
```
~/.config/Claude/claude_desktop_config.json
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
### Wayland Support
By default, Claude Desktop uses X11 mode (via XWayland) on Wayland sessions to ensure global hotkeys work. If you prefer native Wayland and don't need global hotkeys:
```bash
# One-time launch
CLAUDE_USE_WAYLAND=1 claude-desktop
# Or add to your environment permanently
export CLAUDE_USE_WAYLAND=1
```
**Important:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal. If global hotkeys (Ctrl+Alt+Space) are important to your workflow, keep the default X11 mode.
### Menu Bar
By default, the menu bar is hidden but can be toggled with the Alt key (`auto` mode). On KDE Plasma and other DEs where Alt is heavily used, this can cause layout shifts. Use `CLAUDE_MENU_BAR` to control the behavior:
| Value | Menu visible | Alt toggles | Use case |
|-------|-------------|-------------|----------|
| unset / `auto` | No | Yes | Default — hidden, Alt toggles |
| `visible` / `1` / `true` / `yes` / `on` | Yes | No | Stable layout, no shift on Alt |
| `hidden` / `0` / `false` / `no` / `off` | No | No | Menu fully disabled, Alt free |
```bash
# Always show the menu bar (no layout shift on Alt)
CLAUDE_MENU_BAR=visible claude-desktop
# Or add to your environment permanently
export CLAUDE_MENU_BAR=visible
```
## Application Logs
Runtime logs are available at:
```
~/.cache/claude-desktop-debian/launcher.log
```
[< Back to README](../README.md)
# Configuration
## MCP Configuration
Model Context Protocol settings are stored in:
```
~/.config/Claude/claude_desktop_config.json
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_USE_WAYLAND` | unset | Set to `1` to use native Wayland instead of XWayland. Note: Global hotkeys won't work in native Wayland mode. |
| `CLAUDE_MENU_BAR` | unset (`auto`) | Controls menu bar behavior: `auto` (hidden, Alt toggles), `visible` / `1` (always shown), `hidden` / `0` (always hidden, Alt disabled). See [Menu Bar](#menu-bar) below. |
### Wayland Support
By default, Claude Desktop uses X11 mode (via XWayland) on Wayland sessions to ensure global hotkeys work. If you prefer native Wayland and don't need global hotkeys:
```bash
# One-time launch
CLAUDE_USE_WAYLAND=1 claude-desktop
# Or add to your environment permanently
export CLAUDE_USE_WAYLAND=1
```
**Important:** Native Wayland mode doesn't support global hotkeys due to Electron/Chromium limitations with XDG GlobalShortcuts Portal. If global hotkeys (Ctrl+Alt+Space) are important to your workflow, keep the default X11 mode.
### Menu Bar
By default, the menu bar is hidden but can be toggled with the Alt key (`auto` mode). On KDE Plasma and other DEs where Alt is heavily used, this can cause layout shifts. Use `CLAUDE_MENU_BAR` to control the behavior:
| Value | Menu visible | Alt toggles | Use case |
|-------|-------------|-------------|----------|
| unset / `auto` | No | Yes | Default — hidden, Alt toggles |
| `visible` / `1` / `true` / `yes` / `on` | Yes | No | Stable layout, no shift on Alt |
| `hidden` / `0` / `false` / `no` / `off` | No | No | Menu fully disabled, Alt free |
```bash
# Always show the menu bar (no layout shift on Alt)
CLAUDE_MENU_BAR=visible claude-desktop
# Or add to your environment permanently
export CLAUDE_MENU_BAR=visible
```
## Cowork Sandbox Mounts
When using Cowork mode with the BubbleWrap (bwrap) backend, you can customize
the sandbox mount points via `~/.config/Claude/claude_desktop_linux_config.json`
(a dedicated config for the Linux port, separate from the official
`claude_desktop_config.json`):
```json
{
"preferences": {
"coworkBwrapMounts": {
"additionalROBinds": ["/opt/my-tools", "/nix/store"],
"additionalBinds": ["/home/user/shared-data"],
"disabledDefaultBinds": ["/etc"]
}
}
}
```
| Key | Type | Description |
|-----|------|-------------|
| `additionalROBinds` | `string[]` | Extra paths mounted read-only inside the sandbox. Accepts any absolute path except `/`, `/proc`, `/dev`, `/sys`. |
| `additionalBinds` | `string[]` | Extra paths mounted read-write inside the sandbox. **Restricted to paths under `$HOME`** for security. |
| `disabledDefaultBinds` | `string[]` | Default mounts to skip. Cannot disable critical mounts (`/`, `/dev`, `/proc`). Use with caution: disabling `/usr` or `/etc` may break tools inside the sandbox. |
### Security notes
- Paths `/`, `/proc`, `/dev`, `/sys` (and their subpaths) are always rejected
- Read-write mounts (`additionalBinds`) are restricted to paths under your home
directory
- The core sandbox structure (`--tmpfs /`, `--unshare-pid`, `--die-with-parent`,
`--new-session`) cannot be modified
- Mount order is enforced: user mounts cannot override security-critical
read-only mounts
### Applying changes
The daemon reads the configuration at startup. After editing the config file,
restart the daemon:
```bash
pkill -f cowork-vm-service
```
The daemon will be automatically relaunched on the next Cowork session.
### Diagnostics
Run `claude-desktop --doctor` to see your custom mount configuration and any
warnings about potentially dangerous settings.
## Application Logs
Runtime logs are available at:
```
~/.cache/claude-desktop-debian/launcher.log
```

View File

@@ -412,6 +412,183 @@ function resolveCommand(command, sdkBinaryPath) {
}
}
// ============================================================
// Bwrap Mount Configuration
// ============================================================
const FORBIDDEN_MOUNT_PATHS = new Set(['/', '/proc', '/dev', '/sys']);
function validateMountPath(mountPath, opts) {
opts = opts || {};
if (!mountPath || !path.isAbsolute(mountPath)) {
return { valid: false, reason: 'Path must be absolute' };
}
const normalized = path.resolve(mountPath);
// Resolve symlinks when the path exists on disk (defense-in-depth).
// This is a TOCTOU situation, but bwrap is the real security boundary;
// this just catches honest configuration mistakes.
let resolved = normalized;
try {
resolved = fs.realpathSync(normalized);
} catch (_) {
// Path doesn't exist yet — use the unresolved form
}
function checkForbidden(p) {
if (FORBIDDEN_MOUNT_PATHS.has(p)) {
return `Path is forbidden: ${p}`;
}
for (const forbidden of FORBIDDEN_MOUNT_PATHS) {
if (forbidden !== '/' && p.startsWith(forbidden + '/')) {
return `Path is under forbidden path: ${forbidden}`;
}
}
return null;
}
const normalizedErr = checkForbidden(normalized);
if (normalizedErr) {
return { valid: false, reason: normalizedErr };
}
if (resolved !== normalized) {
const resolvedErr = checkForbidden(resolved);
if (resolvedErr) {
return { valid: false, reason: `Symlink resolves to forbidden path: ${resolved}` };
}
}
if (opts.readWrite) {
const home = os.homedir();
const check = resolved !== normalized ? resolved : normalized;
if (check !== home && !check.startsWith(home + '/')) {
return { valid: false, reason: 'Read-write mounts must be under $HOME' };
}
}
return { valid: true };
}
function loadBwrapMountsConfig(configPath, logFn) {
const warn = logFn || log;
const empty = {
additionalROBinds: [],
additionalBinds: [],
disabledDefaultBinds: [],
};
if (!configPath) {
configPath = path.join(
process.env.HOME || os.homedir(),
'.config', 'Claude', 'claude_desktop_linux_config.json'
);
}
let raw;
try {
raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (_) {
return empty;
}
const mounts = raw && raw.preferences && raw.preferences.coworkBwrapMounts;
if (!mounts || typeof mounts !== 'object') {
return empty;
}
function filterPaths(arr, readWrite) {
if (!Array.isArray(arr)) return [];
return arr.filter(p => {
if (typeof p !== 'string') return false;
const result = validateMountPath(p, { readWrite });
if (!result.valid) {
warn(`BwrapConfig: rejected path "${p}": ${result.reason}`);
}
return result.valid;
});
}
return {
additionalROBinds: filterPaths(mounts.additionalROBinds, false),
additionalBinds: filterPaths(mounts.additionalBinds, true),
disabledDefaultBinds: Array.isArray(mounts.disabledDefaultBinds)
? mounts.disabledDefaultBinds
.filter(p => {
if (typeof p !== 'string') return false;
if (!path.isAbsolute(p)) {
warn(`BwrapConfig: rejected disabled path "${p}": Path must be absolute`);
return false;
}
const normalized = path.resolve(p);
if (CRITICAL_MOUNTS.has(normalized)) {
warn(`BwrapConfig: cannot disable critical mount: ${normalized}`);
return false;
}
return true;
})
.map(p => path.resolve(p))
: [],
};
}
const CRITICAL_MOUNTS = new Set(['/', '/dev', '/proc']);
function mergeBwrapArgs(defaultArgs, config) {
const result = [];
const disabled = new Set(
config.disabledDefaultBinds.filter(p => !CRITICAL_MOUNTS.has(p))
);
const TWO_ARG_FLAGS = new Set([
'--tmpfs', '--dev', '--proc', '--dir',
'--remount-ro', '--file', '--unsetenv',
'--chdir', '--size', '--perms',
]);
const THREE_ARG_FLAGS = new Set([
'--ro-bind', '--bind', '--symlink',
'--ro-bind-try', '--bind-try',
'--dev-bind', '--dev-bind-try',
'--chmod', '--setenv',
]);
let i = 0;
while (i < defaultArgs.length) {
const flag = defaultArgs[i];
if (THREE_ARG_FLAGS.has(flag) && i + 2 < defaultArgs.length) {
const dest = defaultArgs[i + 2];
if (disabled.has(dest)) {
i += 3;
continue;
}
result.push(defaultArgs[i], defaultArgs[i + 1], defaultArgs[i + 2]);
i += 3;
} else if (TWO_ARG_FLAGS.has(flag) && i + 1 < defaultArgs.length) {
const dest = defaultArgs[i + 1];
if (disabled.has(dest)) {
i += 2;
continue;
}
result.push(defaultArgs[i], defaultArgs[i + 1]);
i += 2;
} else {
result.push(defaultArgs[i]);
i++;
}
}
for (const p of config.additionalROBinds) {
result.push('--ro-bind', p, p);
}
for (const p of config.additionalBinds) {
result.push('--bind', p, p);
}
return result;
}
// ============================================================
// Backend Base Class
// ============================================================
@@ -772,6 +949,16 @@ class BwrapBackend extends LocalBackend {
constructor(emitEvent) {
super(emitEvent, 'BwrapBackend');
this.mountBinds = new Map(); // mountName -> hostPath
this.bwrapMountsConfig = loadBwrapMountsConfig(null, log);
const mc = this.bwrapMountsConfig;
if (mc.additionalROBinds.length
|| mc.additionalBinds.length
|| mc.disabledDefaultBinds.length) {
log('BwrapBackend: custom mount config: '
+ mc.additionalROBinds.length + ' RO, '
+ mc.additionalBinds.length + ' RW, '
+ mc.disabledDefaultBinds.length + ' disabled');
}
}
async startVM(params) {
@@ -820,7 +1007,7 @@ class BwrapBackend extends LocalBackend {
// necessary system paths bound in read-only. This avoids
// exposing the real home directory and allows creating the
// /sessions/ guest path structure that claude-code-vm expects.
const bwrapArgs = [
const defaultBwrapArgs = [
'--tmpfs', '/',
'--ro-bind', '/usr', '/usr',
'--ro-bind', '/etc', '/etc',
@@ -836,10 +1023,10 @@ class BwrapBackend extends LocalBackend {
for (const dir of ['/bin', '/lib', '/lib64', '/sbin']) {
try {
const target = fs.readlinkSync(dir);
bwrapArgs.push('--symlink', target, dir);
defaultBwrapArgs.push('--symlink', target, dir);
} catch (_) {
if (fs.existsSync(dir)) {
bwrapArgs.push('--ro-bind', dir, dir);
defaultBwrapArgs.push('--ro-bind', dir, dir);
}
}
}
@@ -851,12 +1038,15 @@ class BwrapBackend extends LocalBackend {
const resolvedConf = fs.realpathSync('/etc/resolv.conf');
if (resolvedConf.startsWith('/run/')) {
const resolvedDir = path.dirname(resolvedConf);
bwrapArgs.push('--ro-bind', resolvedDir, resolvedDir);
defaultBwrapArgs.push('--ro-bind', resolvedDir, resolvedDir);
}
} catch (e) {
log('BwrapBackend: could not resolve /etc/resolv.conf:', e.message);
}
// Merge user-configured mounts (disable overrides + additional mounts)
const bwrapArgs = mergeBwrapArgs(defaultBwrapArgs, this.bwrapMountsConfig);
// Bind the SDK binary read-only
const sdkDir = path.dirname(actualCommand);
bwrapArgs.push('--ro-bind', sdkDir, sdkDir);
@@ -2166,5 +2356,15 @@ function startServer() {
// dedup flag (_svcLaunched) preventing duplicate daemon launches, so a
// simple synchronous cleanup avoids the race condition where an async
// connection test delays startup while the app is already retrying.
cleanupSocket();
startServer();
if (require.main === module) {
cleanupSocket();
startServer();
}
module.exports = {
FORBIDDEN_MOUNT_PATHS,
CRITICAL_MOUNTS,
validateMountPath,
loadBwrapMountsConfig,
mergeBwrapArgs,
};

View File

@@ -271,6 +271,146 @@ _fail() {
_warn() { echo -e "${_yellow}[WARN]${_reset} $*"; }
_info() { echo -e " $*"; }
# 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
m = json.loads(sys.argv[1])
for p in m.get('additionalROBinds', []):
print(p)
print('---')
for p in m.get('additionalBinds', []):
print(p)
print('---')
for p in m.get('disabledDefaultBinds', []):
print(p)
PYEOF
)
else
parsed_output=$(node - "$mounts_json" 2>/dev/null <<'JSEOF'
const m = JSON.parse(process.argv[1]);
(m.additionalROBinds || []).forEach(p => console.log(p));
console.log('---');
(m.additionalBinds || []).forEach(p => console.log(p));
console.log('---');
(m.disabledDefaultBinds || []).forEach(p => 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
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
}
# Run all diagnostic checks and print results
# Arguments: $1 = electron path (optional, for package-specific checks)
run_doctor() {
@@ -602,6 +742,9 @@ print(len(servers))
fi
_info "Cowork isolation: $cowork_backend"
# Custom bwrap mount configuration
_doctor_check_bwrap_mounts
# -- Orphaned cowork daemon --
local _cowork_pids
_cowork_pids=$(pgrep -f 'cowork-vm-service\.js' 2>/dev/null) \

View File

@@ -0,0 +1,667 @@
#!/usr/bin/env bats
#
# cowork-bwrap-config.bats
# Tests for configurable bwrap mount points (issue #339)
#
SCRIPT_DIR="$(cd "$(dirname "${BATS_TEST_FILENAME}")" && pwd)"
NODE_PREAMBLE='
const path = require("path");
const os = require("os");
const fs = require("fs");
const {
FORBIDDEN_MOUNT_PATHS,
CRITICAL_MOUNTS,
validateMountPath,
loadBwrapMountsConfig,
mergeBwrapArgs,
} = require("'"${SCRIPT_DIR}"'/../scripts/cowork-vm-service.js");
function loadBwrapMountsConfigWithLog(configPath, logFn) {
return loadBwrapMountsConfig(configPath, logFn);
}
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 assertDeepEqual(actual, expected, msg) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
assert(a === e, msg + " expected=" + e + " actual=" + a);
}
'
setup() {
TEST_TMP=$(mktemp -d)
export TEST_TMP
}
teardown() {
if [[ -n "$TEST_TMP" && -d "$TEST_TMP" ]]; then
rm -rf "$TEST_TMP"
fi
}
# =============================================================================
# validateMountPath
# =============================================================================
@test "validateMountPath: rejects non-absolute paths" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('relative/path');
assertDeepEqual(result, { valid: false, reason: 'Path must be absolute' }, 'relative');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects forbidden path /" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('/');
assertDeepEqual(result, { valid: false, reason: 'Path is forbidden: /' }, 'root');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects forbidden path /proc" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('/proc');
assertDeepEqual(result, { valid: false, reason: 'Path is forbidden: /proc' }, 'proc');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects forbidden path /dev" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('/dev');
assertDeepEqual(result, { valid: false, reason: 'Path is forbidden: /dev' }, 'dev');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects forbidden path /sys" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('/sys');
assertDeepEqual(result, { valid: false, reason: 'Path is forbidden: /sys' }, 'sys');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects subpaths of forbidden paths" {
run node -e "${NODE_PREAMBLE}
const r1 = validateMountPath('/proc/self');
assertDeepEqual(r1, { valid: false, reason: 'Path is under forbidden path: /proc' }, 'proc/self');
const r2 = validateMountPath('/dev/shm');
assertDeepEqual(r2, { valid: false, reason: 'Path is under forbidden path: /dev' }, 'dev/shm');
const r3 = validateMountPath('/sys/class');
assertDeepEqual(r3, { valid: false, reason: 'Path is under forbidden path: /sys' }, 'sys/class');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects RW paths outside HOME" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('/opt/tools', { readWrite: true });
assertDeepEqual(result,
{ valid: false, reason: 'Read-write mounts must be under \$HOME' },
'rw outside home');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: accepts RW paths under HOME" {
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const result = validateMountPath(home + '/projects/data', { readWrite: true });
assertDeepEqual(result, { valid: true }, 'rw under home');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: accepts RO paths anywhere (not forbidden)" {
run node -e "${NODE_PREAMBLE}
const r1 = validateMountPath('/opt/my-tools');
assertDeepEqual(r1, { valid: true }, 'opt ro');
const r2 = validateMountPath('/nix/store');
assertDeepEqual(r2, { valid: true }, 'nix ro');
const r3 = validateMountPath('/media/shared');
assertDeepEqual(r3, { valid: true }, 'media ro');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects empty string" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('');
assertDeepEqual(result, { valid: false, reason: 'Path must be absolute' }, 'empty');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: normalizes path before checking" {
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('/opt/../proc');
assertDeepEqual(result, { valid: false, reason: 'Path is forbidden: /proc' }, 'traversal to proc');
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: rejects symlink to forbidden path" {
local link_path="${TEST_TMP}/sneaky-link"
ln -s /proc "$link_path"
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('${link_path}');
assert(!result.valid, 'symlink to /proc should be rejected');
assert(result.reason.includes('forbidden'), 'reason: ' + result.reason);
"
[[ "$status" -eq 0 ]]
}
@test "validateMountPath: accepts symlink to safe path" {
local link_path="${TEST_TMP}/safe-link"
ln -s /opt "$link_path"
run node -e "${NODE_PREAMBLE}
const result = validateMountPath('${link_path}');
assertDeepEqual(result, { valid: true }, 'symlink to /opt should be accepted');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# loadBwrapMountsConfig
# =============================================================================
@test "loadBwrapMountsConfig: returns empty config when file does not exist" {
run node -e "${NODE_PREAMBLE}
const result = loadBwrapMountsConfig('/nonexistent/path/config.json');
assertDeepEqual(result, {
additionalROBinds: [],
additionalBinds: [],
disabledDefaultBinds: []
}, 'missing file');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: returns empty config when JSON has no preferences" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result, {
additionalROBinds: [],
additionalBinds: [],
disabledDefaultBinds: []
}, 'no preferences');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: returns empty config when coworkBwrapMounts is absent" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({ preferences: {} }));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result, {
additionalROBinds: [],
additionalBinds: [],
disabledDefaultBinds: []
}, 'no coworkBwrapMounts');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: parses valid configuration" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: ['/opt/tools', '/nix/store'],
additionalBinds: [os.homedir() + '/shared-data'],
disabledDefaultBinds: ['/etc']
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result, {
additionalROBinds: ['/opt/tools', '/nix/store'],
additionalBinds: [os.homedir() + '/shared-data'],
disabledDefaultBinds: ['/etc']
}, 'valid config');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: returns empty config on invalid JSON" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, '{ invalid json }');
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result, {
additionalROBinds: [],
additionalBinds: [],
disabledDefaultBinds: []
}, 'invalid json');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: filters out invalid paths from additionalROBinds" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: ['/opt/tools', '/proc', 'relative', '/dev', '/nix/store']
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, ['/opt/tools', '/nix/store'],
'filtered ro binds');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: filters out RW paths outside HOME" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
const home = os.homedir();
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalBinds: [home + '/valid', '/opt/invalid', home + '/also-valid']
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalBinds, [home + '/valid', home + '/also-valid'],
'filtered rw binds');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: ignores non-array values" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: 'not-an-array',
additionalBinds: 42,
disabledDefaultBinds: { bad: true }
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result, {
additionalROBinds: [],
additionalBinds: [],
disabledDefaultBinds: []
}, 'non-array values');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: filters non-string entries from arrays" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: ['/opt/tools', 42, null, '/nix/store', true]
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.additionalROBinds, ['/opt/tools', '/nix/store'],
'non-string entries filtered');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: normalizes disabledDefaultBinds paths" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
disabledDefaultBinds: ['/etc/../usr', '/tmp/./']
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.disabledDefaultBinds, ['/usr', '/tmp'],
'paths should be normalized');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects critical mounts in disabledDefaultBinds" {
run node -e "${NODE_PREAMBLE}
const warnings = [];
function logWarn() { warnings.push(Array.from(arguments).join(' ')); }
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
disabledDefaultBinds: ['/', '/dev', '/proc', '/etc']
}
}
}));
const result = loadBwrapMountsConfig(configPath, logWarn);
assertDeepEqual(result.disabledDefaultBinds, ['/etc'],
'only /etc should survive');
assertEqual(warnings.length, 3, 'three critical mount warnings');
"
[[ "$status" -eq 0 ]]
}
@test "loadBwrapMountsConfig: rejects relative disabledDefaultBinds" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
disabledDefaultBinds: ['relative/path', '/etc']
}
}
}));
const result = loadBwrapMountsConfig(configPath);
assertDeepEqual(result.disabledDefaultBinds, ['/etc'],
'relative path should be rejected');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# mergeBwrapArgs — disabled default binds
# =============================================================================
@test "mergeBwrapArgs: returns default args when config is empty" {
run node -e "${NODE_PREAMBLE}
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [], additionalBinds: [], disabledDefaultBinds: []
});
assertDeepEqual(result, defaults, 'unchanged');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: removes disabled default ro-bind" {
run node -e "${NODE_PREAMBLE}
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [], additionalBinds: [], disabledDefaultBinds: ['/etc']
});
const expected = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run'];
assertDeepEqual(result, expected, 'etc removed');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: refuses to disable --tmpfs /, --dev /dev, --proc /proc" {
run node -e "${NODE_PREAMBLE}
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [], additionalBinds: [],
disabledDefaultBinds: ['/', '/dev', '/proc']
});
assertDeepEqual(result, defaults, 'critical mounts preserved');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: can disable /tmp and /run" {
run node -e "${NODE_PREAMBLE}
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [], additionalBinds: [],
disabledDefaultBinds: ['/tmp', '/run']
});
const expected = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--dev', '/dev', '--proc', '/proc'];
assertDeepEqual(result, expected, 'tmp and run removed');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: appends additional RO binds" {
run node -e "${NODE_PREAMBLE}
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: ['/opt/tools', '/nix/store'],
additionalBinds: [],
disabledDefaultBinds: []
});
const expected = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--ro-bind', '/opt/tools', '/opt/tools',
'--ro-bind', '/nix/store', '/nix/store'];
assertDeepEqual(result, expected, 'ro appended');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: appends additional RW binds" {
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [],
additionalBinds: [home + '/data'],
disabledDefaultBinds: []
});
const expected = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--bind', home + '/data', home + '/data'];
assertDeepEqual(result, expected, 'rw appended');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: combined disable + add" {
run node -e "${NODE_PREAMBLE}
const home = os.homedir();
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run'];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: ['/opt/tools'],
additionalBinds: [home + '/shared'],
disabledDefaultBinds: ['/etc']
});
const expected = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--tmpfs', '/run',
'--ro-bind', '/opt/tools', '/opt/tools',
'--bind', home + '/shared', home + '/shared'];
assertDeepEqual(result, expected, 'combined');
"
[[ "$status" -eq 0 ]]
}
@test "mergeBwrapArgs: handles extended bwrap flags correctly" {
run node -e "${NODE_PREAMBLE}
const defaults = [
'--ro-bind', '/usr', '/usr',
'--ro-bind-try', '/opt/lib', '/opt/lib',
'--dev-bind', '/dev/dri', '/dev/dri',
'--setenv', 'DISPLAY', ':0',
'--chdir', '/home/user',
'--unshare-pid', '--die-with-parent',
];
const result = mergeBwrapArgs(defaults, {
additionalROBinds: [], additionalBinds: [],
disabledDefaultBinds: ['/opt/lib']
});
const expected = [
'--ro-bind', '/usr', '/usr',
'--dev-bind', '/dev/dri', '/dev/dri',
'--setenv', 'DISPLAY', ':0',
'--chdir', '/home/user',
'--unshare-pid', '--die-with-parent',
];
assertDeepEqual(result, expected, 'extended flags parsed correctly');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# buildBwrapArgsWithConfig (integration)
# =============================================================================
@test "buildBwrapArgsWithConfig: includes user mounts in final args" {
run node -e "${NODE_PREAMBLE}
const configPath = '${TEST_TMP}/config.json';
const home = os.homedir();
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: ['/opt/my-sdk'],
additionalBinds: [home + '/workspace'],
disabledDefaultBinds: []
}
}
}));
const config = loadBwrapMountsConfig(configPath);
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr',
'--dev', '/dev', '--proc', '/proc'];
const result = mergeBwrapArgs(defaults, config);
const roIdx = result.indexOf('--ro-bind', result.indexOf('/usr') + 1);
assertEqual(result[roIdx + 1], '/opt/my-sdk', 'ro-bind src');
assertEqual(result[roIdx + 2], '/opt/my-sdk', 'ro-bind dest');
const rwIdx = result.indexOf('--bind');
assertEqual(result[rwIdx + 1], home + '/workspace', 'bind src');
assertEqual(result[rwIdx + 2], home + '/workspace', 'bind dest');
"
[[ "$status" -eq 0 ]]
}
@test "buildBwrapArgsWithConfig: user RO mounts come before session mounts" {
run node -e "${NODE_PREAMBLE}
const config = {
additionalROBinds: ['/opt/tools'],
additionalBinds: [],
disabledDefaultBinds: []
};
const defaults = ['--tmpfs', '/', '--ro-bind', '/usr', '/usr'];
const merged = mergeBwrapArgs(defaults, config);
const fullArgs = [...merged, '--bind', '/home/user/project', '/sessions/s/mnt/project',
'--unshare-pid', '--die-with-parent', '--new-session'];
const optIdx = fullArgs.indexOf('/opt/tools');
const sessionBindIdx = fullArgs.indexOf('--bind');
assert(optIdx < sessionBindIdx,
'user RO mount (' + optIdx + ') before session bind (' + sessionBindIdx + ')');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# loadBwrapMountsConfig: logging
# =============================================================================
@test "loadBwrapMountsConfig: logs rejected paths" {
run node -e "${NODE_PREAMBLE}
const warnings = [];
function logWarn() { warnings.push(Array.from(arguments).join(' ')); }
const configPath = '${TEST_TMP}/config.json';
fs.writeFileSync(configPath, JSON.stringify({
preferences: {
coworkBwrapMounts: {
additionalROBinds: ['/proc', '/opt/ok'],
additionalBinds: ['/outside/home']
}
}
}));
const result = loadBwrapMountsConfigWithLog(configPath, logWarn);
assertEqual(result.additionalROBinds.length, 1, 'one valid ro');
assertEqual(warnings.length, 2, 'two warnings logged');
assert(warnings[0].includes('/proc'), 'warns about /proc');
assert(warnings[1].includes('/outside/home'), 'warns about rw outside home');
"
[[ "$status" -eq 0 ]]
}
# =============================================================================
# --doctor integration (bash)
# =============================================================================
@test "doctor: reports custom bwrap mounts" {
mkdir -p "${TEST_TMP}/.config/Claude"
local home_tmp="${TEST_TMP}"
local config_file="${TEST_TMP}/.config/Claude/claude_desktop_linux_config.json"
cat > "$config_file" <<-ENDJSON
{
"preferences": {
"coworkBwrapMounts": {
"additionalROBinds": ["/opt/tools"],
"additionalBinds": ["${home_tmp}/data"],
"disabledDefaultBinds": ["/etc"]
}
}
}
ENDJSON
# Source launcher-common.sh and run the doctor check function
# shellcheck source=scripts/launcher-common.sh
source "scripts/launcher-common.sh"
# Override HOME for config path resolution
HOME="${TEST_TMP}" run _doctor_check_bwrap_mounts
[[ "$output" == *"/opt/tools"* ]]
[[ "$output" == *"data"* ]]
[[ "$output" == *"/etc"* ]]
[[ "$output" == *"WARN"* ]]
}
@test "doctor: warns about disabled critical mount /usr" {
mkdir -p "${TEST_TMP}/.config/Claude"
local config_file="${TEST_TMP}/.config/Claude/claude_desktop_linux_config.json"
cat > "$config_file" <<-ENDJSON
{
"preferences": {
"coworkBwrapMounts": {
"disabledDefaultBinds": ["/usr"]
}
}
}
ENDJSON
# shellcheck source=scripts/launcher-common.sh
source "scripts/launcher-common.sh"
HOME="${TEST_TMP}" run _doctor_check_bwrap_mounts
[[ "$output" == *"WARN"* ]]
[[ "$output" == *"/usr"* ]]
}
@test "doctor: no output when no custom mounts configured" {
mkdir -p "${TEST_TMP}/.config/Claude"
local config_file="${TEST_TMP}/.config/Claude/claude_desktop_linux_config.json"
echo '{}' > "$config_file"
# shellcheck source=scripts/launcher-common.sh
source "scripts/launcher-common.sh"
HOME="${TEST_TMP}" run _doctor_check_bwrap_mounts
# Should just show info that no custom mounts are configured
[[ "$output" != *"FAIL"* ]]
}