Files
claude-desktop-debian/scripts/cowork-vm-service.js
aaddrick d499d8dc02 fix: address code review findings for --doctor backend display
- Gate /dev/kvm fix hints behind _kvm_active check (was leaking
  unconditionally)
- Check COWORK_VM_BACKEND override in --doctor backend summary
  to match daemon's detectBackend() behavior
- Log hint when KVM deps are present but bwrap wins auto-detect,
  so upgrading users know about COWORK_VM_BACKEND=kvm

Co-Authored-By: Claude <claude@anthropic.com>
2026-03-21 18:39:13 -04:00

2127 lines
74 KiB
JavaScript

#!/usr/bin/env node
/**
* Linux Cowork VM Service Daemon
*
* Replaces the Windows cowork-vm-service for Linux. Listens on a Unix domain
* socket using the same length-prefixed JSON protocol as the Windows named pipe.
*
* Architecture: VMManager (dispatcher) + pluggable backends
* - HostBackend: Run processes directly on host (no isolation)
* - BwrapBackend: Bubblewrap namespace sandbox
* - KvmBackend: QEMU/KVM virtual machine with vsock communication
*
* Backend selection (auto-detected or overridden via COWORK_VM_BACKEND env):
* 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:
* Transport: Unix domain socket at $XDG_RUNTIME_DIR/cowork-vm-service.sock
* Framing: 4-byte big-endian length prefix + JSON payload
* Request: { method: "methodName", params: {...} }
* Response: { success: true, result: {...} } or { success: false, error: "..." }
* Events: { type: "stdout"|"stderr"|"exit"|"error"|"networkStatus"|"apiReachability", ... }
*/
const net = require('net');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn: spawnProcess, execSync, execFileSync } = require('child_process');
// ============================================================
// Configuration
// ============================================================
const SOCKET_PATH = (process.env.XDG_RUNTIME_DIR || '/tmp') +
'/cowork-vm-service.sock';
const DEBUG = process.env.COWORK_VM_DEBUG === '1' ||
process.env.CLAUDE_LINUX_DEBUG === '1';
const LOG_PREFIX = '[cowork-vm-service]';
// Backend override: set COWORK_VM_BACKEND to "host", "bwrap", or "kvm"
// to force a specific backend instead of auto-detection.
const BACKEND_OVERRIDE = process.env.COWORK_VM_BACKEND || null;
// The daemon is forked with stdio:'ignore', so console output goes nowhere.
// Write logs to a file so they're accessible for debugging.
const LOG_FILE = path.join(
process.env.HOME || '/tmp',
'.config', 'Claude', 'logs', 'cowork_vm_daemon.log'
);
function formatArgs(args) {
return args.map(a => typeof a === 'string' ? a : JSON.stringify(a))
.join(' ');
}
function writeLog(level, args) {
const ts = new Date().toISOString();
const msg = `${ts} [${level}] ${LOG_PREFIX} ${formatArgs(args)}\n`;
try {
fs.appendFileSync(LOG_FILE, msg);
} catch (_) {
// Ignore write errors (dir may not exist yet)
}
}
function log(...args) {
if (!DEBUG) return;
writeLog('debug', args);
console.log(LOG_PREFIX, ...args);
}
function logError(...args) {
writeLog('error', args);
console.error(LOG_PREFIX, ...args);
}
// ============================================================
// Length-Prefixed JSON Protocol (matches Windows pipe protocol)
// ============================================================
/**
* Write a length-prefixed JSON message to a socket.
* Format: 4 bytes big-endian length + JSON bytes
*/
function writeMessage(socket, message) {
const json = JSON.stringify(message);
const jsonBuf = Buffer.from(json, 'utf8');
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(jsonBuf.length, 0);
socket.write(Buffer.concat([lenBuf, jsonBuf]));
}
/**
* Parse a length-prefixed JSON message from a buffer.
* Returns { message, remaining } or null if incomplete.
*/
function parseMessage(buffer) {
if (buffer.length < 4) return null;
const len = buffer.readUInt32BE(0);
if (buffer.length < 4 + len) return null;
const json = buffer.subarray(4, 4 + len).toString('utf8');
const remaining = Buffer.from(buffer.subarray(4 + len));
return { message: JSON.parse(json), remaining };
}
// ============================================================
// Shared Helpers (used by multiple backends)
// ============================================================
/**
* Keys to strip from spawned process environments.
* CLAUDECODE triggers "cannot be launched inside another session".
* ELECTRON_* are Electron internals that break child processes.
*/
const BLOCKED_ENV_KEYS = new Set([
'CLAUDECODE', 'ELECTRON_RUN_AS_NODE', 'ELECTRON_NO_ASAR',
]);
/**
* Filter environment variables, removing blocked keys and optional prefixes.
*/
function filterEnv(source, stripPrefixes = []) {
const result = {};
for (const [k, v] of Object.entries(source)) {
if (BLOCKED_ENV_KEYS.has(k)) continue;
if (stripPrefixes.some(p => k.startsWith(p))) continue;
result[k] = v;
}
return result;
}
// ============================================================
// Guest-Path Translation
// ============================================================
/**
* Translate a VM guest path (/sessions/{id}/mnt/{name}[/rest]) to a host
* path using mountMap. Returns the translated path, or null on failure.
*/
function translateGuestPath(guestPath, mountMap) {
if (!guestPath || !guestPath.startsWith('/sessions/')) return null;
if (!mountMap || Object.keys(mountMap).length === 0) return null;
const match = guestPath.match(
/^\/sessions\/[^/]+\/mnt\/([^/]+)(\/.*)?$/
);
if (!match) return null;
const mountName = match[1];
const rest = match[2] || '';
// Electron's ta() normalizer strips leading dots, so try both
// "skills" and ".skills" style lookups.
const hostBase = mountMap[mountName]
|| mountMap['.' + mountName]
|| mountMap[mountName.replace(/^\./, '')];
if (!hostBase) {
log(`translateGuestPath: no mapping for "${mountName}"`);
return null;
}
const translated = rest ? path.join(hostBase, rest) : hostBase;
const normalized = path.resolve(translated);
// Prevent path traversal outside the mount base
if (normalized !== hostBase &&
!normalized.startsWith(hostBase + path.sep)) {
log(`translateGuestPath: traversal blocked: ${guestPath} -> ${normalized}`);
return null;
}
log(`translateGuestPath: ${guestPath} -> ${normalized}`);
return normalized;
}
/**
* Build a mount-name -> host-path mapping from mountBinds (prior
* mountPath() calls) and additionalMounts (spawn params).
* additionalMounts entries take precedence over mountBinds.
*/
function buildMountMap(additionalMounts, mountBinds) {
const map = {};
if (mountBinds) {
for (const [name, hostPath] of mountBinds) {
map[name] = hostPath;
}
}
if (additionalMounts) {
const homeDir = os.homedir();
for (const [name, info] of Object.entries(additionalMounts)) {
if (!info || !info.path) continue;
const resolved = path.resolve(
path.join(homeDir, info.path)
);
if (resolved !== homeDir &&
!resolved.startsWith(homeDir + path.sep)) {
log(`buildMountMap: rejecting "${name}" — resolves outside home: ${resolved}`);
continue;
}
map[name] = resolved;
}
}
return map;
}
/**
* Build a merged environment for a spawned process. Combines filtered
* daemon env with app-provided env, and translates CLAUDE_CONFIG_DIR
* guest paths using mountMap.
*/
function buildSpawnEnv(appEnv, mountMap) {
const mergedEnv = {
...filterEnv(process.env, ['CLAUDE_CODE_']),
...filterEnv(appEnv || {}),
TERM: 'xterm-256color',
};
// Translate CLAUDE_CONFIG_DIR from guest path to host path, or
// remove it so Claude Code falls back to ~/.claude/.
if (mergedEnv.CLAUDE_CONFIG_DIR &&
mergedEnv.CLAUDE_CONFIG_DIR.startsWith('/sessions/')) {
const translated = translateGuestPath(
mergedEnv.CLAUDE_CONFIG_DIR, mountMap
);
if (translated) {
log(`buildSpawnEnv: translated CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR} -> ${translated}`);
mergedEnv.CLAUDE_CONFIG_DIR = translated;
} else {
log(`buildSpawnEnv: removing VM guest CLAUDE_CONFIG_DIR: ${mergedEnv.CLAUDE_CONFIG_DIR}`);
delete mergedEnv.CLAUDE_CONFIG_DIR;
}
}
return mergedEnv;
}
/**
* Translate args that reference VM guest paths (/sessions/...) to host
* paths using mountMap. If translation fails, the flag pair is removed.
*/
function cleanSpawnArgs(rawArgs, mountMap) {
const cleanArgs = [];
const guestPathFlags = new Set(['--add-dir', '--plugin-dir']);
for (let i = 0; i < rawArgs.length; i++) {
if (guestPathFlags.has(rawArgs[i]) &&
i + 1 < rawArgs.length &&
rawArgs[i + 1].startsWith('/sessions/')) {
const flag = rawArgs[i];
let hostPath = translateGuestPath(
rawArgs[i + 1], mountMap
);
if (hostPath) {
// --plugin-dir needs the plugin root, not a skills/
// subdirectory — walk up to find it.
if (flag === '--plugin-dir') {
hostPath = resolvePluginRoot(
hostPath, os.homedir()
);
}
log(`cleanSpawnArgs: translated ${flag} ${rawArgs[i + 1]} -> ${hostPath}`);
cleanArgs.push(flag, hostPath);
} else {
log(`cleanSpawnArgs: removing ${flag} ${rawArgs[i + 1]} (no host mapping)`);
}
i++;
continue;
}
cleanArgs.push(rawArgs[i]);
}
return cleanArgs;
}
/**
* Walk up from pluginPath (at most 3 levels) looking for the plugin
* root (contains .claude-plugin/plugin.json or manifest.json).
* Will not walk above mountBase. Returns pluginPath if no root found.
*/
function resolvePluginRoot(pluginPath, mountBase) {
let candidate = pluginPath;
for (let i = 0; i < 3; i++) {
try {
const hasPluginJson = fs.existsSync(
path.join(candidate, '.claude-plugin', 'plugin.json')
);
const hasManifest = fs.existsSync(
path.join(candidate, 'manifest.json')
);
if (hasPluginJson || hasManifest) {
if (candidate !== pluginPath) {
log(`resolvePluginRoot: adjusted ${pluginPath} -> ${candidate}`);
}
return candidate;
}
} catch (_) {
break;
}
const parent = path.dirname(candidate);
if (parent === candidate) break;
if (mountBase &&
parent !== mountBase &&
!parent.startsWith(mountBase + path.sep)) break;
candidate = parent;
}
return pluginPath;
}
/**
* Resolve the working directory from spawn params. Translates guest
* paths using mountMap, falls back to homedir if translation fails
* or the directory does not exist.
*/
function resolveWorkDir(cwd, sharedCwdPath, mountMap) {
let workDir = cwd || os.homedir();
if (sharedCwdPath) {
workDir = path.join(os.homedir(), sharedCwdPath);
} else if (cwd && cwd.startsWith('/sessions/')) {
const translated = translateGuestPath(cwd, mountMap || {});
if (translated) {
log(`resolveWorkDir: translated "${cwd}" -> "${translated}"`);
workDir = translated;
} else {
log(`resolveWorkDir: cwd is VM guest path "${cwd}", using home dir`);
workDir = os.homedir();
}
}
if (!fs.existsSync(workDir)) {
log(`resolveWorkDir: cwd "${workDir}" does not exist, using home dir`);
workDir = os.homedir();
}
return workDir;
}
/**
* Resolve the SDK binary path from subpath and version.
* Returns the path if found and executable, null otherwise.
*/
function resolveSdkBinary(sdkSubpath, version, label) {
if (!sdkSubpath || !version) return null;
const candidatePath = path.join(
os.homedir(), sdkSubpath, version, 'claude'
);
try {
fs.accessSync(candidatePath, fs.constants.X_OK);
log(`${label}: SDK binary found: ${candidatePath}`);
return candidatePath;
} catch (e) {
log(`${label}: SDK binary not found: ${candidatePath}`);
return null;
}
}
/**
* Resolve the actual command binary to execute.
* Priority: 1) SDK binary from installSdk, 2) command path, 3) which
* Returns { command, error } — error is set if command not found.
*/
function resolveCommand(command, sdkBinaryPath) {
if (sdkBinaryPath && fs.existsSync(sdkBinaryPath)) {
log(`resolveCommand: using SDK binary: ${sdkBinaryPath}`);
return { command: sdkBinaryPath, error: null };
}
if (fs.existsSync(command)) {
return { command, error: null };
}
const basename = path.basename(command);
try {
const resolved = execFileSync('which', [basename],
{ encoding: 'utf-8' }).trim();
log(`resolveCommand: resolved via which: ${resolved}`);
return { command: resolved, error: null };
} catch (e) {
return { command: null, error: `${command} not found` };
}
}
// ============================================================
// Backend Base Class
// ============================================================
/**
* Base class documenting the interface all backends must implement.
* Each backend receives an emitEvent callback for broadcasting events
* (stdout, stderr, exit, error, networkStatus, etc.) to subscribers.
*/
class BackendBase {
constructor(emitEvent) {
/** @type {function} Callback to broadcast events to subscribers */
this.emitEvent = emitEvent;
}
/** One-time initialization with VM config */
async init(config) {
throw new Error('Not implemented: init');
}
/** Start the VM/sandbox/nothing */
async startVM(params) {
throw new Error('Not implemented: startVM');
}
/** Stop everything */
async stopVM() {
throw new Error('Not implemented: stopVM');
}
/** Return { running: bool } */
isRunning() {
throw new Error('Not implemented: isRunning');
}
/** Return { connected: bool } */
isGuestConnected() {
throw new Error('Not implemented: isGuestConnected');
}
/** Spawn a process */
async spawn(params) {
throw new Error('Not implemented: spawn');
}
/** Kill a process */
async kill(params) {
throw new Error('Not implemented: kill');
}
/** Write to process stdin */
async writeStdin(params) {
throw new Error('Not implemented: writeStdin');
}
/** Check if process is running, return { running: bool } */
isProcessRunning(params) {
throw new Error('Not implemented: isProcessRunning');
}
/** Handle mount requests */
async mountPath(params) {
throw new Error('Not implemented: mountPath');
}
/** Read a file */
async readFile(params) {
throw new Error('Not implemented: readFile');
}
/** Handle SDK installation */
async installSdk(params) {
throw new Error('Not implemented: installSdk');
}
/** Handle OAuth */
async addApprovedOauthToken(params) {
throw new Error('Not implemented: addApprovedOauthToken');
}
}
// ============================================================
// LocalBackend — Shared logic for host-local backends
// ============================================================
/**
* Common base for backends that run processes locally (Host and Bwrap).
* Provides shared implementations of process management, file I/O,
* SDK installation, and lifecycle methods. Subclasses override
* startVM(), stopVM(), spawn(), and mountPath() as needed.
*/
class LocalBackend extends BackendBase {
constructor(emitEvent, backendName) {
super(emitEvent);
this.backendName = backendName;
this.config = { memoryMB: 8192, cpuCount: 4 };
this.running = false;
this.guestConnected = false;
this.sdkBinaryPath = null;
this.processes = new Map();
}
async init(config) {
if (config.memoryMB !== undefined) {
this.config.memoryMB = config.memoryMB;
}
if (config.cpuCount !== undefined) {
this.config.cpuCount = config.cpuCount;
}
log(`${this.backendName} configured:`, this.config);
}
isRunning() {
return { running: this.running };
}
isGuestConnected() {
return { connected: this.guestConnected };
}
/**
* Spawn a local process. Subclasses call this with the resolved
* command and args to get consistent event wiring.
* @param {string} id - Process identifier
* @param {string} spawnCmd - Command to execute
* @param {string[]} spawnArgs - Arguments array
* @param {string} workDir - Working directory
* @param {object} env - Environment variables
*/
_spawnLocal(id, spawnCmd, spawnArgs, workDir, env) {
const proc = spawnProcess(spawnCmd, spawnArgs, {
cwd: workDir,
env,
stdio: ['pipe', 'pipe', 'pipe'],
});
log(`${this.backendName} spawn: pid=${proc.pid}`);
this.processes.set(id, proc);
proc.stdout.on('data', (data) => {
this.emitEvent({ type: 'stdout', id, data: data.toString() });
});
proc.stderr.on('data', (data) => {
this.emitEvent({ type: 'stderr', id, data: data.toString() });
});
proc.on('exit', (exitCode, signal) => {
log(`${this.backendName}: process ${id} exited: code=${exitCode}, signal=${signal}`);
this.processes.delete(id);
this.emitEvent({ type: 'exit', id, exitCode, signal });
});
proc.on('error', (err) => {
this.emitEvent({ type: 'error', id, message: err.message });
});
return proc;
}
/**
* Resolve command and prepare environment/args for spawning.
* Returns null and emits error events if command not found.
* Builds a mount map to translate VM guest paths in args, env, and cwd.
*/
_prepareSpawn(params) {
const { id, name, command, args, cwd, env,
sharedCwdPath, additionalMounts } = params;
log(`${this.backendName} spawn: id=${id}, name=${name}, command=${command}`);
const mountMap = buildMountMap(
additionalMounts, this.mountBinds
);
// Store for readFile() — last spawn wins (single-session in practice)
this.lastMountMap = mountMap;
if (Object.keys(mountMap).length > 0) {
log(`${this.backendName} spawn: mountMap=${JSON.stringify(mountMap)}`);
}
const workDir = resolveWorkDir(cwd, sharedCwdPath, mountMap);
const resolved = resolveCommand(command, this.sdkBinaryPath);
if (resolved.error) {
this.emitEvent({
type: 'stderr', id,
data: `Error: ${resolved.error}\n`,
});
this.emitEvent({
type: 'exit', id, exitCode: 127, signal: null,
});
return null;
}
return {
id,
name,
actualCommand: resolved.command,
cleanArgs: cleanSpawnArgs(args || [], mountMap),
mergedEnv: buildSpawnEnv(env, mountMap),
workDir,
mountMap,
};
}
_killAllProcesses(killSignal) {
for (const [id, proc] of this.processes) {
try {
if (proc.kill) proc.kill(killSignal);
} catch (e) {
log(`${this.backendName}: error killing process ${id}:`, e.message);
}
}
this.processes.clear();
}
_setDisconnected() {
this.running = false;
this.guestConnected = false;
this.emitEvent({ type: 'networkStatus', status: 'disconnected' });
}
async kill(params) {
const { id, signal } = params;
const proc = this.processes.get(id);
if (proc) {
try {
proc.kill(signal || 'SIGTERM');
} catch (e) {
log(`${this.backendName}: kill failed for ${id}:`, e.message);
}
}
return {};
}
async writeStdin(params) {
const { id, data } = params;
const proc = this.processes.get(id);
if (proc && proc.stdin && !proc.stdin.destroyed) {
proc.stdin.write(data);
}
return {};
}
isProcessRunning(params) {
return { running: !!this.processes.get(params.id) };
}
async readFile(params) {
const { filePath } = params;
log(`${this.backendName} readFile: ${filePath}`);
let resolved;
if (filePath && filePath.startsWith('/sessions/')) {
resolved = translateGuestPath(
filePath, this.lastMountMap || {}
);
if (!resolved) {
return { error: `Cannot translate guest path: ${filePath}` };
}
log(`${this.backendName} readFile: translated ${filePath} -> ${resolved}`);
} else {
resolved = path.resolve(filePath);
}
const home = os.homedir();
if (!resolved.startsWith(home + path.sep) && resolved !== home) {
return { error: 'Access denied: path outside home directory' };
}
try {
const content = fs.readFileSync(resolved, 'utf8');
return { content };
} catch (e) {
return { error: e.message };
}
}
async installSdk(params) {
const { sdkSubpath, version } = params;
log(`${this.backendName} installSdk: ${sdkSubpath}@${version}`);
const resolved = resolveSdkBinary(
sdkSubpath, version, this.backendName
);
if (resolved) this.sdkBinaryPath = resolved;
return {};
}
async addApprovedOauthToken(params) {
log(`${this.backendName}: addApprovedOauthToken`);
return {};
}
}
// ============================================================
// HostBackend — Run processes directly on host (no isolation)
// ============================================================
class HostBackend extends LocalBackend {
constructor(emitEvent) {
super(emitEvent, 'HostBackend');
}
async startVM(params) {
if (this.running) {
log('HostBackend: already running');
return {};
}
this.running = true;
// Simulate async guest connection
setTimeout(() => {
this.guestConnected = true;
this.emitEvent({
type: 'networkStatus',
status: 'connected',
});
log('HostBackend: guest connected');
}, 500);
return {};
}
async stopVM() {
log('HostBackend: stopVM');
this._killAllProcesses('SIGTERM');
this._setDisconnected();
return {};
}
async spawn(params) {
const prepared = this._prepareSpawn(params);
if (!prepared) return {};
const { id, actualCommand, cleanArgs, mergedEnv, workDir } = prepared;
log(`HostBackend spawn: command=${actualCommand}, args=${JSON.stringify(cleanArgs)}`);
log(`HostBackend spawn: cwd=${workDir}`);
this._spawnLocal(id, actualCommand, cleanArgs, workDir, mergedEnv);
return {};
}
async mountPath(params) {
const { subpath } = params;
log(`HostBackend mountPath: ${subpath}`);
const guestPath = path.join(os.homedir(), subpath || '');
return { guestPath };
}
}
// ============================================================
// BwrapBackend — Bubblewrap namespace sandbox
// ============================================================
class BwrapBackend extends LocalBackend {
constructor(emitEvent) {
super(emitEvent, 'BwrapBackend');
this.mountBinds = new Map(); // mountName -> hostPath
}
async startVM(params) {
if (this.running) {
log('BwrapBackend: already running');
return {};
}
// bwrap is process-level sandboxing; no VM to start
this.running = true;
this.guestConnected = true;
this.emitEvent({
type: 'networkStatus',
status: 'connected',
});
log('BwrapBackend: started (sandbox ready)');
return {};
}
async stopVM() {
log('BwrapBackend: stopVM');
this._killAllProcesses('SIGKILL');
this.mountBinds.clear();
this._setDisconnected();
return {};
}
async spawn(params) {
const prepared = this._prepareSpawn(params);
if (!prepared) return {};
const { id, name, actualCommand } = prepared;
const { additionalMounts } = params;
const mountMap = this.lastMountMap || {};
// Guest paths (/sessions/...) exist inside our bwrap sandbox,
// so pass args and env through as-is (no guest->host translation).
const rawArgs = params.args || [];
const mergedEnv = {
...filterEnv(process.env, ['CLAUDE_CODE_']),
...filterEnv(params.env || {}),
TERM: 'xterm-256color',
};
// Build a minimal sandbox: empty tmpfs root with only the
// 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 = [
'--tmpfs', '/',
'--ro-bind', '/usr', '/usr',
'--ro-bind', '/etc', '/etc',
'--dev', '/dev',
'--proc', '/proc',
'--tmpfs', '/tmp',
'--tmpfs', '/run',
];
// Handle /bin, /lib, /lib64, /sbin: on merged-usr distros
// (Fedora, recent Debian/Ubuntu) these are symlinks into /usr.
// On others they are real directories needing separate mounts.
for (const dir of ['/bin', '/lib', '/lib64', '/sbin']) {
try {
const target = fs.readlinkSync(dir);
bwrapArgs.push('--symlink', target, dir);
} catch (_) {
if (fs.existsSync(dir)) {
bwrapArgs.push('--ro-bind', dir, dir);
}
}
}
// Preserve DNS resolution: /etc/resolv.conf is often a symlink
// to /run/systemd/resolve/stub-resolv.conf which --tmpfs /run
// wipes out. Bind-mount the resolved target back in.
try {
const resolvedConf = fs.realpathSync('/etc/resolv.conf');
if (resolvedConf.startsWith('/run/')) {
const resolvedDir = path.dirname(resolvedConf);
bwrapArgs.push('--ro-bind', resolvedDir, resolvedDir);
}
} catch (e) {
log('BwrapBackend: could not resolve /etc/resolv.conf:', e.message);
}
// Bind the SDK binary read-only
const sdkDir = path.dirname(actualCommand);
bwrapArgs.push('--ro-bind', sdkDir, sdkDir);
// Create home directory (needed for ~ expansion) but don't
// expose real home contents.
const homeDir = os.homedir();
bwrapArgs.push('--dir', homeDir);
// Create /sessions/<name>/mnt/ guest path structure and mount
// host directories at guest paths, matching the KVM backend
// layout. The claude-code-vm binary translates all paths to
// /sessions/ internally, so these must exist inside the sandbox.
const sessionMnt = `/sessions/${name}/mnt`;
bwrapArgs.push('--dir', `/sessions/${name}`);
bwrapArgs.push('--dir', sessionMnt);
for (const [mountName, hostPath] of Object.entries(mountMap)) {
try {
if (!fs.existsSync(hostPath)) {
fs.mkdirSync(hostPath, { recursive: true });
}
} catch (e) {
log(`BwrapBackend spawn: could not create ${hostPath}: ${e.message}`);
continue;
}
const guestPath = `${sessionMnt}/${mountName}`;
const mode = additionalMounts?.[mountName]?.mode;
const bindType = mode === 'ro' ? '--ro-bind' : '--bind';
bwrapArgs.push(bindType, hostPath, guestPath);
log(`BwrapBackend spawn: mount ${mountName}: ${hostPath} -> ${guestPath} (${mode || 'rw'})`);
}
// Namespace isolation + actual command
bwrapArgs.push(
'--unshare-pid',
'--die-with-parent',
'--new-session',
'--',
actualCommand,
...rawArgs,
);
// Use the primary user mount as cwd (first non-dotfile, non-uploads mount)
const primaryMount = Object.keys(mountMap).find(
n => !n.startsWith('.') && n !== 'uploads',
);
const guestWorkDir = primaryMount
? `${sessionMnt}/${primaryMount}`
: sessionMnt;
log(`BwrapBackend spawn: bwrap args=${JSON.stringify(bwrapArgs)}`);
log(`BwrapBackend spawn: cwd=${guestWorkDir}`);
// Use host-side cwd for Node's spawn (guest paths don't exist
// on host). bwrap --chdir sets the actual cwd inside the sandbox.
this._spawnLocal(id, 'bwrap',
['--chdir', guestWorkDir, ...bwrapArgs],
os.homedir(), mergedEnv);
return {};
}
async mountPath(params) {
const { subpath, mountName } = params;
log(`BwrapBackend mountPath: ${mountName} -> ${subpath}`);
const hostPath = path.join(os.homedir(), subpath || '');
// Store for --bind on next spawn
this.mountBinds.set(mountName || subpath, hostPath);
return { guestPath: hostPath };
}
}
// ============================================================
// KvmBackend — QEMU/KVM virtual machine
// ============================================================
const VM_BASE_DIR = path.join(os.homedir(), '.local/share/claude-desktop/vm');
const VM_SESSION_DIR = path.join(VM_BASE_DIR, 'sessions');
const VSOCK_GUEST_PORT = 51234; // 0xC822 — matches guest sdk-daemon
const HOME_SHARE_MOUNT_TAG = 'claudeshared';
const HOME_SHARE_GUEST_MOUNT = '/mnt/.virtiofs-root';
const QMP_CAPABILITIES = JSON.stringify({ execute: 'qmp_capabilities' });
/** Event types forwarded from the guest sdk-daemon to subscribers. */
const FORWARDED_EVENTS = new Set([
'stdout', 'stderr', 'exit', 'networkStatus', 'apiReachability',
'ready', 'startupStep',
]);
class KvmBackend extends BackendBase {
constructor(emitEvent) {
super(emitEvent);
this.config = { memoryMB: 8192, cpuCount: 4 };
this.running = false;
this.guestConnected = false;
this.qemuProcess = null;
this.virtiofsdProcess = null;
this.homeShareType = null; // 'virtiofs', '9p', or null
this.socatProcess = null;
this.sessionDir = null;
this.monitorSock = null;
this.bridgeSock = null;
this.guestCid = null;
this.sdkBinaryPath = null;
this._qmpAvailable = true;
this.processes = new Map(); // id -> bridge connection state
}
async init(config) {
if (config.memoryMB !== undefined) {
this.config.memoryMB = config.memoryMB;
}
if (config.cpuCount !== undefined) {
this.config.cpuCount = config.cpuCount;
}
// Ensure VM directory exists
fs.mkdirSync(VM_BASE_DIR, { recursive: true });
// Convert VHDX to qcow2 if present in VM_BASE_DIR (manual
// placement). The main conversion happens in startVM() using
// the app-provided bundlePath.
const vhdxPath = path.join(VM_BASE_DIR, 'rootfs.vhdx');
const qcow2Path = path.join(VM_BASE_DIR, 'rootfs.qcow2');
if (fs.existsSync(vhdxPath) && !fs.existsSync(qcow2Path)) {
log('KvmBackend: converting VHDX to qcow2...');
try {
execFileSync('qemu-img', [
'convert', '-f', 'vhdx', '-O', 'qcow2',
vhdxPath, qcow2Path
], { stdio: 'pipe', timeout: 300000 });
log('KvmBackend: VHDX conversion complete');
} catch (e) {
logError('KvmBackend: VHDX conversion failed:', e.message);
throw new Error(`VHDX conversion failed: ${e.message}`);
}
}
log('KvmBackend configured:', this.config);
}
_allocateCid() {
// Allocate a unique guest CID starting at 3 (0-2 are reserved)
// Check /dev/vhost-vsock is available and pick next free CID
let cid = 3;
const cidFile = path.join(VM_BASE_DIR, '.next_cid');
try {
cid = parseInt(fs.readFileSync(cidFile, 'utf8').trim(), 10);
if (isNaN(cid) || cid < 3) cid = 3;
} catch (_) {
// First run, start at 3
}
const next = cid >= 65535 ? 3 : cid + 1;
fs.writeFileSync(cidFile, String(next));
return cid;
}
async startVM(params) {
if (this.running) {
log('KvmBackend: already running');
return {};
}
this.bundlePath = params.bundlePath || VM_BASE_DIR;
const memoryGB = params.memoryGB ||
Math.ceil(this.config.memoryMB / 1024);
const cpuCount = this.config.cpuCount;
this.emitEvent({
type: 'startupStep',
step: 'prepare_session', status: 'running',
});
// The app downloads VM images (rootfs.vhdx, vmlinuz, initrd)
// to bundlePath (~/.config/Claude/vm_bundles/claudevm.bundle/).
// Convert VHDX to qcow2 if needed (the app downloads VHDX
// format using the win32 manifest entries).
const bundleDir = this.bundlePath;
const vhdxPath = path.join(bundleDir, 'rootfs.vhdx');
const qcow2Path = path.join(bundleDir, 'rootfs.qcow2');
if (fs.existsSync(vhdxPath) && !fs.existsSync(qcow2Path)) {
log('KvmBackend: converting rootfs.vhdx to qcow2...');
try {
execFileSync('qemu-img', [
'convert', '-f', 'vhdx', '-O', 'qcow2',
vhdxPath, qcow2Path
], { stdio: 'pipe', timeout: 300000 });
log('KvmBackend: rootfs conversion complete');
} catch (e) {
logError('KvmBackend: rootfs conversion failed:',
e.message);
throw new Error(
`rootfs conversion failed: ${e.message}`);
}
}
// Fall back: check VM_BASE_DIR if bundle has no rootfs
const basePath = fs.existsSync(qcow2Path)
? qcow2Path
: path.join(VM_BASE_DIR, 'rootfs.qcow2');
if (!fs.existsSync(basePath)) {
throw new Error(
`rootfs not found in ${bundleDir} or ${VM_BASE_DIR}`);
}
// Create session directory
const sessionId = crypto.randomUUID();
this.sessionDir = path.join(VM_SESSION_DIR, sessionId);
fs.mkdirSync(this.sessionDir, { recursive: true });
// Create overlay disk
const overlayPath = path.join(this.sessionDir, 'overlay.qcow2');
try {
execFileSync('qemu-img', [
'create', '-f', 'qcow2', '-b', basePath,
'-F', 'qcow2', overlayPath
], { stdio: 'pipe' });
} catch (e) {
logError('KvmBackend: overlay creation failed:', e.message);
throw new Error(`Overlay creation failed: ${e.message}`);
}
// Allocate guest CID
this.guestCid = this._allocateCid();
this.monitorSock = path.join(this.sessionDir, 'qmp.sock');
this.bridgeSock = path.join(this.sessionDir, 'bridge.sock');
const vmlinuzPath = path.join(bundleDir, 'vmlinuz');
const initrdPath = path.join(bundleDir, 'initrd');
// Start home directory share for guest VM.
// Try virtiofsd first (best performance), fall back to virtio-9p
// (built into QEMU, no daemon needed, works unprivileged).
const virtiofsSock = path.join(this.sessionDir, 'virtiofs.sock');
try {
this.virtiofsdProcess = spawnProcess('virtiofsd', [
`--socket-path=${virtiofsSock}`,
'-o', `source=${os.homedir()}`,
'-o', 'cache=auto',
], {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.virtiofsdProcess.on('error', (err) => {
log('KvmBackend: virtiofsd error:', err.message);
this.virtiofsdProcess = null;
});
log(`KvmBackend: virtiofsd started, socket=${virtiofsSock}`);
// Wait for virtiofsd to create its socket before starting QEMU
const vfsWaitStart = Date.now();
while (!fs.existsSync(virtiofsSock) &&
Date.now() - vfsWaitStart < 5000) {
await new Promise(r => setTimeout(r, 100));
}
if (fs.existsSync(virtiofsSock)) {
log('KvmBackend: virtiofsd socket ready ' +
`(${Date.now() - vfsWaitStart}ms)`);
this.homeShareType = 'virtiofs';
} else {
log('KvmBackend: virtiofsd socket not ready ' +
'after 5s, will try virtio-9p fallback');
this.virtiofsdProcess.kill();
this.virtiofsdProcess = null;
}
} catch (e) {
log(`KvmBackend: virtiofsd not available: ${e.message}`);
this.virtiofsdProcess = null;
}
// Fallback: use virtio-9p if virtiofsd failed. virtio-9p is
// built into QEMU — no external daemon, no privileges needed.
// Lower performance than virtiofs but works everywhere.
if (!this.virtiofsdProcess) {
log('KvmBackend: using virtio-9p for home directory share');
this.homeShareType = '9p';
}
// Build QEMU arguments
// When virtiofs is used, QEMU requires shared memory backend for
// vhost-user-fs-pci. Use memory-backend-memfd with share=on.
const useSharedMem = this.homeShareType === 'virtiofs';
const qemuArgs = [
'-enable-kvm',
...(useSharedMem
? ['-object', `memory-backend-memfd,id=mem,size=${memoryGB}G,share=on`,
'-numa', 'node,memdev=mem',
'-m', `${memoryGB}G`]
: ['-m', `${memoryGB}G`]),
'-cpu', 'host',
'-smp', String(cpuCount),
'-nographic',
];
// Kernel and initrd (if available)
if (fs.existsSync(vmlinuzPath)) {
qemuArgs.push('-kernel', vmlinuzPath);
if (fs.existsSync(initrdPath)) {
qemuArgs.push('-initrd', initrdPath);
}
qemuArgs.push(
'-append', 'root=LABEL=cloudimg-rootfs console=ttyS0 quiet'
);
}
// Disk (rootfs overlay → /dev/vda)
qemuArgs.push(
'-drive', `file=${overlayPath},format=qcow2,if=virtio`
);
// Session disk (→ /dev/vdb, formatted by guest sdk-daemon)
const sessionDiskPath = path.join(this.sessionDir, 'sessiondata.qcow2');
try {
execFileSync('qemu-img', [
'create', '-f', 'qcow2', sessionDiskPath, '2G'
], { stdio: 'pipe' });
qemuArgs.push(
'-drive', `file=${sessionDiskPath},format=qcow2,if=virtio`
);
log(`KvmBackend: session disk created at ${sessionDiskPath}`);
} catch (e) {
logError('KvmBackend: session disk creation failed:', e.message);
}
// smol-bin disk (contains SDK binaries → /dev/vdc, detected
// by guest via blkid). The app copies smol-bin.vhdx from
// resources to bundleDir at startup. Convert to qcow2 if needed.
const smolVhdx = path.join(bundleDir, 'smol-bin.vhdx');
const smolQcow2 = path.join(bundleDir, 'smol-bin.qcow2');
if (fs.existsSync(smolVhdx) && !fs.existsSync(smolQcow2)) {
log('KvmBackend: converting smol-bin.vhdx to qcow2...');
try {
execFileSync('qemu-img', [
'convert', '-f', 'vhdx', '-O', 'qcow2',
smolVhdx, smolQcow2
], { stdio: 'pipe', timeout: 60000 });
log('KvmBackend: smol-bin conversion complete');
} catch (e) {
log(`KvmBackend: smol-bin conversion failed: ${e.message}`);
}
}
// Check bundle dir first, then VM_BASE_DIR.
// Not fatal if missing — SDK can be accessed via virtiofs.
const smolBinPath =
[bundleDir, VM_BASE_DIR]
.map(d => path.join(d, 'smol-bin.qcow2'))
.find(p => fs.existsSync(p));
if (smolBinPath) {
qemuArgs.push(
'-drive',
`file=${smolBinPath},format=qcow2,if=virtio,readonly=on`
);
log(`KvmBackend: smol-bin attached from ${smolBinPath}`);
} else {
log('KvmBackend: smol-bin.qcow2 not found — ' +
'SDK will be accessed via virtiofs if available');
}
// vsock
qemuArgs.push(
'-device', `vhost-vsock-pci,guest-cid=${this.guestCid}`
);
// QMP monitor
qemuArgs.push(
'-qmp', `unix:${this.monitorSock},server,nowait`
);
// Network
qemuArgs.push(
'-netdev', 'user,id=net0',
'-device', 'virtio-net-pci,netdev=net0'
);
// Home directory share device
if (this.homeShareType === 'virtiofs') {
// virtiofs: high performance, requires virtiofsd daemon
qemuArgs.push(
'-chardev', `socket,id=virtiofs,path=${virtiofsSock}`,
'-device',
`vhost-user-fs-pci,chardev=virtiofs,tag=${HOME_SHARE_MOUNT_TAG}`,
);
} else if (this.homeShareType === '9p') {
// virtio-9p: built into QEMU, no daemon, works unprivileged.
// security_model=none: like passthrough but ignores chown
// failures — designed for unprivileged QEMU operation.
qemuArgs.push(
'-virtfs',
`local,path=${os.homedir()},mount_tag=${HOME_SHARE_MOUNT_TAG}` +
',security_model=none,id=hostshare',
);
}
// Start QEMU
this.emitEvent({
type: 'startupStep',
step: 'start_vm', status: 'running',
});
log(`KvmBackend: starting QEMU with CID ${this.guestCid}`);
this.qemuProcess = spawnProcess('qemu-system-x86_64', qemuArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
});
this.qemuProcess.on('error', (err) => {
logError('KvmBackend: QEMU error:', err.message);
this.running = false;
this.guestConnected = false;
this.emitEvent({ type: 'networkStatus', status: 'disconnected' });
});
this.qemuProcess.on('exit', (code, signal) => {
log(`KvmBackend: QEMU exited: code=${code}, signal=${signal}`);
this.running = false;
this.guestConnected = false;
this.emitEvent({ type: 'networkStatus', status: 'disconnected' });
});
this.qemuProcess.stderr.on('data', (data) => {
log(`KvmBackend QEMU stderr: ${data.toString().trim()}`);
});
this.running = true;
// Connect to QMP monitor and send capabilities
await this._connectQmp();
// Wait for guest sdk-daemon to connect via vsock bridge
// (_waitForGuest starts both the bridge server and socat listener)
this.emitEvent({
type: 'startupStep',
step: 'wait_for_guest', status: 'running',
});
await this._waitForGuest();
this.emitEvent({
type: 'startupStep',
step: 'wait_for_guest',
status: this.guestConnected ? 'completed' : 'failed',
});
return {};
}
async _connectQmp() {
const timeout = 30000;
const start = Date.now();
return new Promise((resolve) => {
const tryConnect = () => {
if (Date.now() - start > timeout) {
logError('KvmBackend: QMP connection timeout — VM control limited');
this._qmpAvailable = false;
resolve();
return;
}
if (!fs.existsSync(this.monitorSock)) {
setTimeout(tryConnect, 200);
return;
}
const qmpClient = net.createConnection(
this.monitorSock, () => {
log('KvmBackend: QMP connected');
}
);
let qmpBuffer = '';
qmpClient.on('data', (data) => {
qmpBuffer += data.toString();
// Wait for QMP greeting, then send capabilities
if (qmpBuffer.includes('"QMP"')) {
qmpClient.write(QMP_CAPABILITIES + '\n');
qmpBuffer = '';
}
if (qmpBuffer.includes('"return"')) {
log('KvmBackend: QMP capabilities negotiated');
this._qmpClient = qmpClient;
resolve();
}
});
qmpClient.on('error', (err) => {
log('KvmBackend: QMP connect error:', err.message);
setTimeout(tryConnect, 500);
});
};
// Give QEMU a moment to create the socket
setTimeout(tryConnect, 500);
});
}
_startVsockBridge() {
// The guest sdk-daemon connects TO the host (CID=2) on the vsock port.
// We listen on vsock and forward to a local Unix bridge socket so that
// _forwardToGuest can connect to the bridge to reach the guest daemon.
//
// Direction: guest → vsock:51234 → socat → bridge.sock
// _forwardToGuest → bridge.sock → socat → vsock → guest
//
// socat listens on the vsock port for the guest's outbound connection
// and bridges it to a Unix socket that we can use for bidirectional RPC.
try {
this.socatProcess = spawnProcess('socat', [
`VSOCK-LISTEN:${VSOCK_GUEST_PORT},reuseaddr,fork`,
`UNIX-CONNECT:${this.bridgeSock}`,
], {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.socatProcess.on('error', (err) => {
log('KvmBackend: socat error:', err.message);
});
log(`KvmBackend: socat vsock listener started on port ${VSOCK_GUEST_PORT}`);
} catch (e) {
logError('KvmBackend: failed to start socat:', e.message);
}
}
_startBridgeServer() {
// Create a Unix socket server that accepts connections from socat
// (guest→vsock→socat→bridge.sock) and from _forwardToGuest.
// The first inbound connection from socat is the guest sdk-daemon.
return new Promise((resolve) => {
this._bridgeServer = net.createServer((conn) => {
if (!this.guestConnected) {
log('KvmBackend: guest connected via vsock bridge');
this.guestConnected = true;
this._guestConn = conn;
this._guestBuffer = Buffer.alloc(0);
conn.on('data', (data) => {
this._handleGuestData(data);
});
conn.on('error', (err) => {
logError('KvmBackend: guest connection error:', err.message);
this.guestConnected = false;
this._guestConn = null;
});
conn.on('close', () => {
log('KvmBackend: guest connection closed');
this.guestConnected = false;
this._guestConn = null;
});
this.emitEvent({
type: 'networkStatus',
status: 'connected',
});
resolve();
}
});
this._bridgeServer.listen(this.bridgeSock, () => {
log(`KvmBackend: bridge server listening on ${this.bridgeSock}`);
});
this._bridgeServer.on('error', (err) => {
logError('KvmBackend: bridge server error:', err.message);
});
});
}
_handleGuestData(data) {
// Parse and route incoming messages from guest sdk-daemon
this._guestBuffer = Buffer.concat([this._guestBuffer, data]);
while (true) {
const parsed = parseMessage(this._guestBuffer);
if (!parsed) break;
this._guestBuffer = parsed.remaining;
const msg = parsed.message;
// Log all guest messages as decoded JSON for debugging
log('KvmBackend: guest message:', JSON.stringify(msg).substring(0, 500));
if (FORWARDED_EVENTS.has(msg.type)) {
this.emitEvent(msg);
} else if (msg.type === 'event' && FORWARDED_EVENTS.has(msg.event)) {
// Guest sends {type:"event", event:"networkStatus", params:{...}}
this.emitEvent({ type: msg.event, ...msg.params });
} else if (msg.type === 'response' || msg.success !== undefined) {
// Response to a request we sent — route to pending callback
// Guest sends {type:"response", id:"1", result:{success:true}}
if (msg.error) {
log(`KvmBackend: guest response ERROR for id=${msg.id}:`, JSON.stringify(msg.error));
}
if (this._pendingCallbacks && msg.id !== undefined) {
const cb = this._pendingCallbacks.get(String(msg.id));
if (cb) {
this._pendingCallbacks.delete(String(msg.id));
cb(msg.result || msg);
}
}
} else {
log('KvmBackend: unhandled guest message:', JSON.stringify(msg));
}
}
}
async _waitForGuest() {
const timeout = 90000;
const start = Date.now();
// Start the bridge Unix socket server, then start socat to listen on
// vsock. The guest sdk-daemon will connect after boot.
const bridgeReady = this._startBridgeServer();
this._startVsockBridge();
// Wait for guest to connect (or timeout)
return Promise.race([
bridgeReady,
new Promise((resolve) => {
const checkTimeout = () => {
if (Date.now() - start > timeout) {
logError('KvmBackend: guest readiness timeout');
resolve();
return;
}
if (this.guestConnected) {
resolve();
return;
}
setTimeout(checkTimeout, 1000);
};
setTimeout(checkTimeout, 2000);
}),
]);
}
_sendQmpCommand(command) {
return new Promise((resolve, reject) => {
if (!this._qmpClient || this._qmpClient.destroyed) {
reject(new Error('QMP not connected'));
return;
}
let responseBuffer = '';
let timer;
const onData = (data) => {
responseBuffer += data.toString();
try {
const parsed = JSON.parse(responseBuffer);
clearTimeout(timer);
this._qmpClient.removeListener('data', onData);
resolve(parsed);
} catch (_) {
// Incomplete JSON, keep buffering
}
};
this._qmpClient.on('data', onData);
this._qmpClient.write(
JSON.stringify({ execute: command }) + '\n'
);
timer = setTimeout(() => {
this._qmpClient.removeListener('data', onData);
reject(new Error('QMP command timeout'));
}, 10000);
});
}
async _ensureSdkInstalled() {
if (!this._pendingSdkInstall || !this.guestConnected) return;
try {
log('KvmBackend: installing SDK in guest');
await this._forwardToGuest({
method: 'installSdk', params: this._pendingSdkInstall
});
} catch (e) {
log(`KvmBackend: installSdk forward failed: ${e.message}`);
}
// Clear regardless of success/failure to avoid infinite retries
this._pendingSdkInstall = null;
}
_forwardToGuest(request) {
return new Promise((resolve, reject) => {
if (!this._guestConn || !this.guestConnected) {
reject(new Error('Guest not connected'));
return;
}
// Assign a unique ID if not present, so we can match responses
if (request.id === undefined) {
if (!this._nextRequestId) this._nextRequestId = 1;
request.id = String(this._nextRequestId++);
}
if (!this._pendingCallbacks) {
this._pendingCallbacks = new Map();
}
const timer = setTimeout(() => {
this._pendingCallbacks.delete(request.id);
reject(new Error('Guest communication timeout'));
}, 30000);
this._pendingCallbacks.set(request.id, (response) => {
clearTimeout(timer);
resolve(response);
});
try {
// Guest expects {type:"request", method:..., params:..., id:...}
const wireMsg = { type: 'request', ...request };
log('KvmBackend: forwarding to guest:', JSON.stringify(wireMsg).substring(0, 200));
writeMessage(this._guestConn, wireMsg);
} catch (err) {
clearTimeout(timer);
this._pendingCallbacks.delete(request.id);
reject(err);
}
});
}
async stopVM() {
log('KvmBackend: stopVM');
// 1. ACPI shutdown via QMP
try {
await this._sendQmpCommand('system_powerdown');
log('KvmBackend: ACPI shutdown sent');
} catch (e) {
log('KvmBackend: ACPI shutdown failed:', e.message);
}
// 2. Wait 10s, then force quit via QMP
await new Promise((resolve) => {
const checkExit = () => {
if (!this.qemuProcess || this.qemuProcess.exitCode !== null) {
resolve();
return;
}
// Force quit after waiting
this._sendQmpCommand('quit').catch(() => {});
setTimeout(() => {
resolve();
}, 3000);
};
setTimeout(checkExit, 10000);
});
// 3. SIGKILL if still running
if (this.qemuProcess && this.qemuProcess.exitCode === null) {
try {
this.qemuProcess.kill('SIGKILL');
log('KvmBackend: QEMU force killed');
} catch (e) {
log('KvmBackend: QEMU kill error:', e.message);
}
}
// 4. Kill helper processes and close connections
const cleanup = (obj, method) => {
if (!obj) return;
try { obj[method](); } catch (_) {}
};
cleanup(this.virtiofsdProcess, 'kill');
cleanup(this.socatProcess, 'kill');
cleanup(this._qmpClient, 'destroy');
cleanup(this._guestConn, 'destroy');
cleanup(this._bridgeServer, 'close');
this.virtiofsdProcess = null;
this.homeShareType = null;
this.socatProcess = null;
this._qmpClient = null;
this._guestConn = null;
this._bridgeServer = null;
// 5. Clean up session directory
if (this.sessionDir) {
try {
fs.rmSync(this.sessionDir, { recursive: true, force: true });
log(`KvmBackend: cleaned up session dir: ${this.sessionDir}`);
} catch (e) {
log('KvmBackend: session cleanup error:', e.message);
}
this.sessionDir = null;
}
this.running = false;
this.guestConnected = false;
this.qemuProcess = null;
this.emitEvent({ type: 'networkStatus', status: 'disconnected' });
return {};
}
isRunning() {
return { running: this.running };
}
isGuestConnected() {
return { connected: this.guestConnected };
}
async spawn(params) {
const { id } = params;
log(`KvmBackend spawn: id=${id}, forwarding to guest`);
// Ensure SDK is installed in the guest before spawning
await this._ensureSdkInstalled();
try {
const result = await this._forwardToGuest({
method: 'spawn', params
});
// Track that this process exists in the guest.
// Events (stdout/stderr/exit) flow back through the
// single guest connection → _handleGuestData → emitEvent.
this.processes.set(id, { remote: true });
return result.result || {};
} catch (e) {
logError(`KvmBackend: spawn forward failed: ${e.message}`);
this.emitEvent({
type: 'stderr', id,
data: `Error: Failed to spawn in VM: ${e.message}\n`,
});
this.emitEvent({
type: 'exit', id, exitCode: 1,
signal: null,
});
return {};
}
}
async kill(params) {
log(`KvmBackend kill: id=${params.id}`);
try {
await this._forwardToGuest({ method: 'kill', params });
} catch (e) {
log(`KvmBackend: kill forward failed: ${e.message}`);
}
return {};
}
async writeStdin(params) {
// Guest RPC treats stdin as a notification (fire-and-forget),
// not a request. Sending as type:"request" returns "unknown method".
if (!this._guestConn || !this.guestConnected) {
log('KvmBackend: writeStdin: guest not connected');
return {};
}
try {
writeMessage(this._guestConn, {
type: 'notification', method: 'stdin', params,
});
} catch (e) {
log(`KvmBackend: writeStdin failed: ${e.message}`);
}
return {};
}
isProcessRunning(params) {
const { id } = params;
return { running: this.processes.has(id) };
}
async mountPath(params) {
const { subpath, mountName } = params;
log(`KvmBackend mountPath: ${mountName} -> ${subpath}`);
if (this.homeShareType) {
// Home share active (virtiofs or 9p) — guest accesses
// host files via the shared mount
const guestPath =
path.join(HOME_SHARE_GUEST_MOUNT, subpath || '');
return { guestPath };
}
// No home share — return host path with a warning
const hostPath = path.join(os.homedir(), subpath || '');
log('KvmBackend: no home share, returning host path');
return { guestPath: hostPath };
}
async readFile(params) {
const { filePath } = params;
log(`KvmBackend readFile: ${filePath}`);
// Try forwarding to guest first
if (this.guestConnected) {
try {
const result = await this._forwardToGuest({
method: 'readFile', params
});
if (result.result) return result.result;
} catch (e) {
log(`KvmBackend: guest readFile failed, trying host: ${e.message}`);
}
}
// Fallback: read from host
const resolved = path.resolve(filePath);
const home = os.homedir();
if (!resolved.startsWith(home + path.sep) && resolved !== home) {
return { error: 'Access denied: path outside home directory' };
}
try {
const content = fs.readFileSync(resolved, 'utf8');
return { content };
} catch (e) {
return { error: e.message };
}
}
async installSdk(params) {
const { sdkSubpath, version } = params;
log(`KvmBackend installSdk: ${sdkSubpath}@${version}`);
const resolved = resolveSdkBinary(
sdkSubpath, version, 'KvmBackend'
);
if (resolved) {
this.sdkBinaryPath = resolved;
// Compute the guest-side path via home share mount
const homeDir = os.homedir();
const relPath = path.relative(homeDir, resolved);
if (relPath.startsWith('..')) {
log('KvmBackend: SDK path is outside home dir,' +
` cannot map to guest: ${resolved}`);
} else {
this.guestSdkPath = path.join(
HOME_SHARE_GUEST_MOUNT, relPath
);
log(`KvmBackend: guest SDK path: ${this.guestSdkPath}`);
}
}
// Forward to guest so it can prepare the SDK (or defer until spawn)
this._pendingSdkInstall = params;
if (this.guestConnected) {
await this._ensureSdkInstalled();
} else {
log('KvmBackend: guest not connected yet, will install SDK before spawn');
}
return {};
}
async addApprovedOauthToken(params) {
log('KvmBackend: addApprovedOauthToken');
// Forward to guest if connected
if (this.guestConnected) {
try {
await this._forwardToGuest({
method: 'addApprovedOauthToken', params
});
} catch (e) {
log('KvmBackend: OAuth forward failed:', e.message);
}
}
return {};
}
}
// ============================================================
// Backend Detection
// ============================================================
function detectBackend(emitEvent) {
const override = BACKEND_OVERRIDE;
if (override) {
log(`Backend override: ${override}`);
switch (override.toLowerCase()) {
case 'kvm':
return new KvmBackend(emitEvent);
case 'bwrap':
return new BwrapBackend(emitEvent);
case 'host':
return new HostBackend(emitEvent);
default:
logError(`Unknown backend override "${override}", falling back to auto-detect`);
}
}
// 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');
// Hint for users upgrading from KVM-first auto-detection
try {
fs.accessSync('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK);
log('Note: KVM is available but bwrap is now the default. '
+ 'Set COWORK_VM_BACKEND=kvm for full VM isolation.');
} catch (_) { /* KVM not available, no hint needed */ }
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.
try {
fs.accessSync('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK);
execFileSync('which', ['qemu-system-x86_64'], { stdio: 'pipe' });
fs.accessSync('/dev/vhost-vsock', fs.constants.R_OK);
log('Backend: kvm (all requirements met)');
return new KvmBackend(emitEvent);
} catch (e) {
log(`KVM not available: ${e.message}`);
}
log('Backend: host (no isolation)');
return new HostBackend(emitEvent);
}
// ============================================================
// VMManager — Thin Dispatcher
// ============================================================
class VMManager {
constructor() {
this.eventSubscribers = new Set();
this.backend = detectBackend((event) => this.broadcastEvent(event));
}
// --- Configuration ---
configure(params) {
const config = {};
if (params.memoryMB !== undefined) config.memoryMB = params.memoryMB;
if (params.cpuCount !== undefined) config.cpuCount = params.cpuCount;
// init is async but configure is sync in the protocol —
// fire-and-forget is fine for config
this.backend.init(config).catch((e) => {
logError('Backend init error:', e.message);
});
log('Configured:', params);
return {};
}
// --- VM Lifecycle (delegate to backend) ---
async createVM(params) {
log(`createVM: bundle=${params.bundlePath}`);
return {};
}
async startVM(params) {
return this.backend.startVM(params);
}
async stopVM() {
return this.backend.stopVM();
}
isRunning() {
return this.backend.isRunning();
}
isGuestConnected() {
return this.backend.isGuestConnected();
}
// --- Process Management (delegate to backend) ---
async spawn(params) {
return this.backend.spawn(params);
}
async kill(params) {
return this.backend.kill(params);
}
async writeStdin(params) {
return this.backend.writeStdin(params);
}
isProcessRunning(params) {
return this.backend.isProcessRunning(params);
}
// --- File System (delegate to backend) ---
async mountPath(params) {
return this.backend.mountPath(params);
}
async readFile(params) {
return this.backend.readFile(params);
}
// --- SDK Management (delegate to backend) ---
async installSdk(params) {
return this.backend.installSdk(params);
}
// --- OAuth (delegate to backend) ---
async addApprovedOauthToken(params) {
return this.backend.addApprovedOauthToken(params);
}
// --- Debug Logging ---
setDebugLogging(params) {
const { enabled } = params;
log(`setDebugLogging: ${enabled}`);
return {};
}
// --- Events (managed by VMManager, not backend) ---
subscribeEvents(socket) {
this.eventSubscribers.add(socket);
socket.on('close', () => {
this.eventSubscribers.delete(socket);
});
return {};
}
broadcastEvent(event) {
for (const socket of this.eventSubscribers) {
try {
writeMessage(socket, event);
} catch (e) {
log('Failed to send event:', e.message);
this.eventSubscribers.delete(socket);
}
}
}
}
// ============================================================
// Method Dispatch
// ============================================================
const vm = new VMManager();
const METHODS = {
configure: (params) => vm.configure(params),
createVM: (params) => vm.createVM(params),
startVM: (params) => vm.startVM(params),
stopVM: () => vm.stopVM(),
isRunning: () => vm.isRunning(),
isGuestConnected: () => vm.isGuestConnected(),
spawn: (params) => vm.spawn(params),
kill: (params) => vm.kill(params),
writeStdin: (params) => vm.writeStdin(params),
isProcessRunning: (params) => vm.isProcessRunning(params),
mountPath: (params) => vm.mountPath(params),
readFile: (params) => vm.readFile(params),
installSdk: (params) => vm.installSdk(params),
addApprovedOauthToken: (params) => vm.addApprovedOauthToken(params),
setDebugLogging: (params) => vm.setDebugLogging(params),
subscribeEvents: (params, socket) => vm.subscribeEvents(socket),
};
async function handleRequest(request, socket) {
const { method, params } = request;
// Redact env block (may contain API keys/tokens)
if (params) {
const { env, ...rest } = params;
const summary = JSON.stringify(rest).substring(0, 2000)
+ (env ? ' [env: redacted]' : '');
log(`Request: ${method}`, summary);
} else {
log(`Request: ${method}`);
}
const handler = METHODS[method];
if (!handler) {
return { success: false, error: `Unknown method: ${method}` };
}
try {
const result = await handler(params || {}, socket);
return { success: true, result: result || {} };
} catch (e) {
logError(`Method ${method} failed:`, e.message);
return { success: false, error: e.message };
}
}
// ============================================================
// Socket Server
// ============================================================
function cleanupSocket() {
try {
if (fs.existsSync(SOCKET_PATH)) {
fs.unlinkSync(SOCKET_PATH);
}
} catch (e) {
// Ignore cleanup errors
}
}
function startServer() {
// Clean up stale socket
cleanupSocket();
const server = net.createServer((socket) => {
log('Client connected');
let buffer = Buffer.alloc(0);
socket.on('data', async (data) => {
buffer = Buffer.concat([buffer, data]);
// Process all complete messages in buffer
let parsed;
try {
parsed = parseMessage(buffer);
} catch (e) {
logError('Parse error:', e.message);
buffer = Buffer.alloc(0);
return;
}
while (parsed) {
buffer = parsed.remaining;
const response = await handleRequest(parsed.message, socket);
// Echo back request id so persistent-connection clients
// can match responses to pending requests.
if (parsed.message.id !== undefined) {
response.id = parsed.message.id;
}
writeMessage(socket, response);
try {
parsed = parseMessage(buffer);
} catch (e) {
logError('Parse error:', e.message);
buffer = Buffer.alloc(0);
return;
}
}
});
socket.on('error', (err) => {
if (err.code !== 'ECONNRESET' && err.code !== 'EPIPE') {
log('Socket error:', err.message);
}
});
socket.on('close', () => {
log('Client disconnected');
});
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
logError('Socket already in use:', SOCKET_PATH);
logError('Another instance may be running. Exiting.');
process.exit(1);
}
logError('Server error:', err.message);
});
server.listen(SOCKET_PATH, () => {
// Set socket permissions (owner-only access)
try {
fs.chmodSync(SOCKET_PATH, 0o700);
} catch (e) {
// Non-fatal
}
log(`Listening on ${SOCKET_PATH}`);
console.log(`${LOG_PREFIX} Service started on ${SOCKET_PATH}`);
});
// Graceful shutdown
const shutdown = () => {
log('Shutting down...');
vm.stopVM().catch(() => {}).finally(() => {
server.close();
cleanupSocket();
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('uncaughtException', (err) => {
logError('Uncaught exception:', err);
shutdown();
});
}
// ============================================================
// Entry Point
// ============================================================
// Always clean up stale socket and start. The app's retry wrapper has a
// 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();