From daf5580c398789f4155fb68487fb6955aadecc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 6 Mar 2026 10:49:53 +0000 Subject: [PATCH] vnc sidecar: per-user desktop sessions via sidecar architecture replaces the single hardcoded systemd VNC service with a dynamic sidecar that manages per-user VNC sessions on demand. any authenticated user can now access their own desktop, not just Super Admin. Co-Authored-By: Claude Opus 4.6 --- ecosystem.config.cjs | 22 ++- scripts/cleanup-desktop.sh | 18 +-- scripts/provision-existing-users.sh | 23 ++++ scripts/setup-desktop.sh | 114 +++------------- src/servers/api/desktop/rest.ts | 10 ++ src/servers/api/desktop/vnc-config.ts | 14 +- src/servers/api/desktop/websocket.ts | 43 ++++-- src/servers/api/users/provision.ts | 47 +++++++ src/servers/sidecar-registry.ts | 72 +++++----- src/servers/sidecar/protocol.ts | 46 ++++--- src/servers/sidecar/vnc/index.ts | 68 ++++++++++ src/servers/sidecar/vnc/vnc-manager.ts | 180 +++++++++++++++++++++++++ 12 files changed, 467 insertions(+), 190 deletions(-) create mode 100644 src/servers/sidecar/vnc/index.ts create mode 100644 src/servers/sidecar/vnc/vnc-manager.ts diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index bf151e32..74e1cee1 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -7,9 +7,21 @@ module.exports = { watch: false, }, { - name: 'officer-sidecar', + name: 'officer-claude', script: 'bun', - args: 'run src/servers/sidecar/index.ts', + args: 'run src/servers/sidecar/claude/index.ts', + watch: false, + }, + { + name: 'officer-pi', + script: 'bun', + args: 'run src/servers/sidecar/pi/index.ts', + watch: false, + }, + { + name: 'officer-email', + script: 'bun', + args: 'run src/servers/sidecar/email/index.ts', watch: false, }, { @@ -18,5 +30,11 @@ module.exports = { args: 'src/servers/api/terminal/pty-sidecar.mjs', watch: false, }, + { + name: 'officer-vnc', + script: 'bun', + args: 'run src/servers/sidecar/vnc/index.ts', + watch: false, + }, ], }; diff --git a/scripts/cleanup-desktop.sh b/scripts/cleanup-desktop.sh index 3b0f72ce..1405c235 100755 --- a/scripts/cleanup-desktop.sh +++ b/scripts/cleanup-desktop.sh @@ -3,20 +3,12 @@ set -euo pipefail echo "=== Cleaning up Remote Desktop setup ===" -# Stop and remove VNC service -echo "[1/6] Removing VNC service..." -sudo systemctl stop officer-vnc 2>/dev/null || true -sudo systemctl disable officer-vnc 2>/dev/null || true -sudo rm -f /etc/systemd/system/officer-vnc.service -sudo systemctl daemon-reload -vncserver -kill :1 2>/dev/null || true - # Remove VNC config -echo "[2/6] Removing VNC config..." +echo "[1/5] Removing VNC config..." rm -rf ~/.vnc # Remove Brave -echo "[3/6] Removing Brave..." +echo "[2/5] Removing Brave..." sudo apt remove -y --purge brave-browser 2>/dev/null || true sudo rm -f /usr/share/keyrings/brave-browser-archive-keyring.gpg sudo rm -f /etc/apt/sources.list.d/brave-browser-release.list @@ -25,20 +17,20 @@ sudo rm -f /usr/bin/brave-browser-stable rm -rf ~/.config/BraveSoftware # Remove Chromium (snap + deb) -echo "[4/6] Removing Chromium..." +echo "[3/5] Removing Chromium..." sudo snap remove chromium 2>/dev/null || true sudo apt remove -y --purge chromium-browser 2>/dev/null || true rm -rf ~/.config/chromium # Remove XFCE, TigerVNC, dbus-x11 -echo "[5/6] Removing XFCE, TigerVNC, dbus-x11..." +echo "[4/5] Removing XFCE, TigerVNC, dbus-x11..." sudo apt remove -y --purge xfce4 xfce4-goodies tigervnc-standalone-server tigervnc-common dbus-x11 2>/dev/null || true sudo apt autoremove -y 2>/dev/null || true rm -rf ~/.config/xfce4 rm -rf ~/.cache/xfce4 # Remove keyring data -echo "[6/6] Removing keyring data..." +echo "[5/5] Removing keyring data..." rm -rf ~/.local/share/keyrings rm -f ~/.config/autostart/gnome-keyring-*.desktop sudo apt install -y -qq gnome-keyring > /dev/null 2>&1 || true # Restore if needed by other apps diff --git a/scripts/provision-existing-users.sh b/scripts/provision-existing-users.sh index 55511413..4aee797d 100755 --- a/scripts/provision-existing-users.sh +++ b/scripts/provision-existing-users.sh @@ -160,6 +160,29 @@ while IFS='|' read -r email username; do sudo mkdir -p "$HOME_DIR/.local/bin" sudo mkdir -p "$HOME_DIR/.pi/agent/sessions" + # VNC environment + VNC_DIR="$HOME_DIR/.vnc" + sudo mkdir -p "$VNC_DIR" + if [ ! -f "$VNC_DIR/passwd" ]; then + VNC_PASS=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 8) + echo -n "$VNC_PASS" | sudo tee "$VNC_DIR/password" > /dev/null + echo -n "$VNC_PASS" | vncpasswd -f | sudo tee "$VNC_DIR/passwd" > /dev/null + sudo tee "$VNC_DIR/xstartup" > /dev/null << 'XSTARTUP' +#!/bin/sh +unset SESSION_MANAGER +unset DBUS_SESSION_BUS_ADDRESS +eval $(dbus-launch --sh-syntax) +export DBUS_SESSION_BUS_ADDRESS +exec startxfce4 +XSTARTUP + sudo chmod +x "$VNC_DIR/xstartup" + sudo chmod 600 "$VNC_DIR/passwd" + sudo chmod 600 "$VNC_DIR/password" + ok "Provisioned VNC environment" + else + skip "VNC environment" + fi + # Set ownership and permissions last # chmod 770 so only owner and group can access (service user is added to group above) sudo chown -R "$shell_user:$shell_user" "$USER_ROOT" diff --git a/scripts/setup-desktop.sh b/scripts/setup-desktop.sh index 781bcf64..0369f0cd 100755 --- a/scripts/setup-desktop.sh +++ b/scripts/setup-desktop.sh @@ -1,22 +1,15 @@ #!/bin/bash set -euo pipefail -# Officer Remote Desktop Setup -# Run as the user who will own the VNC session (not root). -# Usage: ./scripts/setup-desktop.sh [resolution] - -VNC_PASS="${1:-$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 8)}" -RESOLUTION="${2:-1920x1080}" -USER_NAME="$(whoami)" -ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env" +# Officer Remote Desktop Setup — System packages only. +# Per-user VNC config is handled by user provisioning (provision.ts). +# Usage: ./scripts/setup-desktop.sh echo "=== Officer Remote Desktop Setup ===" -echo "User: $USER_NAME" -echo "Resolution: $RESOLUTION" echo "" # --- Step 1: Install system packages --- -echo "[1/8] Installing system packages..." +echo "[1/4] Installing system packages..." sudo apt update -qq sudo apt install -y -qq \ xfce4 xfce4-goodies \ @@ -26,7 +19,7 @@ sudo apt install -y -qq \ echo " Done." # --- Step 2: Install Brave browser (native .deb, not snap) --- -echo "[2/8] Installing Brave browser..." +echo "[2/4] Installing Brave browser..." if ! command -v brave-browser-stable > /dev/null 2>&1; then sudo curl -fsSLo /usr/share/keyrings/brave-browser-archive-keyring.gpg \ https://brave-browser-apt-release.s3.brave.com/brave-browser-archive-keyring.gpg @@ -35,8 +28,7 @@ if ! command -v brave-browser-stable > /dev/null 2>&1; then sudo apt update -qq sudo apt install -y -qq brave-browser > /dev/null 2>&1 fi -# Fix launcher symlink (the installed /usr/bin/brave-browser-stable is a copy -# that breaks because $HERE resolves to /usr/bin instead of /opt/brave.com/brave) +# Fix launcher symlink if [ -f /opt/brave.com/brave/brave-browser ]; then sudo rm -f /usr/bin/brave-browser-stable sudo ln -s /opt/brave.com/brave/brave-browser /usr/bin/brave-browser-stable @@ -46,97 +38,31 @@ sudo mkdir -p /etc/brave echo '--password-store=basic' | sudo tee /etc/brave/brave-flags.conf > /dev/null echo " Done." -# --- Step 3: Configure VNC password --- -echo "[3/8] Configuring VNC password..." -mkdir -p ~/.vnc -echo "$VNC_PASS" | vncpasswd -f > ~/.vnc/passwd -chmod 600 ~/.vnc/passwd -# Plain-text password + port for the Officer server to read -echo -n "$VNC_PASS" > ~/.vnc/password -chmod 600 ~/.vnc/password -echo "5901" > ~/.vnc/port -echo " Done." - -# --- Step 4: Create xstartup --- -echo "[4/8] Creating VNC xstartup..." -cat > ~/.vnc/xstartup << 'XSTARTUP' -#!/bin/sh -unset SESSION_MANAGER -unset DBUS_SESSION_BUS_ADDRESS -eval $(dbus-launch --sh-syntax) -export DBUS_SESSION_BUS_ADDRESS -exec startxfce4 -XSTARTUP -chmod +x ~/.vnc/xstartup -echo " Done." - -# --- Step 5: Remove GNOME Keyring (prevents password prompts on login) --- -echo "[5/8] Removing GNOME Keyring..." +# --- Step 3: Remove GNOME Keyring (prevents password prompts on login) --- +echo "[3/4] Removing GNOME Keyring..." sudo apt remove -y --purge gnome-keyring > /dev/null 2>&1 || true rm -rf ~/.local/share/keyrings echo " Done." -# --- Step 6: Create systemd service --- -echo "[6/8] Creating systemd service..." -sudo tee /etc/systemd/system/officer-vnc.service > /dev/null << EOF -[Unit] -Description=TigerVNC Server for Officer Remote Desktop -After=syslog.target network.target - -[Service] -Type=simple -User=$USER_NAME -ExecStartPre=/bin/sh -c '/usr/bin/vncserver -kill :1 > /dev/null 2>&1 || true' -ExecStart=/usr/bin/vncserver :1 -geometry $RESOLUTION -depth 24 -localhost yes -fg -ExecStop=/usr/bin/vncserver -kill :1 -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target -EOF -sudo systemctl daemon-reload -sudo systemctl enable --now officer-vnc -echo " Done." - -# --- Step 7: Set default browser --- -echo "[7/8] Setting default browser..." -sleep 2 # Wait for VNC session to start +# --- Step 4: Set default browser --- +echo "[4/4] Setting default browser..." if command -v brave-browser-stable > /dev/null 2>&1; then sudo update-alternatives --set x-www-browser /opt/brave.com/brave/brave 2>/dev/null || true - DISPLAY=:1 xdg-settings set default-web-browser brave-browser.desktop 2>/dev/null || true echo " Brave set as default." else echo " No supported browser found, skipping." fi -# --- Step 8: Add env vars --- -echo "[8/8] Updating .env..." -if [ -f "$ENV_FILE" ]; then - grep -q '^VNC_PASSWORD=' "$ENV_FILE" && sed -i "s/^VNC_PASSWORD=.*/VNC_PASSWORD=$VNC_PASS/" "$ENV_FILE" || echo "VNC_PASSWORD=$VNC_PASS" >> "$ENV_FILE" - grep -q '^VNC_PORT=' "$ENV_FILE" && sed -i "s/^VNC_PORT=.*/VNC_PORT=5901/" "$ENV_FILE" || echo "VNC_PORT=5901" >> "$ENV_FILE" -else - echo "VNC_PASSWORD=$VNC_PASS" >> "$ENV_FILE" - echo "VNC_PORT=5901" >> "$ENV_FILE" -fi -echo " Done." - -# --- Verify --- -echo "" -echo "=== Verification ===" -sleep 2 -if systemctl is-active --quiet officer-vnc; then - echo "VNC service: running" -else - echo "VNC service: FAILED — check 'journalctl -u officer-vnc'" -fi - -LISTEN=$(ss -tlnp | grep 5901 | head -1) -if echo "$LISTEN" | grep -q '127.0.0.1'; then - echo "VNC binding: localhost only (secure)" -else - echo "VNC binding: WARNING — check 'ss -tlnp | grep 5901'" +# --- Cleanup old systemd service if it exists --- +if systemctl list-unit-files officer-vnc.service &>/dev/null; then + echo "" + echo "Removing old officer-vnc systemd service..." + sudo systemctl stop officer-vnc 2>/dev/null || true + sudo systemctl disable officer-vnc 2>/dev/null || true + sudo rm -f /etc/systemd/system/officer-vnc.service + sudo systemctl daemon-reload + echo " Done." fi echo "" -echo "Setup complete. Restart the Officer server and navigate to /desktop." +echo "Setup complete. Per-user VNC sessions are managed by the VNC sidecar." diff --git a/src/servers/api/desktop/rest.ts b/src/servers/api/desktop/rest.ts index 7b786b10..38e0318b 100644 --- a/src/servers/api/desktop/rest.ts +++ b/src/servers/api/desktop/rest.ts @@ -1,5 +1,6 @@ import { createRouter } from '../../create-router'; import { getVncPassword } from './vnc-config'; +import * as sidecar from '@@/sidecar-registry'; export const desktopRouter = createRouter(); @@ -11,3 +12,12 @@ desktopRouter.get('/vnc-password', async (ctx) => { } return ctx.json({ password }); }); + +desktopRouter.get('/vnc-status', async (ctx) => { + const user = ctx.get('user'); + if (!sidecar.isVncConnected()) { + return ctx.json({ connected: false, session: null }); + } + const session = await sidecar.getVncStatus(user.email); + return ctx.json({ connected: true, session }); +}); diff --git a/src/servers/api/desktop/vnc-config.ts b/src/servers/api/desktop/vnc-config.ts index 3b0873f2..53178c9d 100644 --- a/src/servers/api/desktop/vnc-config.ts +++ b/src/servers/api/desktop/vnc-config.ts @@ -1,11 +1,8 @@ import { join } from 'node:path'; -import { getHomeDir } from '@@/data-path'; +import { getHomeDirForRole } from '@@/data-path'; function getVncDir(email: string, role: string | null): string { - const home = role === 'Super Admin' && process.env.HOME_DIR - ? process.env.HOME_DIR - : getHomeDir(email); - return join(home, '.vnc'); + return join(getHomeDirForRole(email, role), '.vnc'); } export async function getVncPassword(email: string, role: string | null): Promise { @@ -13,10 +10,3 @@ export async function getVncPassword(email: string, role: string | null): Promis if (!(await file.exists())) return null; return (await file.text()).trim(); } - -export async function getVncPort(email: string, role: string | null): Promise { - const file = Bun.file(join(getVncDir(email, role), 'port')); - if (!(await file.exists())) return 5901; - const port = parseInt((await file.text()).trim(), 10); - return isNaN(port) ? 5901 : port; -} diff --git a/src/servers/api/desktop/websocket.ts b/src/servers/api/desktop/websocket.ts index f51908c4..1541bfe8 100644 --- a/src/servers/api/desktop/websocket.ts +++ b/src/servers/api/desktop/websocket.ts @@ -1,6 +1,6 @@ import type { ServerWebSocket } from 'bun'; import type { Socket } from 'bun'; -import { getVncPort } from './vnc-config'; +import * as sidecar from '@@/sidecar-registry'; type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string }; @@ -13,15 +13,25 @@ const sessions = new Map, VncSession>(); export const desktopWebsocket = { async open(ws: ServerWebSocket) { - if (ws.data.role !== 'Super Admin') { - ws.close(4003, 'Forbidden'); - return; - } - - const port = await getVncPort(ws.data.email, ws.data.role); const session: VncSession = { tcpSocket: null, pendingMessages: [] }; sessions.set(ws, session); + let port: number; + try { + const result = await sidecar.startVnc({ + email: ws.data.email, + username: ws.data.username, + role: ws.data.role, + }); + port = result.port; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to start VNC session'; + console.error('[desktop] VNC start failed:', message); + sessions.delete(ws); + ws.close(4004, 'VNC server unavailable'); + return; + } + try { const tcpSocket = await Bun.connect({ hostname: '127.0.0.1', @@ -36,20 +46,31 @@ export const desktopWebsocket = { }, close() { sessions.delete(ws); - try { ws.close(); } catch { /* ignore */ } + try { + ws.close(); + } catch { + /* ignore */ + } }, error(_socket, err) { console.error('[desktop] TCP error:', err.message); sessions.delete(ws); - try { ws.close(); } catch { /* ignore */ } + try { + ws.close(); + } catch { + /* ignore */ + } }, connectError(_socket, err) { console.error('[desktop] TCP connect error:', err.message); sessions.delete(ws); - try { ws.close(4004, 'VNC server unavailable'); } catch { /* ignore */ } + try { + ws.close(4004, 'VNC server unavailable'); + } catch { + /* ignore */ + } }, open(socket) { - // Flush any pending messages for (const msg of session.pendingMessages) { socket.write(msg); } diff --git a/src/servers/api/users/provision.ts b/src/servers/api/users/provision.ts index 19ee64ce..710a5a19 100644 --- a/src/servers/api/users/provision.ts +++ b/src/servers/api/users/provision.ts @@ -83,6 +83,9 @@ export async function provisionLinuxUser(email: string, username: string): Promi const settingsContent = await Bun.file(settingsFile).text(); await Bun.write(join(claudeDir, 'settings.json'), settingsContent); + // Provision VNC environment + await provisionVncEnv(homeDir); + // Service user (pastilhas) owns everything — server can always read/write. // User's personal group gives only that user terminal access. Others get nothing. const serviceUser = process.env.USER ?? 'pastilhas'; @@ -139,6 +142,50 @@ async function seedShellConfigs(homeDir: string): Promise { mkdirSync(join(homeDir, '.pi', 'agent', 'sessions'), { recursive: true }); } +async function provisionVncEnv(homeDir: string): Promise { + const vncDir = join(homeDir, '.vnc'); + mkdirSync(vncDir, { recursive: true }); + + // Skip if already provisioned + if (existsSync(join(vncDir, 'passwd'))) return; + + // Generate random 8-char password + const password = Array.from(crypto.getRandomValues(new Uint8Array(6))) + .map((b) => String.fromCharCode(33 + (b % 94))) + .join(''); + + // Write plaintext password (for API to read) + await Bun.write(join(vncDir, 'password'), password); + + // Create encrypted passwd using vncpasswd -f + const proc = Bun.spawnSync({ + cmd: ['bash', '-c', `echo '${password.replace(/'/g, "'\\''")}' | vncpasswd -f`], + stdout: 'pipe', + stderr: 'ignore', + }); + + if (proc.exitCode === 0 && proc.stdout.byteLength > 0) { + await Bun.write(join(vncDir, 'passwd'), proc.stdout); + } + + // Write xstartup + const xstartup = `#!/bin/sh +unset SESSION_MANAGER +unset DBUS_SESSION_BUS_ADDRESS +eval $(dbus-launch --sh-syntax) +export DBUS_SESSION_BUS_ADDRESS +exec startxfce4 +`; + await Bun.write(join(vncDir, 'xstartup'), xstartup); + + // Set permissions + run(['chmod', '+x', join(vncDir, 'xstartup')]); + run(['chmod', '600', join(vncDir, 'passwd')]); + run(['chmod', '600', join(vncDir, 'password')]); + + console.log(`[provision] VNC environment provisioned at ${vncDir}`); +} + export function deprovisionLinuxUser(email: string, username: string): boolean { const shellUsername = toShellUsername(username, email); console.log(`[provision] deprovisioning Linux user ${shellUsername}`); diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index 6434d30c..b213ff6b 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -2,17 +2,18 @@ import type { ServerWebSocket } from 'bun'; import type { SidecarCommand, SidecarEvent, - SidecarState, + ClaudeState, ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult, PiSpawnParams, PtyCommand, PtyEvent, + VncStartParams, + VncSessionInfo, } from './sidecar/protocol'; import type { SidecarRegistration } from './sidecar/registration-protocol'; import type { PiEvent } from './api/pi/types'; -import type { Job, EnqueueParams } from './queue/types'; // ── Types ── @@ -36,7 +37,7 @@ type EventHandler = (event: SidecarEvent | PtyEvent) => void; const sidecars = new Map(); const pending = new Map(); const eventHandlers = new Map>(); -let cachedState: SidecarState | null = null; +let cachedState: ClaudeState | null = null; let idCounter = 0; function nextId(): string { @@ -81,7 +82,7 @@ export function unregisterSidecar(id: string): void { pending.delete(reqId); } - // Clear cached state if the process sidecar disconnects + // Clear cached state if the claude sidecar disconnects if (sc.capabilities.includes('proxy')) { cachedState = null; } @@ -110,12 +111,6 @@ function findSidecarByCapability(cap: string): RegisteredSidecar | undefined { return undefined; } -function requireSidecar(cap: string): RegisteredSidecar { - const sc = findSidecarByCapability(cap); - if (!sc) throw new Error(`No sidecar with capability "${cap}" is connected`); - return sc; -} - // ── Event dispatch ── function dispatchEvent(msg: SidecarEvent | PtyEvent) { @@ -171,17 +166,17 @@ function sendFire(cap: string, cmd: SidecarCommand | PtyCommand): void { } } -// ── Public API (same signatures as sidecar-client.ts) ── +// ── Public API ── export function isConnected(): boolean { return findSidecarByCapability('proxy') !== undefined; } -export function getCachedState(): SidecarState | null { +export function getCachedState(): ClaudeState | null { return cachedState; } -export async function syncState(): Promise { +export async function syncState(): Promise { const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() }); if (res.type === 'state:sync') { cachedState = res.state; @@ -272,34 +267,6 @@ export function onPiEvent(handler: (sessionId: string, event: PiEvent) => void): }); } -// ── Queue ── - -export async function enqueueJob(params: EnqueueParams): Promise { - const res = await sendCommand('queue', { type: 'queue:enqueue', id: nextId(), params }); - if (res.type === 'queue:enqueued') return res.job; - if (res.type === 'queue:error') throw new Error(res.error); - throw new Error('Unexpected response'); -} - -export async function cancelJob(jobId: string): Promise { - const res = await sendCommand('queue', { type: 'queue:cancel', id: nextId(), jobId }); - if (res.type === 'queue:cancelled') return res.job; - if (res.type === 'queue:error') throw new Error(res.error); - throw new Error('Unexpected response'); -} - -export async function listJobs(): Promise { - const res = await sendCommand('queue', { type: 'queue:list', id: nextId() }); - if (res.type === 'queue:list') return res.jobs; - throw new Error('Unexpected response'); -} - -export async function getJob(jobId: string): Promise { - const res = await sendCommand('queue', { type: 'queue:get', id: nextId(), jobId }); - if (res.type === 'queue:get') return res.job; - throw new Error('Unexpected response'); -} - // ── Terminal (PTY sidecar) ── export function sendPtyCommand(cmd: PtyCommand): void { @@ -313,3 +280,26 @@ export async function sendPtyCommandAsync(cmd: PtyCommand, timeoutMs = DEFAULT_T export function isTerminalConnected(): boolean { return findSidecarByCapability('terminal') !== undefined; } + +// ── VNC ── + +export async function startVnc(params: VncStartParams): Promise<{ port: number; display: number }> { + const res = await sendCommand('vnc', { type: 'vnc:start', id: nextId(), params }); + if (res.type === 'vnc:started') return { port: res.port, display: res.display }; + if (res.type === 'vnc:error') throw new Error(res.error); + throw new Error('Unexpected response'); +} + +export function stopVnc(email: string): void { + sendFire('vnc', { type: 'vnc:stop', id: nextId(), email }); +} + +export async function getVncStatus(email: string): Promise { + const res = await sendCommand('vnc', { type: 'vnc:status', id: nextId(), email }); + if (res.type === 'vnc:status') return res.session; + throw new Error('Unexpected response'); +} + +export function isVncConnected(): boolean { + return findSidecarByCapability('vnc') !== undefined; +} diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 172875e1..d5f9218e 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -1,5 +1,4 @@ import type { MessageCost, PiEvent } from '../api/pi/types'; -import type { Job, EnqueueParams, JobProgress } from '../queue/types'; // ── Envelope ── @@ -23,17 +22,16 @@ export type SidecarCommand = | { type: 'pi:abort'; id: string; sessionId: string; requestId: string } | { type: 'pi:kill'; id: string; sessionId: string } | { type: 'pi:set-thinking'; id: string; sessionId: string; level: string } - // Queue - | { type: 'queue:enqueue'; id: string; params: EnqueueParams } - | { type: 'queue:cancel'; id: string; jobId: string } - | { type: 'queue:list'; id: string } - | { type: 'queue:get'; id: string; jobId: string }; + // VNC + | { type: 'vnc:start'; id: string; params: VncStartParams } + | { type: 'vnc:stop'; id: string; email: string } + | { type: 'vnc:status'; id: string; email: string }; // ── Responses/Events (sidecar → API server) ── export type SidecarEvent = | { type: 'pong'; id: string } - | { type: 'state:sync'; id: string; state: SidecarState } + | { type: 'state:sync'; id: string; state: ClaudeState } | { type: 'proxy:secret'; id: string; secret: string } // Claude Code | { type: 'claude:spawned'; id: string; sessionKey: string } @@ -47,22 +45,19 @@ export type SidecarEvent = | { type: 'pi:event'; sessionId: string; event: PiEvent } | { type: 'pi:error'; id: string; error: string } | { type: 'pi:killed'; id: string } - // Queue - | { type: 'queue:enqueued'; id: string; job: Job } - | { type: 'queue:cancelled'; id: string; job: Job | null } - | { type: 'queue:list'; id: string; jobs: Job[] } - | { type: 'queue:get'; id: string; job: Job | null } - | { type: 'queue:error'; id: string; error: string } + // VNC + | { type: 'vnc:started'; id: string; port: number; display: number } + | { type: 'vnc:stopped'; id: string } + | { type: 'vnc:status'; id: string; session: VncSessionInfo | null } + | { type: 'vnc:error'; id: string; error: string } // Generic | { type: 'error'; id?: string; error: string }; -// ── Shared state snapshot ── +// ── Claude sidecar state ── -export type SidecarState = { +export type ClaudeState = { proxySecret: string; claudeSessions: Record; // sessionKey → Claude Code session_id - piSessions: PiSessionInfo[]; - uptime: number; }; export type PiSessionInfo = { @@ -114,6 +109,23 @@ export type PiSpawnParams = { sessionFile?: string; }; +// ── VNC types ── + +export type VncStartParams = { + email: string; + username: string; + role: string | null; + resolution?: string; +}; + +export type VncSessionInfo = { + email: string; + display: number; + port: number; + pid: number; + alive: boolean; +}; + // ── PTY types ── export type PtyInitConfig = { diff --git a/src/servers/sidecar/vnc/index.ts b/src/servers/sidecar/vnc/index.ts new file mode 100644 index 00000000..f19acafb --- /dev/null +++ b/src/servers/sidecar/vnc/index.ts @@ -0,0 +1,68 @@ +import type { SidecarCommand, SidecarEvent } from '../protocol'; +import * as vncManager from './vnc-manager'; +import { createSidecarConnector } from '../connect'; + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +// ── Command handlers ── + +type ReplyFn = (msg: SidecarEvent) => void; + +async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { + switch (cmd.type) { + case 'ping': + reply({ type: 'pong', id: cmd.id }); + break; + + case 'vnc:start': { + try { + const { port, display } = await vncManager.startSession(cmd.params); + reply({ type: 'vnc:started', id: cmd.id, port, display }); + } catch (err) { + reply({ type: 'vnc:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) }); + } + break; + } + + case 'vnc:stop': + vncManager.stopSession(cmd.email); + reply({ type: 'vnc:stopped', id: cmd.id }); + break; + + case 'vnc:status': { + const session = vncManager.getSession(cmd.email); + reply({ type: 'vnc:status', id: cmd.id, session }); + break; + } + + default: + reply({ + type: 'error', + id: (cmd as SidecarCommand).id, + error: `Unknown command type: ${(cmd as Record).type}`, + }); + } +} + +// ── Connect to API server ── + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'vnc', + capabilities: ['vnc'], + onCommand(cmd, reply) { + handleCommand(cmd as SidecarCommand, reply as ReplyFn); + }, +}); + +// ── Graceful shutdown ── + +function shutdown(signal: string) { + console.log(`[vnc] ${signal} received, stopping all sessions...`); + vncManager.stopAll(); + connection.destroy(); + process.exit(0); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts new file mode 100644 index 00000000..53f535ec --- /dev/null +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -0,0 +1,180 @@ +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { VncStartParams, VncSessionInfo } from '../protocol'; +import { getHomeDirForRole, toShellUsername } from '@@/data-path'; + +type VncSession = { + email: string; + username: string; + display: number; + port: number; + pid: number; +}; + +const sessions = new Map(); + +function findFreeDisplay(): number { + for (let n = 1; n <= 99; n++) { + if (existsSync(`/tmp/.X${n}-lock`)) continue; + // Also check no existing session uses this display + let inUse = false; + for (const s of sessions.values()) { + if (s.display === n) { + inUse = true; + break; + } + } + if (!inUse) return n; + } + throw new Error('No free display number available'); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function ensureVncEnv(homeDir: string): Promise { + const vncDir = join(homeDir, '.vnc'); + if (existsSync(join(vncDir, 'passwd'))) return; + + mkdirSync(vncDir, { recursive: true }); + + // Generate random 8-char password + const password = Array.from(crypto.getRandomValues(new Uint8Array(6))) + .map((b) => String.fromCharCode(33 + (b % 94))) + .join(''); + + // Write plaintext password (for API to read) + await Bun.write(join(vncDir, 'password'), password); + + // Create encrypted passwd using vncpasswd -f + const proc = Bun.spawnSync({ + cmd: ['bash', '-c', `echo '${password.replace(/'/g, "'\\''")}' | vncpasswd -f`], + stdout: 'pipe', + stderr: 'ignore', + }); + if (proc.exitCode === 0 && proc.stdout.byteLength > 0) { + await Bun.write(join(vncDir, 'passwd'), proc.stdout); + } + + // Write xstartup + await Bun.write( + join(vncDir, 'xstartup'), + `#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\neval $(dbus-launch --sh-syntax)\nexport DBUS_SESSION_BUS_ADDRESS\nexec startxfce4\n`, + ); + + Bun.spawnSync({ cmd: ['chmod', '+x', join(vncDir, 'xstartup')], stdout: 'ignore', stderr: 'ignore' }); + Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'passwd')], stdout: 'ignore', stderr: 'ignore' }); + Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'password')], stdout: 'ignore', stderr: 'ignore' }); + + console.log(`[vnc] lazy-provisioned VNC environment at ${vncDir}`); +} + +export async function startSession(params: VncStartParams): Promise<{ port: number; display: number }> { + // If session already exists and is alive, return it + const existing = sessions.get(params.email); + if (existing && isProcessAlive(existing.pid)) { + return { port: existing.port, display: existing.display }; + } + + // Clean up stale session + if (existing) { + sessions.delete(params.email); + } + + const shellUsername = toShellUsername(params.username, params.email); + const homeDir = getHomeDirForRole(params.email, params.role); + const resolution = params.resolution ?? '1920x1080'; + const display = findFreeDisplay(); + const port = 5900 + display; + + // Lazy-provision VNC environment if missing + await ensureVncEnv(homeDir); + + // Spawn VNC server as the target user + const proc = Bun.spawn({ + cmd: [ + 'sudo', + '-u', + shellUsername, + 'vncserver', + `:${display}`, + '-geometry', + resolution, + '-depth', + '24', + '-localhost', + 'yes', + ], + env: { ...process.env, HOME: homeDir }, + stdout: 'pipe', + stderr: 'pipe', + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`vncserver failed (exit ${exitCode}): ${stderr.trim()}`); + } + + // Read PID from lock file + let pid = 0; + const lockFile = `/tmp/.X${display}-lock`; + if (existsSync(lockFile)) { + const content = await Bun.file(lockFile).text(); + pid = parseInt(content.trim(), 10) || 0; + } + + sessions.set(params.email, { email: params.email, username: params.username, display, port, pid }); + console.log(`[vnc] started session for ${params.email} on :${display} (port ${port}, pid ${pid})`); + + return { port, display }; +} + +export function stopSession(email: string): void { + const session = sessions.get(email); + if (!session) return; + + const shellUsername = toShellUsername(session.username, email); + const homeDir = getHomeDirForRole(email, null); + + Bun.spawnSync({ + cmd: ['sudo', '-u', shellUsername, 'vncserver', '-kill', `:${session.display}`], + env: { ...process.env, HOME: homeDir }, + stdout: 'ignore', + stderr: 'ignore', + }); + + sessions.delete(email); + console.log(`[vnc] stopped session for ${email} on :${session.display}`); +} + +export function getSession(email: string): VncSessionInfo | null { + const session = sessions.get(email); + if (!session) return null; + + const alive = session.pid > 0 && isProcessAlive(session.pid); + if (!alive) { + sessions.delete(email); + return null; + } + + return { + email: session.email, + display: session.display, + port: session.port, + pid: session.pid, + alive, + }; +} + +export function stopAll(): void { + for (const email of [...sessions.keys()]) { + stopSession(email); + } +}