From 7556c9ed00874c52d2407a2260fa289021475643 Mon Sep 17 00:00:00 2001 From: brunorezio Date: Sun, 26 Jul 2026 05:15:46 +0100 Subject: [PATCH] fix /desktop: break the VNC password deadlock, drop the vncpasswd dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop page has never worked on a fresh install. Two faults, both fatal. The password could never be created. DesktopView fetches /desktop/vnc-password before opening the WebSocket, but ensureVncPassword ran only from startSession, which only the WebSocket triggers — so the endpoint answered "not configured", the UI stopped, and the socket that would have provisioned it was never opened. A new vnc:ensure-password sidecar command provisions it directly; the endpoint asks for it instead of returning 500. The rfbauth file could never be written either. ensureVncPassword shelled out to tigervnc's `vncpasswd -f`, which is not installed — and, contrary to the comment in setup-desktop.sh, is not in tigervnc-common, which ships only tigervncconfig. The failure was swallowed because only a zero exit wrote the file, so x11vnc got -rfbauth pointing at nothing. x11vnc writes that format itself with -storepasswd, so the dependency is gone and a failure now throws. Verified on the box: the endpoint returns a password, .vnc/{passwd,password} are written 0600, and the sidecar reports mirroring :0 on 5900 with x11vnc using the generated rfbauth file. Co-Authored-By: Claude Opus 5 --- scripts/setup-desktop.sh | 7 +++-- src/servers/api/desktop/rest.ts | 23 +++++++++++--- src/servers/sidecar-registry.ts | 9 ++++++ src/servers/sidecar/protocol.ts | 3 ++ src/servers/sidecar/vnc/index.ts | 11 +++++++ src/servers/sidecar/vnc/vnc-manager.ts | 42 +++++++++++++++++--------- 6 files changed, 73 insertions(+), 22 deletions(-) diff --git a/scripts/setup-desktop.sh b/scripts/setup-desktop.sh index 8db18518..decc0ee4 100755 --- a/scripts/setup-desktop.sh +++ b/scripts/setup-desktop.sh @@ -21,11 +21,12 @@ sudo DEBIAN_FRONTEND=noninteractive apt install -y -qq \ ubuntu-desktop \ gdm3 \ x11vnc \ - tigervnc-common \ dbus-x11 echo " Done." -# tigervnc-common provides `vncpasswd`, which the VNC sidecar uses to write the .vnc/passwd rfbauth -# file that x11vnc mirrors :0 with (see vnc-manager.ts). x11vnc alone does NOT ship vncpasswd. +# No tigervnc package is needed. The VNC sidecar writes the .vnc/passwd rfbauth file with +# `x11vnc -storepasswd` (see vnc-manager.ts). tigervnc-common was installed here for `vncpasswd`, +# but it does not ship that binary — it only provides tigervncconfig — so the rfbauth file was never +# created and the desktop could not authenticate. # --- Step 2: Force GDM onto Xorg + enable auto-login (x11vnc cannot mirror Wayland) --- echo "[2/5] Forcing Xorg session and auto-login in GDM..." diff --git a/src/servers/api/desktop/rest.ts b/src/servers/api/desktop/rest.ts index 6701b581..3cac087a 100644 --- a/src/servers/api/desktop/rest.ts +++ b/src/servers/api/desktop/rest.ts @@ -4,13 +4,28 @@ import * as sidecar from '@@/sidecar-registry'; export const desktopRouter = createRouter(); +// The desktop UI asks for the password before it can open the WebSocket — and that WebSocket is what +// starts the VNC session. So this cannot wait for a session to exist: on a fresh install nothing has +// ever written the password, and answering "not configured" deadlocked the page permanently. Ask the +// sidecar to provision it instead; it owns the .vnc directory and the call is idempotent. desktopRouter.get('/vnc-password', async (ctx) => { const user = ctx.get('user'); - const password = await getVncPassword(user.email); - if (!password) { - return ctx.json({ error: 'VNC password not configured' }, 500); + + const existing = await getVncPassword(user.email); + if (existing) return ctx.json({ password: existing }); + + if (!sidecar.isVncConnected()) { + return ctx.json({ error: 'VNC sidecar is not connected' }, 503); + } + + try { + const password = await sidecar.ensureVncPassword(user.email); + return ctx.json({ password }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Could not provision the VNC password'; + console.error('[desktop] VNC password provisioning failed:', message); + return ctx.json({ error: message }, 500); } - return ctx.json({ password }); }); desktopRouter.get('/vnc-status', async (ctx) => { diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index f1a80d86..28cff231 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -409,6 +409,15 @@ export async function startVnc(params: VncStartParams): Promise<{ port: number; throw new Error('Unexpected response'); } +// Provision the VNC password without starting a server. The desktop UI needs it before it can open +// the WebSocket that would start one, so asking vnc:start here would be circular. +export async function ensureVncPassword(email: string): Promise { + const res = await sendCommand('vnc', { type: 'vnc:ensure-password', id: nextId(), email }); + if (res.type === 'vnc:password') return res.password; + if (res.type === 'vnc:error') throw new Error(res.error); + throw new Error('Unexpected response from VNC sidecar'); +} + export function stopVnc(email: string): void { sendFire('vnc', { type: 'vnc:stop', id: nextId(), email }); } diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index d4aa9124..880f8c2b 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -21,6 +21,8 @@ export type SidecarCommand = | { type: 'opencode:kill'; id: string; sessionKey: string } // VNC | { type: 'vnc:start'; id: string; params: VncStartParams } + // Provision the VNC password without starting a server — the UI needs it before it can connect + | { type: 'vnc:ensure-password'; id: string; email: string } | { type: 'vnc:stop'; id: string; email: string } | { type: 'vnc:status'; id: string; email: string }; @@ -39,6 +41,7 @@ export type SidecarEvent = | { type: 'claude:session-cleared'; id: string } // VNC | { type: 'vnc:started'; id: string; port: number; display: number } + | { type: 'vnc:password'; id: string; password: string } | { type: 'vnc:stopped'; id: string } | { type: 'vnc:status'; id: string; session: VncSessionInfo | null } | { type: 'vnc:error'; id: string; error: string } diff --git a/src/servers/sidecar/vnc/index.ts b/src/servers/sidecar/vnc/index.ts index f19acafb..b8af5611 100644 --- a/src/servers/sidecar/vnc/index.ts +++ b/src/servers/sidecar/vnc/index.ts @@ -1,6 +1,7 @@ import type { SidecarCommand, SidecarEvent } from '../protocol'; import * as vncManager from './vnc-manager'; import { createSidecarConnector } from '../connect'; +import { getOwnerHomeDir } from '@@/data-path'; const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; @@ -24,6 +25,16 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { break; } + case 'vnc:ensure-password': { + try { + const { password } = await vncManager.ensureVncPassword(getOwnerHomeDir(cmd.email)); + reply({ type: 'vnc:password', id: cmd.id, password }); + } 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 }); diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index aaae0add..0c3ce84f 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -44,36 +44,48 @@ function isProcessAlive(pid: number): boolean { } } -async function ensureVncPassword(homeDir: string): Promise { +// Provision the pair of files a VNC session needs: `password` in plaintext, which the API hands to +// the browser, and `passwd` in rfbauth format, which x11vnc authenticates against. Idempotent — +// returns the existing password when both are already present. +// +// x11vnc writes the rfbauth file itself via -storepasswd. This used to shell out to tigervnc's +// `vncpasswd -f`, which is not installed by default (and is not in tigervnc-common, despite what +// setup-desktop.sh claimed), so the rfbauth file silently never appeared — the failure was swallowed +// because only a zero exit code triggered the write. +export async function ensureVncPassword(homeDir: string): Promise<{ password: string; passwdFile: string }> { const vncDir = join(homeDir, '.vnc'); const passwdFile = join(vncDir, 'passwd'); - if (existsSync(passwdFile)) return passwdFile; + const plainFile = join(vncDir, 'password'); + + if (existsSync(passwdFile) && existsSync(plainFile)) { + const password = (await Bun.file(plainFile).text()).trim(); + if (password) return { password, passwdFile }; + } mkdirSync(vncDir, { recursive: true }); - // Generate random 8-char password - const password = Array.from(crypto.getRandomValues(new Uint8Array(6))) + // 8 printable ASCII characters. VNC truncates to 8, so there is no point generating more. + const password = Array.from(crypto.getRandomValues(new Uint8Array(8))) .map((b) => String.fromCharCode(33 + (b % 94))) .join(''); - // Write plaintext password (for API to read) - await Bun.write(join(vncDir, 'password'), password); + await Bun.write(plainFile, password); - // Create encrypted passwd using vncpasswd -f const proc = Bun.spawnSync({ - cmd: ['bash', '-c', `echo '${password.replace(/'/g, "'\\''")}' | vncpasswd -f`], - stdout: 'pipe', - stderr: 'ignore', + cmd: ['x11vnc', '-storepasswd', password, passwdFile], + stdout: 'ignore', + stderr: 'pipe', }); - if (proc.exitCode === 0 && proc.stdout.byteLength > 0) { - await Bun.write(passwdFile, proc.stdout); + if (proc.exitCode !== 0 || !existsSync(passwdFile)) { + const stderr = proc.stderr.toString().trim(); + throw new Error(`Could not write the VNC password file: ${stderr || 'x11vnc -storepasswd failed'}`); } Bun.spawnSync({ cmd: ['chmod', '600', passwdFile], stdout: 'ignore', stderr: 'ignore' }); - Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'password')], stdout: 'ignore', stderr: 'ignore' }); + Bun.spawnSync({ cmd: ['chmod', '600', plainFile], stdout: 'ignore', stderr: 'ignore' }); console.log(`[vnc] provisioned VNC password at ${vncDir}`); - return passwdFile; + return { password, passwdFile }; } async function isPortOpen(port: number): Promise { @@ -107,7 +119,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb mirror = null; const homeDir = getOwnerHomeDir(params.email); - const passwdFile = await ensureVncPassword(homeDir); + const { passwdFile } = await ensureVncPassword(homeDir); const xauthority = resolveXauthority(); if (!xauthority) {