diff --git a/apps/cli/src/commands/screen.ts b/apps/cli/src/commands/screen.ts index fa6a59b..cc04bce 100644 --- a/apps/cli/src/commands/screen.ts +++ b/apps/cli/src/commands/screen.ts @@ -97,7 +97,13 @@ export const screenCommand = new Command('screen') const engine = await detectEngine(); const urls = buildVncUrls(box, engine); - const resolved = opts.loopback ? urls.loopbackUrl : (urls.orbUrl ?? urls.loopbackUrl); + // Preference when --loopback is off: portless > orb.local > loopback. + // Portless gives a stable name across box restarts (loopback port + // rerolls every `docker run`); orb.local is OrbStack-only; loopback is + // the always-available fallback. `--loopback` forces the raw port. + const resolved = opts.loopback + ? urls.loopbackUrl + : (urls.portlessUrl ?? urls.orbUrl ?? urls.loopbackUrl); if (!resolved) { throw new Error( `VNC URL unavailable (daemon may not be up); try \`agentbox inspect ${box.name}\``, diff --git a/packages/core/src/box-record.ts b/packages/core/src/box-record.ts index 5eb4a2e..edb1ac0 100644 --- a/packages/core/src/box-record.ts +++ b/packages/core/src/box-record.ts @@ -43,6 +43,10 @@ export interface DockerBoxFields { portlessAlias?: string; /** Full user-facing URL the Portless proxy serves for this box. */ portlessUrl?: string; + /** Portless route name registered for this box's noVNC port (`vnc-`). */ + portlessVncAlias?: string; + /** Full user-facing URL the Portless proxy serves for this box's VNC. */ + portlessVncUrl?: string; /** Volume mounted at /var/lib/docker for the in-box dockerd. */ dockerVolume?: string; /** True when this box's `dockerVolume` is the shared cache. */ @@ -202,6 +206,10 @@ export interface BoxRecord { portlessAlias?: string; /** Full user-facing URL the Portless proxy serves for this box. Docker only. */ portlessUrl?: string; + /** Portless route name registered for this box's noVNC port (`vnc-`). */ + portlessVncAlias?: string; + /** Full user-facing URL the Portless proxy serves for this box's VNC. */ + portlessVncUrl?: string; /** Volume mounted at /var/lib/docker for the in-box dockerd. Docker only. */ dockerVolume?: string; /** True when this box's `dockerVolume` is the shared cache. Docker only. */ diff --git a/packages/sandbox-cloud/src/cloud-provider.ts b/packages/sandbox-cloud/src/cloud-provider.ts index a4333f0..226f76a 100644 --- a/packages/sandbox-cloud/src/cloud-provider.ts +++ b/packages/sandbox-cloud/src/cloud-provider.ts @@ -150,6 +150,34 @@ function parseLoopbackPort(url: string): number | undefined { } } +/** + * Register a single host Portless alias `.localhost -> ` + * when `previewUrl` resolves to a loopback `http://127.0.0.1:` (Hetzner's + * `ssh -L` forward). For backends that return a public URL (Daytona's signed + * preview) the alias is naturally skipped. Best-effort: every Portless call + * already swallows; we never throw from here. Returns the resolved URL on + * success. + */ +async function registerHostPortlessAlias(args: { + alias: string; + previewUrl: string; + label: string; + onLog: (line: string) => void; +}): Promise { + const localPort = parseLoopbackPort(args.previewUrl); + if (localPort === undefined) return undefined; + const ok = await portlessAlias(args.alias, localPort); + if (!ok) { + args.onLog( + `portless: ${args.label} alias not registered (portless CLI missing or not running) — host URL stays http://127.0.0.1:${String(localPort)}`, + ); + return undefined; + } + const url = await portlessGetUrl(args.alias); + args.onLog(`portless alias ${url} -> 127.0.0.1:${String(localPort)}`); + return url; +} + /** * Register the host Portless alias for `.localhost -> ` * and bring up the in-VPS mirror proxy so the same URL works from inside the @@ -162,17 +190,13 @@ async function bootstrapPortlessForCloudBox( handle: CloudHandle, args: { boxName: string; webPreviewUrl: string; webPort: number; onLog: (line: string) => void }, ): Promise<{ alias: string; url: string } | undefined> { - const localPort = parseLoopbackPort(args.webPreviewUrl); - if (localPort === undefined) return undefined; - const ok = await portlessAlias(args.boxName, localPort); - if (!ok) { - args.onLog( - `portless: alias not registered (portless CLI missing or not running) — host URL stays http://127.0.0.1:${String(localPort)}`, - ); - return undefined; - } - const url = await portlessGetUrl(args.boxName); - args.onLog(`portless alias ${url} -> 127.0.0.1:${String(localPort)}`); + const url = await registerHostPortlessAlias({ + alias: args.boxName, + previewUrl: args.webPreviewUrl, + label: 'web', + onLog: args.onLog, + }); + if (!url) return undefined; if (backend.startInBoxPortless) { const mode = parsePortlessUrl(url) ?? { proxyPort: DEFAULT_PORTLESS_PROXY_PORT, tls: false }; try { @@ -454,6 +478,33 @@ export function createCloudProvider( portlessUrlResolved = r.url; } } + // Parallel `vnc-.localhost` alias against the in-box noVNC + // port. Host-only — no in-box mirror; an agent inside the box opening + // its own VNC view is a degenerate self-loop. Same loopback-URL gate + // as the web path, so Daytona naturally skips and Hetzner registers. + let vncPreview: { url: string; token?: string } | undefined; + if (portlessOpt && vncEnabled) { + try { + vncPreview = await backend.previewUrl(handle, CLOUD_VNC_PORT); + } catch { + vncPreview = undefined; + } + } + let portlessVncAliasName: string | undefined; + let portlessVncUrlResolved: string | undefined; + if (portlessOpt && vncPreview) { + const vncAlias = `vnc-${name}`; + const url = await registerHostPortlessAlias({ + alias: vncAlias, + previewUrl: vncPreview.url, + label: 'vnc', + onLog: log, + }); + if (url) { + portlessVncAliasName = vncAlias; + portlessVncUrlResolved = url; + } + } // Per-service preview URLs. Each `services.*.expose.port` from // `agentbox.yaml` gets a direct preview URL alongside the main // WebProxy URL — lets users hit services without going through the @@ -531,6 +582,8 @@ export function createCloudProvider( carry: carrySummary, portlessAlias: portlessAliasName, portlessUrl: portlessUrlResolved, + portlessVncAlias: portlessVncAliasName, + portlessVncUrl: portlessVncUrlResolved, vncEnabled, vncPassword, vncContainerPort: vncEnabled ? CLOUD_VNC_PORT : undefined, @@ -642,11 +695,35 @@ export function createCloudProvider( portlessUrlResolved = r.url; } } + // Same story for the VNC alias — the ssh -L port for 6080 is fresh. + // Best-effort, silent (startBox has no onLog). Skipped when no VNC + // alias was set at create. + let portlessVncAliasName: string | undefined = box.portlessVncAlias; + let portlessVncUrlResolved: string | undefined = box.portlessVncUrl; + if (box.portlessVncAlias && box.vncEnabled) { + try { + const vncPreview = await backend.previewUrl(h, CLOUD_VNC_PORT); + const url = await registerHostPortlessAlias({ + alias: box.portlessVncAlias, + previewUrl: vncPreview.url, + label: 'vnc', + onLog: () => {}, + }); + if (url) { + portlessVncAliasName = box.portlessVncAlias; + portlessVncUrlResolved = url; + } + } catch { + /* best-effort */ + } + } const next: BoxRecord = { ...box, portlessAlias: portlessAliasName, portlessUrl: portlessUrlResolved, + portlessVncAlias: portlessVncAliasName, + portlessVncUrl: portlessVncUrlResolved, cloud: { ...(box.cloud ?? { backend: providerName, sandboxId: h.sandboxId }), webPort, @@ -731,8 +808,9 @@ export function createCloudProvider( const msg = err instanceof Error ? err.message : String(err); if (!/not.?found|missing/i.test(msg)) throw err; } - // Best-effort: drop the host Portless alias so `.localhost` stops - // pointing at a dead ssh -L. The in-VPS portless dies with the VPS. + // Best-effort: drop the host Portless aliases (web + vnc) so neither + // `.localhost` nor `vnc-.localhost` keeps pointing at a dead + // ssh -L. The in-VPS portless dies with the VPS. if (box.portlessAlias) { try { await portlessUnalias(box.portlessAlias); @@ -740,6 +818,13 @@ export function createCloudProvider( // portlessUnalias swallows already; paranoid catch in case. } } + if (box.portlessVncAlias) { + try { + await portlessUnalias(box.portlessVncAlias); + } catch { + // best-effort + } + } // Best-effort: stop the host poller and drop the registration. try { await forgetBoxFromRelay(box.id); @@ -875,6 +960,17 @@ export function createCloudProvider( ): Promise { const h = handleFor(box); const kind = opts?.kind ?? 'web'; + // Prefer the stable Portless URL when one was registered (Hetzner gets + // both; Daytona naturally skips since previewUrl is non-loopback). The + // `--loopback` flag forces the raw signed/loopback path instead. + if (!opts?.loopback) { + if (kind === 'web' && box.portlessAlias) { + return box.portlessUrl ?? `https://${box.portlessAlias}.localhost`; + } + if (kind === 'vnc' && box.portlessVncAlias) { + return box.portlessVncUrl ?? `https://${box.portlessVncAlias}.localhost`; + } + } // VNC port is fixed by Dockerfile.box (websockify serves noVNC on :6080). const port = kind === 'vnc' ? CLOUD_VNC_PORT : (box.cloud?.webPort ?? CLOUD_WEB_PROXY_PORT); // Always re-resolve through the SDK — cached URLs on the record may be diff --git a/packages/sandbox-core/src/state.ts b/packages/sandbox-core/src/state.ts index 96af50c..e802c00 100644 --- a/packages/sandbox-core/src/state.ts +++ b/packages/sandbox-core/src/state.ts @@ -83,6 +83,8 @@ function projectDockerFields(box: BoxRecord): DockerBoxFields { webHostPort: box.webHostPort, portlessAlias: box.portlessAlias, portlessUrl: box.portlessUrl, + portlessVncAlias: box.portlessVncAlias, + portlessVncUrl: box.portlessVncUrl, dockerVolume: box.dockerVolume, dockerCacheShared: box.dockerCacheShared, checkpointImage: box.checkpointImage, diff --git a/packages/sandbox-core/test/state.test.ts b/packages/sandbox-core/test/state.test.ts index a905f61..ebb7e8d 100644 --- a/packages/sandbox-core/test/state.test.ts +++ b/packages/sandbox-core/test/state.test.ts @@ -173,6 +173,8 @@ describe('state.ts', () => { vncHostPort: 49001, webHostPort: 49002, portlessAlias: 'shape.localhost', + portlessVncAlias: 'vnc-shape.localhost', + portlessVncUrl: 'https://vnc-shape.localhost', createdAt: '2026-05-12T12:00:00.000Z', }; await recordBox(box, file); @@ -184,6 +186,8 @@ describe('state.ts', () => { expect(r.docker?.vncHostPort).toBe(49001); expect(r.docker?.webHostPort).toBe(49002); expect(r.docker?.portlessAlias).toBe('shape.localhost'); + expect(r.docker?.portlessVncAlias).toBe('vnc-shape.localhost'); + expect(r.docker?.portlessVncUrl).toBe('https://vnc-shape.localhost'); }); it('cloud records do NOT get a docker shape mirrored in', async () => { diff --git a/packages/sandbox-docker/src/create.ts b/packages/sandbox-docker/src/create.ts index a737438..51cb69c 100644 --- a/packages/sandbox-docker/src/create.ts +++ b/packages/sandbox-docker/src/create.ts @@ -947,13 +947,16 @@ export async function createBox(opts: CreateBoxOptions): Promise { ); } - // Portless: register `https://.localhost -> 127.0.0.1:`. - // Best-effort — Portless is user-installed and never required; any failure - // here just leaves the box on its loopback URL. Skipped on OrbStack (which - // already has .orb.local). + // Portless: register `https://.localhost -> 127.0.0.1:` + // and a parallel `https://vnc-.localhost -> 127.0.0.1:` + // for the noVNC viewer. Best-effort — Portless is user-installed and never + // required; any failure here just leaves the box on its loopback URL. + // Skipped on OrbStack (which already has .orb.local). let portlessAliasName: string | undefined; let portlessUrl: string | undefined; - if (opts.portless === true && webHostPort) { + let portlessVncAliasName: string | undefined; + let portlessVncUrl: string | undefined; + if (opts.portless === true && (webHostPort || (vncEnabled && vncHostPort))) { try { const engine = await detectEngine(); if (engine === 'orbstack') { @@ -962,17 +965,31 @@ export async function createBox(opts: CreateBoxOptions): Promise { const portless = await detectPortless(); if (!portless.installed) { log('portless not installed — run `npm install -g portless` for a .localhost URL'); - } else if (await portlessAlias(name, webHostPort)) { - portlessAliasName = name; - // Resolve the real URL from the proxy: scheme + port depend on how - // the proxy was started (http://…:1355 no-TLS, or https://… on :443). - portlessUrl = await portlessGetUrl(name); - log(`portless alias ${portlessUrl} -> 127.0.0.1:${String(webHostPort)}`); - if (!portless.proxyRunning) { + } else { + if (webHostPort) { + if (await portlessAlias(name, webHostPort)) { + portlessAliasName = name; + // Resolve the real URL from the proxy: scheme + port depend on how + // the proxy was started (http://…:1355 no-TLS, or https://… on :443). + portlessUrl = await portlessGetUrl(name); + log(`portless alias ${portlessUrl} -> 127.0.0.1:${String(webHostPort)}`); + } else { + log('portless alias failed (best-effort) — box still reachable on the loopback URL'); + } + } + if (vncEnabled && vncHostPort) { + const vncAlias = `vnc-${name}`; + if (await portlessAlias(vncAlias, vncHostPort)) { + portlessVncAliasName = vncAlias; + portlessVncUrl = await portlessGetUrl(vncAlias); + log(`portless alias ${portlessVncUrl} -> 127.0.0.1:${String(vncHostPort)}`); + } else { + log('portless vnc alias failed (best-effort) — VNC still reachable on the loopback URL'); + } + } + if (!portless.proxyRunning && (portlessAliasName || portlessVncAliasName)) { log(`portless proxy not running — start it with \`${portlessStartHint()}\``); } - } else { - log('portless alias failed (best-effort) — box still reachable on the loopback URL'); } } } catch (err) { @@ -1006,6 +1023,8 @@ export async function createBox(opts: CreateBoxOptions): Promise { webHostPort: webHostPort ?? undefined, portlessAlias: portlessAliasName, portlessUrl, + portlessVncAlias: portlessVncAliasName, + portlessVncUrl, dockerVolume, dockerCacheShared: dockerCacheShared || undefined, projectRoot: opts.projectRoot, diff --git a/packages/sandbox-docker/src/docker-provider.ts b/packages/sandbox-docker/src/docker-provider.ts index 2e75f28..3682573 100644 --- a/packages/sandbox-docker/src/docker-provider.ts +++ b/packages/sandbox-docker/src/docker-provider.ts @@ -159,7 +159,8 @@ export const dockerProvider: Provider = { } const engine = await detectEngine(); if (engine === 'orbstack' && !opts?.loopback) { - // OrbStack auto-routes .orb.local to container :80. + // OrbStack auto-routes .orb.local to the container; :80 is + // declared (EXPOSE 80) so no port suffix is needed. return `http://${box.container}.orb.local`; } if (box.portlessAlias && !opts?.loopback) { diff --git a/packages/sandbox-docker/src/endpoints.ts b/packages/sandbox-docker/src/endpoints.ts index 0577b35..0bcb1c8 100644 --- a/packages/sandbox-docker/src/endpoints.ts +++ b/packages/sandbox-docker/src/endpoints.ts @@ -38,7 +38,12 @@ export async function getBoxEndpoints( if (record.vncEnabled && record.vncPassword) { const vncUrls = buildVncUrls(record, engine); - const url = vncUrls.orbUrl ?? vncUrls.loopbackUrl; + // Preference: portless (stable name) > orb.local > loopback. Mirrors the + // web endpoint's choice below and `agentbox screen`'s default. + const url = + engine === 'orbstack' + ? (vncUrls.orbUrl ?? vncUrls.loopbackUrl) + : (vncUrls.portlessUrl ?? vncUrls.loopbackUrl); endpoints.push({ kind: 'vnc', name: 'vnc', diff --git a/packages/sandbox-docker/src/lifecycle.ts b/packages/sandbox-docker/src/lifecycle.ts index d28b65f..168dc50 100644 --- a/packages/sandbox-docker/src/lifecycle.ts +++ b/packages/sandbox-docker/src/lifecycle.ts @@ -52,7 +52,7 @@ import { launchDockerdDaemon, SHARED_DOCKER_CACHE_VOLUME } from './dockerd.js'; import { launchVncDaemon, VNC_CONTAINER_PORT } from './vnc.js'; import { WEB_CONTAINER_PORT } from './web.js'; import { detectPortless, portlessAlias, portlessGetUrl, portlessUnalias } from './portless.js'; -import { getBoxEndpoints, type BoxEndpoints } from './endpoints.js'; +import { getBoxEndpoints, type BoxEndpoint, type BoxEndpoints } from './endpoints.js'; import { ensureRelay, forgetBoxFromRelay, @@ -115,20 +115,36 @@ export async function listBoxes(): Promise { : undefined; const cachedWebUrl = webPort > 0 ? b.cloud?.previewUrls?.[webPort] : undefined; const webUrl = portlessWebUrl ?? cachedWebUrl; + const portlessVncBase = + b.portlessVncAlias !== undefined + ? (b.portlessVncUrl ?? `https://${b.portlessVncAlias}.localhost`) + : undefined; + const vncUrl = + portlessVncBase && b.vncPassword + ? `${portlessVncBase}/vnc.html?autoconnect=1&password=${encodeURIComponent(b.vncPassword)}` + : undefined; + const cloudEndpoints: BoxEndpoint[] = []; + if (webUrl) { + cloudEndpoints.push({ + kind: 'web', + name: 'web', + containerPort: webPort, + url: webUrl, + reachable: true, + }); + } + if (b.vncEnabled && b.vncPassword) { + cloudEndpoints.push({ + kind: 'vnc', + name: 'vnc', + containerPort: b.vncContainerPort ?? 6080, + ...(vncUrl ? { url: vncUrl, reachable: true } : { reachable: false }), + }); + } const endpoints: BoxEndpoints = { domain: webUrl ? safeHost(webUrl) : '', domainIsOrb: false, - endpoints: webUrl - ? [ - { - kind: 'web', - name: 'web', - containerPort: webPort, - url: webUrl, - reachable: true, - }, - ] - : [], + endpoints: cloudEndpoints, }; return { ...b, @@ -309,24 +325,37 @@ export async function startBox(idOrName: string): Promise { box.webHostPort = freshWebPort; await recordBox(box); } - // Docker reallocated the host port, so the Portless route now points at a - // stale port — re-register it. Best-effort and silent (startBox has no - // onLog); if the proxy/Portless is gone the box still works on loopback. - if (box.portlessAlias && box.webHostPort) { - try { - const portless = await detectPortless(); - if (portless.installed) { + } + // Docker reallocated the ephemeral host ports above, so both Portless + // routes now point at stale ports — re-register them. Best-effort and + // silent (startBox has no onLog); if the proxy/Portless is gone the box + // still works on loopback. + if ((box.portlessAlias && box.webHostPort) || (box.portlessVncAlias && box.vncHostPort)) { + try { + const portless = await detectPortless(); + if (portless.installed) { + let dirty = false; + if (box.portlessAlias && box.webHostPort) { await portlessAlias(box.portlessAlias, box.webHostPort); // The proxy's scheme/port can change between sessions — re-resolve. const url = await portlessGetUrl(box.portlessAlias); if (url !== box.portlessUrl) { box.portlessUrl = url; - await recordBox(box); + dirty = true; } } - } catch { - /* best-effort */ + if (box.portlessVncAlias && box.vncHostPort) { + await portlessAlias(box.portlessVncAlias, box.vncHostPort); + const url = await portlessGetUrl(box.portlessVncAlias); + if (url !== box.portlessVncUrl) { + box.portlessVncUrl = url; + dirty = true; + } + } + if (dirty) await recordBox(box); } + } catch { + /* best-effort */ } } // Relay's in-memory registry may have been lost if the relay restarted @@ -477,7 +506,8 @@ export async function destroyBox( // best-effort — relay may be down or already wiped the entry } } - // Remove the Portless route so it doesn't dangle in the user's proxy config. + // Remove the Portless routes so they don't dangle in the user's proxy + // config. Web alias + VNC alias are independent — drop whichever was set. if (box.portlessAlias) { try { await portlessUnalias(box.portlessAlias); @@ -485,6 +515,13 @@ export async function destroyBox( // best-effort — Portless may be uninstalled or the route already gone } } + if (box.portlessVncAlias) { + try { + await portlessUnalias(box.portlessVncAlias); + } catch { + // best-effort + } + } // Deregister each in-container worktree from the host main repo. Skip // when this box was checkpoint-restored: its `gitWorktrees` were inherited // from the source box via the checkpoint manifest, and the same diff --git a/packages/sandbox-docker/src/vnc.ts b/packages/sandbox-docker/src/vnc.ts index 64d52aa..cad35c5 100644 --- a/packages/sandbox-docker/src/vnc.ts +++ b/packages/sandbox-docker/src/vnc.ts @@ -69,12 +69,15 @@ export interface VncUrls { orbUrl?: string; /** Loopback URL via the auto-allocated host port, e.g. http://127.0.0.1:54321/... Present whenever vncHostPort is known. */ loopbackUrl?: string; + /** Portless URL, e.g. https://vnc-mybox.localhost/vnc.html?... Present when `portlessVncAlias` is set on the record. */ + portlessUrl?: string; } /** * Build the noVNC URLs for a box, given the box record + (host engine). * `engine === 'orbstack'` triggers the `.orb.local:6080` route; - * either engine produces the loopback URL when the host port is resolved. + * a stored Portless alias gives `vnc-.localhost`; either engine + * produces the loopback URL when the host port is resolved. * Returns an empty object when VNC isn't enabled or the password isn't known. */ export function buildVncUrls( @@ -84,6 +87,8 @@ export function buildVncUrls( vncHostPort?: number; vncContainerPort?: number; vncPassword?: string; + portlessVncAlias?: string; + portlessVncUrl?: string; }, engine: 'orbstack' | 'docker-desktop' | 'other', ): VncUrls { @@ -97,5 +102,9 @@ export function buildVncUrls( if (record.vncHostPort) { urls.loopbackUrl = `http://127.0.0.1:${String(record.vncHostPort)}/vnc.html?${qs}`; } + if (record.portlessVncAlias) { + const base = record.portlessVncUrl ?? `https://${record.portlessVncAlias}.localhost`; + urls.portlessUrl = `${base}/vnc.html?${qs}`; + } return urls; } diff --git a/packages/sandbox-docker/test/endpoints.test.ts b/packages/sandbox-docker/test/endpoints.test.ts index db0b844..abb5f70 100644 --- a/packages/sandbox-docker/test/endpoints.test.ts +++ b/packages/sandbox-docker/test/endpoints.test.ts @@ -32,6 +32,10 @@ function webEndpoint(eps: Awaited>) { return eps.endpoints.find((e) => e.kind === 'web'); } +function vncEndpoint(eps: Awaited>) { + return eps.endpoints.find((e) => e.kind === 'vnc'); +} + describe('getBoxEndpoints — Portless web URL', () => { it('uses the stored portlessUrl on Docker Desktop', async () => { const record = { @@ -65,3 +69,58 @@ describe('getBoxEndpoints — Portless web URL', () => { expect(webEndpoint(eps)?.url).toBe('http://127.0.0.1:54321'); }); }); + +describe('getBoxEndpoints — Portless VNC URL', () => { + const vncBase: BoxRecord = { + ...baseRecord, + vncEnabled: true, + vncContainerPort: 6080, + vncHostPort: 64080, + vncPassword: 'pw12345A', + }; + + it('uses the stored portlessVncUrl on Docker Desktop', async () => { + const record = { + ...vncBase, + portlessVncAlias: 'vnc-mybox', + portlessVncUrl: 'http://vnc-mybox.localhost:1355', + }; + const eps = await getBoxEndpoints(record, 'docker-desktop', webStatus); + expect(vncEndpoint(eps)?.url).toBe( + 'http://vnc-mybox.localhost:1355/vnc.html?autoconnect=1&password=pw12345A', + ); + expect(vncEndpoint(eps)?.reachable).toBe(true); + }); + + it('falls back to https://vnc-.localhost when portlessVncUrl is absent', async () => { + const record = { ...vncBase, portlessVncAlias: 'vnc-mybox' }; + const eps = await getBoxEndpoints(record, 'docker-desktop', webStatus); + expect(vncEndpoint(eps)?.url).toBe( + 'https://vnc-mybox.localhost/vnc.html?autoconnect=1&password=pw12345A', + ); + }); + + it('falls back to loopback VNC URL when no Portless route is registered', async () => { + const eps = await getBoxEndpoints(vncBase, 'docker-desktop', webStatus); + expect(vncEndpoint(eps)?.url).toBe( + 'http://127.0.0.1:64080/vnc.html?autoconnect=1&password=pw12345A', + ); + }); + + it('ignores the Portless VNC route on OrbStack (orb.local is preferred)', async () => { + const record = { + ...vncBase, + portlessVncAlias: 'vnc-mybox', + portlessVncUrl: 'http://vnc-mybox.localhost:1355', + }; + const eps = await getBoxEndpoints(record, 'orbstack', webStatus); + expect(vncEndpoint(eps)?.url).toBe( + 'http://agentbox-mybox.orb.local:6080/vnc.html?autoconnect=1&password=pw12345A', + ); + }); + + it('omits the VNC endpoint when vncEnabled is false', async () => { + const eps = await getBoxEndpoints(baseRecord, 'docker-desktop', webStatus); + expect(vncEndpoint(eps)).toBeUndefined(); + }); +});