mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 08:36:35 +03:00
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:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) \
|
||||
|
||||
667
tests/cowork-bwrap-config.bats
Normal file
667
tests/cowork-bwrap-config.bats
Normal 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"* ]]
|
||||
}
|
||||
Reference in New Issue
Block a user