fix /desktop: break the VNC password deadlock, drop the vncpasswd dependency

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 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-26 05:15:46 +01:00
co-authored by Claude Opus 5
parent 96dd1bfe9e
commit 7556c9ed00
6 changed files with 73 additions and 22 deletions
+19 -4
View File
@@ -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) => {
+9
View File
@@ -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<string> {
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 });
}
+3
View File
@@ -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 }
+11
View File
@@ -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 });
+27 -15
View File
@@ -44,36 +44,48 @@ function isProcessAlive(pid: number): boolean {
}
}
async function ensureVncPassword(homeDir: string): Promise<string> {
// 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<boolean> {
@@ -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) {