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 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 10:49:53 +00:00
co-authored by Claude Opus 4.6
parent a0d9ea63f9
commit daf5580c39
12 changed files with 467 additions and 190 deletions
+29 -17
View File
@@ -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<string, string>; // 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 = {
+68
View File
@@ -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<string, unknown>).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'));
+180
View File
@@ -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<string, VncSession>();
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<void> {
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);
}
}