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:
co-authored by
Claude Opus 5
parent
96dd1bfe9e
commit
7556c9ed00
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user