feat(cloud): remote desktop Beta — single-use tickets, fit, clipboard, keyboard release (W10 RD-1..6) (#122)

* feat(cloud): remote desktop single-use tickets, fit, clipboard and keyboard release

- VM: Xvnc on a 0600 socket with no VNC auth plus an X cookie; orca-desktop-proxy (websockify
  subclass) admits one WebSocket per unused 60 s ticket from the same localhost origin, refuses
  rebinding Hosts, scrubs tickets from logs, serves the page with connect-src 'self'.
- Worker: POST /desktop mints a fresh ticket per call (schemaVersion 2).
- Desktop: no secret in the tab URL; main hands the page tickets on load and reconnect (backoff,
  floor, pause with Reconnect), only for the exact URL of a live forward it opened; keyboard stays
  in the desktop until Ctrl+Cmd / Ctrl+Alt; a paste key pushes this computer's clipboard; sandbox
  copies reach this computer after a copy gesture.
- UI: keyboard hint, asleep/starting neutral tone and mapping, persistent error toasts, Beta badge.

* fix(cloud): remote desktop review fixes

- Security review: noVNC host/port/encrypt pinned to the page's origin, CSP connect-src 'self',
  exact-URL + live-forward trust, mint floor and per-minute cap, copy gate on copy gestures only,
  X display cookie.
- Design review: archived is its own neutral state, starting covers waking, page messages say
  "Wake it in Orca", pause overlay is a status for calm states and focuses Reconnect, hint wraps
  and hides from assistive tech when faded.
- FE review: paste follows the keyboard layout, AltGr never releases, page calls settle on
  navigation, state notices only on change, hint once per load/release, paste always pushes,
  no committed bytecode, session tests.

* fix(cloud): remote desktop archived page message, hint layout, fixtures

* fix(cloud): remote desktop copy gate counts double-clicks (pointer events have no click count)

* chore(cloud): remote desktop lint (exhaustive pause reasons, no test casts or wall clock)
This commit is contained in:
Neil
2026-11-09 17:36:19 -08:00
committed by GitHub
parent 04e6a800d8
commit 60377d3364
39 changed files with 2014 additions and 94 deletions
+3
View File
@@ -157,3 +157,6 @@ ci-shards/
/.build/windows-native-benchmark/
.local/static-analysis-research/
# Python bytecode (VM entry points tested locally)
__pycache__/
@@ -0,0 +1,79 @@
// The remote desktop's ticket + origin gate (vm/orca-desktop-proxy), exercised in python3 with a
// stub websockify. The real thing runs on a VM in the W3 desktop e2e (see the w3-desktop report).
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const proxy = fileURLToPath(new URL('./vm/orca-desktop-proxy', import.meta.url).href)
const hasPython = spawnSync('python3', ['--version']).status === 0
function runPython(body: string): { status: number | null; stdout: string; stderr: string } {
const stub = mkdtempSync(join(tmpdir(), 'orca-desktop-proxy-'))
mkdirSync(join(stub, 'websockify'))
writeFileSync(join(stub, 'websockify', '__init__.py'), '')
writeFileSync(join(stub, 'websockify', 'websocketproxy.py'), 'class ProxyRequestHandler:\n pass\n')
const prelude = [
'import importlib.machinery, importlib.util, os, sys, tempfile, hashlib',
`loader = importlib.machinery.SourceFileLoader('proxy', ${JSON.stringify(proxy)})`,
'spec = importlib.util.spec_from_loader("proxy", loader)',
'p = importlib.util.module_from_spec(spec); loader.exec_module(p)',
'd = tempfile.mkdtemp()',
'def mint(t, expires):',
' open(os.path.join(d, hashlib.sha256(t.encode()).hexdigest()), "w").write(str(expires))',
''
].join('\n')
const run = spawnSync('python3', ['-c', `${prelude}\n${body}`], {
env: { ...process.env, PYTHONPATH: stub, PYTHONDONTWRITEBYTECODE: '1' },
encoding: 'utf8'
})
return { status: run.status, stdout: run.stdout, stderr: run.stderr }
}
describe.skipIf(!hasPython)('orca-desktop-proxy', () => {
it('admits a ticket once, before it expires, and never a malformed one', () => {
const run = runPython(
[
'T = "A" * 43; U = "B" * 43; X = "C" * 43',
'mint(T, 2000); mint(U, 2000); mint(X, 999)',
'print(p.consume_ticket(d, T, now=1000))', // fresh
'print(p.consume_ticket(d, T, now=1000))', // replay
'print(p.consume_ticket(d, X, now=1000))', // expired
'print(p.consume_ticket(d, X, now=1000))', // and it was still spent
'print(p.consume_ticket(d, "B" * 42, now=1000))', // malformed
'print(p.consume_ticket(d, "../" + "B" * 40, now=1000))', // path tricks never reach the disk
'print(p.consume_ticket(d, U, now=1000))',
'print(sorted(os.listdir(d)))'
].join('\n')
)
expect(run.stderr).toBe('')
expect(run.stdout.trim().split('\n')).toEqual(['True', 'False', 'False', 'False', 'False', 'False', 'True', '[]'])
})
it('wants the page and the WebSocket on one localhost origin', () => {
const run = runPython(
[
'cases = [',
' ({"Host": "localhost:6081", "Origin": "http://localhost:6081"}, True),',
' ({"Host": "127.0.0.1:6091", "Origin": "http://127.0.0.1:6091"}, True),',
' ({"Host": "localhost:6081", "Origin": "http://localhost:3000"}, False),',
' ({"Host": "localhost:6081", "Origin": "http://evil.example"}, False),',
' ({"Host": "localhost:6081"}, False),',
' ({"Host": "evil.example:6081", "Origin": "http://evil.example:6081"}, False),',
' ({"Host": "localhost.evil.example", "Origin": "http://localhost.evil.example"}, False),',
' ({"Host": "localhost:6081", "Origin": "https://localhost:6081"}, False),',
']',
'print(all(p.same_loopback_origin(h) == want for h, want in cases))'
].join('\n')
)
expect(run.stderr).toBe('')
expect(run.stdout.trim()).toBe('True')
})
it('scrubs tickets from anything it logs', () => {
const run = runPython('print(p.scrub("GET /websockify?ticket=SECRETSECRET&x=1 HTTP/1.1"))')
expect(run.stdout.trim()).toBe('GET /websockify?ticket=<redacted>&x=1 HTTP/1.1')
})
})
@@ -101,9 +101,19 @@ describe('golden image build', () => {
it('ships VM scripts that parse', () => {
for (const script of ['provision-golden.sh', ...VM_SCRIPTS.map((name) => `vm/${name}`)]) {
const path = fileURLToPath(new URL(`./${script}`, import.meta.url).href)
// Node entry points (shebang `env node`) parse with node, the rest with bash.
const isNode = readFileSync(path, 'utf8').startsWith('#!/usr/bin/env node')
expect(spawnSync(isNode ? process.execPath : 'bash', isNode ? ['--check', path] : ['-n', path]).status, script).toBe(0)
// Node entry points (shebang `env node`) and page modules (.js) parse with node, Python ones
// with python3 (when this machine has it), the rest with bash.
const source = readFileSync(path, 'utf8')
const isNode = source.startsWith('#!/usr/bin/env node')
if (script.endsWith('.js')) {
const check = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: source })
expect(check.status, script).toBe(0)
} else if (source.startsWith('#!/usr/bin/env python3')) {
const check = spawnSync('python3', ['-c', 'import ast, sys; ast.parse(open(sys.argv[1]).read())', path])
expect(check.error ? 0 : check.status, script).toBe(0)
} else {
expect(spawnSync(isNode ? process.execPath : 'bash', isNode ? ['--check', path] : ['-n', path]).status, script).toBe(0)
}
}
})
@@ -15,6 +15,8 @@ export const VM_SCRIPTS = [
'orca-sshd',
'orca-ssh-keys',
'orca-desktop-start',
'orca-desktop-proxy',
'orca-desktop-page.js',
'orca-sandbox-grant',
'orca-sandbox-revoke',
'orca-sandbox-members',
@@ -155,7 +155,7 @@ if [ -n "${NOVNC_VERSION:-}" ]; then
fi
log 'Orca entry points'
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-git-as orca-git-identity git; do
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-git-as orca-git-identity git; do
var="ORCA_VM_SCRIPT_${name//[-.]/_}"
printf '%s' "${!var}" | base64 -d >"$ORCA_HOME/bin/$name"
chmod 0755 "$ORCA_HOME/bin/$name"
File diff suppressed because one or more lines are too long
@@ -0,0 +1,224 @@
// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's
// main process) drives it through window.orcaDesktop:
// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,
// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on
// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live
// only in memory here: never in the URL, storage or noVNC's settings.
// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()
// (Orca reopened the desktop) or the button asks for a ticket again.
// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.
// - clipboard: a copy inside the desktop right after user input there goes to this computer's
// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.
import UI from './app/ui.js';
import * as WebUtil from './app/webutil.js';
const WS_PATH = 'websockify';
const RESIZE_FALLBACK_MS = 1500;
// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other
// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's
// clipboard. Plain typing does not open the window.
const USER_COPY_WINDOW_MS = 1500;
const DRAG_PX = 4;
const MAX_CLIPBOARD_CHARS = 1 << 20;
const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;
const XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };
let state = 'idle'; // idle | connecting | connected | disconnected | paused
let attemptConnected = true;
let ticketWaiters = [];
let lastCopyIntent = 0;
let pointerStart = null;
const style = document.createElement('style');
// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,
// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).
style.textContent = `
#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }
#orca_pause { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
background: rgba(30, 31, 34, 0.92); z-index: 1000; font: 13px/1.5 system-ui, sans-serif; }
#orca_pause.open { display: flex; }
#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid #3a3b3f; border-radius: 8px;
background: #26272b; color: #d4d4d4; text-align: center; }
#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid #4a4b50; border-radius: 6px;
background: transparent; color: #f2f2f2; font: inherit; cursor: pointer; }
#orca_pause button:focus-visible { outline: 2px solid #6ea8fe; outline-offset: 2px; }
/* The VM page cannot read Orca's --focus-ring token; this matches its dark value. */
`;
document.head.appendChild(style);
const overlay = document.createElement('div');
overlay.id = 'orca_pause';
overlay.innerHTML = '<div role="alert"><p></p><button type="button"></button></div>';
overlay.querySelector('button').addEventListener('click', () => resume());
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));
window.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {
lastCopyIntent = Date.now();
}
}, true);
window.addEventListener('pointerdown', (event) => {
pointerStart = { x: event.clientX, y: event.clientY };
}, true);
// A double- or triple-click selects a word or line (pointer events carry no click count).
window.addEventListener('mousedown', (event) => {
if (event.detail >= 2) {
lastCopyIntent = Date.now();
}
}, true);
window.addEventListener('pointerup', (event) => {
if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {
lastCopyIntent = Date.now();
}
pointerStart = null;
}, true);
function requestTicket() {
const answer = attemptConnected ? 'ticket' : 'retry';
const waiters = ticketWaiters;
ticketWaiters = [];
waiters.forEach((resolve) => resolve(answer));
}
async function whenStarted() {
while (document.documentElement.classList.contains('noVNC_loading')) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
function onConnect() {
state = 'connected';
attemptConnected = true;
UI.hideStatus();
setTimeout(() => {
// Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.
if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {
UI.rfb.scaleViewport = true;
}
}, RESIZE_FALLBACK_MS);
}
function onDisconnect() {
if (state === 'paused') {
return;
}
state = 'disconnected';
// The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).
requestTicket();
}
function onClipboard(event) {
const text = event.detail && event.detail.text;
if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {
return;
}
if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {
return;
}
navigator.clipboard.writeText(text).catch(() => {});
}
async function connect(ticket) {
if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {
return 'invalid';
}
if (state === 'connecting' || state === 'connected' || state === 'paused') {
return state;
}
await whenStarted();
if (UI.rfb) {
return 'connecting';
}
state = 'connecting';
attemptConnected = false;
// UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;
// the in-memory setting is restored right after, so the ticket is never kept or shown.
// Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging
// from the URL, which must never send a ticketed session (and the user's keys) elsewhere.
WebUtil.setSetting('host', '');
WebUtil.setSetting('port', '');
WebUtil.setSetting('encrypt', false);
WebUtil.setSetting('repeaterID', '');
WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);
try {
UI.connect();
} finally {
WebUtil.setSetting('path', WS_PATH);
}
const rfb = UI.rfb;
if (!rfb) {
state = 'disconnected';
requestTicket();
return 'failed';
}
rfb.addEventListener('connect', onConnect);
rfb.addEventListener('disconnect', onDisconnect);
rfb.addEventListener('clipboard', onClipboard);
return 'connecting';
}
/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */
function paste(text, chord) {
const rfb = UI.rfb;
if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {
return false;
}
rfb.clipboardPasteFrom(text);
if (chord && chord.kind === 'mac') {
// Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.
rfb.sendKey(XK.Super_L, 'MetaLeft', false);
rfb.sendKey(XK.Super_R, 'MetaRight', false);
rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);
rfb.sendKey(XK.Insert, 'Insert');
rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);
} else if (chord && chord.kind === 'replay') {
// Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.
const insert = chord.key === 'Insert';
rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');
}
return true;
}
function pause(message, label, tone) {
state = 'paused';
if (UI.rfb) {
UI.rfb.disconnect();
}
UI.hideStatus();
overlay.querySelector('p').textContent = String(message || '');
overlay.querySelector('button').textContent = String(label || 'Reconnect');
// Asleep and starting are states, not errors: announce them politely.
overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');
overlay.classList.add('open');
overlay.querySelector('button').focus();
return true;
}
function resume() {
if (state !== 'paused') {
return false;
}
overlay.classList.remove('open');
state = 'disconnected';
attemptConnected = true; // a person asked: start the retry budget over
requestTicket();
return true;
}
Object.defineProperty(window, 'orcaDesktop', {
value: Object.freeze({
version: 1,
state: () => state,
/** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */
nextTicketRequest: () => new Promise((resolve) => {
ticketWaiters.push(resolve);
if (state === 'idle' || state === 'disconnected') {
requestTicket();
}
}),
connect,
paste,
pause,
resume
})
});
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one
# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this
# uid can open the socket). The port is reached only through Orca's localhost forward on the user's
# computer, where any local process or web page could knock, so every WebSocket must carry an
# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same
# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.
# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR
# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.
import argparse
import hashlib
import hmac
import logging
import os
import re
import sys
import time
from urllib.parse import parse_qs, urlsplit
from websockify import websocketproxy
TICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding
SCRUB_RE = re.compile(r'([?&]ticket=)[^&\s\'"]*')
LOOPBACK_HOST_RE = re.compile(r'^(localhost|127\.0\.0\.1|\[::1\])(:[0-9]{1,5})?$')
WS_PATH = '/websockify'
PAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')
# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point
# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.
PAGE_CSP = "connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
PAGE_TAG = b'<script type="module" src="orca-desktop-page.js"></script>\n</head>'
def scrub(text):
return SCRUB_RE.sub(r'\1<redacted>', str(text))
def ticket_digest(ticket):
return hashlib.sha256(ticket.encode('ascii')).hexdigest()
def consume_ticket(directory, ticket, now=None):
"""True once per minted ticket, within its lifetime. The ticket file (named by the ticket's
sha256, holding its expiry) is unlinked on first use, so a replay or a racing second
connection finds nothing; each request runs in its own forked process, so state is on disk."""
if not isinstance(ticket, str) or not TICKET_RE.match(ticket):
return False
digest = ticket_digest(ticket)
match = None
try:
names = os.listdir(directory)
except OSError:
return False
for name in names:
# Compare every entry in constant time; never short-circuit on a prefix.
if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):
match = name
if match is None:
return False
path = os.path.join(directory, match)
try:
with open(path, 'r', encoding='ascii') as handle:
expires_at = int(handle.read().strip() or '0')
except (OSError, ValueError):
expires_at = 0
try:
os.unlink(path) # the consume: only one caller can win this
except FileNotFoundError:
return False
return (now if now is not None else time.time()) <= expires_at
def same_loopback_origin(headers):
"""The page and the WebSocket share one localhost origin (blocks other local sites and DNS
rebinding). The port is the user's local forward, which may differ from ours, so compare the
Origin with the Host the browser dialed rather than with a fixed port."""
host = (headers.get('Host') or '').strip().lower()
origin = (headers.get('Origin') or '').strip().lower()
return loopback_host(host) and origin == 'http://' + host
def loopback_host(host):
return bool(LOOPBACK_HOST_RE.match(host or ''))
class TicketedProxyHandler(websocketproxy.ProxyRequestHandler):
"""websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so
websockify's per-connection multiprocessing can pickle it under any start method."""
def log_message(self, format, *args): # noqa: A002 (websockify's signature)
super().log_message('%s', scrub(format % args if args else format))
def reject(self, code, reason):
self.send_response(code)
self.send_header('Content-Length', '0')
self.send_header('Connection', 'close')
self.end_headers()
self.close_connection = True
raise self.server.EClose('desktop: websocket refused (%s)' % reason)
def validate_connection(self):
parts = urlsplit(self.path)
ticket = (parse_qs(parts.query).get('ticket') or [''])[0]
# Drop it before anything below (websockify's own logging included) can see self.path.
self.path = parts.path
if parts.path != WS_PATH:
self.reject(404, 'path')
if not same_loopback_origin(self.headers):
self.reject(403, 'origin')
if not consume_ticket(self.server.orca_tickets_dir, ticket):
self.reject(403, 'ticket')
def loopback_request(self):
if loopback_host((self.headers.get('Host') or '').strip().lower()):
return True
self.send_error(403)
return False
def do_GET(self):
if not self.loopback_request():
return None
path = urlsplit(self.path).path
if path == '/orca-desktop-page.js':
return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')
if path == '/vnc.html':
page = read_file(os.path.join(self.server.web, 'vnc.html'))
return self.send_bytes(page.replace(b'</head>', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)
return super().do_GET()
def do_HEAD(self):
if not self.loopback_request():
return None
return super().do_HEAD()
def send_bytes(self, body, content_type, csp=None):
self.send_response(200)
self.send_header('Content-Type', content_type)
if csp:
self.send_header('Content-Security-Policy', csp)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
self.wfile.write(body)
return None
def read_file(path):
with open(path, 'rb') as handle:
return handle.read()
def main(argv):
parser = argparse.ArgumentParser()
parser.add_argument('--listen', type=int, required=True)
parser.add_argument('--web', required=True)
parser.add_argument('--unix-target', required=True)
parser.add_argument('--tickets', required=True)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format='%(message)s')
server = websocketproxy.WebSocketProxy(
RequestHandlerClass=TicketedProxyHandler,
listen_host='127.0.0.1',
listen_port=args.listen,
web=args.web,
file_only=True,
unix_target=args.unix_target,
)
server.orca_tickets_dir = os.path.abspath(args.tickets)
server.start_server()
if __name__ == '__main__':
main(sys.argv[1:])
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
# Starts the on-demand remote desktop (idempotent) and prints one JSON line:
# {"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"<8 chars>"}
# Xvnc (X server + VNC in one) listens on a unix socket only; websockify serves noVNC and bridges
# WebSockets to that socket on 127.0.0.1 only. Nothing is reachable from outside the VM: the
# desktop reaches it through Orca's port forward. The forward is a localhost port on the user's
# computer, which any local process or web page can reach, so VNC auth with a per-boot random
# password guards it (Xvnc blacklists repeated failures). Never started at boot.
# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:
# {"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"<43 chars>","expiresInMs":60000}
# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid
# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and
# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app
# reaches it through Orca's port forward, a localhost port on the user's computer that any local
# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,
# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).
# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.
set -euo pipefail
umask 077
DESKTOP=/opt/orca/desktop
@@ -23,8 +25,13 @@ case "$PORT" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac
case "$DISPLAY_NUM" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac
case "$GEOMETRY" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac
command -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3
command -v vncpasswd >/dev/null || fail 'not installed in this image (vncpasswd missing)' 3
[ -s "$DESKTOP/novnc/vnc.html" ] || fail 'not installed in this image (noVNC missing)' 3
PROXY="$(dirname "$(readlink -f "$0")")/orca-desktop-proxy"
[ -x "$PROXY" ] || fail 'orca-desktop-proxy missing next to this script'
TICKETS="$STATE/tickets"
TICKET_TTL_S=60
# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.
MODE=ticket-v1
mkdir -p "$STATE"
exec 9>"$STATE/start.lock"
@@ -37,22 +44,47 @@ pid_of() { # never "0": kill 0 targets our own process group
printf '%s' "$pid"
}
alive() { local pid; pid="$(pid_of "$1")" && kill -0 "$pid" 2>/dev/null; }
ready_line() {
printf '{"schemaVersion":1,"port":%s,"display":":%s","path":"vnc.html","password":"%s"}\n' \
"$PORT" "$DISPLAY_NUM" "$(cat "$STATE/password")"
mint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content
local ticket now digest
mkdir -p "$TICKETS"
now="$(date +%s)"
# Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).
for file in "$TICKETS"/*; do
[ -f "$file" ] || continue
[ "$(cat "$file" 2>/dev/null || echo 0)" -ge "$now" ] 2>/dev/null || rm -f "$file"
done
# shellcheck disable=SC2012 # names are hex digests
ls -1t "$TICKETS" 2>/dev/null | tail -n +16 | while read -r old; do rm -f "$TICKETS/$old"; done
ticket="$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
[ "${#ticket}" = 43 ] || fail 'could not generate a ticket'
digest="$(printf '%s' "$ticket" | sha256sum | cut -d' ' -f1)"
printf '%s' "$((now + TICKET_TTL_S))" >"$TICKETS/.$digest" && mv "$TICKETS/.$digest" "$TICKETS/$digest"
printf '%s' "$ticket"
}
listening() { # websockify answers HTTP on loopback
ready_line() {
local ticket
ticket="$(mint_ticket)" || exit 1
printf '{"schemaVersion":2,"port":%s,"display":":%s","path":"vnc.html","ticket":"%s","expiresInMs":%s}\n' \
"$PORT" "$DISPLAY_NUM" "$ticket" "$((TICKET_TTL_S * 1000))"
}
listening() { # the proxy answers HTTP on loopback
curl -fsS -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/vnc.html" 2>/dev/null
}
# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.
boot_id="$(cat /proc/sys/kernel/random/boot_id)"
if [ "$(cat "$STATE/boot-id" 2>/dev/null || true)" != "$boot_id" ]; then
rm -f "$STATE"/*.pid "$SOCKET" "$STATE/password" "$STATE/passwd"
rm -rf "$STATE"/*.pid "$SOCKET" "$STATE/password" "$STATE/passwd" "$STATE/mode" "$TICKETS"
printf '%s' "$boot_id" >"$STATE/boot-id"
fi
if alive xvnc && alive websockify && listening && [ -s "$STATE/password" ]; then
# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.
rm -f "$STATE/password" "$STATE/passwd"
if alive websockify; then kill "$(pid_of websockify)" 2>/dev/null || true; fi
rm -f "$STATE/websockify.pid"
current_mode() { cat "$STATE/mode" 2>/dev/null || true; }
if alive xvnc && alive proxy && listening && [ "$(current_mode)" = "$MODE" ]; then
ready_line
exit 0
fi
@@ -64,19 +96,23 @@ start() { # name cmd... — detached, logs in $STATE/<name>.log
echo $! >"$STATE/$name.pid"
}
if ! alive xvnc || [ ! -s "$STATE/password" ]; then
if ! alive xvnc || [ "$(current_mode)" != "$MODE" ]; then
if alive xvnc; then kill "$(pid_of xvnc)" 2>/dev/null || true; sleep 0.2; fi
rm -f "$SOCKET"
# VNC auth uses 8 password bytes; 8 random alphanumerics (~48 bits) plus Xvnc's blacklist.
password="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 8 || true)"
[ "${#password}" = 8 ] || fail 'could not generate a password'
printf '%s' "$password" | vncpasswd -f >"$STATE/passwd"
printf '%s' "$password" >"$STATE/password"
# -rfbport -1: no TCP VNC port at all, only a 0600 unix socket for websockify.
start xvnc Xvnc ":$DISPLAY_NUM" -geometry "$GEOMETRY" -depth 24 -nolisten tcp \
# -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the
# proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.
# SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.
# The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only
# this uid's processes (agents, apps they start on DISPLAY=:1) can open it.
auth=()
if command -v xauth >/dev/null && command -v mcookie >/dev/null; then
xauth -q -f "$HOME/.Xauthority" add ":$DISPLAY_NUM" . "$(mcookie)" 9>&- && auth=(-auth "$HOME/.Xauthority")
fi
start xvnc Xvnc ":$DISPLAY_NUM" "${auth[@]}" -geometry "$GEOMETRY" -depth 24 -nolisten tcp \
-rfbport -1 -rfbunixpath "$SOCKET" -rfbunixmode 0600 \
-SecurityTypes VncAuth -PasswordFile "$STATE/passwd" \
-SecurityTypes None -SendPrimary=0 \
-AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'
printf '%s' "$MODE" >"$STATE/mode"
for _ in $(seq 1 100); do
[ -S "$SOCKET" ] && break
alive xvnc || { tail -n 20 "$STATE/xvnc.log" >&2; fail 'Xvnc exited'; }
@@ -91,23 +127,28 @@ if ! alive xvnc || [ ! -s "$STATE/password" ]; then
'<applications><application class="XTerm"><maximized>yes</maximized></application></applications>' \
'</openbox_config>' >"$STATE/openbox.xml"
start wm openbox --config-file "$STATE/openbox.xml"
# Let it manage the screen before the terminal maps, so its maximize rule applies.
sleep 0.2
elif command -v twm >/dev/null; then
start wm twm
fi
if command -v xterm >/dev/null; then
start xterm xterm -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11
# selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).
start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \
-xrm 'XTerm*selectToClipboard: true'
fi
fi
if ! alive websockify || ! listening; then
if alive websockify; then kill "$(pid_of websockify)" 2>/dev/null || true; fi
PYTHONPATH="$DESKTOP/websockify" start websockify python3 -P -W ignore::UserWarning -m websockify \
--web "$DESKTOP/novnc" --unix-target "$SOCKET" "127.0.0.1:$PORT"
if ! alive proxy || ! listening; then
if alive proxy; then kill "$(pid_of proxy)" 2>/dev/null || true; sleep 0.1; fi
mkdir -p "$TICKETS"
PYTHONPATH="$DESKTOP/websockify" start proxy python3 -P -W ignore::UserWarning "$PROXY" \
--listen "$PORT" --web "$DESKTOP/novnc" --unix-target "$SOCKET" --tickets "$TICKETS"
for _ in $(seq 1 100); do
listening && break
alive websockify || { tail -n 20 "$STATE/websockify.log" >&2; fail 'websockify exited'; }
alive proxy || { tail -n 20 "$STATE/proxy.log" >&2; fail 'orca-desktop-proxy exited'; }
sleep 0.05
done
listening || fail "websockify is not answering on 127.0.0.1:$PORT"
listening || fail "orca-desktop-proxy is not answering on 127.0.0.1:$PORT"
fi
ready_line
+6 -2
View File
@@ -1149,14 +1149,18 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
}
case 'POST desktop': {
// Runs only the image's fixed entry point; the desktop listens on the VM's loopback and is
// reached through the paired runtime's port forward, never a public route.
// reached through the paired runtime's port forward, never a public route. Every call mints
// a fresh single-use ticket (each WebSocket, including a reconnect, needs its own); it is
// never logged here.
if (row.status !== 'running' || !row.session_id) {
return fail('conflict', 409, `sandbox_${row.status}`)
}
const started = Date.now()
const desktop = await startDesktop(life.provider, row.session_id)
console.log(JSON.stringify({ event: 'desktop_start', ms: Date.now() - started }))
return json({ desktop: { port: desktop.port, path: desktop.path, password: desktop.password } })
return json({
desktop: { port: desktop.port, path: desktop.path, ticket: desktop.ticket, expiresInMs: desktop.expiresInMs }
})
}
case 'POST lease': {
const body = LeaseRequestSchema.safeParse(await readJson(request))
@@ -196,12 +196,16 @@ const DESKTOP_NOT_INSTALLED_EXIT = 3
const DesktopReadySchema = z
.object({
schemaVersion: z.literal(1),
schemaVersion: z.literal(2),
port: z.number().int().min(1024).max(65_535),
display: z.string().regex(/^:\d{1,3}$/),
path: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
/** Per-boot VNC password: the desktop's localhost forward is reachable by any local process. */
password: z.string().regex(/^[A-Za-z0-9]{8}$/)
/**
* Single-use connection ticket (32 random bytes, base64url): the desktop's localhost forward is
* reachable by any local process, so the VM's proxy admits one WebSocket per unexpired ticket.
*/
ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
expiresInMs: z.number().int().min(1_000).max(300_000)
})
.strict()
@@ -214,7 +218,7 @@ export class DesktopCommandError extends Error {
}
}
/** Starts the VM's remote desktop (idempotent per boot): Xvnc + noVNC on the VM's loopback only. */
/** Starts the VM's remote desktop (idempotent per boot) and mints one ticket: Xvnc + noVNC on the VM's loopback only. */
export async function startDesktop(provider: SandboxProviderApi, sessionId: string): Promise<DesktopReady> {
let result: Awaited<ReturnType<SandboxProviderApi['runCommand']>>
try {
@@ -14,7 +14,10 @@ describe('POST /v1/sandboxes/:id/desktop', () => {
it("runs only the image's desktop entry point and returns its loopback port", async () => {
const { h, id } = await running()
const response = await h.call('POST', `/v1/sandboxes/${id}/desktop`)
expect(response).toEqual({ status: 200, body: { desktop: { port: 6080, path: 'vnc.html', password: 'Ab3dE6gH' } } })
expect(response).toEqual({
status: 200,
body: { desktop: { port: 6080, path: 'vnc.html', ticket: 't'.repeat(43), expiresInMs: 60000 } }
})
const ran = h.vercel.commands.filter((c) => c.command === DESKTOP_COMMAND)
expect(ran).toHaveLength(1)
expect(ran[0]!.env).toEqual({})
@@ -36,11 +39,24 @@ describe('POST /v1/sandboxes/:id/desktop', () => {
status: 502,
body: { error: 'provider_unavailable', reason: 'desktop_start_failed' }
})
h.vercel.desktop = { exitCode: 0, stdout: '{"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"short"}' }
h.vercel.desktop = { exitCode: 0, stdout: '{"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"short","expiresInMs":60000}' }
expect(await h.call('POST', `/v1/sandboxes/${id}/desktop`)).toEqual({
status: 502,
body: { error: 'provider_unavailable', reason: 'desktop_ready_invalid' }
})
// An older entry point (VNC password, no ticket) is never passed through.
h.vercel.desktop = { exitCode: 0, stdout: '{"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"Ab3dE6gH"}' }
expect((await h.call('POST', `/v1/sandboxes/${id}/desktop`)).body).toEqual({
error: 'provider_unavailable',
reason: 'desktop_ready_invalid'
})
})
it('mints a fresh ticket on every call (a reconnect needs its own)', async () => {
const { h, id } = await running()
await h.call('POST', `/v1/sandboxes/${id}/desktop`)
await h.call('POST', `/v1/sandboxes/${id}/desktop`)
expect(h.vercel.commands.filter((c) => c.command === DESKTOP_COMMAND)).toHaveLength(2)
})
it('refuses a suspended sandbox and hides other accounts\' sandboxes', async () => {
@@ -168,7 +168,7 @@ export function createFakeVercel(now: () => number): FakeVercel {
failEnvRefresh: false,
sshKeysExit: 0,
authorizedKeys: new Map(),
desktop: { exitCode: 0, stdout: '{"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"Ab3dE6gH"}\n' },
desktop: { exitCode: 0, stdout: `{"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"${'t'.repeat(43)}","expiresInMs":60000}\n` },
failCreate: false,
snapshotExpirations: new Map(),
expire(name) {
@@ -33,6 +33,8 @@ export function setupGuestShortcutForwarding(args: {
resolveWorkspaceId?: (browserTabId: string) => string | null
/** False for plugin frames (see GuestShortcutForwardContext). */
browserPage?: boolean
/** True while the page owns every key (a focused remote desktop): Orca forwards nothing. */
isKeyboardCaptured?: () => boolean
}): () => void {
const {
browserTabId,
@@ -43,7 +45,8 @@ export function setupGuestShortcutForwarding(args: {
getKeybindings,
resolveWorktreeId,
resolveWorkspaceId,
browserPage
browserPage,
isKeyboardCaptured
} = args
let ctrlTabSwitching = false
const doubleTapDetector = new ModifierDoubleTapDetector()
@@ -72,7 +75,7 @@ export function setupGuestShortcutForwarding(args: {
const handler = (event: Electron.Event, input: Electron.Input): void => {
// Agent-injected keys must not trigger Orca actions (#20595).
if (isAgentInjectingInput(guest)) {
if (isAgentInjectingInput(guest) || isKeyboardCaptured?.()) {
return
}
const keybindings = getKeybindings?.()
@@ -5,6 +5,10 @@ import { setupGuestMouseWheelZoomForwarding } from '../input/browser-guest-wheel
import { setupGuestShortcutForwarding } from '../input/shortcuts/browser-guest-shortcut-forwarding'
import { setupGuestTargetUrlForwarding } from '../input/browser-guest-target-url'
import { BrowserManagerGrab } from './grab/browser-manager-grab'
import {
isRemoteDesktopGuest,
setupRemoteDesktopGuest
} from '../../../hosted-sandbox/remote-desktop-guest'
export abstract class BrowserManagerBindings extends BrowserManagerGrab {
protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void {
@@ -56,7 +60,9 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab {
isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false,
getKeybindings: () => this.settingsResolver?.().keybindings,
resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null,
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null,
// A focused remote desktop keeps every key (VM-console style) until its release chord.
isKeyboardCaptured: () => isRemoteDesktopGuest(guest)
})
)
}
@@ -83,6 +89,28 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab {
)
}
protected setupRemoteDesktopGuest(browserTabId: string, guest: Electron.WebContents): void {
this.remoteDesktopCleanupByTabId.get(browserTabId)?.()
this.remoteDesktopCleanupByTabId.set(
browserTabId,
setupRemoteDesktopGuest({
browserPageId: browserTabId,
guest,
notify: (event) => {
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer || renderer.isDestroyed()) {
return
}
if (event.kind === 'release') {
renderer.send('browser:remoteDesktopRelease', { browserPageId: browserTabId })
} else {
renderer.send('browser:remoteDesktopState', event.state)
}
}
})
)
}
protected setupTargetUrlForwarding(browserTabId: string, guest: Electron.WebContents): void {
this.targetUrlCleanupByTabId.get(browserTabId)?.()
this.targetUrlCleanupByTabId.set(
@@ -89,6 +89,10 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
browserTabId: string,
guest: Electron.WebContents
): void
protected abstract setupRemoteDesktopGuest(
browserTabId: string,
guest: Electron.WebContents
): void
protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void
protected abstract hasActiveGrabOp(browserTabId: string): boolean
protected abstract unregisterGuest(
@@ -155,6 +159,7 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
protected readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
protected readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
protected readonly targetUrlCleanupByTabId = new Map<string, () => void>()
protected readonly remoteDesktopCleanupByTabId = new Map<string, () => void>()
protected readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
protected readonly worktreeIdByTabId = new Map<string, string>()
protected readonly policyAttachedGuestIds = new Set<number>()
@@ -89,6 +89,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
this.setupShortcutForwarding(browserTabId, guest)
this.setupMouseWheelZoomForwarding(browserTabId, guest)
this.setupTargetUrlForwarding(browserTabId, guest)
this.setupRemoteDesktopGuest(browserTabId, guest)
this.flushPendingLoadFailure(browserTabId, webContentsId)
this.flushPendingPermissionEvents(browserTabId, webContentsId)
this.flushPendingPopupEvents(browserTabId, webContentsId)
@@ -141,6 +142,8 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
}
this.targetUrlCleanupByTabId.get(browserTabId)?.()
this.targetUrlCleanupByTabId.delete(browserTabId)
this.remoteDesktopCleanupByTabId.get(browserTabId)?.()
this.remoteDesktopCleanupByTabId.delete(browserTabId)
let hasActiveDownloads = false
for (const [downloadId, download] of this.downloadsById.entries()) {
if (download.browserTabId === browserTabId && !download.terminalEvent) {
@@ -243,6 +246,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
this.pendingDownloadIdsByGuestId.clear()
this.mouseWheelZoomCleanupByTabId.clear()
this.targetUrlCleanupByTabId.clear()
this.remoteDesktopCleanupByTabId.clear()
this.annotationViewportBridgeOpsByTabId.clear()
}
}
@@ -18,6 +18,8 @@ export type FakeHostedControlPlane = {
requests: { method: string; path: string; authorization: string | null }[]
/** False models an image built before the remote desktop. */
desktopInstalled: boolean
/** Desktop starts served (each mints one ticket). */
desktopTickets: number
}
export function createFakeHostedControlPlane(
@@ -143,8 +145,17 @@ export function createFakeHostedControlPlane(
if (sandbox.status !== 'running') {
return fail('conflict', 409, `sandbox_${sandbox.status}`)
}
fake.desktopTickets += 1
return fake.desktopInstalled
? json({ desktop: { port: 6080, path: 'vnc.html', password: 'Ab3dE6gH' } })
? json({
desktop: {
port: 6080,
path: 'vnc.html',
// A fresh single-use ticket per call, as orca-desktop-start mints them.
ticket: String(fake.desktopTickets).padStart(43, 't'),
expiresInMs: 60_000
}
})
: fail('conflict', 409, 'desktop_not_installed')
case 'POST /rollover':
if (sandbox.status !== 'running') {
@@ -180,7 +191,8 @@ export function createFakeHostedControlPlane(
pendingDeny = reason
},
requests,
desktopInstalled: true
desktopInstalled: true,
desktopTickets: 0
}
return fake
}
@@ -9,10 +9,16 @@ import { createHostedSandboxDriver } from './hosted-sandbox-driver'
import { createHostedSandboxStepProvider } from './hosted-sandbox-recipe'
import { createFakeHostedControlPlane } from './fake-hosted-control-plane.test-support'
import { cloudSandboxIdForEnvironment, openCloudRemoteDesktop } from './hosted-remote-desktop'
import {
remoteDesktopSessionForUrl,
resetRemoteDesktopSessionsForTest,
takeRemoteDesktopTicket
} from './remote-desktop-sessions'
const ENV = 'env-cloud-1'
async function setup() {
resetRemoteDesktopSessionsForTest()
const plane = createFakeHostedControlPlane()
const client = createHostedSandboxControlPlaneClient({
baseUrl: 'https://sandbox.cloud.test',
@@ -55,12 +61,31 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
const { plane, client, userDataPath, openForward } = await setup()
const sandboxId = cloudSandboxIdForEnvironment(userDataPath, ENV)
expect(sandboxId).toBe([...plane.sandboxes.keys()][0])
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)
const now = () => 1_000
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward, now }, ENV)
// No secret in the URL: the page gets single-use tickets from main after it loads.
expect(result).toEqual({
ok: true,
localPort: 6081,
url: 'http://localhost:6081/vnc.html?autoconnect=1&resize=remote&reconnect=1&reconnect_delay=1000#password=Ab3dE6gH'
url: 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
})
const session = remoteDesktopSessionForUrl(result.ok ? result.url : '')
expect(session).toMatchObject({ environmentId: ENV, localPort: 6081, path: 'vnc.html' })
// The first ticket rides with the open; the next one is minted fresh (a reconnect).
expect(await takeRemoteDesktopTicket(session!, now)).toEqual({
ok: true,
ticket: '1'.padStart(43, 't'),
expiresAt: 61_000
})
expect(await takeRemoteDesktopTicket(session!, now)).toEqual({
ok: true,
ticket: '2'.padStart(43, 't'),
expiresAt: 61_000
})
expect(remoteDesktopSessionForUrl('http://localhost:6082/vnc.html')).toBeNull()
expect(remoteDesktopSessionForUrl('http://localhost:6081/other.html')).toBeNull()
expect(remoteDesktopSessionForUrl('https://localhost:6081/vnc.html')).toBeNull()
expect(remoteDesktopSessionForUrl('http://example.com:6081/vnc.html')).toBeNull()
expect(plane.requests.at(-1)).toMatchObject({
method: 'POST',
path: `/v1/sandboxes/${sandboxId}/desktop`
@@ -102,6 +127,13 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
ok: false,
code: 'not_running'
})
for (const sandbox of plane.sandboxes.values()) {
Object.assign(sandbox, { status: 'resuming' })
}
expect(await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)).toEqual({
ok: false,
code: 'starting'
})
expect(openForward).not.toHaveBeenCalled()
for (const sandbox of plane.sandboxes.values()) {
Object.assign(sandbox, { status: 'running' })
@@ -112,6 +144,17 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
).toEqual({ ok: false, code: 'forward_failed' })
})
it('says why a later ticket could not be minted (the sandbox went to sleep)', async () => {
const { plane, client, userDataPath, openForward } = await setup()
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)
const session = remoteDesktopSessionForUrl(result.ok ? result.url : '')!
await takeRemoteDesktopTicket(session) // the open's own ticket
for (const sandbox of plane.sandboxes.values()) {
Object.assign(sandbox, { status: 'suspended', session: null })
}
expect(await takeRemoteDesktopTicket(session)).toEqual({ ok: false, reason: 'asleep' })
})
it('asks for sign-in when the control plane refuses the token', async () => {
const { userDataPath, openForward } = await setup()
const plane = createFakeHostedControlPlane()
@@ -1,17 +1,24 @@
// "Open Remote Desktop" for an Orca Cloud workspace: the control plane starts the VM's desktop
// (Xvnc + noVNC on the VM's loopback), then the paired runtime connection forwards its port here.
// No public route: the desktop is reachable only through this computer's localhost.
// No public route: the desktop is reachable only through this computer's localhost, and each
// WebSocket to it needs a single-use ticket that main hands the page (remote-desktop-guest.ts).
import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store'
import {
cloudRemoteDesktopUrl,
type CloudRemoteDesktopFailureCode,
type CloudRemoteDesktopResult
} from '../../shared/hosted-sandbox/cloud-remote-desktop'
import type { HostedSandboxDesktop } from '../../shared/hosted-sandbox/hosted-sandbox-wire'
import type { RuntimePortForwardOpenRequest } from '../../shared/runtime-port-forwards'
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import { environmentHandleFromRecipeResult } from '../environment-providers/environment-provider-recipe-adapter'
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
import { HOSTED_SANDBOX_DRIVER_ID } from './hosted-sandbox-driver'
import {
registerRemoteDesktopSession,
type RemoteDesktopMintFailure,
type RemoteDesktopMintResult
} from './remote-desktop-sessions'
export type CloudRemoteDesktopDeps = {
userDataPath: string
@@ -19,6 +26,13 @@ export type CloudRemoteDesktopDeps = {
openForward: (
request: RuntimePortForwardOpenRequest
) => Promise<{ ok: true; localPort: number } | { ok: false }>
now?: () => number
/** Whether this computer still forwards localPort to the environment's remotePort. */
isForwardLive?: (forward: {
environmentId: string
localPort: number
remotePort: number
}) => boolean
}
/** The Orca Cloud sandbox behind a paired runtime environment, from the local runtime records. */
@@ -54,14 +68,32 @@ function failure(code: CloudRemoteDesktopFailureCode): CloudRemoteDesktopResult
return { ok: false, code }
}
function startFailure(error: unknown): CloudRemoteDesktopResult {
const ASLEEP_REASONS = new Set(['sandbox_suspended', 'sandbox_suspending'])
const STARTING_REASONS = new Set(['sandbox_creating', 'sandbox_resuming'])
/** How a desktop start (or ticket) failure reads; a conflict names the sandbox's state. */
function startFailureCode(error: unknown): CloudRemoteDesktopFailureCode {
if (isEnvironmentProviderError(error, 'conflict')) {
return failure(error.reason === 'desktop_not_installed' ? 'not_installed' : 'not_running')
const reason = error.reason ?? ''
if (reason === 'desktop_not_installed') {
return 'not_installed'
}
if (reason === 'sandbox_archived') {
return 'archived'
}
if (ASLEEP_REASONS.has(reason)) {
return 'not_running'
}
return STARTING_REASONS.has(reason) ? 'starting' : 'unavailable'
}
if (isEnvironmentProviderError(error, 'unauthorized')) {
return failure('signed_out')
}
return failure('unavailable')
return isEnvironmentProviderError(error, 'unauthorized') ? 'signed_out' : 'unavailable'
}
const MINT_FAILURE: Partial<Record<CloudRemoteDesktopFailureCode, RemoteDesktopMintFailure>> = {
not_running: 'asleep',
archived: 'archived',
starting: 'starting',
signed_out: 'signed_out'
}
export async function openCloudRemoteDesktop(
@@ -69,14 +101,16 @@ export async function openCloudRemoteDesktop(
environmentId: string
): Promise<CloudRemoteDesktopResult> {
const sandboxId = cloudSandboxIdForEnvironment(deps.userDataPath, environmentId)
if (!sandboxId || !deps.client) {
const client = deps.client
if (!sandboxId || !client) {
return failure('not_cloud')
}
let desktop: { port: number; path: string; password: string }
const now = deps.now ?? Date.now
let desktop: HostedSandboxDesktop
try {
desktop = await deps.client.startDesktop(sandboxId)
desktop = await client.startDesktop(sandboxId)
} catch (error) {
return startFailure(error)
return failure(startFailureCode(error))
}
// Same local number when free (stable bookmarks), else the next free one.
const forward = await deps.openForward({
@@ -88,9 +122,30 @@ export async function openCloudRemoteDesktop(
if (!forward.ok) {
return failure('forward_failed')
}
// Each later WebSocket (a reload, a reconnect after a drop or a wake) gets its own ticket.
const mint = async (): Promise<RemoteDesktopMintResult> => {
try {
const next = await client.startDesktop(sandboxId)
return { ok: true, ticket: next.ticket, expiresAt: now() + next.expiresInMs }
} catch (error) {
return { ok: false, reason: MINT_FAILURE[startFailureCode(error)] ?? 'unavailable' }
}
}
const isForwardLive = deps.isForwardLive
const live = { environmentId, localPort: forward.localPort, remotePort: desktop.port }
registerRemoteDesktopSession(
{
environmentId,
localPort: forward.localPort,
path: desktop.path,
mint,
...(isForwardLive ? { isLive: () => isForwardLive(live) } : {})
},
{ ticket: desktop.ticket, expiresAt: now() + desktop.expiresInMs }
)
return {
ok: true,
localPort: forward.localPort,
url: cloudRemoteDesktopUrl(forward.localPort, desktop.path, desktop.password)
url: cloudRemoteDesktopUrl(forward.localPort, desktop.path)
}
}
@@ -0,0 +1,276 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ clipboard: { readText: () => '' } }))
vi.mock('../i18n/main-i18n', () => ({
translateMain: (_key: string, fallback: string) => fallback
}))
vi.mock('../browser/cdp/input/agent-input-injection', () => ({
isAgentInjectingInput: () => false
}))
import {
registerRemoteDesktopSession,
resetRemoteDesktopSessionsForTest,
type RemoteDesktopMintResult
} from './remote-desktop-sessions'
import { isRemoteDesktopGuest, setupRemoteDesktopGuest } from './remote-desktop-guest'
const DESKTOP_URL = 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
const ticket = (n: number): string => String(n).padStart(43, 't')
/** Fixed, far-future expiry: these tests never depend on the clock. */
const FAR_FUTURE = 4_000_000_000_000
/** A guest whose page answers orcaDesktop calls from a script the test controls. */
class FakeGuest extends EventEmitter {
url = DESKTOP_URL
focused = false
destroyed = false
calls: string[] = []
/** Pending nextTicketRequest() answers, resolved by the test. */
private waiters: ((answer: string) => void)[] = []
connectAnswer = 'connecting'
pasteAnswer = true
getURL(): string {
return this.url
}
isLoading(): boolean {
return false
}
isFocused(): boolean {
return this.focused
}
isDestroyed(): boolean {
return this.destroyed
}
async executeJavaScript(code: string): Promise<unknown> {
this.calls.push(code)
if (code.includes('nextTicketRequest')) {
return new Promise((resolve) => this.waiters.push(resolve))
}
if (code.includes('orcaDesktop.connect(')) {
return this.connectAnswer
}
if (code.includes('orcaDesktop.paste(')) {
return this.pasteAnswer
}
return true
}
/** The page asks for a ticket ('ticket') or reports a failed attempt ('retry'). */
ask(answer: 'ticket' | 'retry'): void {
this.waiters.shift()?.(answer)
}
get waiting(): number {
return this.waiters.length
}
key(input: Partial<Electron.Input>): { prevented: boolean } {
const event = { prevented: false, preventDefault: () => (event.prevented = true) }
this.emit('before-input-event', event, {
type: 'keyDown',
key: '',
code: '',
control: false,
alt: false,
meta: false,
shift: false,
isAutoRepeat: false,
...input
})
return event
}
}
function asWebContents(guest: FakeGuest): Electron.WebContents {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeGuest implements every WebContents member the driver calls (on/off, getURL, isLoading, isFocused, isDestroyed, executeJavaScript).
return guest as unknown as Electron.WebContents
}
const flush = async (): Promise<void> => {
for (let i = 0; i < 5; i += 1) {
await new Promise((resolve) => setImmediate(resolve))
}
}
function setup(options: { clipboard?: string; platform?: NodeJS.Platform } = {}) {
const guest = new FakeGuest()
const minted: string[] = []
let next = 1
let mintResult: () => RemoteDesktopMintResult = () => {
const value = ticket((next += 1))
minted.push(value)
return { ok: true, ticket: value, expiresAt: FAR_FUTURE }
}
registerRemoteDesktopSession(
{
environmentId: 'env-1',
localPort: 6081,
path: 'vnc.html',
mint: async () => mintResult()
},
{ ticket: ticket(1), expiresAt: FAR_FUTURE }
)
const events: unknown[] = []
const stop = setupRemoteDesktopGuest({
browserPageId: 'page-1',
guest: asWebContents(guest),
platform: options.platform ?? 'darwin',
notify: (event) => events.push(event),
readClipboard: () => options.clipboard ?? '',
sleep: async () => {}
})
return {
guest,
minted,
events,
stop,
failMint: (reason: 'asleep' | 'unavailable') => {
mintResult = () => ({ ok: false, reason })
}
}
}
const connects = (guest: FakeGuest): string[] =>
guest.calls.filter((c) => c.includes('orcaDesktop.connect('))
describe('remote desktop page driver', () => {
beforeEach(() => resetRemoteDesktopSessionsForTest())
it('hands the page the open’s ticket first, then a fresh one for each reconnect', async () => {
const { guest } = setup()
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(true)
await flush()
guest.ask('ticket')
await flush()
expect(connects(guest)).toEqual([`window.orcaDesktop.connect("${ticket(1)}")`])
// The session dropped (forward re-dial, desktop restart): a new ticket, never the spent one.
guest.ask('ticket')
await flush()
expect(connects(guest).at(-1)).toBe(`window.orcaDesktop.connect("${ticket(2)}")`)
})
it('pauses with Reconnect after repeated failed attempts instead of spinning', async () => {
const { guest } = setup()
await flush()
// One try and three retries, then the page waits for a person.
for (let i = 0; i < 5; i += 1) {
guest.ask(i === 0 ? 'ticket' : 'retry')
await flush()
}
expect(connects(guest)).toHaveLength(4)
expect(guest.calls.at(-2)).toContain('window.orcaDesktop.pause(')
expect(guest.calls.at(-2)).toContain('The remote desktop disconnected.')
})
it('says the sandbox is asleep when no ticket can be minted, and resumes on reopen', async () => {
const { guest, failMint } = setup()
await flush()
guest.ask('ticket') // uses the open's ticket
await flush()
failMint('asleep')
guest.ask('ticket')
await flush()
expect(guest.calls.at(-2)).toContain('This sandbox is asleep.')
// Open remote desktop again: main wakes the paused page.
registerRemoteDesktopSession(
{
environmentId: 'env-1',
localPort: 6081,
path: 'vnc.html',
mint: async () => ({ ok: false, reason: 'asleep' })
},
null
)
expect(guest.calls.at(-1)).toContain('orcaDesktop.resume()')
})
it('ignores pages that are not this computer’s desktop forwards', async () => {
resetRemoteDesktopSessionsForTest()
const guest = new FakeGuest()
guest.url = 'http://localhost:6081/vnc.html'
const stop = setupRemoteDesktopGuest({
browserPageId: 'page-2',
guest: asWebContents(guest),
notify: () => {},
readClipboard: () => 'secret',
sleep: async () => {}
})
await flush()
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
expect(guest.calls).toEqual([])
expect(guest.key({ key: 'v', code: 'KeyV', meta: true }).prevented).toBe(false)
stop()
})
it('reads the clipboard only on a paste key, and pushes it on every paste', async () => {
const { guest } = setup({ clipboard: 'from this computer', platform: 'linux' })
await flush()
guest.ask('ticket')
await flush()
expect(guest.key({ key: 'a', code: 'KeyA', control: true }).prevented).toBe(false)
expect(guest.calls.some((c) => c.includes('paste('))).toBe(false)
expect(guest.key({ key: 'v', code: 'KeyV', control: true }).prevented).toBe(true)
await flush()
expect(guest.calls.at(-1)).toBe(
'window.orcaDesktop.paste("from this computer", {"kind":"replay","key":"v"})'
)
// Same text again is pushed again: the sandbox's clipboard may have changed meanwhile.
expect(guest.key({ key: 'v', code: 'KeyV', control: true }).prevented).toBe(true)
// Auto-repeat of a Linux paste key passes through untouched.
expect(guest.key({ key: 'v', code: 'KeyV', control: true, isAutoRepeat: true }).prevented).toBe(
false
)
})
it('turns Cmd+V into a sandbox paste on macOS', async () => {
const { guest } = setup({ clipboard: 'hi' })
await flush()
expect(guest.key({ key: 'v', code: 'KeyV', meta: true }).prevented).toBe(true)
await flush()
expect(guest.calls.at(-1)).toBe('window.orcaDesktop.paste("hi", {"kind":"mac"})')
})
it('reports keyboard capture and the release chord to the renderer', async () => {
const { guest, events } = setup()
guest.focused = true
guest.emit('focus')
expect(events.at(-1)).toEqual({
kind: 'state',
state: { browserPageId: 'page-1', desktop: true, captured: true }
})
guest.key({ type: 'keyDown', key: 'Control', control: true })
guest.key({ type: 'keyDown', key: 'Meta', control: true, meta: true })
guest.key({ type: 'keyUp', key: 'Meta', control: true })
expect(events.at(-1)).toEqual({ kind: 'release' })
})
it('never strands a page call when the page goes away mid-call', async () => {
const { guest } = setup()
await flush()
expect(guest.waiting).toBe(1) // nextTicketRequest() pending in the page
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
await flush()
// The pending call settled (cancelled) and the driver stopped: no connect, no new request.
expect(connects(guest)).toEqual([])
expect(guest.calls.filter((c) => c.includes('nextTicketRequest'))).toHaveLength(1)
})
it('stops driving the page when it navigates away or the tab closes', async () => {
const { guest, events, stop } = setup()
await flush()
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
guest.ask('ticket')
await flush()
expect(connects(guest)).toEqual([])
guest.url = DESKTOP_URL
guest.emit('did-finish-load')
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(true)
stop()
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
expect(events.at(-1)).toEqual({
kind: 'state',
state: { browserPageId: 'page-1', desktop: false, captured: false }
})
})
})
@@ -0,0 +1,299 @@
// Main's side of a remote desktop browser page (a page whose URL names a desktop forward this
// computer opened; see remote-desktop-sessions.ts). Main, not the page, holds the means to mint
// tickets, so it:
// - hands the page one single-use ticket per WebSocket (on load, and after each drop), backing off
// and pausing on repeated failures; the page offers Reconnect, and reopening from the menu
// resumes it. Tickets never touch the URL, the tab state or a log.
// - keeps the keyboard in the desktop while it has focus and gives it back to Orca on the release
// chord (remote-desktop-keyboard.ts), telling the renderer so it can show the hint.
// - pastes this computer's clipboard into the sandbox only on a paste key in the desktop.
import { clipboard } from 'electron'
import { translateMain } from '../i18n/main-i18n'
import { isAgentInjectingInput } from '../browser/cdp/input/agent-input-injection'
import {
onRemoteDesktopSessionRefreshed,
remoteDesktopSessionForUrl,
takeRemoteDesktopTicket,
type RemoteDesktopMintFailure,
type RemoteDesktopSession
} from './remote-desktop-sessions'
import {
createReleaseChordTracker,
remoteDesktopPasteChord,
type RemoteDesktopPasteChord
} from './remote-desktop-keyboard'
/** Consecutive connects that never reached the desktop before the page pauses with Reconnect. */
const MAX_FAILED_CONNECTS = 4
const RETRY_DELAYS_MS = [0, 1_000, 2_000, 4_000]
/** Floor between tickets, and a cap per minute, so a page that keeps dropping never spins mints. */
const MIN_MINT_INTERVAL_MS = 1_000
const MAX_MINTS_PER_MINUTE = 10
const MAX_PASTE_CHARS = 1 << 20
const PAGE_READY_TIMEOUT_MS = 10_000
export type RemoteDesktopGuestState = {
browserPageId: string
/** The page is a remote desktop with Orca's page glue. */
desktop: boolean
/** It has keyboard focus: Orca's shortcuts are off until the release chord. */
captured: boolean
}
export type RemoteDesktopGuestDeps = {
browserPageId: string
guest: Electron.WebContents
platform?: NodeJS.Platform
/** Renderer notices: hint visibility, and the release chord (the renderer moves focus out). */
notify: (event: { kind: 'state'; state: RemoteDesktopGuestState } | { kind: 'release' }) => void
readClipboard?: () => string
sleep?: (ms: number) => Promise<void>
}
const guestsWithDesktop = new WeakSet<Electron.WebContents>()
/** True while this guest shows a remote desktop: Orca's guest shortcut forwarding stands down. */
export function isRemoteDesktopGuest(guest: Electron.WebContents): boolean {
return guestsWithDesktop.has(guest)
}
const WAIT_FOR_REQUEST = `(async () => {
for (let i = 0; i < ${PAGE_READY_TIMEOUT_MS / 25} && !window.orcaDesktop; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
return window.orcaDesktop ? window.orcaDesktop.nextTicketRequest() : 'missing'
})()`
function pauseMessage(reason: RemoteDesktopMintFailure | 'failed'): string {
switch (reason) {
case 'asleep':
return translateMain(
'components.cloudRemoteDesktop.pageAsleep',
'This sandbox is asleep. Wake it in Orca, then reconnect.'
)
case 'archived':
return translateMain(
'components.cloudRemoteDesktop.pageArchived',
'This sandbox is archived. Unarchive the workspace in Orca, then reconnect.'
)
case 'starting':
return translateMain(
'components.cloudRemoteDesktop.pageStarting',
'The sandbox is starting or waking. Reconnect in a moment.'
)
case 'signed_out':
return translateMain(
'components.cloudRemoteDesktop.pageSignedOut',
'You’re signed out of Orca Cloud. Sign in, then reconnect.'
)
case 'failed':
case 'unavailable':
return translateMain(
'components.cloudRemoteDesktop.pageDisconnected',
'The remote desktop disconnected. Your files are safe.'
)
}
}
export function setupRemoteDesktopGuest(deps: RemoteDesktopGuestDeps): () => void {
const { guest, browserPageId, notify } = deps
const platform = deps.platform ?? process.platform
const readClipboard = deps.readClipboard ?? (() => clipboard.readText())
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
const releaseChord = createReleaseChordTracker(platform)
let generation = 0
let session: RemoteDesktopSession | null = null
let stopRefreshListener = (): void => {}
let disposed = false
let published = ''
/** Settles every in-flight page call when the page goes away (a torn-down page may never answer). */
let cancelInFlight = (): void => {}
let cancelled = new Promise<undefined>((resolve) => {
cancelInFlight = () => resolve(undefined)
})
const cancelPageCalls = (): void => {
cancelInFlight()
cancelled = new Promise<undefined>((resolve) => {
cancelInFlight = () => resolve(undefined)
})
}
const alive = (gen: number): boolean => !disposed && gen === generation && !guest.isDestroyed()
const publish = (): void => {
const state = {
browserPageId,
desktop: session !== null,
captured: session !== null && !guest.isDestroyed() && guest.isFocused()
}
// Only changes, and nothing at all for ordinary pages' focus churn.
const key = `${state.desktop}:${state.captured}`
if (key === published || (!state.desktop && published === '')) {
return
}
published = key
notify({ kind: 'state', state })
}
const run = async (gen: number, code: string): Promise<unknown> => {
if (!alive(gen)) {
return undefined
}
try {
return await Promise.race([guest.executeJavaScript(code), cancelled])
} catch {
return undefined
}
}
async function driveTickets(gen: number, current: RemoteDesktopSession): Promise<void> {
let failures = 0
let refused = false
let lastMintAt = 0
let mintTimes: number[] = []
while (alive(gen)) {
const ask = await run(gen, WAIT_FOR_REQUEST)
if (!alive(gen) || (ask !== 'ticket' && ask !== 'retry')) {
return
}
// 'retry': the last ticket never got a session (refused, or the forward was down).
failures = ask === 'retry' || refused ? failures + 1 : 0
refused = false
if (failures >= MAX_FAILED_CONNECTS) {
failures = 0
await pause(gen, 'failed')
continue
}
// Even sessions that connect and then drop at once never mint faster than this.
const sinceLast = Date.now() - lastMintAt
await sleep(Math.max(RETRY_DELAYS_MS[failures] ?? 4_000, MIN_MINT_INTERVAL_MS - sinceLast))
// The forward may have closed meanwhile; a page now on that port gets nothing.
if (!alive(gen) || remoteDesktopSessionForUrl(guest.getURL()) !== current) {
return
}
mintTimes = mintTimes.filter((at) => Date.now() - at < 60_000)
if (mintTimes.length >= MAX_MINTS_PER_MINUTE) {
mintTimes = []
await pause(gen, 'failed')
continue
}
lastMintAt = Date.now()
mintTimes.push(lastMintAt)
const minted = await takeRemoteDesktopTicket(current)
if (!minted.ok) {
failures = 0
await pause(gen, minted.reason)
continue
}
const started = await run(gen, `window.orcaDesktop.connect(${JSON.stringify(minted.ticket)})`)
// Anything but a started attempt still counts against the budget, so this never spins.
refused = started !== 'connecting'
}
}
async function pause(gen: number, reason: RemoteDesktopMintFailure | 'failed'): Promise<void> {
const label = translateMain('components.cloudRemoteDesktop.reconnect', 'Reconnect')
await run(
gen,
`window.orcaDesktop.pause(${JSON.stringify(pauseMessage(reason))}, ${JSON.stringify(label)}, ${JSON.stringify(reason === 'asleep' || reason === 'archived' || reason === 'starting' ? 'neutral' : 'error')})`
)
}
const onLoad = (): void => {
generation += 1
cancelPageCalls()
const gen = generation
stopRefreshListener()
stopRefreshListener = () => {}
session = remoteDesktopSessionForUrl(guest.getURL())
if (session) {
guestsWithDesktop.add(guest)
// Reopening from the menu resumes a page that stopped retrying (asleep, then woken).
stopRefreshListener = onRemoteDesktopSessionRefreshed(session.localPort, () => {
void run(gen, 'window.orcaDesktop && window.orcaDesktop.resume()')
})
void driveTickets(gen, session)
} else {
guestsWithDesktop.delete(guest)
}
publish()
}
const onNavigate = (
details: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>
): void => {
if (details.isMainFrame && !details.isSameDocument && session) {
// Leaving the page: stop handing it tickets until the next load says what it is.
generation += 1
cancelPageCalls()
session = null
guestsWithDesktop.delete(guest)
stopRefreshListener()
publish()
}
}
// Async by nature: keys typed in the same instant can reach the sandbox before the replayed one.
const pasteInto = async (
gen: number,
text: string,
chord: RemoteDesktopPasteChord
): Promise<void> => {
await run(gen, `window.orcaDesktop.paste(${JSON.stringify(text)}, ${JSON.stringify(chord)})`)
}
const onInput = (event: Electron.Event, input: Electron.Input): void => {
if (!session || isAgentInjectingInput(guest)) {
return
}
if (releaseChord(input)) {
notify({ kind: 'release' })
return
}
const chord = remoteDesktopPasteChord(input, platform)
if (!chord) {
return
}
if (input.isAutoRepeat) {
if (chord.kind === 'mac') {
event.preventDefault()
}
return
}
// Read only on a paste key in the desktop: the sandbox never sees the clipboard otherwise.
const text = readClipboard()
if (!text || text.length > MAX_PASTE_CHARS) {
return
}
// Always pushed: the sandbox's clipboard may have changed by a route that never reached
// this computer (an agent's xclip), so "it already has this text" cannot be assumed.
event.preventDefault()
void pasteInto(generation, text, chord)
}
guest.on('did-finish-load', onLoad)
guest.on('did-start-navigation', onNavigate)
guest.on('before-input-event', onInput)
guest.on('focus', publish)
guest.on('blur', publish)
if (!guest.isLoading() && guest.getURL()) {
onLoad()
}
return () => {
disposed = true
cancelPageCalls()
stopRefreshListener()
guestsWithDesktop.delete(guest)
try {
guest.off('did-finish-load', onLoad)
guest.off('did-start-navigation', onNavigate)
guest.off('before-input-event', onInput)
guest.off('focus', publish)
guest.off('blur', publish)
} catch {
// The guest may already be destroyed.
}
if (session) {
session = null
publish()
}
}
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import {
createReleaseChordTracker,
remoteDesktopPasteChord,
type RemoteDesktopKeyInput
} from './remote-desktop-keyboard'
function key(
type: 'keyDown' | 'keyUp',
keyName: string,
mods: Partial<Pick<RemoteDesktopKeyInput, 'control' | 'alt' | 'meta' | 'shift'>> = {}
): RemoteDesktopKeyInput {
return {
type,
key: keyName,
code: keyName === 'v' || keyName === 'V' ? 'KeyV' : keyName,
control: false,
alt: false,
meta: false,
shift: false,
isAutoRepeat: false,
...mods
}
}
describe('remote desktop release chord', () => {
it('releases on macOS when Control+Command are pressed and let go with nothing else', () => {
const track = createReleaseChordTracker('darwin')
expect(track(key('keyDown', 'Control', { control: true }))).toBe(false)
expect(track(key('keyDown', 'Meta', { control: true, meta: true }))).toBe(false)
expect(track(key('keyUp', 'Meta', { control: true }))).toBe(true)
// Its other key going up does not release twice.
expect(track(key('keyUp', 'Control'))).toBe(false)
})
it('keeps Ctrl+Alt+T and other chords for the desktop', () => {
const track = createReleaseChordTracker('linux')
track(key('keyDown', 'Control', { control: true }))
track(key('keyDown', 'Alt', { control: true, alt: true }))
expect(track(key('keyDown', 't', { control: true, alt: true }))).toBe(false)
expect(track(key('keyUp', 'Alt', { control: true }))).toBe(false)
// Shift joining the chord is something else too.
track(key('keyDown', 'Control', { control: true }))
track(key('keyDown', 'Alt', { control: true, alt: true }))
track(key('keyDown', 'Shift', { control: true, alt: true, shift: true }))
expect(track(key('keyUp', 'Alt', { control: true, shift: true }))).toBe(false)
})
it('uses Ctrl+Alt off macOS and ignores Control+Command there', () => {
const win = createReleaseChordTracker('win32')
win(key('keyDown', 'Control', { control: true }))
win(key('keyDown', 'Meta', { control: true, meta: true }))
expect(win(key('keyUp', 'Meta', { control: true }))).toBe(false)
win(key('keyDown', 'Alt', { control: true, alt: true }))
expect(win(key('keyUp', 'Control', { alt: true }))).toBe(true)
})
it('ignores AltGr (reported as Ctrl+Alt) on Windows', () => {
const win = createReleaseChordTracker('win32')
win({ ...key('keyDown', 'Control', { control: true }), code: 'ControlLeft' })
win({ ...key('keyDown', 'Alt', { control: true, alt: true }), code: 'AltRight' })
expect(win({ ...key('keyUp', 'Alt', { control: true }), code: 'AltRight' })).toBe(false)
})
it('never releases on plain typing or a lone modifier', () => {
const track = createReleaseChordTracker('darwin')
for (const input of [
key('keyDown', 'Meta', { meta: true }),
key('keyDown', 'c', { meta: true }),
key('keyUp', 'c', { meta: true }),
key('keyUp', 'Meta'),
key('keyDown', 'Control', { control: true }),
key('keyUp', 'Control')
]) {
expect(track(input)).toBe(false)
}
})
})
describe('remote desktop paste keys', () => {
it('turns Cmd+V on macOS into a sandbox paste', () => {
expect(remoteDesktopPasteChord(key('keyDown', 'v', { meta: true }), 'darwin')).toEqual({
kind: 'mac'
})
expect(remoteDesktopPasteChord(key('keyDown', 'v', { meta: true }), 'linux')).toBeNull()
})
it('replays the Linux paste keys after setting the clipboard', () => {
expect(remoteDesktopPasteChord(key('keyDown', 'v', { control: true }), 'linux')).toEqual({
kind: 'replay',
key: 'v'
})
expect(
remoteDesktopPasteChord(key('keyDown', 'V', { control: true, shift: true }), 'darwin')
).toEqual({ kind: 'replay', key: 'V' })
expect(remoteDesktopPasteChord(key('keyDown', 'Insert', { shift: true }), 'win32')).toEqual({
kind: 'replay',
key: 'Insert'
})
})
it('follows the keyboard layout, not the physical key', () => {
// Dvorak: Ctrl+K is typed on the physical V key and must reach the sandbox untouched.
expect(
remoteDesktopPasteChord({ ...key('keyDown', 'k', { control: true }), code: 'KeyV' }, 'linux')
).toBeNull()
// Cyrillic: the V key types 'м'; Ctrl+V there is still paste.
expect(
remoteDesktopPasteChord({ ...key('keyDown', 'м', { control: true }), code: 'KeyV' }, 'linux')
).toEqual({ kind: 'replay', key: 'v' })
})
it('leaves other keys and key-ups alone', () => {
expect(remoteDesktopPasteChord(key('keyUp', 'v', { control: true }), 'linux')).toBeNull()
expect(remoteDesktopPasteChord(key('keyDown', 'c', { control: true }), 'linux')).toBeNull()
expect(remoteDesktopPasteChord(key('keyDown', 'v'), 'linux')).toBeNull()
expect(
remoteDesktopPasteChord(key('keyDown', 'v', { control: true, alt: true }), 'linux')
).toBeNull()
})
})
@@ -0,0 +1,74 @@
// Keyboard rules for a remote desktop page. While it has focus every key goes to the sandbox
// (Orca's shortcuts included), like a VM console, so Ctrl/Cmd combos type normally there. One
// chord gives the keyboard back to Orca: Control+Command on macOS, Ctrl+Alt elsewhere, pressed
// together and released with no other key in between (so Ctrl+Alt+T still reaches the sandbox).
export type RemoteDesktopKeyInput = Pick<
Electron.Input,
'type' | 'key' | 'code' | 'control' | 'alt' | 'meta' | 'shift' | 'isAutoRepeat'
>
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Meta', 'Shift', 'AltGraph', 'OS'])
/** Feeds every key event; returns true on the event that completes the release chord. */
export function createReleaseChordTracker(
platform: NodeJS.Platform
): (input: RemoteDesktopKeyInput) => boolean {
const mac = platform === 'darwin'
const chordKeys = mac ? new Set(['Control', 'Meta']) : new Set(['Control', 'Alt'])
let armed = false
return (input) => {
// Windows reports AltGr as Ctrl+Alt: a lone AltGr tap must not release the keyboard.
if (!mac && input.code === 'AltRight') {
armed = false
return false
}
if (input.type === 'keyDown') {
if (!MODIFIER_KEYS.has(input.key)) {
armed = false
return false
}
const held = mac
? input.control && input.meta && !input.alt && !input.shift
: input.control && input.alt && !input.meta && !input.shift
armed = held && chordKeys.has(input.key) ? true : armed && held
return false
}
if (input.type === 'keyUp' && armed && chordKeys.has(input.key)) {
armed = false
return true
}
return false
}
}
/**
* How a paste key reaches the sandbox after its clipboard is set: Cmd+V on macOS becomes
* Shift+Insert (Linux apps ignore Super+V), Ctrl+V / Ctrl+Shift+V / Shift+Insert are replayed.
*/
export type RemoteDesktopPasteChord =
| { kind: 'mac' }
| { kind: 'replay'; key: 'v' | 'V' | 'Insert' }
export function remoteDesktopPasteChord(
input: RemoteDesktopKeyInput,
platform: NodeJS.Platform
): RemoteDesktopPasteChord | null {
if (input.type !== 'keyDown' || input.alt) {
return null
}
// The letter the layout produces (Dvorak Ctrl+K sits on the physical V key); the physical key
// only when the layout has no Latin letter there (Cyrillic, Greek).
const latin = /^[a-z]$/i.test(input.key)
const v = latin ? input.key === 'v' || input.key === 'V' : input.code === 'KeyV'
if (platform === 'darwin' && input.meta && !input.control && v) {
return { kind: 'mac' }
}
if (input.control && !input.meta && v) {
return { kind: 'replay', key: input.shift ? 'V' : 'v' }
}
if (input.shift && !input.control && !input.meta && input.key === 'Insert') {
return { kind: 'replay', key: 'Insert' }
}
return null
}
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
registerRemoteDesktopSession,
remoteDesktopSessionForUrl,
resetRemoteDesktopSessionsForTest,
takeRemoteDesktopTicket
} from './remote-desktop-sessions'
const URL_6081 = 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
const ticket = (n: number): string => String(n).padStart(43, 't')
function register(options: { live?: () => boolean; firstExpiresAt?: number } = {}): void {
let minted = 0
registerRemoteDesktopSession(
{
environmentId: 'env-1',
localPort: 6081,
path: 'vnc.html',
mint: async () => ({ ok: true, ticket: ticket((minted += 1) + 100), expiresAt: 999_999 }),
...(options.live ? { isLive: options.live } : {})
},
{ ticket: ticket(1), expiresAt: options.firstExpiresAt ?? 100_000 }
)
}
describe('remote desktop sessions', () => {
beforeEach(() => resetRemoteDesktopSessionsForTest())
it('trusts only the exact URL Orca built, on localhost or 127.0.0.1', () => {
register()
expect(remoteDesktopSessionForUrl(URL_6081)).not.toBeNull()
expect(remoteDesktopSessionForUrl(URL_6081.replace('localhost', '127.0.0.1'))).not.toBeNull()
for (const url of [
`${URL_6081}&host=evil.example`,
`${URL_6081}#host=evil.example&encrypt=1`,
`${URL_6081}&logging=debug`,
'http://localhost:6081/vnc.html',
'http://localhost:6081/vnc_lite.html?autoconnect=0&reconnect=0&resize=remote',
URL_6081.replace('http:', 'https:'),
URL_6081.replace('localhost', 'localhost.evil.example'),
URL_6081.replace('6081', '6082'),
URL_6081.replace('http://', 'http://user:pw@')
]) {
expect(remoteDesktopSessionForUrl(url), url).toBeNull()
}
})
it('forgets a session once its forward is gone', () => {
let live = true
register({ live: () => live })
expect(remoteDesktopSessionForUrl(URL_6081)).not.toBeNull()
live = false
expect(remoteDesktopSessionForUrl(URL_6081)).toBeNull()
live = true
// Pruned: a later page on that port is not trusted until Orca opens the desktop again.
expect(remoteDesktopSessionForUrl(URL_6081)).toBeNull()
})
it('hands out the open’s ticket once, and never within 5 s of its expiry', async () => {
register({ firstExpiresAt: 10_000 })
const session = remoteDesktopSessionForUrl(URL_6081)!
expect(await takeRemoteDesktopTicket(session, () => 1_000)).toMatchObject({ ticket: ticket(1) })
expect(await takeRemoteDesktopTicket(session, () => 1_000)).toMatchObject({
ticket: ticket(101)
})
register({ firstExpiresAt: 10_000 })
const fresh = remoteDesktopSessionForUrl(URL_6081)!
expect(await takeRemoteDesktopTicket(fresh, () => 6_000)).toMatchObject({ ticket: ticket(101) })
})
})
@@ -0,0 +1,126 @@
// Remote desktop sessions this computer opened: which localhost forward port carries which cloud
// workspace's desktop, and how to mint that desktop a connection ticket. A browser page is a
// remote desktop only when its URL names one of these ports (main decides, never the page), and
// only such a page is handed tickets, keyboard capture and the clipboard bridge. The URL must be
// exactly the one Orca built (no host/port/encrypt overrides for noVNC), and the session ends with
// its forward: a later forward or local server on the same port is never trusted.
import { cloudRemoteDesktopUrl } from '../../shared/hosted-sandbox/cloud-remote-desktop'
export type RemoteDesktopMintFailure =
| 'asleep'
| 'archived'
| 'starting'
| 'signed_out'
| 'unavailable'
export type RemoteDesktopMintResult =
| { ok: true; ticket: string; expiresAt: number }
| { ok: false; reason: RemoteDesktopMintFailure }
export type RemoteDesktopSession = {
environmentId: string
localPort: number
/** noVNC's page on the VM ("vnc.html"). */
path: string
/** Asks the sandbox for a fresh single-use ticket (starts the desktop if it stopped). */
mint: () => Promise<RemoteDesktopMintResult>
/** False once the forward this session opened is gone (closed, slept, re-dialed elsewhere). */
isLive?: () => boolean
}
type Entry = RemoteDesktopSession & {
/** The ticket minted with the open itself, saved for the page load that follows. */
pending: { ticket: string; expiresAt: number } | null
listeners: Set<() => void>
}
/** A ticket this close to expiry is not handed out: the page still has to dial the forward. */
const TICKET_MARGIN_MS = 5_000
const sessionsByPort = new Map<number, Entry>()
/** Records (or refreshes) a session and wakes any open page for it that stopped retrying. */
export function registerRemoteDesktopSession(
session: RemoteDesktopSession,
firstTicket: { ticket: string; expiresAt: number } | null
): void {
const previous = sessionsByPort.get(session.localPort)
const entry: Entry = {
...session,
pending: firstTicket,
listeners: previous?.listeners ?? new Set()
}
sessionsByPort.set(session.localPort, entry)
for (const listener of entry.listeners) {
listener()
}
}
/** The session behind a desktop page URL, or null for any other page. */
export function remoteDesktopSessionForUrl(url: string): RemoteDesktopSession | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return null
}
const port = Number(parsed.port)
const entry = sessionsByPort.get(port)
if (!entry) {
return null
}
if (entry.isLive && !entry.isLive()) {
sessionsByPort.delete(port)
return null
}
// Exactly Orca's URL: noVNC reads host, port, encrypt and logging from the query and the hash,
// which would let a page point a ticketed session (and the user's keys) somewhere else.
const expected = new URL(cloudRemoteDesktopUrl(port, entry.path))
return parsed.protocol === 'http:' &&
!parsed.username &&
!parsed.password &&
parsed.pathname === expected.pathname &&
parsed.search === expected.search &&
parsed.hash === ''
? entry
: null
}
/** A ticket for the next WebSocket: the one minted with the open if still fresh, else a new one. */
export async function takeRemoteDesktopTicket(
session: RemoteDesktopSession,
now: () => number = Date.now
): Promise<RemoteDesktopMintResult> {
const entry = sessionsByPort.get(session.localPort)
const pending = entry?.pending
if (entry && pending) {
entry.pending = null
if (pending.expiresAt - TICKET_MARGIN_MS > now()) {
return { ok: true, ...pending }
}
}
return (entry ?? session).mint()
}
/** Called when the session for this port is opened again (the user chose Open remote desktop). */
export function onRemoteDesktopSessionRefreshed(
localPort: number,
listener: () => void
): () => void {
let entry = sessionsByPort.get(localPort)
if (!entry) {
return () => {}
}
entry.listeners.add(listener)
return () => {
entry?.listeners.delete(listener)
entry = undefined
}
}
export function resetRemoteDesktopSessionsForTest(): void {
sessionsByPort.clear()
}
+9 -2
View File
@@ -5,7 +5,10 @@ import {
openCloudRemoteDesktop
} from '../hosted-sandbox/hosted-remote-desktop'
import type { HostedSandboxControlPlaneClient } from '../hosted-sandbox/hosted-sandbox-control-plane-client'
import { openRuntimePortForward } from '../runtime-port-forward/runtime-port-forward-manager'
import {
getRuntimePortForwardManager,
openRuntimePortForward
} from '../runtime-port-forward/runtime-port-forward-service'
function environmentIdOf(value: unknown): string | null {
const id =
@@ -33,7 +36,11 @@ export function registerCloudRemoteDesktopHandlers(client: HostedSandboxControlP
{
userDataPath: app.getPath('userData'),
client,
openForward: openRuntimePortForward
openForward: openRuntimePortForward,
isForwardLive: ({ environmentId: env, localPort, remotePort }) =>
getRuntimePortForwardManager()
?.list(env)
.some((f) => f.localPort === localPort && f.remotePort === remotePort) ?? false
},
environmentId
)
+6
View File
@@ -212,6 +212,12 @@ export type BrowserApi = {
) => () => void
/** Escape pressed in a focused page; the page also receives it unless a grab pick is running. */
onGuestEscape: (callback: (args: { browserPageId: string }) => void) => () => void
/** A browser page became (or stopped being) a remote desktop, or gained or lost the keyboard. */
onRemoteDesktopState: (
callback: (args: { browserPageId: string; desktop: boolean; captured: boolean }) => void
) => () => void
/** The release chord was pressed in a remote desktop: give the keyboard back to Orca. */
onRemoteDesktopRelease: (callback: (args: { browserPageId: string }) => void) => () => void
sessionListProfiles: () => Promise<BrowserSessionProfile[]>
onSessionProfilesChanged: (callback: () => void) => () => void
/** Resolves once the SSH workspace's partition is bound and proxy-verified; the webview must wait for it. */
@@ -168,6 +168,22 @@ export const browserPageInteractionAndSessionsApi = {
ipcRenderer.on('browser:guestEscape', listener)
return () => ipcRenderer.removeListener('browser:guestEscape', listener)
},
onRemoteDesktopState: (
callback: (args: { browserPageId: string; desktop: boolean; captured: boolean }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { browserPageId: string; desktop: boolean; captured: boolean }
) => callback(data)
ipcRenderer.on('browser:remoteDesktopState', listener)
return () => ipcRenderer.removeListener('browser:remoteDesktopState', listener)
},
onRemoteDesktopRelease: (callback: (args: { browserPageId: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) =>
callback(data)
ipcRenderer.on('browser:remoteDesktopRelease', listener)
return () => ipcRenderer.removeListener('browser:remoteDesktopRelease', listener)
},
sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'),
onSessionProfilesChanged: (callback: () => void): (() => void) => {
const listener = (): void => callback()
@@ -21,6 +21,7 @@ import { BrowserLoadFailureOverlay } from '../navigate/browser-load-failure-over
import { useSshWorkspaceBrowserLoadFailureRoute } from '../use-ssh-workspace-browser-route'
import type { BrowserLoadFailureAutoRetry } from '../navigate/browser-load-failure-auto-retry'
import BrowserFind from './BrowserFind'
import { RemoteDesktopKeyboardHint } from '../../cloud-remote-desktop/RemoteDesktopKeyboardHint'
import { BrowserNewTabPage } from '../new-tab/BrowserNewTabPage'
import { BrowserGuestAnnotateOverlays } from '../annotate/browser-guest-annotate-overlays'
import { BrowserPasswordFillDropdown } from '../passwords/BrowserPasswordFillDropdown'
@@ -114,6 +115,7 @@ export function BrowserPageViewportOverlays({
webviewRef={webviewRef}
/>
<BrowserLinkStatusBubble browserPageId={browserTab.id} />
<RemoteDesktopKeyboardHint browserPageId={browserTab.id} webviewRef={webviewRef} />
{showFailureOverlay && browserTab.loadError ? (
<BrowserPageLoadFailure
browserTab={browserTab}
@@ -12,6 +12,7 @@ import {
type CloudRemoteDesktopToastState
} from './CloudRemoteDesktopToast'
import { RemoteDesktopMenuEntry } from './RemoteDesktopMenuEntry'
import { RemoteDesktopKeyboardHintView } from './RemoteDesktopKeyboardHint'
const noop = (): void => {}
@@ -53,3 +54,23 @@ export const unavailable = toastFixture({ kind: 'failed', code: 'unavailable' })
export const connectFailed = toastFixture({ kind: 'failed', code: 'forward_failed' })
export const signedOut = toastFixture({ kind: 'failed', code: 'signed_out' })
export const notCloud = toastFixture({ kind: 'failed', code: 'not_cloud' })
export const sandboxStarting = toastFixture({ kind: 'failed', code: 'starting' })
export const archived = toastFixture({ kind: 'failed', code: 'archived' })
function keyboardHintFixture(kind: 'captured' | 'released', chord: string, width = 520) {
return defineFixture({
width,
render: () => (
// The hint floats over the desktop at the top of the tab's page area.
<div className="relative h-[120px] overflow-hidden bg-[#1e1f22]">
<RemoteDesktopKeyboardHintView kind={kind} visible chord={chord} />
</div>
)
})
}
export const keyboardCaptured = keyboardHintFixture('captured', '⌃⌘')
export const keyboardCapturedWindows = keyboardHintFixture('captured', 'Ctrl+Alt')
export const keyboardReleased = keyboardHintFixture('released', '⌃⌘')
/** A narrow split pane: the hint wraps instead of clipping. */
export const keyboardCapturedNarrow = keyboardHintFixture('captured', '⌃⌘', 320)
@@ -11,7 +11,13 @@ export type CloudRemoteDesktopToastState =
| { kind: 'starting'; slow: boolean }
| { kind: 'failed'; code: CloudRemoteDesktopFailureCode }
type FailureCopy = { title: string; detail: string; action: CloudRemoteDesktopNextAction | null }
type FailureCopy = {
title: string
detail: string
action: CloudRemoteDesktopNextAction | null
/** Asleep and starting are states, not errors. */
tone?: 'neutral'
}
function failureCopy(t: Translate, code: CloudRemoteDesktopFailureCode): FailureCopy {
switch (code) {
@@ -29,12 +35,36 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopFailureCode): Failure
}
case 'not_running':
return {
title: t('components.cloudRemoteDesktop.asleepTitle', 'This workspace is asleep.'),
title: t('components.cloudRemoteDesktop.asleepTitle', 'This sandbox is asleep.'),
detail: t(
'components.cloudRemoteDesktop.asleepDetail',
'Wake it, then open the remote desktop again. Your files are safe.'
),
action: 'wake'
action: 'wake',
tone: 'neutral'
}
case 'archived':
return {
title: t('components.cloudRemoteDesktop.archivedTitle', 'This sandbox is archived.'),
detail: t(
'components.cloudRemoteDesktop.archivedDetail',
'Unarchive the workspace to use the remote desktop again. Your files are safe.'
),
action: null,
tone: 'neutral'
}
case 'starting':
return {
title: t(
'components.cloudRemoteDesktop.startingTitle',
'The sandbox is still starting or waking.'
),
detail: t(
'components.cloudRemoteDesktop.startingDetail',
'Try again in a moment. Your files are safe.'
),
action: 'retry',
tone: 'neutral'
}
case 'not_installed':
return {
@@ -90,7 +120,7 @@ function failureCopy(t: Translate, code: CloudRemoteDesktopFailureCode): Failure
function actionLabel(t: Translate, action: CloudRemoteDesktopNextAction): string {
switch (action) {
case 'wake':
return t('components.cloudRemoteDesktop.wake', 'Wake workspace')
return t('components.cloudRemoteDesktop.wake', 'Wake sandbox')
case 'sign_in':
return t('components.cloudRemoteDesktop.signIn', 'Sign in')
case 'create_workspace':
@@ -146,6 +176,7 @@ export function CloudRemoteDesktopToast({
<InlineError
title={copy.title}
detail={copy.detail}
tone={copy.tone}
layout="toast"
action={
copy.action ? (
@@ -0,0 +1,142 @@
import { useEffect, useState, type MutableRefObject } from 'react'
import { Trans } from 'react-i18next'
import { i18n } from '@/i18n/i18n'
import { useTranslate } from '@/i18n/use-translate'
import { isMacUserAgent } from '@/lib/user-agent-platform'
import { cn } from '@/lib/utils'
/** Main's release chord (remote-desktop-keyboard.ts), as the person presses it. */
export function remoteDesktopReleaseChordLabel(mac: boolean = isMacUserAgent()): string {
return mac ? '⌃⌘' : 'Ctrl+Alt'
}
const CAPTURED_HINT_MS = 4_000
const RELEASED_HINT_MS = 2_500
type HintKind = 'captured' | 'released'
export function RemoteDesktopKeyboardHintView({
kind,
visible,
chord
}: {
kind: HintKind
visible: boolean
chord: string
}): React.JSX.Element {
const t = useTranslate()
return (
<div
role="status"
aria-hidden={!visible}
className={cn(
'pointer-events-none absolute inset-x-2 top-2 z-20 mx-auto w-fit rounded-md border border-border bg-popover/95 px-2.5 py-1 text-center text-xs text-balance text-popover-foreground shadow-md motion-safe:transition-opacity motion-safe:duration-200',
visible ? 'opacity-100' : 'opacity-0'
)}
>
{kind === 'captured' ? (
<Trans
i18n={i18n}
defaults={t(
'components.cloudRemoteDesktop.keyboardCaptured',
'Keys go to the remote desktop. Press <kbd>{{chord}}</kbd> to use Orca shortcuts.',
{ chord }
)}
components={{
kbd: (
<kbd className="mx-0.5 inline-flex h-4 items-center rounded border border-border bg-muted px-1 font-mono text-[10px] font-medium" />
)
}}
/>
) : (
t(
'components.cloudRemoteDesktop.keyboardReleased',
'Orca shortcuts are back. Click the desktop to type in it.'
)
)}
</div>
)
}
/**
* A remote desktop page keeps every key while focused (main stops forwarding Orca's shortcuts).
* Shows how to get them back when the desktop takes the keyboard, and moves focus out of the page
* when main reports the release chord.
*/
export function RemoteDesktopKeyboardHint({
browserPageId,
webviewRef
}: {
browserPageId: string
webviewRef: MutableRefObject<Electron.WebviewTag | null>
}): React.JSX.Element | null {
const [desktop, setDesktop] = useState(false)
// Kept after it hides so the text does not change while it fades out.
const [hint, setHint] = useState<{ kind: HintKind; until: number; visible: boolean }>({
kind: 'captured',
until: 0,
visible: false
})
useEffect(() => {
let wasCaptured = false
// Once per page load and after each release: not on every click back into the desktop.
let hintDue = true
const unsubscribeState = window.api.browser.onRemoteDesktopState((event) => {
if (event.browserPageId !== browserPageId) {
return
}
setDesktop(event.desktop)
if (!event.desktop) {
hintDue = true
}
if (event.captured && !wasCaptured && hintDue) {
hintDue = false
setHint({ kind: 'captured', until: Date.now() + CAPTURED_HINT_MS, visible: true })
} else if (!event.captured) {
setHint((current) =>
current.kind === 'captured' ? { ...current, visible: false } : current
)
}
wasCaptured = event.captured
})
const unsubscribeRelease = window.api.browser.onRemoteDesktopRelease((event) => {
if (event.browserPageId !== browserPageId) {
return
}
// Focus leaves the page, so keys reach Orca's window again.
webviewRef.current?.blur()
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
hintDue = true
setHint({ kind: 'released', until: Date.now() + RELEASED_HINT_MS, visible: true })
})
return () => {
unsubscribeState()
unsubscribeRelease()
}
}, [browserPageId, webviewRef])
useEffect(() => {
if (!hint.visible) {
return
}
const timer = setTimeout(
() => setHint((current) => ({ ...current, visible: false })),
Math.max(0, hint.until - Date.now())
)
return () => clearTimeout(timer)
}, [hint])
if (!desktop) {
return null
}
return (
<RemoteDesktopKeyboardHintView
kind={hint.kind}
visible={hint.visible}
chord={remoteDesktopReleaseChordLabel()}
/>
)
}
@@ -1,5 +1,6 @@
import type React from 'react'
import { Monitor } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { useTranslate } from '@/i18n/use-translate'
@@ -11,6 +12,9 @@ export function RemoteDesktopMenuEntry({ onSelect }: { onSelect: () => void }):
<DropdownMenuItem onSelect={onSelect}>
<Monitor className="size-3.5" />
{t('components.cloudRemoteDesktop.openMenuItem', 'Open remote desktop')}
<Badge variant="tag" className="ml-auto">
{t('components.cloudRemoteDesktop.beta', 'Beta')}
</Badge>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
+16 -3
View File
@@ -22880,7 +22880,7 @@
"retry": "Try again",
"notCloudTitle": "Remote desktop is only available in cloud workspaces.",
"notCloudDetail": "Create a cloud workspace to run apps with a screen.",
"asleepTitle": "This workspace is asleep.",
"asleepTitle": "This sandbox is asleep.",
"asleepDetail": "Wake it, then open the remote desktop again. Your files are safe.",
"notInstalledTitle": "This sandbox doesn’t have a remote desktop yet.",
"notInstalledDetail": "It was created before remote desktop was available. New cloud workspaces include it. Your files are safe.",
@@ -22890,12 +22890,25 @@
"forwardFailedDetail": "The desktop is running in the sandbox. Your files are safe.",
"unavailableTitle": "Couldn’t start the remote desktop.",
"unavailableDetail": "Orca Cloud didn’t respond in time. Your files are safe.",
"wake": "Wake workspace",
"wake": "Wake sandbox",
"signIn": "Sign in",
"createWorkspace": "Create workspace",
"slowDetail": "The first start in a sandbox can take up to a minute.",
"dismiss": "Dismiss",
"openMenuItem": "Open remote desktop"
"openMenuItem": "Open remote desktop",
"startingTitle": "The sandbox is still starting or waking.",
"archivedTitle": "This sandbox is archived.",
"archivedDetail": "Unarchive the workspace to use the remote desktop again. Your files are safe.",
"startingDetail": "Try again in a moment. Your files are safe.",
"keyboardCaptured": "Keys go to the remote desktop. Press <kbd>{{chord}}</kbd> to use Orca shortcuts.",
"keyboardReleased": "Orca shortcuts are back. Click the desktop to type in it.",
"pageAsleep": "This sandbox is asleep. Wake it in Orca, then reconnect.",
"pageArchived": "This sandbox is archived. Unarchive the workspace in Orca, then reconnect.",
"pageStarting": "The sandbox is starting or waking. Reconnect in a moment.",
"pageSignedOut": "You’re signed out of Orca Cloud. Sign in, then reconnect.",
"pageDisconnected": "The remote desktop disconnected. Your files are safe.",
"reconnect": "Reconnect",
"beta": "Beta"
},
"orcaCloud": {
"offline": {
@@ -13,21 +13,20 @@ import type { CloudRemoteDesktopResult } from '../../../../shared/hosted-sandbox
const SLOW_START_MS = 5_000
const opening = new Set<string>()
/** Desktop identity without the per-boot password or the local port a re-dialed forward may change. */
/** Desktop identity without the local port a re-dialed forward may change (or an older URL form). */
function desktopPage(url: string): string | null {
try {
const parsed = new URL(url)
return parsed.hostname === 'localhost'
? `${parsed.pathname}?${parsed.searchParams.toString()}`
: null
return parsed.hostname === 'localhost' ? parsed.pathname : null
} catch {
return null
}
}
/**
* This workspace's desktop tab: reused when it is the current session, replaced when it holds a
* previous boot's password (it would only retry a dead login), so there is never a second tab.
* This workspace's desktop tab: reused when it is this session's page (main hands it a fresh ticket
* and resumes it if it stopped retrying), replaced when it points at an older forward or failed to
* load, so there is never a second tab.
*/
function takeDesktopTab(worktreeId: string, url: string): string | null {
const state = useAppStore.getState()
@@ -69,7 +68,8 @@ export async function openCloudRemoteDesktop(
}}
/>
),
{ id, duration: state.kind === 'starting' ? Number.POSITIVE_INFINITY : 12_000 }
// Errors stay until dismissed or acted on: each names the one next step.
{ id, duration: Number.POSITIVE_INFINITY }
)
}
show({ kind: 'starting', slow: false })
@@ -77,6 +77,8 @@ export function createBrowserApi(): NonNullable<Partial<PreloadApi>['browser']>
onGrabModeToggle: () => noopUnsubscribe,
onGrabActionShortcut: () => noopUnsubscribe,
onGuestEscape: () => noopUnsubscribe,
onRemoteDesktopState: () => noopUnsubscribe,
onRemoteDesktopRelease: () => noopUnsubscribe,
sessionListProfiles: () => Promise.resolve([]),
onSessionProfilesChanged: () => noopUnsubscribe,
// Web clients render remote workspaces through the server; no local SSH routing exists.
@@ -2,8 +2,12 @@
export type CloudRemoteDesktopFailureCode =
/** Not an Orca Cloud workspace, or this build has no control plane. */
| 'not_cloud'
/** The workspace is asleep or still starting. */
/** The sandbox is asleep: waking it is the next step. */
| 'not_running'
/** The workspace is archived: unarchive it first. */
| 'archived'
/** The sandbox is starting or waking: try again in a moment. */
| 'starting'
/** The workspace's image predates the remote desktop. */
| 'not_installed'
| 'signed_out'
@@ -17,16 +21,12 @@ export type CloudRemoteDesktopResult =
| { ok: false; code: CloudRemoteDesktopFailureCode }
/**
* noVNC page on the forwarded port. resize=remote makes the VM's screen follow the tab size;
* reconnect rides out a forward re-dial. The password rides in the fragment, which never goes
* over HTTP; it differs per VM boot, so a stale tab can never attach to another workspace.
* noVNC page on the forwarded port. It holds no secret: Orca hands the page a single-use ticket
* for each WebSocket after it loads (and again for a reconnect), so the URL can be shown, kept in
* the tab and restored. resize=remote makes the VM's screen follow the tab size; noVNC's own
* connect and reconnect are off because each attempt needs a ticket from Orca.
*/
export function cloudRemoteDesktopUrl(localPort: number, path: string, password: string): string {
const params = new URLSearchParams({
autoconnect: '1',
resize: 'remote',
reconnect: '1',
reconnect_delay: '1000'
})
return `http://localhost:${localPort}/${path}?${params.toString()}#password=${encodeURIComponent(password)}`
export function cloudRemoteDesktopUrl(localPort: number, path: string): string {
const params = new URLSearchParams({ autoconnect: '0', reconnect: '0', resize: 'remote' })
return `http://localhost:${localPort}/${path}?${params.toString()}`
}
@@ -152,12 +152,16 @@ export const HostedSandboxListResponseSchema = z
.object({ sandboxes: z.array(HostedSandboxViewSchema).max(1000) })
.strict()
/** `POST /v1/sandboxes/:id/desktop`: the remote desktop's port on the VM's loopback, never a public route. */
/**
* `POST /v1/sandboxes/:id/desktop`: the remote desktop's port on the VM's loopback (never a public
* route) and a fresh single-use ticket for one WebSocket to it. Each call mints a new ticket.
*/
export const HostedSandboxDesktopResponseSchema = z.object({
desktop: z.object({
port: z.number().int().min(1024).max(65_535),
path: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
password: z.string().regex(/^[A-Za-z0-9]{8}$/)
ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
expiresInMs: z.number().int().min(1_000).max(300_000)
})
})