mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+5
-2
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user