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
+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) {