Merge remote-tracking branch 'private/cloud/integration' into cloud/w2-fastwake

This commit is contained in:
Neil
2026-11-09 17:39:31 -08:00
88 changed files with 5928 additions and 160 deletions
+3
View File
@@ -157,3 +157,6 @@ ci-shards/
/.build/windows-native-benchmark/
.local/static-analysis-research/
# Python bytecode (VM entry points tested locally)
__pycache__/
@@ -0,0 +1,79 @@
// The remote desktop's ticket + origin gate (vm/orca-desktop-proxy), exercised in python3 with a
// stub websockify. The real thing runs on a VM in the W3 desktop e2e (see the w3-desktop report).
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const proxy = fileURLToPath(new URL('./vm/orca-desktop-proxy', import.meta.url).href)
const hasPython = spawnSync('python3', ['--version']).status === 0
function runPython(body: string): { status: number | null; stdout: string; stderr: string } {
const stub = mkdtempSync(join(tmpdir(), 'orca-desktop-proxy-'))
mkdirSync(join(stub, 'websockify'))
writeFileSync(join(stub, 'websockify', '__init__.py'), '')
writeFileSync(join(stub, 'websockify', 'websocketproxy.py'), 'class ProxyRequestHandler:\n pass\n')
const prelude = [
'import importlib.machinery, importlib.util, os, sys, tempfile, hashlib',
`loader = importlib.machinery.SourceFileLoader('proxy', ${JSON.stringify(proxy)})`,
'spec = importlib.util.spec_from_loader("proxy", loader)',
'p = importlib.util.module_from_spec(spec); loader.exec_module(p)',
'd = tempfile.mkdtemp()',
'def mint(t, expires):',
' open(os.path.join(d, hashlib.sha256(t.encode()).hexdigest()), "w").write(str(expires))',
''
].join('\n')
const run = spawnSync('python3', ['-c', `${prelude}\n${body}`], {
env: { ...process.env, PYTHONPATH: stub, PYTHONDONTWRITEBYTECODE: '1' },
encoding: 'utf8'
})
return { status: run.status, stdout: run.stdout, stderr: run.stderr }
}
describe.skipIf(!hasPython)('orca-desktop-proxy', () => {
it('admits a ticket once, before it expires, and never a malformed one', () => {
const run = runPython(
[
'T = "A" * 43; U = "B" * 43; X = "C" * 43',
'mint(T, 2000); mint(U, 2000); mint(X, 999)',
'print(p.consume_ticket(d, T, now=1000))', // fresh
'print(p.consume_ticket(d, T, now=1000))', // replay
'print(p.consume_ticket(d, X, now=1000))', // expired
'print(p.consume_ticket(d, X, now=1000))', // and it was still spent
'print(p.consume_ticket(d, "B" * 42, now=1000))', // malformed
'print(p.consume_ticket(d, "../" + "B" * 40, now=1000))', // path tricks never reach the disk
'print(p.consume_ticket(d, U, now=1000))',
'print(sorted(os.listdir(d)))'
].join('\n')
)
expect(run.stderr).toBe('')
expect(run.stdout.trim().split('\n')).toEqual(['True', 'False', 'False', 'False', 'False', 'False', 'True', '[]'])
})
it('wants the page and the WebSocket on one localhost origin', () => {
const run = runPython(
[
'cases = [',
' ({"Host": "localhost:6081", "Origin": "http://localhost:6081"}, True),',
' ({"Host": "127.0.0.1:6091", "Origin": "http://127.0.0.1:6091"}, True),',
' ({"Host": "localhost:6081", "Origin": "http://localhost:3000"}, False),',
' ({"Host": "localhost:6081", "Origin": "http://evil.example"}, False),',
' ({"Host": "localhost:6081"}, False),',
' ({"Host": "evil.example:6081", "Origin": "http://evil.example:6081"}, False),',
' ({"Host": "localhost.evil.example", "Origin": "http://localhost.evil.example"}, False),',
' ({"Host": "localhost:6081", "Origin": "https://localhost:6081"}, False),',
']',
'print(all(p.same_loopback_origin(h) == want for h, want in cases))'
].join('\n')
)
expect(run.stderr).toBe('')
expect(run.stdout.trim()).toBe('True')
})
it('scrubs tickets from anything it logs', () => {
const run = runPython('print(p.scrub("GET /websockify?ticket=SECRETSECRET&x=1 HTTP/1.1"))')
expect(run.stdout.trim()).toBe('GET /websockify?ticket=<redacted>&x=1 HTTP/1.1')
})
})
@@ -101,9 +101,19 @@ describe('golden image build', () => {
it('ships VM scripts that parse', () => {
for (const script of ['provision-golden.sh', ...VM_SCRIPTS.map((name) => `vm/${name}`)]) {
const path = fileURLToPath(new URL(`./${script}`, import.meta.url).href)
// Node entry points (shebang `env node`) parse with node, the rest with bash.
const isNode = readFileSync(path, 'utf8').startsWith('#!/usr/bin/env node')
expect(spawnSync(isNode ? process.execPath : 'bash', isNode ? ['--check', path] : ['-n', path]).status, script).toBe(0)
// Node entry points (shebang `env node`) and page modules (.js) parse with node, Python ones
// with python3 (when this machine has it), the rest with bash.
const source = readFileSync(path, 'utf8')
const isNode = source.startsWith('#!/usr/bin/env node')
if (script.endsWith('.js')) {
const check = spawnSync(process.execPath, ['--input-type=module', '--check'], { input: source })
expect(check.status, script).toBe(0)
} else if (source.startsWith('#!/usr/bin/env python3')) {
const check = spawnSync('python3', ['-c', 'import ast, sys; ast.parse(open(sys.argv[1]).read())', path])
expect(check.error ? 0 : check.status, script).toBe(0)
} else {
expect(spawnSync(isNode ? process.execPath : 'bash', isNode ? ['--check', path] : ['-n', path]).status, script).toBe(0)
}
}
})
@@ -15,6 +15,8 @@ export const VM_SCRIPTS = [
'orca-sshd',
'orca-ssh-keys',
'orca-desktop-start',
'orca-desktop-proxy',
'orca-desktop-page.js',
'orca-sandbox-grant',
'orca-sandbox-revoke',
'orca-sandbox-members',
@@ -155,7 +155,7 @@ if [ -n "${NOVNC_VERSION:-}" ]; then
fi
log 'Orca entry points'
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-git-as orca-git-identity git; do
for name in orca-sandbox-start orca-sandbox-drain orca-sandbox-env orca-session-env.sh orca-egress-probe orca-image-wipe orca-share-proxy orca-sshd orca-ssh-keys orca-desktop-start orca-desktop-proxy orca-desktop-page.js orca-sandbox-grant orca-sandbox-revoke orca-sandbox-members orca-git-as orca-git-identity git; do
var="ORCA_VM_SCRIPT_${name//[-.]/_}"
printf '%s' "${!var}" | base64 -d >"$ORCA_HOME/bin/$name"
chmod 0755 "$ORCA_HOME/bin/$name"
File diff suppressed because one or more lines are too long
@@ -0,0 +1,224 @@
// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's
// main process) drives it through window.orcaDesktop:
// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,
// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on
// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live
// only in memory here: never in the URL, storage or noVNC's settings.
// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()
// (Orca reopened the desktop) or the button asks for a ticket again.
// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.
// - clipboard: a copy inside the desktop right after user input there goes to this computer's
// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.
import UI from './app/ui.js';
import * as WebUtil from './app/webutil.js';
const WS_PATH = 'websockify';
const RESIZE_FALLBACK_MS = 1500;
// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other
// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's
// clipboard. Plain typing does not open the window.
const USER_COPY_WINDOW_MS = 1500;
const DRAG_PX = 4;
const MAX_CLIPBOARD_CHARS = 1 << 20;
const TICKET_RE = /^[A-Za-z0-9_-]{43}$/;
const XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };
let state = 'idle'; // idle | connecting | connected | disconnected | paused
let attemptConnected = true;
let ticketWaiters = [];
let lastCopyIntent = 0;
let pointerStart = null;
const style = document.createElement('style');
// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,
// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).
style.textContent = `
#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }
#orca_pause { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;
background: rgba(30, 31, 34, 0.92); z-index: 1000; font: 13px/1.5 system-ui, sans-serif; }
#orca_pause.open { display: flex; }
#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid #3a3b3f; border-radius: 8px;
background: #26272b; color: #d4d4d4; text-align: center; }
#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid #4a4b50; border-radius: 6px;
background: transparent; color: #f2f2f2; font: inherit; cursor: pointer; }
#orca_pause button:focus-visible { outline: 2px solid #6ea8fe; outline-offset: 2px; }
/* The VM page cannot read Orca's --focus-ring token; this matches its dark value. */
`;
document.head.appendChild(style);
const overlay = document.createElement('div');
overlay.id = 'orca_pause';
overlay.innerHTML = '<div role="alert"><p></p><button type="button"></button></div>';
overlay.querySelector('button').addEventListener('click', () => resume());
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));
window.addEventListener('keydown', (event) => {
if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {
lastCopyIntent = Date.now();
}
}, true);
window.addEventListener('pointerdown', (event) => {
pointerStart = { x: event.clientX, y: event.clientY };
}, true);
// A double- or triple-click selects a word or line (pointer events carry no click count).
window.addEventListener('mousedown', (event) => {
if (event.detail >= 2) {
lastCopyIntent = Date.now();
}
}, true);
window.addEventListener('pointerup', (event) => {
if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {
lastCopyIntent = Date.now();
}
pointerStart = null;
}, true);
function requestTicket() {
const answer = attemptConnected ? 'ticket' : 'retry';
const waiters = ticketWaiters;
ticketWaiters = [];
waiters.forEach((resolve) => resolve(answer));
}
async function whenStarted() {
while (document.documentElement.classList.contains('noVNC_loading')) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
function onConnect() {
state = 'connected';
attemptConnected = true;
UI.hideStatus();
setTimeout(() => {
// Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.
if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {
UI.rfb.scaleViewport = true;
}
}, RESIZE_FALLBACK_MS);
}
function onDisconnect() {
if (state === 'paused') {
return;
}
state = 'disconnected';
// The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).
requestTicket();
}
function onClipboard(event) {
const text = event.detail && event.detail.text;
if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {
return;
}
if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {
return;
}
navigator.clipboard.writeText(text).catch(() => {});
}
async function connect(ticket) {
if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {
return 'invalid';
}
if (state === 'connecting' || state === 'connected' || state === 'paused') {
return state;
}
await whenStarted();
if (UI.rfb) {
return 'connecting';
}
state = 'connecting';
attemptConnected = false;
// UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;
// the in-memory setting is restored right after, so the ticket is never kept or shown.
// Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging
// from the URL, which must never send a ticketed session (and the user's keys) elsewhere.
WebUtil.setSetting('host', '');
WebUtil.setSetting('port', '');
WebUtil.setSetting('encrypt', false);
WebUtil.setSetting('repeaterID', '');
WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);
try {
UI.connect();
} finally {
WebUtil.setSetting('path', WS_PATH);
}
const rfb = UI.rfb;
if (!rfb) {
state = 'disconnected';
requestTicket();
return 'failed';
}
rfb.addEventListener('connect', onConnect);
rfb.addEventListener('disconnect', onDisconnect);
rfb.addEventListener('clipboard', onClipboard);
return 'connecting';
}
/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */
function paste(text, chord) {
const rfb = UI.rfb;
if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {
return false;
}
rfb.clipboardPasteFrom(text);
if (chord && chord.kind === 'mac') {
// Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.
rfb.sendKey(XK.Super_L, 'MetaLeft', false);
rfb.sendKey(XK.Super_R, 'MetaRight', false);
rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);
rfb.sendKey(XK.Insert, 'Insert');
rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);
} else if (chord && chord.kind === 'replay') {
// Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.
const insert = chord.key === 'Insert';
rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');
}
return true;
}
function pause(message, label, tone) {
state = 'paused';
if (UI.rfb) {
UI.rfb.disconnect();
}
UI.hideStatus();
overlay.querySelector('p').textContent = String(message || '');
overlay.querySelector('button').textContent = String(label || 'Reconnect');
// Asleep and starting are states, not errors: announce them politely.
overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');
overlay.classList.add('open');
overlay.querySelector('button').focus();
return true;
}
function resume() {
if (state !== 'paused') {
return false;
}
overlay.classList.remove('open');
state = 'disconnected';
attemptConnected = true; // a person asked: start the retry budget over
requestTicket();
return true;
}
Object.defineProperty(window, 'orcaDesktop', {
value: Object.freeze({
version: 1,
state: () => state,
/** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */
nextTicketRequest: () => new Promise((resolve) => {
ticketWaiters.push(resolve);
if (state === 'idle' || state === 'disconnected') {
requestTicket();
}
}),
connect,
paste,
pause,
resume
})
});
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one
# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this
# uid can open the socket). The port is reached only through Orca's localhost forward on the user's
# computer, where any local process or web page could knock, so every WebSocket must carry an
# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same
# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.
# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR
# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.
import argparse
import hashlib
import hmac
import logging
import os
import re
import sys
import time
from urllib.parse import parse_qs, urlsplit
from websockify import websocketproxy
TICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding
SCRUB_RE = re.compile(r'([?&]ticket=)[^&\s\'"]*')
LOOPBACK_HOST_RE = re.compile(r'^(localhost|127\.0\.0\.1|\[::1\])(:[0-9]{1,5})?$')
WS_PATH = '/websockify'
PAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')
# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point
# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.
PAGE_CSP = "connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
PAGE_TAG = b'<script type="module" src="orca-desktop-page.js"></script>\n</head>'
def scrub(text):
return SCRUB_RE.sub(r'\1<redacted>', str(text))
def ticket_digest(ticket):
return hashlib.sha256(ticket.encode('ascii')).hexdigest()
def consume_ticket(directory, ticket, now=None):
"""True once per minted ticket, within its lifetime. The ticket file (named by the ticket's
sha256, holding its expiry) is unlinked on first use, so a replay or a racing second
connection finds nothing; each request runs in its own forked process, so state is on disk."""
if not isinstance(ticket, str) or not TICKET_RE.match(ticket):
return False
digest = ticket_digest(ticket)
match = None
try:
names = os.listdir(directory)
except OSError:
return False
for name in names:
# Compare every entry in constant time; never short-circuit on a prefix.
if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):
match = name
if match is None:
return False
path = os.path.join(directory, match)
try:
with open(path, 'r', encoding='ascii') as handle:
expires_at = int(handle.read().strip() or '0')
except (OSError, ValueError):
expires_at = 0
try:
os.unlink(path) # the consume: only one caller can win this
except FileNotFoundError:
return False
return (now if now is not None else time.time()) <= expires_at
def same_loopback_origin(headers):
"""The page and the WebSocket share one localhost origin (blocks other local sites and DNS
rebinding). The port is the user's local forward, which may differ from ours, so compare the
Origin with the Host the browser dialed rather than with a fixed port."""
host = (headers.get('Host') or '').strip().lower()
origin = (headers.get('Origin') or '').strip().lower()
return loopback_host(host) and origin == 'http://' + host
def loopback_host(host):
return bool(LOOPBACK_HOST_RE.match(host or ''))
class TicketedProxyHandler(websocketproxy.ProxyRequestHandler):
"""websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so
websockify's per-connection multiprocessing can pickle it under any start method."""
def log_message(self, format, *args): # noqa: A002 (websockify's signature)
super().log_message('%s', scrub(format % args if args else format))
def reject(self, code, reason):
self.send_response(code)
self.send_header('Content-Length', '0')
self.send_header('Connection', 'close')
self.end_headers()
self.close_connection = True
raise self.server.EClose('desktop: websocket refused (%s)' % reason)
def validate_connection(self):
parts = urlsplit(self.path)
ticket = (parse_qs(parts.query).get('ticket') or [''])[0]
# Drop it before anything below (websockify's own logging included) can see self.path.
self.path = parts.path
if parts.path != WS_PATH:
self.reject(404, 'path')
if not same_loopback_origin(self.headers):
self.reject(403, 'origin')
if not consume_ticket(self.server.orca_tickets_dir, ticket):
self.reject(403, 'ticket')
def loopback_request(self):
if loopback_host((self.headers.get('Host') or '').strip().lower()):
return True
self.send_error(403)
return False
def do_GET(self):
if not self.loopback_request():
return None
path = urlsplit(self.path).path
if path == '/orca-desktop-page.js':
return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')
if path == '/vnc.html':
page = read_file(os.path.join(self.server.web, 'vnc.html'))
return self.send_bytes(page.replace(b'</head>', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)
return super().do_GET()
def do_HEAD(self):
if not self.loopback_request():
return None
return super().do_HEAD()
def send_bytes(self, body, content_type, csp=None):
self.send_response(200)
self.send_header('Content-Type', content_type)
if csp:
self.send_header('Content-Security-Policy', csp)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
self.wfile.write(body)
return None
def read_file(path):
with open(path, 'rb') as handle:
return handle.read()
def main(argv):
parser = argparse.ArgumentParser()
parser.add_argument('--listen', type=int, required=True)
parser.add_argument('--web', required=True)
parser.add_argument('--unix-target', required=True)
parser.add_argument('--tickets', required=True)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format='%(message)s')
server = websocketproxy.WebSocketProxy(
RequestHandlerClass=TicketedProxyHandler,
listen_host='127.0.0.1',
listen_port=args.listen,
web=args.web,
file_only=True,
unix_target=args.unix_target,
)
server.orca_tickets_dir = os.path.abspath(args.tickets)
server.start_server()
if __name__ == '__main__':
main(sys.argv[1:])
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
# Starts the on-demand remote desktop (idempotent) and prints one JSON line:
# {"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"<8 chars>"}
# Xvnc (X server + VNC in one) listens on a unix socket only; websockify serves noVNC and bridges
# WebSockets to that socket on 127.0.0.1 only. Nothing is reachable from outside the VM: the
# desktop reaches it through Orca's port forward. The forward is a localhost port on the user's
# computer, which any local process or web page can reach, so VNC auth with a per-boot random
# password guards it (Xvnc blacklists repeated failures). Never started at boot.
# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:
# {"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"<43 chars>","expiresInMs":60000}
# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid
# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and
# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app
# reaches it through Orca's port forward, a localhost port on the user's computer that any local
# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,
# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).
# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.
set -euo pipefail
umask 077
DESKTOP=/opt/orca/desktop
@@ -23,8 +25,13 @@ case "$PORT" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac
case "$DISPLAY_NUM" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac
case "$GEOMETRY" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac
command -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3
command -v vncpasswd >/dev/null || fail 'not installed in this image (vncpasswd missing)' 3
[ -s "$DESKTOP/novnc/vnc.html" ] || fail 'not installed in this image (noVNC missing)' 3
PROXY="$(dirname "$(readlink -f "$0")")/orca-desktop-proxy"
[ -x "$PROXY" ] || fail 'orca-desktop-proxy missing next to this script'
TICKETS="$STATE/tickets"
TICKET_TTL_S=60
# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.
MODE=ticket-v1
mkdir -p "$STATE"
exec 9>"$STATE/start.lock"
@@ -37,22 +44,47 @@ pid_of() { # never "0": kill 0 targets our own process group
printf '%s' "$pid"
}
alive() { local pid; pid="$(pid_of "$1")" && kill -0 "$pid" 2>/dev/null; }
ready_line() {
printf '{"schemaVersion":1,"port":%s,"display":":%s","path":"vnc.html","password":"%s"}\n' \
"$PORT" "$DISPLAY_NUM" "$(cat "$STATE/password")"
mint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content
local ticket now digest
mkdir -p "$TICKETS"
now="$(date +%s)"
# Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).
for file in "$TICKETS"/*; do
[ -f "$file" ] || continue
[ "$(cat "$file" 2>/dev/null || echo 0)" -ge "$now" ] 2>/dev/null || rm -f "$file"
done
# shellcheck disable=SC2012 # names are hex digests
ls -1t "$TICKETS" 2>/dev/null | tail -n +16 | while read -r old; do rm -f "$TICKETS/$old"; done
ticket="$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')"
[ "${#ticket}" = 43 ] || fail 'could not generate a ticket'
digest="$(printf '%s' "$ticket" | sha256sum | cut -d' ' -f1)"
printf '%s' "$((now + TICKET_TTL_S))" >"$TICKETS/.$digest" && mv "$TICKETS/.$digest" "$TICKETS/$digest"
printf '%s' "$ticket"
}
listening() { # websockify answers HTTP on loopback
ready_line() {
local ticket
ticket="$(mint_ticket)" || exit 1
printf '{"schemaVersion":2,"port":%s,"display":":%s","path":"vnc.html","ticket":"%s","expiresInMs":%s}\n' \
"$PORT" "$DISPLAY_NUM" "$ticket" "$((TICKET_TTL_S * 1000))"
}
listening() { # the proxy answers HTTP on loopback
curl -fsS -o /dev/null --max-time 1 "http://127.0.0.1:$PORT/vnc.html" 2>/dev/null
}
# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.
boot_id="$(cat /proc/sys/kernel/random/boot_id)"
if [ "$(cat "$STATE/boot-id" 2>/dev/null || true)" != "$boot_id" ]; then
rm -f "$STATE"/*.pid "$SOCKET" "$STATE/password" "$STATE/passwd"
rm -rf "$STATE"/*.pid "$SOCKET" "$STATE/password" "$STATE/passwd" "$STATE/mode" "$TICKETS"
printf '%s' "$boot_id" >"$STATE/boot-id"
fi
if alive xvnc && alive websockify && listening && [ -s "$STATE/password" ]; then
# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.
rm -f "$STATE/password" "$STATE/passwd"
if alive websockify; then kill "$(pid_of websockify)" 2>/dev/null || true; fi
rm -f "$STATE/websockify.pid"
current_mode() { cat "$STATE/mode" 2>/dev/null || true; }
if alive xvnc && alive proxy && listening && [ "$(current_mode)" = "$MODE" ]; then
ready_line
exit 0
fi
@@ -64,19 +96,23 @@ start() { # name cmd... — detached, logs in $STATE/<name>.log
echo $! >"$STATE/$name.pid"
}
if ! alive xvnc || [ ! -s "$STATE/password" ]; then
if ! alive xvnc || [ "$(current_mode)" != "$MODE" ]; then
if alive xvnc; then kill "$(pid_of xvnc)" 2>/dev/null || true; sleep 0.2; fi
rm -f "$SOCKET"
# VNC auth uses 8 password bytes; 8 random alphanumerics (~48 bits) plus Xvnc's blacklist.
password="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 8 || true)"
[ "${#password}" = 8 ] || fail 'could not generate a password'
printf '%s' "$password" | vncpasswd -f >"$STATE/passwd"
printf '%s' "$password" >"$STATE/password"
# -rfbport -1: no TCP VNC port at all, only a 0600 unix socket for websockify.
start xvnc Xvnc ":$DISPLAY_NUM" -geometry "$GEOMETRY" -depth 24 -nolisten tcp \
# -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the
# proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.
# SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.
# The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only
# this uid's processes (agents, apps they start on DISPLAY=:1) can open it.
auth=()
if command -v xauth >/dev/null && command -v mcookie >/dev/null; then
xauth -q -f "$HOME/.Xauthority" add ":$DISPLAY_NUM" . "$(mcookie)" 9>&- && auth=(-auth "$HOME/.Xauthority")
fi
start xvnc Xvnc ":$DISPLAY_NUM" "${auth[@]}" -geometry "$GEOMETRY" -depth 24 -nolisten tcp \
-rfbport -1 -rfbunixpath "$SOCKET" -rfbunixmode 0600 \
-SecurityTypes VncAuth -PasswordFile "$STATE/passwd" \
-SecurityTypes None -SendPrimary=0 \
-AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'
printf '%s' "$MODE" >"$STATE/mode"
for _ in $(seq 1 100); do
[ -S "$SOCKET" ] && break
alive xvnc || { tail -n 20 "$STATE/xvnc.log" >&2; fail 'Xvnc exited'; }
@@ -91,23 +127,28 @@ if ! alive xvnc || [ ! -s "$STATE/password" ]; then
'<applications><application class="XTerm"><maximized>yes</maximized></application></applications>' \
'</openbox_config>' >"$STATE/openbox.xml"
start wm openbox --config-file "$STATE/openbox.xml"
# Let it manage the screen before the terminal maps, so its maximize rule applies.
sleep 0.2
elif command -v twm >/dev/null; then
start wm twm
fi
if command -v xterm >/dev/null; then
start xterm xterm -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11
# selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).
start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \
-xrm 'XTerm*selectToClipboard: true'
fi
fi
if ! alive websockify || ! listening; then
if alive websockify; then kill "$(pid_of websockify)" 2>/dev/null || true; fi
PYTHONPATH="$DESKTOP/websockify" start websockify python3 -P -W ignore::UserWarning -m websockify \
--web "$DESKTOP/novnc" --unix-target "$SOCKET" "127.0.0.1:$PORT"
if ! alive proxy || ! listening; then
if alive proxy; then kill "$(pid_of proxy)" 2>/dev/null || true; sleep 0.1; fi
mkdir -p "$TICKETS"
PYTHONPATH="$DESKTOP/websockify" start proxy python3 -P -W ignore::UserWarning "$PROXY" \
--listen "$PORT" --web "$DESKTOP/novnc" --unix-target "$SOCKET" --tickets "$TICKETS"
for _ in $(seq 1 100); do
listening && break
alive websockify || { tail -n 20 "$STATE/websockify.log" >&2; fail 'websockify exited'; }
alive proxy || { tail -n 20 "$STATE/proxy.log" >&2; fail 'orca-desktop-proxy exited'; }
sleep 0.05
done
listening || fail "websockify is not answering on 127.0.0.1:$PORT"
listening || fail "orca-desktop-proxy is not answering on 127.0.0.1:$PORT"
fi
ready_line
@@ -34,6 +34,8 @@ const SNAPSHOTTING_STUCK_MS = 3 * 60_000
const MIN_REDACTED_SECRET = 6
/** Event-stream keepalive; desktop watchers treat ~90 s of silence as a dead connection. */
const HEARTBEAT_MS = 20_000
/** How long one advance waits for a running command's exit before calling it still running. */
const EXIT_PROBE_MS = 4_000
/** Succeeded builds whose snapshot is kept (rollback window); older ones keep their record and log. */
export const KEPT_BUILDS = 5
const MAX_LOG_BYTES = 2 * 1024 * 1024
@@ -371,7 +373,14 @@ export async function advanceBuild(deps: BuildDeps, row: BuildRow, options: { wa
}
let exitCode: number | null
try {
exitCode = (await deps.provider.getCommand(row.session_id, row.command_id, { wait: options.waitForExit })).exitCode
// Always a (bounded) wait: the provider reports an exit code only to waiting readers, so a plain
// poll would keep a finished build "running" until a log stream happened to ask (W9 stall).
exitCode = (
await deps.provider.getCommand(row.session_id, row.command_id, {
wait: true,
waitMs: options.waitForExit ? 30_000 : EXIT_PROBE_MS
})
).exitCode
} catch (error) {
if (error instanceof ProviderError && (error.status === 404 || error.status === 410)) {
return (await failBuild(deps, row, 'install', 'vm_lost')) ?? row
@@ -22,7 +22,12 @@ export type GoldenImageProviderApi = {
params: { command: string; args?: string[]; env?: Record<string, string>; timeoutMs: number }
): Promise<string>
/** Null exit code while it runs; `wait` blocks (provider-side) until it ends. */
getCommand(sessionId: string, commandId: string, options?: { wait?: boolean }): Promise<{ exitCode: number | null }>
getCommand(
sessionId: string,
commandId: string,
/** `wait`: block until it exits (the only way the provider reports an exit code); `waitMs` caps that. */
options?: { wait?: boolean; waitMs?: number }
): Promise<{ exitCode: number | null }>
/**
* The command's output as provider ndjson (`{"stream":"stdout"|"stderr","data":"..."}` lines), from
* the first byte; follows live output until the command ends.
+6 -2
View File
@@ -1149,14 +1149,18 @@ export async function handleRequest(request: Request, env: Env, deps: Deps = {})
}
case 'POST desktop': {
// Runs only the image's fixed entry point; the desktop listens on the VM's loopback and is
// reached through the paired runtime's port forward, never a public route.
// reached through the paired runtime's port forward, never a public route. Every call mints
// a fresh single-use ticket (each WebSocket, including a reconnect, needs its own); it is
// never logged here.
if (row.status !== 'running' || !row.session_id) {
return fail('conflict', 409, `sandbox_${row.status}`)
}
const started = Date.now()
const desktop = await startDesktop(life.provider, row.session_id)
console.log(JSON.stringify({ event: 'desktop_start', ms: Date.now() - started }))
return json({ desktop: { port: desktop.port, path: desktop.path, password: desktop.password } })
return json({
desktop: { port: desktop.port, path: desktop.path, ticket: desktop.ticket, expiresInMs: desktop.expiresInMs }
})
}
case 'POST lease': {
const body = LeaseRequestSchema.safeParse(await readJson(request))
@@ -196,12 +196,16 @@ const DESKTOP_NOT_INSTALLED_EXIT = 3
const DesktopReadySchema = z
.object({
schemaVersion: z.literal(1),
schemaVersion: z.literal(2),
port: z.number().int().min(1024).max(65_535),
display: z.string().regex(/^:\d{1,3}$/),
path: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
/** Per-boot VNC password: the desktop's localhost forward is reachable by any local process. */
password: z.string().regex(/^[A-Za-z0-9]{8}$/)
/**
* Single-use connection ticket (32 random bytes, base64url): the desktop's localhost forward is
* reachable by any local process, so the VM's proxy admits one WebSocket per unexpired ticket.
*/
ticket: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
expiresInMs: z.number().int().min(1_000).max(300_000)
})
.strict()
@@ -214,7 +218,7 @@ export class DesktopCommandError extends Error {
}
}
/** Starts the VM's remote desktop (idempotent per boot): Xvnc + noVNC on the VM's loopback only. */
/** Starts the VM's remote desktop (idempotent per boot) and mints one ticket: Xvnc + noVNC on the VM's loopback only. */
export async function startDesktop(provider: SandboxProviderApi, sessionId: string): Promise<DesktopReady> {
let result: Awaited<ReturnType<SandboxProviderApi['runCommand']>>
try {
@@ -64,7 +64,7 @@ export function createVercelSandboxApi(
async function request(
method: string,
path: string,
options: { query?: Record<string, string | undefined>; body?: unknown } = {}
options: { query?: Record<string, string | undefined>; body?: unknown; signal?: AbortSignal } = {}
): Promise<Response> {
const url = new URL(`${base}${path}`)
url.searchParams.set('teamId', config.teamId)
@@ -80,7 +80,8 @@ export function createVercelSandboxApi(
'content-type': 'application/json'
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
redirect: 'manual'
redirect: 'manual',
signal: options.signal
})
if (!response.ok) {
const parsed = ErrorBody.safeParse(await response.json().catch(() => null))
@@ -280,11 +281,22 @@ export function createVercelSandboxApi(
},
async getCommand(sessionId, commandId, options) {
const response = await request(
'GET',
`/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}/cmd/${encodeURIComponent(commandId)}`,
{ query: { wait: options?.wait ? 'true' : undefined } }
)
// Found live: a detached command's exitCode stays null on plain GETs even after it exited;
// only `wait=true` reports it. `waitMs` bounds that wait so a running command reads as null.
const signal = options?.wait && options.waitMs ? AbortSignal.timeout(options.waitMs) : undefined
let response: Response
try {
response = await request(
'GET',
`/v2/sandboxes/sessions/${encodeURIComponent(sessionId)}/cmd/${encodeURIComponent(commandId)}`,
{ query: { wait: options?.wait ? 'true' : undefined }, signal }
)
} catch (error) {
if (signal?.aborted) {
return { exitCode: null }
}
throw error
}
const body = await json(z.looseObject({ command: z.looseObject({ exitCode: z.number().nullable().optional() }) }), response)
return { exitCode: body.command.exitCode ?? null }
},
@@ -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 () => {
@@ -145,12 +145,13 @@ export function createFakeVercel(now: () => number): FakeVercel {
buildCommands.set(id, { sessionId, env: params.env ?? {}, args: params.args ?? [], exitCode: null, logs: '' })
return id
},
async getCommand(_sessionId, commandId) {
async getCommand(_sessionId, commandId, options) {
const command = buildCommands.get(commandId)
if (!command) {
throw new ProviderError(404, 'not_found')
}
return { exitCode: command.exitCode }
// As Vercel (found live): a detached command's exit code is reported only to `wait=true` reads.
return { exitCode: options?.wait ? command.exitCode : null }
},
async streamCommandLogs(_sessionId, commandId) {
const command = buildCommands.get(commandId)
@@ -167,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) {
@@ -4,6 +4,32 @@ import { createVercelSandboxApi } from '../src/vercel-sandbox-api'
type Seen = { method: string; url: URL; headers: Headers; body: any }
describe('command exit codes', () => {
it('asks with wait=true and treats a bounded wait that runs out as still running', async () => {
const seen: URL[] = []
const client = createVercelSandboxApi({
token: 'vercel_test_token',
teamId: 'team_orca',
projectId: 'prj_orca',
fetch: (input, init) => {
seen.push(new URL(String(input)))
// A running command: the provider holds the wait open until the caller gives up.
return new Promise<Response>((_, reject) =>
init?.signal?.addEventListener('abort', () => reject(init.signal!.reason))
)
}
})
expect(await client.getCommand('sess_1', 'cmd_1', { wait: true, waitMs: 20 })).toEqual({ exitCode: null })
expect(seen[0]!.searchParams.get('wait')).toBe('true')
})
it('reports the exit code a finished command answers the wait with', async () => {
const { client, seen } = api(() => Response.json({ command: { id: 'cmd_1', exitCode: 0 } }))
expect(await client.getCommand('sess_1', 'cmd_1', { wait: true, waitMs: 4000 })).toEqual({ exitCode: 0 })
expect(seen[0]!.url.searchParams.get('wait')).toBe('true')
})
})
function api(answer: (seen: Seen) => Response) {
const seen: Seen[] = []
const client = createVercelSandboxApi({
@@ -33,6 +33,8 @@ export function setupGuestShortcutForwarding(args: {
resolveWorkspaceId?: (browserTabId: string) => string | null
/** False for plugin frames (see GuestShortcutForwardContext). */
browserPage?: boolean
/** True while the page owns every key (a focused remote desktop): Orca forwards nothing. */
isKeyboardCaptured?: () => boolean
}): () => void {
const {
browserTabId,
@@ -43,7 +45,8 @@ export function setupGuestShortcutForwarding(args: {
getKeybindings,
resolveWorktreeId,
resolveWorkspaceId,
browserPage
browserPage,
isKeyboardCaptured
} = args
let ctrlTabSwitching = false
const doubleTapDetector = new ModifierDoubleTapDetector()
@@ -72,7 +75,7 @@ export function setupGuestShortcutForwarding(args: {
const handler = (event: Electron.Event, input: Electron.Input): void => {
// Agent-injected keys must not trigger Orca actions (#20595).
if (isAgentInjectingInput(guest)) {
if (isAgentInjectingInput(guest) || isKeyboardCaptured?.()) {
return
}
const keybindings = getKeybindings?.()
@@ -5,6 +5,10 @@ import { setupGuestMouseWheelZoomForwarding } from '../input/browser-guest-wheel
import { setupGuestShortcutForwarding } from '../input/shortcuts/browser-guest-shortcut-forwarding'
import { setupGuestTargetUrlForwarding } from '../input/browser-guest-target-url'
import { BrowserManagerGrab } from './grab/browser-manager-grab'
import {
isRemoteDesktopGuest,
setupRemoteDesktopGuest
} from '../../../hosted-sandbox/remote-desktop-guest'
export abstract class BrowserManagerBindings extends BrowserManagerGrab {
protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void {
@@ -56,7 +60,9 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab {
isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false,
getKeybindings: () => this.settingsResolver?.().keybindings,
resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null,
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null,
// A focused remote desktop keeps every key (VM-console style) until its release chord.
isKeyboardCaptured: () => isRemoteDesktopGuest(guest)
})
)
}
@@ -83,6 +89,28 @@ export abstract class BrowserManagerBindings extends BrowserManagerGrab {
)
}
protected setupRemoteDesktopGuest(browserTabId: string, guest: Electron.WebContents): void {
this.remoteDesktopCleanupByTabId.get(browserTabId)?.()
this.remoteDesktopCleanupByTabId.set(
browserTabId,
setupRemoteDesktopGuest({
browserPageId: browserTabId,
guest,
notify: (event) => {
const renderer = this.resolveRendererForBrowserTab(browserTabId)
if (!renderer || renderer.isDestroyed()) {
return
}
if (event.kind === 'release') {
renderer.send('browser:remoteDesktopRelease', { browserPageId: browserTabId })
} else {
renderer.send('browser:remoteDesktopState', event.state)
}
}
})
)
}
protected setupTargetUrlForwarding(browserTabId: string, guest: Electron.WebContents): void {
this.targetUrlCleanupByTabId.get(browserTabId)?.()
this.targetUrlCleanupByTabId.set(
@@ -89,6 +89,10 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
browserTabId: string,
guest: Electron.WebContents
): void
protected abstract setupRemoteDesktopGuest(
browserTabId: string,
guest: Electron.WebContents
): void
protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void
protected abstract hasActiveGrabOp(browserTabId: string): boolean
protected abstract unregisterGuest(
@@ -155,6 +159,7 @@ export abstract class BrowserManagerState extends BrowserManagerViewportScrollSt
protected readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
protected readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
protected readonly targetUrlCleanupByTabId = new Map<string, () => void>()
protected readonly remoteDesktopCleanupByTabId = new Map<string, () => void>()
protected readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
protected readonly worktreeIdByTabId = new Map<string, string>()
protected readonly policyAttachedGuestIds = new Set<number>()
@@ -89,6 +89,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
this.setupShortcutForwarding(browserTabId, guest)
this.setupMouseWheelZoomForwarding(browserTabId, guest)
this.setupTargetUrlForwarding(browserTabId, guest)
this.setupRemoteDesktopGuest(browserTabId, guest)
this.flushPendingLoadFailure(browserTabId, webContentsId)
this.flushPendingPermissionEvents(browserTabId, webContentsId)
this.flushPendingPopupEvents(browserTabId, webContentsId)
@@ -141,6 +142,8 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
}
this.targetUrlCleanupByTabId.get(browserTabId)?.()
this.targetUrlCleanupByTabId.delete(browserTabId)
this.remoteDesktopCleanupByTabId.get(browserTabId)?.()
this.remoteDesktopCleanupByTabId.delete(browserTabId)
let hasActiveDownloads = false
for (const [downloadId, download] of this.downloadsById.entries()) {
if (download.browserTabId === browserTabId && !download.terminalEvent) {
@@ -243,6 +246,7 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
this.pendingDownloadIdsByGuestId.clear()
this.mouseWheelZoomCleanupByTabId.clear()
this.targetUrlCleanupByTabId.clear()
this.remoteDesktopCleanupByTabId.clear()
this.annotationViewportBridgeOpsByTabId.clear()
}
}
@@ -18,6 +18,8 @@ export type FakeHostedControlPlane = {
requests: { method: string; path: string; authorization: string | null }[]
/** False models an image built before the remote desktop. */
desktopInstalled: boolean
/** Desktop starts served (each mints one ticket). */
desktopTickets: number
}
export function createFakeHostedControlPlane(
@@ -143,8 +145,17 @@ export function createFakeHostedControlPlane(
if (sandbox.status !== 'running') {
return fail('conflict', 409, `sandbox_${sandbox.status}`)
}
fake.desktopTickets += 1
return fake.desktopInstalled
? json({ desktop: { port: 6080, path: 'vnc.html', password: 'Ab3dE6gH' } })
? json({
desktop: {
port: 6080,
path: 'vnc.html',
// A fresh single-use ticket per call, as orca-desktop-start mints them.
ticket: String(fake.desktopTickets).padStart(43, 't'),
expiresInMs: 60_000
}
})
: fail('conflict', 409, 'desktop_not_installed')
case 'POST /rollover':
if (sandbox.status !== 'running') {
@@ -180,7 +191,8 @@ export function createFakeHostedControlPlane(
pendingDeny = reason
},
requests,
desktopInstalled: true
desktopInstalled: true,
desktopTickets: 0
}
return fake
}
@@ -9,10 +9,16 @@ import { createHostedSandboxDriver } from './hosted-sandbox-driver'
import { createHostedSandboxStepProvider } from './hosted-sandbox-recipe'
import { createFakeHostedControlPlane } from './fake-hosted-control-plane.test-support'
import { cloudSandboxIdForEnvironment, openCloudRemoteDesktop } from './hosted-remote-desktop'
import {
remoteDesktopSessionForUrl,
resetRemoteDesktopSessionsForTest,
takeRemoteDesktopTicket
} from './remote-desktop-sessions'
const ENV = 'env-cloud-1'
async function setup() {
resetRemoteDesktopSessionsForTest()
const plane = createFakeHostedControlPlane()
const client = createHostedSandboxControlPlaneClient({
baseUrl: 'https://sandbox.cloud.test',
@@ -55,12 +61,31 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
const { plane, client, userDataPath, openForward } = await setup()
const sandboxId = cloudSandboxIdForEnvironment(userDataPath, ENV)
expect(sandboxId).toBe([...plane.sandboxes.keys()][0])
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)
const now = () => 1_000
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward, now }, ENV)
// No secret in the URL: the page gets single-use tickets from main after it loads.
expect(result).toEqual({
ok: true,
localPort: 6081,
url: 'http://localhost:6081/vnc.html?autoconnect=1&resize=remote&reconnect=1&reconnect_delay=1000#password=Ab3dE6gH'
url: 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
})
const session = remoteDesktopSessionForUrl(result.ok ? result.url : '')
expect(session).toMatchObject({ environmentId: ENV, localPort: 6081, path: 'vnc.html' })
// The first ticket rides with the open; the next one is minted fresh (a reconnect).
expect(await takeRemoteDesktopTicket(session!, now)).toEqual({
ok: true,
ticket: '1'.padStart(43, 't'),
expiresAt: 61_000
})
expect(await takeRemoteDesktopTicket(session!, now)).toEqual({
ok: true,
ticket: '2'.padStart(43, 't'),
expiresAt: 61_000
})
expect(remoteDesktopSessionForUrl('http://localhost:6082/vnc.html')).toBeNull()
expect(remoteDesktopSessionForUrl('http://localhost:6081/other.html')).toBeNull()
expect(remoteDesktopSessionForUrl('https://localhost:6081/vnc.html')).toBeNull()
expect(remoteDesktopSessionForUrl('http://example.com:6081/vnc.html')).toBeNull()
expect(plane.requests.at(-1)).toMatchObject({
method: 'POST',
path: `/v1/sandboxes/${sandboxId}/desktop`
@@ -102,6 +127,13 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
ok: false,
code: 'not_running'
})
for (const sandbox of plane.sandboxes.values()) {
Object.assign(sandbox, { status: 'resuming' })
}
expect(await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)).toEqual({
ok: false,
code: 'starting'
})
expect(openForward).not.toHaveBeenCalled()
for (const sandbox of plane.sandboxes.values()) {
Object.assign(sandbox, { status: 'running' })
@@ -112,6 +144,17 @@ describe('Open Remote Desktop for an Orca Cloud workspace', () => {
).toEqual({ ok: false, code: 'forward_failed' })
})
it('says why a later ticket could not be minted (the sandbox went to sleep)', async () => {
const { plane, client, userDataPath, openForward } = await setup()
const result = await openCloudRemoteDesktop({ userDataPath, client, openForward }, ENV)
const session = remoteDesktopSessionForUrl(result.ok ? result.url : '')!
await takeRemoteDesktopTicket(session) // the open's own ticket
for (const sandbox of plane.sandboxes.values()) {
Object.assign(sandbox, { status: 'suspended', session: null })
}
expect(await takeRemoteDesktopTicket(session)).toEqual({ ok: false, reason: 'asleep' })
})
it('asks for sign-in when the control plane refuses the token', async () => {
const { userDataPath, openForward } = await setup()
const plane = createFakeHostedControlPlane()
@@ -1,17 +1,24 @@
// "Open Remote Desktop" for an Orca Cloud workspace: the control plane starts the VM's desktop
// (Xvnc + noVNC on the VM's loopback), then the paired runtime connection forwards its port here.
// No public route: the desktop is reachable only through this computer's localhost.
// No public route: the desktop is reachable only through this computer's localhost, and each
// WebSocket to it needs a single-use ticket that main hands the page (remote-desktop-guest.ts).
import { listEphemeralVmRuntimes } from '../../shared/ephemeral-vm-runtime-store'
import {
cloudRemoteDesktopUrl,
type CloudRemoteDesktopFailureCode,
type CloudRemoteDesktopResult
} from '../../shared/hosted-sandbox/cloud-remote-desktop'
import type { HostedSandboxDesktop } from '../../shared/hosted-sandbox/hosted-sandbox-wire'
import type { RuntimePortForwardOpenRequest } from '../../shared/runtime-port-forwards'
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
import { environmentHandleFromRecipeResult } from '../environment-providers/environment-provider-recipe-adapter'
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
import { HOSTED_SANDBOX_DRIVER_ID } from './hosted-sandbox-driver'
import {
registerRemoteDesktopSession,
type RemoteDesktopMintFailure,
type RemoteDesktopMintResult
} from './remote-desktop-sessions'
export type CloudRemoteDesktopDeps = {
userDataPath: string
@@ -19,6 +26,13 @@ export type CloudRemoteDesktopDeps = {
openForward: (
request: RuntimePortForwardOpenRequest
) => Promise<{ ok: true; localPort: number } | { ok: false }>
now?: () => number
/** Whether this computer still forwards localPort to the environment's remotePort. */
isForwardLive?: (forward: {
environmentId: string
localPort: number
remotePort: number
}) => boolean
}
/** The Orca Cloud sandbox behind a paired runtime environment, from the local runtime records. */
@@ -54,14 +68,32 @@ function failure(code: CloudRemoteDesktopFailureCode): CloudRemoteDesktopResult
return { ok: false, code }
}
function startFailure(error: unknown): CloudRemoteDesktopResult {
const ASLEEP_REASONS = new Set(['sandbox_suspended', 'sandbox_suspending'])
const STARTING_REASONS = new Set(['sandbox_creating', 'sandbox_resuming'])
/** How a desktop start (or ticket) failure reads; a conflict names the sandbox's state. */
function startFailureCode(error: unknown): CloudRemoteDesktopFailureCode {
if (isEnvironmentProviderError(error, 'conflict')) {
return failure(error.reason === 'desktop_not_installed' ? 'not_installed' : 'not_running')
const reason = error.reason ?? ''
if (reason === 'desktop_not_installed') {
return 'not_installed'
}
if (reason === 'sandbox_archived') {
return 'archived'
}
if (ASLEEP_REASONS.has(reason)) {
return 'not_running'
}
return STARTING_REASONS.has(reason) ? 'starting' : 'unavailable'
}
if (isEnvironmentProviderError(error, 'unauthorized')) {
return failure('signed_out')
}
return failure('unavailable')
return isEnvironmentProviderError(error, 'unauthorized') ? 'signed_out' : 'unavailable'
}
const MINT_FAILURE: Partial<Record<CloudRemoteDesktopFailureCode, RemoteDesktopMintFailure>> = {
not_running: 'asleep',
archived: 'archived',
starting: 'starting',
signed_out: 'signed_out'
}
export async function openCloudRemoteDesktop(
@@ -69,14 +101,16 @@ export async function openCloudRemoteDesktop(
environmentId: string
): Promise<CloudRemoteDesktopResult> {
const sandboxId = cloudSandboxIdForEnvironment(deps.userDataPath, environmentId)
if (!sandboxId || !deps.client) {
const client = deps.client
if (!sandboxId || !client) {
return failure('not_cloud')
}
let desktop: { port: number; path: string; password: string }
const now = deps.now ?? Date.now
let desktop: HostedSandboxDesktop
try {
desktop = await deps.client.startDesktop(sandboxId)
desktop = await client.startDesktop(sandboxId)
} catch (error) {
return startFailure(error)
return failure(startFailureCode(error))
}
// Same local number when free (stable bookmarks), else the next free one.
const forward = await deps.openForward({
@@ -88,9 +122,30 @@ export async function openCloudRemoteDesktop(
if (!forward.ok) {
return failure('forward_failed')
}
// Each later WebSocket (a reload, a reconnect after a drop or a wake) gets its own ticket.
const mint = async (): Promise<RemoteDesktopMintResult> => {
try {
const next = await client.startDesktop(sandboxId)
return { ok: true, ticket: next.ticket, expiresAt: now() + next.expiresInMs }
} catch (error) {
return { ok: false, reason: MINT_FAILURE[startFailureCode(error)] ?? 'unavailable' }
}
}
const isForwardLive = deps.isForwardLive
const live = { environmentId, localPort: forward.localPort, remotePort: desktop.port }
registerRemoteDesktopSession(
{
environmentId,
localPort: forward.localPort,
path: desktop.path,
mint,
...(isForwardLive ? { isLive: () => isForwardLive(live) } : {})
},
{ ticket: desktop.ticket, expiresAt: now() + desktop.expiresInMs }
)
return {
ok: true,
localPort: forward.localPort,
url: cloudRemoteDesktopUrl(forward.localPort, desktop.path, desktop.password)
url: cloudRemoteDesktopUrl(forward.localPort, desktop.path)
}
}
@@ -0,0 +1,276 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({ clipboard: { readText: () => '' } }))
vi.mock('../i18n/main-i18n', () => ({
translateMain: (_key: string, fallback: string) => fallback
}))
vi.mock('../browser/cdp/input/agent-input-injection', () => ({
isAgentInjectingInput: () => false
}))
import {
registerRemoteDesktopSession,
resetRemoteDesktopSessionsForTest,
type RemoteDesktopMintResult
} from './remote-desktop-sessions'
import { isRemoteDesktopGuest, setupRemoteDesktopGuest } from './remote-desktop-guest'
const DESKTOP_URL = 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
const ticket = (n: number): string => String(n).padStart(43, 't')
/** Fixed, far-future expiry: these tests never depend on the clock. */
const FAR_FUTURE = 4_000_000_000_000
/** A guest whose page answers orcaDesktop calls from a script the test controls. */
class FakeGuest extends EventEmitter {
url = DESKTOP_URL
focused = false
destroyed = false
calls: string[] = []
/** Pending nextTicketRequest() answers, resolved by the test. */
private waiters: ((answer: string) => void)[] = []
connectAnswer = 'connecting'
pasteAnswer = true
getURL(): string {
return this.url
}
isLoading(): boolean {
return false
}
isFocused(): boolean {
return this.focused
}
isDestroyed(): boolean {
return this.destroyed
}
async executeJavaScript(code: string): Promise<unknown> {
this.calls.push(code)
if (code.includes('nextTicketRequest')) {
return new Promise((resolve) => this.waiters.push(resolve))
}
if (code.includes('orcaDesktop.connect(')) {
return this.connectAnswer
}
if (code.includes('orcaDesktop.paste(')) {
return this.pasteAnswer
}
return true
}
/** The page asks for a ticket ('ticket') or reports a failed attempt ('retry'). */
ask(answer: 'ticket' | 'retry'): void {
this.waiters.shift()?.(answer)
}
get waiting(): number {
return this.waiters.length
}
key(input: Partial<Electron.Input>): { prevented: boolean } {
const event = { prevented: false, preventDefault: () => (event.prevented = true) }
this.emit('before-input-event', event, {
type: 'keyDown',
key: '',
code: '',
control: false,
alt: false,
meta: false,
shift: false,
isAutoRepeat: false,
...input
})
return event
}
}
function asWebContents(guest: FakeGuest): Electron.WebContents {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: FakeGuest implements every WebContents member the driver calls (on/off, getURL, isLoading, isFocused, isDestroyed, executeJavaScript).
return guest as unknown as Electron.WebContents
}
const flush = async (): Promise<void> => {
for (let i = 0; i < 5; i += 1) {
await new Promise((resolve) => setImmediate(resolve))
}
}
function setup(options: { clipboard?: string; platform?: NodeJS.Platform } = {}) {
const guest = new FakeGuest()
const minted: string[] = []
let next = 1
let mintResult: () => RemoteDesktopMintResult = () => {
const value = ticket((next += 1))
minted.push(value)
return { ok: true, ticket: value, expiresAt: FAR_FUTURE }
}
registerRemoteDesktopSession(
{
environmentId: 'env-1',
localPort: 6081,
path: 'vnc.html',
mint: async () => mintResult()
},
{ ticket: ticket(1), expiresAt: FAR_FUTURE }
)
const events: unknown[] = []
const stop = setupRemoteDesktopGuest({
browserPageId: 'page-1',
guest: asWebContents(guest),
platform: options.platform ?? 'darwin',
notify: (event) => events.push(event),
readClipboard: () => options.clipboard ?? '',
sleep: async () => {}
})
return {
guest,
minted,
events,
stop,
failMint: (reason: 'asleep' | 'unavailable') => {
mintResult = () => ({ ok: false, reason })
}
}
}
const connects = (guest: FakeGuest): string[] =>
guest.calls.filter((c) => c.includes('orcaDesktop.connect('))
describe('remote desktop page driver', () => {
beforeEach(() => resetRemoteDesktopSessionsForTest())
it('hands the page the open’s ticket first, then a fresh one for each reconnect', async () => {
const { guest } = setup()
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(true)
await flush()
guest.ask('ticket')
await flush()
expect(connects(guest)).toEqual([`window.orcaDesktop.connect("${ticket(1)}")`])
// The session dropped (forward re-dial, desktop restart): a new ticket, never the spent one.
guest.ask('ticket')
await flush()
expect(connects(guest).at(-1)).toBe(`window.orcaDesktop.connect("${ticket(2)}")`)
})
it('pauses with Reconnect after repeated failed attempts instead of spinning', async () => {
const { guest } = setup()
await flush()
// One try and three retries, then the page waits for a person.
for (let i = 0; i < 5; i += 1) {
guest.ask(i === 0 ? 'ticket' : 'retry')
await flush()
}
expect(connects(guest)).toHaveLength(4)
expect(guest.calls.at(-2)).toContain('window.orcaDesktop.pause(')
expect(guest.calls.at(-2)).toContain('The remote desktop disconnected.')
})
it('says the sandbox is asleep when no ticket can be minted, and resumes on reopen', async () => {
const { guest, failMint } = setup()
await flush()
guest.ask('ticket') // uses the open's ticket
await flush()
failMint('asleep')
guest.ask('ticket')
await flush()
expect(guest.calls.at(-2)).toContain('This sandbox is asleep.')
// Open remote desktop again: main wakes the paused page.
registerRemoteDesktopSession(
{
environmentId: 'env-1',
localPort: 6081,
path: 'vnc.html',
mint: async () => ({ ok: false, reason: 'asleep' })
},
null
)
expect(guest.calls.at(-1)).toContain('orcaDesktop.resume()')
})
it('ignores pages that are not this computer’s desktop forwards', async () => {
resetRemoteDesktopSessionsForTest()
const guest = new FakeGuest()
guest.url = 'http://localhost:6081/vnc.html'
const stop = setupRemoteDesktopGuest({
browserPageId: 'page-2',
guest: asWebContents(guest),
notify: () => {},
readClipboard: () => 'secret',
sleep: async () => {}
})
await flush()
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
expect(guest.calls).toEqual([])
expect(guest.key({ key: 'v', code: 'KeyV', meta: true }).prevented).toBe(false)
stop()
})
it('reads the clipboard only on a paste key, and pushes it on every paste', async () => {
const { guest } = setup({ clipboard: 'from this computer', platform: 'linux' })
await flush()
guest.ask('ticket')
await flush()
expect(guest.key({ key: 'a', code: 'KeyA', control: true }).prevented).toBe(false)
expect(guest.calls.some((c) => c.includes('paste('))).toBe(false)
expect(guest.key({ key: 'v', code: 'KeyV', control: true }).prevented).toBe(true)
await flush()
expect(guest.calls.at(-1)).toBe(
'window.orcaDesktop.paste("from this computer", {"kind":"replay","key":"v"})'
)
// Same text again is pushed again: the sandbox's clipboard may have changed meanwhile.
expect(guest.key({ key: 'v', code: 'KeyV', control: true }).prevented).toBe(true)
// Auto-repeat of a Linux paste key passes through untouched.
expect(guest.key({ key: 'v', code: 'KeyV', control: true, isAutoRepeat: true }).prevented).toBe(
false
)
})
it('turns Cmd+V into a sandbox paste on macOS', async () => {
const { guest } = setup({ clipboard: 'hi' })
await flush()
expect(guest.key({ key: 'v', code: 'KeyV', meta: true }).prevented).toBe(true)
await flush()
expect(guest.calls.at(-1)).toBe('window.orcaDesktop.paste("hi", {"kind":"mac"})')
})
it('reports keyboard capture and the release chord to the renderer', async () => {
const { guest, events } = setup()
guest.focused = true
guest.emit('focus')
expect(events.at(-1)).toEqual({
kind: 'state',
state: { browserPageId: 'page-1', desktop: true, captured: true }
})
guest.key({ type: 'keyDown', key: 'Control', control: true })
guest.key({ type: 'keyDown', key: 'Meta', control: true, meta: true })
guest.key({ type: 'keyUp', key: 'Meta', control: true })
expect(events.at(-1)).toEqual({ kind: 'release' })
})
it('never strands a page call when the page goes away mid-call', async () => {
const { guest } = setup()
await flush()
expect(guest.waiting).toBe(1) // nextTicketRequest() pending in the page
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
await flush()
// The pending call settled (cancelled) and the driver stopped: no connect, no new request.
expect(connects(guest)).toEqual([])
expect(guest.calls.filter((c) => c.includes('nextTicketRequest'))).toHaveLength(1)
})
it('stops driving the page when it navigates away or the tab closes', async () => {
const { guest, events, stop } = setup()
await flush()
guest.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
guest.ask('ticket')
await flush()
expect(connects(guest)).toEqual([])
guest.url = DESKTOP_URL
guest.emit('did-finish-load')
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(true)
stop()
expect(isRemoteDesktopGuest(asWebContents(guest))).toBe(false)
expect(events.at(-1)).toEqual({
kind: 'state',
state: { browserPageId: 'page-1', desktop: false, captured: false }
})
})
})
@@ -0,0 +1,299 @@
// Main's side of a remote desktop browser page (a page whose URL names a desktop forward this
// computer opened; see remote-desktop-sessions.ts). Main, not the page, holds the means to mint
// tickets, so it:
// - hands the page one single-use ticket per WebSocket (on load, and after each drop), backing off
// and pausing on repeated failures; the page offers Reconnect, and reopening from the menu
// resumes it. Tickets never touch the URL, the tab state or a log.
// - keeps the keyboard in the desktop while it has focus and gives it back to Orca on the release
// chord (remote-desktop-keyboard.ts), telling the renderer so it can show the hint.
// - pastes this computer's clipboard into the sandbox only on a paste key in the desktop.
import { clipboard } from 'electron'
import { translateMain } from '../i18n/main-i18n'
import { isAgentInjectingInput } from '../browser/cdp/input/agent-input-injection'
import {
onRemoteDesktopSessionRefreshed,
remoteDesktopSessionForUrl,
takeRemoteDesktopTicket,
type RemoteDesktopMintFailure,
type RemoteDesktopSession
} from './remote-desktop-sessions'
import {
createReleaseChordTracker,
remoteDesktopPasteChord,
type RemoteDesktopPasteChord
} from './remote-desktop-keyboard'
/** Consecutive connects that never reached the desktop before the page pauses with Reconnect. */
const MAX_FAILED_CONNECTS = 4
const RETRY_DELAYS_MS = [0, 1_000, 2_000, 4_000]
/** Floor between tickets, and a cap per minute, so a page that keeps dropping never spins mints. */
const MIN_MINT_INTERVAL_MS = 1_000
const MAX_MINTS_PER_MINUTE = 10
const MAX_PASTE_CHARS = 1 << 20
const PAGE_READY_TIMEOUT_MS = 10_000
export type RemoteDesktopGuestState = {
browserPageId: string
/** The page is a remote desktop with Orca's page glue. */
desktop: boolean
/** It has keyboard focus: Orca's shortcuts are off until the release chord. */
captured: boolean
}
export type RemoteDesktopGuestDeps = {
browserPageId: string
guest: Electron.WebContents
platform?: NodeJS.Platform
/** Renderer notices: hint visibility, and the release chord (the renderer moves focus out). */
notify: (event: { kind: 'state'; state: RemoteDesktopGuestState } | { kind: 'release' }) => void
readClipboard?: () => string
sleep?: (ms: number) => Promise<void>
}
const guestsWithDesktop = new WeakSet<Electron.WebContents>()
/** True while this guest shows a remote desktop: Orca's guest shortcut forwarding stands down. */
export function isRemoteDesktopGuest(guest: Electron.WebContents): boolean {
return guestsWithDesktop.has(guest)
}
const WAIT_FOR_REQUEST = `(async () => {
for (let i = 0; i < ${PAGE_READY_TIMEOUT_MS / 25} && !window.orcaDesktop; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 25))
}
return window.orcaDesktop ? window.orcaDesktop.nextTicketRequest() : 'missing'
})()`
function pauseMessage(reason: RemoteDesktopMintFailure | 'failed'): string {
switch (reason) {
case 'asleep':
return translateMain(
'components.cloudRemoteDesktop.pageAsleep',
'This sandbox is asleep. Wake it in Orca, then reconnect.'
)
case 'archived':
return translateMain(
'components.cloudRemoteDesktop.pageArchived',
'This sandbox is archived. Unarchive the workspace in Orca, then reconnect.'
)
case 'starting':
return translateMain(
'components.cloudRemoteDesktop.pageStarting',
'The sandbox is starting or waking. Reconnect in a moment.'
)
case 'signed_out':
return translateMain(
'components.cloudRemoteDesktop.pageSignedOut',
'You’re signed out of Orca Cloud. Sign in, then reconnect.'
)
case 'failed':
case 'unavailable':
return translateMain(
'components.cloudRemoteDesktop.pageDisconnected',
'The remote desktop disconnected. Your files are safe.'
)
}
}
export function setupRemoteDesktopGuest(deps: RemoteDesktopGuestDeps): () => void {
const { guest, browserPageId, notify } = deps
const platform = deps.platform ?? process.platform
const readClipboard = deps.readClipboard ?? (() => clipboard.readText())
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
const releaseChord = createReleaseChordTracker(platform)
let generation = 0
let session: RemoteDesktopSession | null = null
let stopRefreshListener = (): void => {}
let disposed = false
let published = ''
/** Settles every in-flight page call when the page goes away (a torn-down page may never answer). */
let cancelInFlight = (): void => {}
let cancelled = new Promise<undefined>((resolve) => {
cancelInFlight = () => resolve(undefined)
})
const cancelPageCalls = (): void => {
cancelInFlight()
cancelled = new Promise<undefined>((resolve) => {
cancelInFlight = () => resolve(undefined)
})
}
const alive = (gen: number): boolean => !disposed && gen === generation && !guest.isDestroyed()
const publish = (): void => {
const state = {
browserPageId,
desktop: session !== null,
captured: session !== null && !guest.isDestroyed() && guest.isFocused()
}
// Only changes, and nothing at all for ordinary pages' focus churn.
const key = `${state.desktop}:${state.captured}`
if (key === published || (!state.desktop && published === '')) {
return
}
published = key
notify({ kind: 'state', state })
}
const run = async (gen: number, code: string): Promise<unknown> => {
if (!alive(gen)) {
return undefined
}
try {
return await Promise.race([guest.executeJavaScript(code), cancelled])
} catch {
return undefined
}
}
async function driveTickets(gen: number, current: RemoteDesktopSession): Promise<void> {
let failures = 0
let refused = false
let lastMintAt = 0
let mintTimes: number[] = []
while (alive(gen)) {
const ask = await run(gen, WAIT_FOR_REQUEST)
if (!alive(gen) || (ask !== 'ticket' && ask !== 'retry')) {
return
}
// 'retry': the last ticket never got a session (refused, or the forward was down).
failures = ask === 'retry' || refused ? failures + 1 : 0
refused = false
if (failures >= MAX_FAILED_CONNECTS) {
failures = 0
await pause(gen, 'failed')
continue
}
// Even sessions that connect and then drop at once never mint faster than this.
const sinceLast = Date.now() - lastMintAt
await sleep(Math.max(RETRY_DELAYS_MS[failures] ?? 4_000, MIN_MINT_INTERVAL_MS - sinceLast))
// The forward may have closed meanwhile; a page now on that port gets nothing.
if (!alive(gen) || remoteDesktopSessionForUrl(guest.getURL()) !== current) {
return
}
mintTimes = mintTimes.filter((at) => Date.now() - at < 60_000)
if (mintTimes.length >= MAX_MINTS_PER_MINUTE) {
mintTimes = []
await pause(gen, 'failed')
continue
}
lastMintAt = Date.now()
mintTimes.push(lastMintAt)
const minted = await takeRemoteDesktopTicket(current)
if (!minted.ok) {
failures = 0
await pause(gen, minted.reason)
continue
}
const started = await run(gen, `window.orcaDesktop.connect(${JSON.stringify(minted.ticket)})`)
// Anything but a started attempt still counts against the budget, so this never spins.
refused = started !== 'connecting'
}
}
async function pause(gen: number, reason: RemoteDesktopMintFailure | 'failed'): Promise<void> {
const label = translateMain('components.cloudRemoteDesktop.reconnect', 'Reconnect')
await run(
gen,
`window.orcaDesktop.pause(${JSON.stringify(pauseMessage(reason))}, ${JSON.stringify(label)}, ${JSON.stringify(reason === 'asleep' || reason === 'archived' || reason === 'starting' ? 'neutral' : 'error')})`
)
}
const onLoad = (): void => {
generation += 1
cancelPageCalls()
const gen = generation
stopRefreshListener()
stopRefreshListener = () => {}
session = remoteDesktopSessionForUrl(guest.getURL())
if (session) {
guestsWithDesktop.add(guest)
// Reopening from the menu resumes a page that stopped retrying (asleep, then woken).
stopRefreshListener = onRemoteDesktopSessionRefreshed(session.localPort, () => {
void run(gen, 'window.orcaDesktop && window.orcaDesktop.resume()')
})
void driveTickets(gen, session)
} else {
guestsWithDesktop.delete(guest)
}
publish()
}
const onNavigate = (
details: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>
): void => {
if (details.isMainFrame && !details.isSameDocument && session) {
// Leaving the page: stop handing it tickets until the next load says what it is.
generation += 1
cancelPageCalls()
session = null
guestsWithDesktop.delete(guest)
stopRefreshListener()
publish()
}
}
// Async by nature: keys typed in the same instant can reach the sandbox before the replayed one.
const pasteInto = async (
gen: number,
text: string,
chord: RemoteDesktopPasteChord
): Promise<void> => {
await run(gen, `window.orcaDesktop.paste(${JSON.stringify(text)}, ${JSON.stringify(chord)})`)
}
const onInput = (event: Electron.Event, input: Electron.Input): void => {
if (!session || isAgentInjectingInput(guest)) {
return
}
if (releaseChord(input)) {
notify({ kind: 'release' })
return
}
const chord = remoteDesktopPasteChord(input, platform)
if (!chord) {
return
}
if (input.isAutoRepeat) {
if (chord.kind === 'mac') {
event.preventDefault()
}
return
}
// Read only on a paste key in the desktop: the sandbox never sees the clipboard otherwise.
const text = readClipboard()
if (!text || text.length > MAX_PASTE_CHARS) {
return
}
// Always pushed: the sandbox's clipboard may have changed by a route that never reached
// this computer (an agent's xclip), so "it already has this text" cannot be assumed.
event.preventDefault()
void pasteInto(generation, text, chord)
}
guest.on('did-finish-load', onLoad)
guest.on('did-start-navigation', onNavigate)
guest.on('before-input-event', onInput)
guest.on('focus', publish)
guest.on('blur', publish)
if (!guest.isLoading() && guest.getURL()) {
onLoad()
}
return () => {
disposed = true
cancelPageCalls()
stopRefreshListener()
guestsWithDesktop.delete(guest)
try {
guest.off('did-finish-load', onLoad)
guest.off('did-start-navigation', onNavigate)
guest.off('before-input-event', onInput)
guest.off('focus', publish)
guest.off('blur', publish)
} catch {
// The guest may already be destroyed.
}
if (session) {
session = null
publish()
}
}
}
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import {
createReleaseChordTracker,
remoteDesktopPasteChord,
type RemoteDesktopKeyInput
} from './remote-desktop-keyboard'
function key(
type: 'keyDown' | 'keyUp',
keyName: string,
mods: Partial<Pick<RemoteDesktopKeyInput, 'control' | 'alt' | 'meta' | 'shift'>> = {}
): RemoteDesktopKeyInput {
return {
type,
key: keyName,
code: keyName === 'v' || keyName === 'V' ? 'KeyV' : keyName,
control: false,
alt: false,
meta: false,
shift: false,
isAutoRepeat: false,
...mods
}
}
describe('remote desktop release chord', () => {
it('releases on macOS when Control+Command are pressed and let go with nothing else', () => {
const track = createReleaseChordTracker('darwin')
expect(track(key('keyDown', 'Control', { control: true }))).toBe(false)
expect(track(key('keyDown', 'Meta', { control: true, meta: true }))).toBe(false)
expect(track(key('keyUp', 'Meta', { control: true }))).toBe(true)
// Its other key going up does not release twice.
expect(track(key('keyUp', 'Control'))).toBe(false)
})
it('keeps Ctrl+Alt+T and other chords for the desktop', () => {
const track = createReleaseChordTracker('linux')
track(key('keyDown', 'Control', { control: true }))
track(key('keyDown', 'Alt', { control: true, alt: true }))
expect(track(key('keyDown', 't', { control: true, alt: true }))).toBe(false)
expect(track(key('keyUp', 'Alt', { control: true }))).toBe(false)
// Shift joining the chord is something else too.
track(key('keyDown', 'Control', { control: true }))
track(key('keyDown', 'Alt', { control: true, alt: true }))
track(key('keyDown', 'Shift', { control: true, alt: true, shift: true }))
expect(track(key('keyUp', 'Alt', { control: true, shift: true }))).toBe(false)
})
it('uses Ctrl+Alt off macOS and ignores Control+Command there', () => {
const win = createReleaseChordTracker('win32')
win(key('keyDown', 'Control', { control: true }))
win(key('keyDown', 'Meta', { control: true, meta: true }))
expect(win(key('keyUp', 'Meta', { control: true }))).toBe(false)
win(key('keyDown', 'Alt', { control: true, alt: true }))
expect(win(key('keyUp', 'Control', { alt: true }))).toBe(true)
})
it('ignores AltGr (reported as Ctrl+Alt) on Windows', () => {
const win = createReleaseChordTracker('win32')
win({ ...key('keyDown', 'Control', { control: true }), code: 'ControlLeft' })
win({ ...key('keyDown', 'Alt', { control: true, alt: true }), code: 'AltRight' })
expect(win({ ...key('keyUp', 'Alt', { control: true }), code: 'AltRight' })).toBe(false)
})
it('never releases on plain typing or a lone modifier', () => {
const track = createReleaseChordTracker('darwin')
for (const input of [
key('keyDown', 'Meta', { meta: true }),
key('keyDown', 'c', { meta: true }),
key('keyUp', 'c', { meta: true }),
key('keyUp', 'Meta'),
key('keyDown', 'Control', { control: true }),
key('keyUp', 'Control')
]) {
expect(track(input)).toBe(false)
}
})
})
describe('remote desktop paste keys', () => {
it('turns Cmd+V on macOS into a sandbox paste', () => {
expect(remoteDesktopPasteChord(key('keyDown', 'v', { meta: true }), 'darwin')).toEqual({
kind: 'mac'
})
expect(remoteDesktopPasteChord(key('keyDown', 'v', { meta: true }), 'linux')).toBeNull()
})
it('replays the Linux paste keys after setting the clipboard', () => {
expect(remoteDesktopPasteChord(key('keyDown', 'v', { control: true }), 'linux')).toEqual({
kind: 'replay',
key: 'v'
})
expect(
remoteDesktopPasteChord(key('keyDown', 'V', { control: true, shift: true }), 'darwin')
).toEqual({ kind: 'replay', key: 'V' })
expect(remoteDesktopPasteChord(key('keyDown', 'Insert', { shift: true }), 'win32')).toEqual({
kind: 'replay',
key: 'Insert'
})
})
it('follows the keyboard layout, not the physical key', () => {
// Dvorak: Ctrl+K is typed on the physical V key and must reach the sandbox untouched.
expect(
remoteDesktopPasteChord({ ...key('keyDown', 'k', { control: true }), code: 'KeyV' }, 'linux')
).toBeNull()
// Cyrillic: the V key types 'м'; Ctrl+V there is still paste.
expect(
remoteDesktopPasteChord({ ...key('keyDown', 'м', { control: true }), code: 'KeyV' }, 'linux')
).toEqual({ kind: 'replay', key: 'v' })
})
it('leaves other keys and key-ups alone', () => {
expect(remoteDesktopPasteChord(key('keyUp', 'v', { control: true }), 'linux')).toBeNull()
expect(remoteDesktopPasteChord(key('keyDown', 'c', { control: true }), 'linux')).toBeNull()
expect(remoteDesktopPasteChord(key('keyDown', 'v'), 'linux')).toBeNull()
expect(
remoteDesktopPasteChord(key('keyDown', 'v', { control: true, alt: true }), 'linux')
).toBeNull()
})
})
@@ -0,0 +1,74 @@
// Keyboard rules for a remote desktop page. While it has focus every key goes to the sandbox
// (Orca's shortcuts included), like a VM console, so Ctrl/Cmd combos type normally there. One
// chord gives the keyboard back to Orca: Control+Command on macOS, Ctrl+Alt elsewhere, pressed
// together and released with no other key in between (so Ctrl+Alt+T still reaches the sandbox).
export type RemoteDesktopKeyInput = Pick<
Electron.Input,
'type' | 'key' | 'code' | 'control' | 'alt' | 'meta' | 'shift' | 'isAutoRepeat'
>
const MODIFIER_KEYS = new Set(['Control', 'Alt', 'Meta', 'Shift', 'AltGraph', 'OS'])
/** Feeds every key event; returns true on the event that completes the release chord. */
export function createReleaseChordTracker(
platform: NodeJS.Platform
): (input: RemoteDesktopKeyInput) => boolean {
const mac = platform === 'darwin'
const chordKeys = mac ? new Set(['Control', 'Meta']) : new Set(['Control', 'Alt'])
let armed = false
return (input) => {
// Windows reports AltGr as Ctrl+Alt: a lone AltGr tap must not release the keyboard.
if (!mac && input.code === 'AltRight') {
armed = false
return false
}
if (input.type === 'keyDown') {
if (!MODIFIER_KEYS.has(input.key)) {
armed = false
return false
}
const held = mac
? input.control && input.meta && !input.alt && !input.shift
: input.control && input.alt && !input.meta && !input.shift
armed = held && chordKeys.has(input.key) ? true : armed && held
return false
}
if (input.type === 'keyUp' && armed && chordKeys.has(input.key)) {
armed = false
return true
}
return false
}
}
/**
* How a paste key reaches the sandbox after its clipboard is set: Cmd+V on macOS becomes
* Shift+Insert (Linux apps ignore Super+V), Ctrl+V / Ctrl+Shift+V / Shift+Insert are replayed.
*/
export type RemoteDesktopPasteChord =
| { kind: 'mac' }
| { kind: 'replay'; key: 'v' | 'V' | 'Insert' }
export function remoteDesktopPasteChord(
input: RemoteDesktopKeyInput,
platform: NodeJS.Platform
): RemoteDesktopPasteChord | null {
if (input.type !== 'keyDown' || input.alt) {
return null
}
// The letter the layout produces (Dvorak Ctrl+K sits on the physical V key); the physical key
// only when the layout has no Latin letter there (Cyrillic, Greek).
const latin = /^[a-z]$/i.test(input.key)
const v = latin ? input.key === 'v' || input.key === 'V' : input.code === 'KeyV'
if (platform === 'darwin' && input.meta && !input.control && v) {
return { kind: 'mac' }
}
if (input.control && !input.meta && v) {
return { kind: 'replay', key: input.shift ? 'V' : 'v' }
}
if (input.shift && !input.control && !input.meta && input.key === 'Insert') {
return { kind: 'replay', key: 'Insert' }
}
return null
}
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
registerRemoteDesktopSession,
remoteDesktopSessionForUrl,
resetRemoteDesktopSessionsForTest,
takeRemoteDesktopTicket
} from './remote-desktop-sessions'
const URL_6081 = 'http://localhost:6081/vnc.html?autoconnect=0&reconnect=0&resize=remote'
const ticket = (n: number): string => String(n).padStart(43, 't')
function register(options: { live?: () => boolean; firstExpiresAt?: number } = {}): void {
let minted = 0
registerRemoteDesktopSession(
{
environmentId: 'env-1',
localPort: 6081,
path: 'vnc.html',
mint: async () => ({ ok: true, ticket: ticket((minted += 1) + 100), expiresAt: 999_999 }),
...(options.live ? { isLive: options.live } : {})
},
{ ticket: ticket(1), expiresAt: options.firstExpiresAt ?? 100_000 }
)
}
describe('remote desktop sessions', () => {
beforeEach(() => resetRemoteDesktopSessionsForTest())
it('trusts only the exact URL Orca built, on localhost or 127.0.0.1', () => {
register()
expect(remoteDesktopSessionForUrl(URL_6081)).not.toBeNull()
expect(remoteDesktopSessionForUrl(URL_6081.replace('localhost', '127.0.0.1'))).not.toBeNull()
for (const url of [
`${URL_6081}&host=evil.example`,
`${URL_6081}#host=evil.example&encrypt=1`,
`${URL_6081}&logging=debug`,
'http://localhost:6081/vnc.html',
'http://localhost:6081/vnc_lite.html?autoconnect=0&reconnect=0&resize=remote',
URL_6081.replace('http:', 'https:'),
URL_6081.replace('localhost', 'localhost.evil.example'),
URL_6081.replace('6081', '6082'),
URL_6081.replace('http://', 'http://user:pw@')
]) {
expect(remoteDesktopSessionForUrl(url), url).toBeNull()
}
})
it('forgets a session once its forward is gone', () => {
let live = true
register({ live: () => live })
expect(remoteDesktopSessionForUrl(URL_6081)).not.toBeNull()
live = false
expect(remoteDesktopSessionForUrl(URL_6081)).toBeNull()
live = true
// Pruned: a later page on that port is not trusted until Orca opens the desktop again.
expect(remoteDesktopSessionForUrl(URL_6081)).toBeNull()
})
it('hands out the open’s ticket once, and never within 5 s of its expiry', async () => {
register({ firstExpiresAt: 10_000 })
const session = remoteDesktopSessionForUrl(URL_6081)!
expect(await takeRemoteDesktopTicket(session, () => 1_000)).toMatchObject({ ticket: ticket(1) })
expect(await takeRemoteDesktopTicket(session, () => 1_000)).toMatchObject({
ticket: ticket(101)
})
register({ firstExpiresAt: 10_000 })
const fresh = remoteDesktopSessionForUrl(URL_6081)!
expect(await takeRemoteDesktopTicket(fresh, () => 6_000)).toMatchObject({ ticket: ticket(101) })
})
})
@@ -0,0 +1,126 @@
// Remote desktop sessions this computer opened: which localhost forward port carries which cloud
// workspace's desktop, and how to mint that desktop a connection ticket. A browser page is a
// remote desktop only when its URL names one of these ports (main decides, never the page), and
// only such a page is handed tickets, keyboard capture and the clipboard bridge. The URL must be
// exactly the one Orca built (no host/port/encrypt overrides for noVNC), and the session ends with
// its forward: a later forward or local server on the same port is never trusted.
import { cloudRemoteDesktopUrl } from '../../shared/hosted-sandbox/cloud-remote-desktop'
export type RemoteDesktopMintFailure =
| 'asleep'
| 'archived'
| 'starting'
| 'signed_out'
| 'unavailable'
export type RemoteDesktopMintResult =
| { ok: true; ticket: string; expiresAt: number }
| { ok: false; reason: RemoteDesktopMintFailure }
export type RemoteDesktopSession = {
environmentId: string
localPort: number
/** noVNC's page on the VM ("vnc.html"). */
path: string
/** Asks the sandbox for a fresh single-use ticket (starts the desktop if it stopped). */
mint: () => Promise<RemoteDesktopMintResult>
/** False once the forward this session opened is gone (closed, slept, re-dialed elsewhere). */
isLive?: () => boolean
}
type Entry = RemoteDesktopSession & {
/** The ticket minted with the open itself, saved for the page load that follows. */
pending: { ticket: string; expiresAt: number } | null
listeners: Set<() => void>
}
/** A ticket this close to expiry is not handed out: the page still has to dial the forward. */
const TICKET_MARGIN_MS = 5_000
const sessionsByPort = new Map<number, Entry>()
/** Records (or refreshes) a session and wakes any open page for it that stopped retrying. */
export function registerRemoteDesktopSession(
session: RemoteDesktopSession,
firstTicket: { ticket: string; expiresAt: number } | null
): void {
const previous = sessionsByPort.get(session.localPort)
const entry: Entry = {
...session,
pending: firstTicket,
listeners: previous?.listeners ?? new Set()
}
sessionsByPort.set(session.localPort, entry)
for (const listener of entry.listeners) {
listener()
}
}
/** The session behind a desktop page URL, or null for any other page. */
export function remoteDesktopSessionForUrl(url: string): RemoteDesktopSession | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
if (parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return null
}
const port = Number(parsed.port)
const entry = sessionsByPort.get(port)
if (!entry) {
return null
}
if (entry.isLive && !entry.isLive()) {
sessionsByPort.delete(port)
return null
}
// Exactly Orca's URL: noVNC reads host, port, encrypt and logging from the query and the hash,
// which would let a page point a ticketed session (and the user's keys) somewhere else.
const expected = new URL(cloudRemoteDesktopUrl(port, entry.path))
return parsed.protocol === 'http:' &&
!parsed.username &&
!parsed.password &&
parsed.pathname === expected.pathname &&
parsed.search === expected.search &&
parsed.hash === ''
? entry
: null
}
/** A ticket for the next WebSocket: the one minted with the open if still fresh, else a new one. */
export async function takeRemoteDesktopTicket(
session: RemoteDesktopSession,
now: () => number = Date.now
): Promise<RemoteDesktopMintResult> {
const entry = sessionsByPort.get(session.localPort)
const pending = entry?.pending
if (entry && pending) {
entry.pending = null
if (pending.expiresAt - TICKET_MARGIN_MS > now()) {
return { ok: true, ...pending }
}
}
return (entry ?? session).mint()
}
/** Called when the session for this port is opened again (the user chose Open remote desktop). */
export function onRemoteDesktopSessionRefreshed(
localPort: number,
listener: () => void
): () => void {
let entry = sessionsByPort.get(localPort)
if (!entry) {
return () => {}
}
entry.listeners.add(listener)
return () => {
entry?.listeners.delete(listener)
entry = undefined
}
}
export function resetRemoteDesktopSessionsForTest(): void {
sessionsByPort.clear()
}
+9 -2
View File
@@ -5,7 +5,10 @@ import {
openCloudRemoteDesktop
} from '../hosted-sandbox/hosted-remote-desktop'
import type { HostedSandboxControlPlaneClient } from '../hosted-sandbox/hosted-sandbox-control-plane-client'
import { openRuntimePortForward } from '../runtime-port-forward/runtime-port-forward-manager'
import {
getRuntimePortForwardManager,
openRuntimePortForward
} from '../runtime-port-forward/runtime-port-forward-service'
function environmentIdOf(value: unknown): string | null {
const id =
@@ -33,7 +36,11 @@ export function registerCloudRemoteDesktopHandlers(client: HostedSandboxControlP
{
userDataPath: app.getPath('userData'),
client,
openForward: openRuntimePortForward
openForward: openRuntimePortForward,
isForwardLive: ({ environmentId: env, localPort, remotePort }) =>
getRuntimePortForwardManager()
?.list(env)
.some((f) => f.localPort === localPort && f.remotePort === remotePort) ?? false
},
environmentId
)
+2
View File
@@ -8,6 +8,7 @@ import {
} from './filesystem-handler-context'
import { registerFilesystemReadHandlers } from './filesystem-read-handlers'
import { registerFilesystemDownloadHandlers } from './filesystem-download-handlers'
import { registerRuntimeDownloadHandlers } from '../../runtime-downloads/runtime-download-handlers'
import { registerFilesystemWriteHandlers } from './filesystem-write-handlers'
import { registerFilesystemSearchHandlers } from './filesystem-search-handlers'
import { registerFilesystemGitStatusHandlers } from './filesystem-git-status-handlers'
@@ -33,6 +34,7 @@ export function registerFilesystemHandlers(
registerFilesystemReadHandlers(context)
registerFilesystemDownloadHandlers(context)
registerRuntimeDownloadHandlers()
registerFilesystemWriteHandlers(context)
registerFilesystemSearchHandlers(context)
registerFilesystemGitStatusHandlers(context)
@@ -2,8 +2,11 @@ const WINDOWS_RESERVED_LOCAL_BASENAME =
/^(?:con|prn|aux|nul|conin\$|conout\$|com[1-9\u00B9\u00B2\u00B3]|lpt[1-9\u00B9\u00B2\u00B3])(?:\..*)?$/i
const LOCAL_FILENAME_REPLACEMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*'])
// Bidi overrides and zero-width characters let `exe.txt` display as `txt.exe` in a save dialog.
const INVISIBLE_FORMAT_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g
export function sanitizeLocalDownloadFilename(remoteBasename: string): string {
const sanitized = Array.from(remoteBasename, (char) =>
const sanitized = Array.from(remoteBasename.replace(INVISIBLE_FORMAT_CHARS, ''), (char) =>
char.charCodeAt(0) < 32 || LOCAL_FILENAME_REPLACEMENT_CHARS.has(char) ? '_' : char
)
.join('')
@@ -1,4 +1,7 @@
import { RuntimeRpcCallQueuePool } from '../../../shared/runtime-rpc-call-queue'
import {
RuntimeRpcCallQueuePool,
type RuntimeRpcCallLane
} from '../../../shared/runtime-rpc-call-queue'
const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
@@ -6,7 +9,8 @@ export function enqueueRuntimeCall<T>(
selector: string,
method: string,
run: () => Promise<T>,
signal?: AbortSignal
signal?: AbortSignal,
lane?: RuntimeRpcCallLane
): Promise<T> {
return runtimeCallQueuePool.enqueue(selector, method, run, 0, signal)
return runtimeCallQueuePool.enqueue(selector, method, run, 0, signal, lane)
}
@@ -24,6 +24,7 @@ import type { RemoteRuntimeTransportSubscription } from '../../../shared/remote-
import { withRuntimeUnreachableHint } from './runtime-environment-unreachable-hint'
import { decodeRuntimeEnvironmentSubscriptionEvents } from './runtime-environment-subscription-events'
import { enqueueRuntimeCall } from './runtime-environment-call-queue'
import type { RuntimeRpcCallLane } from '../../../shared/runtime-rpc-call-queue'
import { holdForInFlightRecipeVmWake } from './runtime-environment-recipe-vm-wake'
import {
sendRemoteRuntimeConnectionRequestAbortable,
@@ -54,6 +55,8 @@ export async function callRuntimeEnvironment(
signal?: AbortSignal
expectedEnvironmentRuntimeId?: string
preparation?: ManagedRuntimeCallPreparation
/** Queue lane override; see RuntimeRpcCallLane. */
lane?: RuntimeRpcCallLane
}
): Promise<Rpc.RuntimeRpcResponse<unknown>> {
if (method === 'status.get') {
@@ -168,7 +171,8 @@ export async function callRuntimeEnvironment(
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
},
options?.signal
options?.signal,
options?.lane
)
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
@@ -0,0 +1,46 @@
import type { RuntimeDownloadErrorCode } from '../../shared/runtime-downloads/runtime-download-contract'
import { RuntimeDownloadError } from './runtime-download-transfer'
const HOST_MESSAGE_CODES: Record<string, RuntimeDownloadErrorCode> = {
archive_too_large: 'too_large',
archive_too_many_entries: 'too_many_entries',
archive_busy: 'host_busy',
archive_not_directory: 'not_a_directory',
archive_canceled: 'canceled',
file_too_large: 'too_large'
}
/** Maps a failed host reply onto a stable code the renderer can word. */
export function runtimeDownloadErrorFromResponse(error: {
code: string
message: string
}): RuntimeDownloadError {
if (error.code === 'method_not_found') {
return new RuntimeDownloadError('host_update_required', error.message)
}
const mapped = HOST_MESSAGE_CODES[error.message]
if (mapped) {
return new RuntimeDownloadError(mapped, error.message)
}
if (/ENOENT|not[_ ]found|no such file/i.test(error.message)) {
return new RuntimeDownloadError('not_found', error.message)
}
if (/disconnect|unreachable|closed|timed? ?out|offline/i.test(`${error.code} ${error.message}`)) {
return new RuntimeDownloadError('disconnected', error.message)
}
return new RuntimeDownloadError('transfer_failed', error.message)
}
export function toRuntimeDownloadError(error: unknown, signal: AbortSignal): RuntimeDownloadError {
if (signal.aborted) {
return new RuntimeDownloadError('canceled')
}
if (error instanceof RuntimeDownloadError) {
return error
}
const message = error instanceof Error ? error.message : String(error)
const rawCode =
typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined
const code = typeof rawCode === 'string' ? rawCode : 'runtime_error'
return runtimeDownloadErrorFromResponse({ code, message })
}
@@ -0,0 +1,278 @@
import { app, BrowserWindow, dialog, ipcMain, shell, type WebContents } from 'electron'
import { open, rm } from 'node:fs/promises'
import { z } from 'zod'
import { dirname, join } from 'node:path'
import {
canOpenRuntimeDownloadLocally,
RUNTIME_OPEN_LOCALLY_MAX_BYTES,
type RuntimeDownloadProgressEvent,
type RuntimeDownloadResult,
type RuntimeDownloadStartArgs
} from '../../shared/runtime-downloads/runtime-download-contract'
import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing'
import { getRuntimeEnvironmentRegistryRoot } from '../ipc/runtime-environments/runtime-environment-registry-root'
import { sanitizeLocalDownloadFilename } from '../ipc/filesystem/local-download-filename'
import {
cleanupLocalTransferPath,
createSiblingTransferPath,
inspectDownloadDestination,
promoteDownloadedFile
} from '../ipc/filesystem/filesystem-download-promotion'
import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort'
import { markPluginSavedFileAsDownloaded as markFileAsDownloaded } from '../plugins/panels/plugin-save-as-download-mark'
import {
RuntimeDownloadError,
transferRuntimeFile,
transferRuntimeFolder,
type RuntimeDownloadCall,
type RuntimeDownloadProgress
} from './runtime-download-transfer'
import { runtimeDownloadErrorFromResponse, toRuntimeDownloadError } from './runtime-download-errors'
import {
createRuntimeOpenLocalDirectory,
pruneRuntimeOpenLocalCache
} from './runtime-open-local-cache'
export const RUNTIME_DOWNLOAD_PROGRESS_CHANNEL = 'runtimeDownloads:progress'
// A toast cannot show finer progress than this, and every event crosses IPC.
const PROGRESS_INTERVAL_MS = 120
const MAX_ACTIVE_DOWNLOADS = 8
const activeDownloads = new Map<string, AbortController>()
// Counted from the moment a destination is chosen: a download still at its save dialog isn't
// using the link and has no toast to cancel it from.
let transferringCount = 0
const Text = z.string().max(4096)
const RequiredText = Text.refine((value) => value.trim() !== '')
const StartArgs = z.object({
downloadId: RequiredText,
environmentId: RequiredText,
worktree: RequiredText,
relativePath: Text,
name: RequiredText,
kind: z.enum(['file', 'folder']),
intent: z.enum(['save', 'open']),
excludeDefaultDirs: z.boolean().optional(),
expectedEnvironmentPairingRevision: z.number().int().optional()
}) satisfies z.ZodType<RuntimeDownloadStartArgs>
function validateArgs(args: unknown): RuntimeDownloadStartArgs {
const parsed = StartArgs.safeParse(args)
if (!parsed.success) {
throw new Error('Invalid runtime download request')
}
if (parsed.data.intent === 'open' && parsed.data.kind !== 'file') {
throw new Error('Only files can be opened locally')
}
return parsed.data
}
function createCall(args: RuntimeDownloadStartArgs): RuntimeDownloadCall {
return async <T>(
method: string,
params: unknown,
options: { timeoutMs: number; signal: AbortSignal }
): Promise<T> => {
const response = await callRuntimeEnvironment(
getRuntimeEnvironmentRegistryRoot(),
args.environmentId,
method,
params,
options.timeoutMs,
args.expectedEnvironmentPairingRevision,
undefined,
{ signal: options.signal, lane: 'download' }
)
if (!response.ok) {
throw runtimeDownloadErrorFromResponse(response.error)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result type of the host method it calls; the host's RPC contract defines that shape.
return response.result as T
}
}
function createProgressEmitter(sender: WebContents, downloadId: string) {
let lastSentAt = 0
let destinationPath: string | undefined
const send = (event: Omit<RuntimeDownloadProgressEvent, 'downloadId' | 'destinationPath'>) => {
if (!sender.isDestroyed()) {
sender.send(RUNTIME_DOWNLOAD_PROGRESS_CHANNEL, {
downloadId,
...event,
...(destinationPath ? { destinationPath } : {})
} satisfies RuntimeDownloadProgressEvent)
}
}
return {
setDestination(path: string) {
destinationPath = path
},
preparing() {
send({ phase: 'preparing', receivedBytes: 0, totalBytes: null, processedBytes: 0 })
},
progress(progress: RuntimeDownloadProgress) {
const now = Date.now()
if (now - lastSentAt < PROGRESS_INTERVAL_MS) {
return
}
lastSentAt = now
send({ phase: 'transferring', ...progress })
}
}
}
async function chooseDestination(
sender: WebContents,
args: RuntimeDownloadStartArgs
): Promise<string | null> {
const baseName = sanitizeLocalDownloadFilename(args.name)
if (args.intent === 'open') {
void pruneRuntimeOpenLocalCache()
return join(await createRuntimeOpenLocalDirectory(), baseName)
}
const suggestedName = args.kind === 'folder' ? `${baseName}.zip` : baseName
const parentWindow = BrowserWindow.fromWebContents(sender) ?? undefined
const options: Electron.SaveDialogOptions = {
defaultPath: join(app.getPath('downloads'), suggestedName),
...(args.kind === 'folder' ? { filters: [{ name: 'ZIP archive', extensions: ['zip'] }] } : {})
}
const result = parentWindow
? await dialog.showSaveDialog(parentWindow, options)
: await dialog.showSaveDialog(options)
return result.canceled || !result.filePath ? null : result.filePath
}
async function runDownload(
sender: WebContents,
args: RuntimeDownloadStartArgs,
controller: AbortController
): Promise<RuntimeDownloadResult> {
if (
args.intent === 'open' &&
!canOpenRuntimeDownloadLocally(sanitizeLocalDownloadFilename(args.name))
) {
throw new RuntimeDownloadError('open_refused')
}
const destinationPath = await chooseDestination(sender, args)
if (!destinationPath || controller.signal.aborted) {
return { status: 'canceled' }
}
if (transferringCount >= MAX_ACTIVE_DOWNLOADS) {
if (args.intent === 'open') {
await rm(dirname(destinationPath), { recursive: true, force: true }).catch(() => {})
}
return {
status: 'error',
code: 'host_busy',
message: 'Too many downloads are running; wait for one to finish.'
}
}
transferringCount += 1
try {
return await transferToDestination(sender, args, controller, destinationPath)
} finally {
transferringCount -= 1
}
}
async function transferToDestination(
sender: WebContents,
args: RuntimeDownloadStartArgs,
controller: AbortController,
destinationPath: string
): Promise<RuntimeDownloadResult> {
const emitter = createProgressEmitter(sender, args.downloadId)
emitter.setDestination(destinationPath)
emitter.preparing()
const { existed } = await inspectDownloadDestination(destinationPath)
const tempPath = createSiblingTransferPath(destinationPath, 'download')
const sink = await open(tempPath, 'wx')
let promoted = false
try {
const transferInput = {
call: createCall(args),
worktree: args.worktree,
relativePath: args.relativePath,
sink,
signal: controller.signal,
onProgress: (progress: RuntimeDownloadProgress) => emitter.progress(progress)
}
const outcome =
args.kind === 'folder'
? await transferRuntimeFolder({
...transferInput,
excludeDefaultDirs: args.excludeDefaultDirs !== false
})
: await transferRuntimeFile({
...transferInput,
...(args.intent === 'open' ? { maxBytes: RUNTIME_OPEN_LOCALLY_MAX_BYTES } : {})
})
await sink.close()
if (controller.signal.aborted) {
throw new RuntimeDownloadError('canceled')
}
await promoteDownloadedFile(tempPath, destinationPath, existed)
promoted = true
// The bytes come from a remote machine: mark them like a browser download so Gatekeeper,
// SmartScreen and Protected View still apply (a zip's mark carries over to what it extracts).
await markFileAsDownloaded(destinationPath)
let openError: string | undefined
if (args.intent === 'open') {
const message = await shell.openPath(destinationPath)
openError = message === '' ? undefined : message
}
return {
status: 'done',
destinationPath,
...outcome,
...(openError ? { openError } : {})
}
} finally {
await sink.close().catch(() => {})
if (!promoted) {
await cleanupLocalTransferPath(tempPath)
if (args.intent === 'open') {
// The per-open folder holds nothing else; don't leave it behind empty.
await rm(dirname(destinationPath), { recursive: true, force: true }).catch(() => {})
}
}
}
}
export function registerRuntimeDownloadHandlers(): void {
ipcMain.handle(
'runtimeDownloads:start',
async (event, rawArgs: unknown): Promise<RuntimeDownloadResult> => {
const args = validateArgs(rawArgs)
if (activeDownloads.has(args.downloadId)) {
throw new Error('Download already running')
}
const controller = new AbortController()
activeDownloads.set(args.downloadId, controller)
// Closing the window must not leave a transfer running nobody can cancel.
const lifetime = abortWhenRendererGone(event.sender)
const onGone = () => controller.abort()
lifetime.signal.addEventListener('abort', onGone, { once: true })
try {
return await runDownload(event.sender, args, controller)
} catch (error) {
const failure = toRuntimeDownloadError(error, controller.signal)
return failure.code === 'canceled'
? { status: 'canceled' }
: { status: 'error', code: failure.code, message: failure.message }
} finally {
lifetime.signal.removeEventListener('abort', onGone)
lifetime.dispose()
activeDownloads.delete(args.downloadId)
}
}
)
ipcMain.handle('runtimeDownloads:cancel', (_event, args: { downloadId?: unknown }) => {
const controller =
typeof args?.downloadId === 'string' ? activeDownloads.get(args.downloadId) : undefined
controller?.abort()
return { canceled: controller !== undefined }
})
}
@@ -0,0 +1,185 @@
import { mkdtemp, open, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
RuntimeDownloadError,
transferRuntimeFile,
transferRuntimeFolder,
type RuntimeDownloadCall
} from './runtime-download-transfer'
let dir: string
/** A scripted host; the transfer code names each method's result type itself. */
function fakeCall<P>(impl: (method: string, params: P) => Promise<unknown>): RuntimeDownloadCall {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each scripted reply matches the result type the transfer code expects for that method.
return impl as RuntimeDownloadCall
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'orca-runtime-download-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
function fileHost(content: Buffer, statSize = content.byteLength): RuntimeDownloadCall {
return fakeCall(async (method: string, params: { offset: number; length: number }) => {
if (method === 'files.stat') {
return { size: statSize, isDirectory: false }
}
const bytes = content.subarray(params.offset, params.offset + params.length)
// Out-of-order replies: later pages may land first.
for (let turn = 0; turn < (params.offset / 7) % 3; turn += 1) {
await Promise.resolve()
}
return {
contentBase64: bytes.toString('base64'),
bytesRead: bytes.byteLength,
eof: params.offset + bytes.byteLength >= content.byteLength
}
})
}
async function runFile(call: RuntimeDownloadCall, extra: { maxBytes?: number } = {}) {
const sink = await open(join(dir, 'out'), 'w+')
try {
const result = await transferRuntimeFile({
call,
worktree: 'wt',
relativePath: 'a.bin',
sink,
signal: new AbortController().signal,
onProgress: () => {},
chunkBytes: 7,
concurrency: 3,
...extra
})
return { result, bytes: await readFile(join(dir, 'out')) }
} finally {
await sink.close()
}
}
describe('transferRuntimeFile', () => {
it('reassembles pages fetched in parallel in order', async () => {
const content = Buffer.from('the quick brown fox jumps over the lazy dog, twice over')
const { result, bytes } = await runFile(fileHost(content))
expect(result.bytes).toBe(content.byteLength)
expect(bytes.equals(content)).toBe(true)
})
it('keeps a file that shrank after stat to its final length', async () => {
const content = Buffer.from('short file')
const { bytes } = await runFile(fileHost(content, 1000))
expect(bytes.toString()).toBe('short file')
})
it('downloads an empty file', async () => {
const { bytes } = await runFile(fileHost(Buffer.alloc(0)))
expect(bytes.byteLength).toBe(0)
})
it('refuses a file over the caller limit before reading it', async () => {
await expect(runFile(fileHost(Buffer.alloc(20)), { maxBytes: 10 })).rejects.toEqual(
new RuntimeDownloadError('too_large')
)
})
it('stops a host that keeps sending pages past the limit', async () => {
const endless = fakeCall(async (method: string, params: { length: number }) =>
method === 'files.stat'
? { size: 10, isDirectory: false }
: {
contentBase64: Buffer.alloc(params.length, 1).toString('base64'),
bytesRead: params.length,
eof: false
}
)
await expect(runFile(endless, { maxBytes: 50 })).rejects.toEqual(
new RuntimeDownloadError('too_large')
)
})
})
describe('transferRuntimeFolder', () => {
it('releases the host archive when the download is canceled', async () => {
const controller = new AbortController()
const calls: string[] = []
const call = fakeCall(async (method: string) => {
calls.push(method)
if (method === 'files.archiveStart') {
return { archiveId: 'z1', totalBytes: 10, excludedDirCount: 0, fileCount: 1 }
}
if (method === 'files.archiveRead') {
controller.abort()
return {
contentBase64: 'AA==',
bytesRead: 1,
eof: false,
processedBytes: 1,
skippedCount: 0
}
}
return { released: true }
})
const sink = await open(join(dir, 'out.zip'), 'w+')
try {
await expect(
transferRuntimeFolder({
call,
worktree: 'wt',
relativePath: 'src',
sink,
signal: controller.signal,
onProgress: () => {},
excludeDefaultDirs: true
})
).rejects.toEqual(new RuntimeDownloadError('canceled'))
} finally {
await sink.close()
}
expect(calls).toEqual(['files.archiveStart', 'files.archiveRead', 'files.archiveCancel'])
})
it('releases an archive whose start reply lands after the user canceled', async () => {
const controller = new AbortController()
const calls: { method: string; params: unknown }[] = []
let finishStart: (value: unknown) => void = () => {}
const call = fakeCall(async (method: string, params: unknown) => {
calls.push({ method, params })
if (method === 'files.archiveStart') {
return new Promise<unknown>((resolve) => {
finishStart = resolve
})
}
return { released: true }
})
const sink = await open(join(dir, 'late.zip'), 'w+')
try {
const transfer = transferRuntimeFolder({
call,
worktree: 'wt',
relativePath: 'src',
sink,
signal: controller.signal,
onProgress: () => {},
excludeDefaultDirs: true
})
controller.abort()
// Canceled at once, before the host has even finished walking.
await expect(transfer).rejects.toEqual(new RuntimeDownloadError('canceled'))
finishStart({ archiveId: 'late', totalBytes: 1, excludedDirCount: 0, fileCount: 1 })
await vi.waitFor(() => expect(calls).toHaveLength(2))
} finally {
await sink.close()
}
expect(calls.map((entry) => entry.method)).toEqual([
'files.archiveStart',
'files.archiveCancel'
])
expect(calls[1].params).toEqual({ archiveId: 'late' })
})
})
@@ -0,0 +1,245 @@
import type { FileHandle } from 'node:fs/promises'
import type { RuntimeFileReadChunkResult } from '../../shared/file-content/runtime-file-read'
import {
FOLDER_ARCHIVE_MAX_ENTRIES,
FOLDER_ARCHIVE_MAX_READ_BYTES,
FOLDER_ARCHIVE_MAX_TOTAL_BYTES,
type FileArchiveReadResult,
type FileArchiveStartResult
} from '../../shared/rpc-contract/files-archive-params'
import type { RuntimeDownloadErrorCode } from '../../shared/runtime-downloads/runtime-download-contract'
/** files.readChunk's schema ceiling; older hosts accept nothing larger. */
const FILE_CHUNK_BYTES = 512 * 1024
// Matches the main-process download lane: four pages in flight hide ~65 ms of RTT.
const FILE_CHUNK_CONCURRENCY = 4
const CHUNK_TIMEOUT_MS = 60_000
// The walk of a large tree runs before the first byte; give it room.
const ARCHIVE_START_TIMEOUT_MS = 120_000
// The host is not trusted to stop. A file may grow a little while it is copied; past this much over
// its announced size the download stops instead of filling this computer's disk.
const FILE_GROWTH_ALLOWANCE_BYTES = 64 * 1024 * 1024
// A ZIP of at most 2 GiB of files: deflate never grows data by more than ~0.1 %, plus local and
// central headers (well under 1 KiB each for 65,000 entries).
const FOLDER_ARCHIVE_MAX_ZIP_BYTES =
FOLDER_ARCHIVE_MAX_TOTAL_BYTES * 1.01 + FOLDER_ARCHIVE_MAX_ENTRIES * 1024
export class RuntimeDownloadError extends Error {
constructor(
readonly code: RuntimeDownloadErrorCode,
message: string = code
) {
super(message)
this.name = 'RuntimeDownloadError'
}
}
export type RuntimeDownloadCall = <T>(
method: string,
params: unknown,
options: { timeoutMs: number; signal: AbortSignal }
) => Promise<T>
export type RuntimeDownloadProgress = {
receivedBytes: number
/** Expected bytes; for folders this counts uncompressed source bytes, not ZIP bytes. */
totalBytes: number | null
/** Source bytes covered so far, comparable with totalBytes. */
processedBytes: number
/** Folder downloads only. */
fileCount?: number
}
type TransferInput = {
call: RuntimeDownloadCall
worktree: string
relativePath: string
sink: FileHandle
signal: AbortSignal
onProgress: (progress: RuntimeDownloadProgress) => void
}
/** Pages a remote file into `sink` with a few positional reads in flight to hide link latency. */
export async function transferRuntimeFile(
input: TransferInput & { maxBytes?: number; chunkBytes?: number; concurrency?: number }
): Promise<{ bytes: number }> {
const chunkBytes = input.chunkBytes ?? FILE_CHUNK_BYTES
const stat = await input.call<{ size: number; isDirectory: boolean }>(
'files.stat',
{ worktree: input.worktree, relativePath: input.relativePath },
{ timeoutMs: CHUNK_TIMEOUT_MS, signal: input.signal }
)
if (stat.isDirectory) {
throw new RuntimeDownloadError('not_a_file')
}
if (input.maxBytes !== undefined && stat.size > input.maxBytes) {
throw new RuntimeDownloadError('too_large')
}
// Hard stop for a host that keeps answering full pages past the size it announced.
const byteLimit = Math.min(
input.maxBytes ?? Number.POSITIVE_INFINITY,
stat.size + FILE_GROWTH_ALLOWANCE_BYTES
)
let nextOffset = 0
// The file may change size mid-transfer; the first EOF page fixes the final length.
let endOffset: number | null = null
let received = 0
const worker = async (): Promise<void> => {
for (;;) {
if (input.signal.aborted) {
throw new RuntimeDownloadError('canceled')
}
if (endOffset !== null && nextOffset >= endOffset) {
return
}
const offset = nextOffset
nextOffset += chunkBytes
const chunk = await input.call<RuntimeFileReadChunkResult>(
'files.readChunk',
{
worktree: input.worktree,
relativePath: input.relativePath,
offset,
length: chunkBytes
},
{ timeoutMs: CHUNK_TIMEOUT_MS, signal: input.signal }
)
if (offset + chunk.bytesRead > byteLimit) {
throw new RuntimeDownloadError('too_large')
}
if (chunk.bytesRead > 0) {
const bytes = Buffer.from(chunk.contentBase64, 'base64')
if (bytes.byteLength !== chunk.bytesRead) {
throw new RuntimeDownloadError('transfer_failed', 'Remote page length mismatch')
}
await input.sink.write(bytes, 0, bytes.byteLength, offset)
received += bytes.byteLength
input.onProgress({
receivedBytes: received,
totalBytes: stat.size,
processedBytes: received
})
}
if (chunk.eof || chunk.bytesRead < chunkBytes) {
const end = offset + chunk.bytesRead
endOffset = endOffset === null ? end : Math.min(endOffset, end)
return
}
}
}
const concurrency = Math.max(1, input.concurrency ?? FILE_CHUNK_CONCURRENCY)
await Promise.all(Array.from({ length: concurrency }, worker))
const finalLength = endOffset ?? received
// A page past a shrunken EOF may have landed; cut the file back to the agreed length.
await input.sink.truncate(finalLength)
return { bytes: finalLength }
}
export type FolderTransferResult = {
bytes: number
skippedCount: number
excludedDirCount: number
fileCount: number
}
/** Asks the host to zip a folder, then appends its pages to `sink` until EOF. */
export async function transferRuntimeFolder(
input: TransferInput & { excludeDefaultDirs: boolean }
): Promise<FolderTransferResult> {
// Not tied to the user's cancel: the host only learns of a cancel through archiveCancel, so a
// start canceled mid-walk must still deliver its id for release (else it holds a slot until idle).
const startRequest = input.call<FileArchiveStartResult>(
'files.archiveStart',
{
worktree: input.worktree,
relativePath: input.relativePath,
excludeDefaultDirs: input.excludeDefaultDirs
},
{ timeoutMs: ARCHIVE_START_TIMEOUT_MS, signal: new AbortController().signal }
)
const started = await raceAbort(startRequest, input.signal, (late) =>
releaseArchive(input.call, late.archiveId)
)
let finished = false
let received = 0
try {
for (;;) {
if (input.signal.aborted) {
throw new RuntimeDownloadError('canceled')
}
const page = await input.call<FileArchiveReadResult>(
'files.archiveRead',
{ archiveId: started.archiveId, maxBytes: FOLDER_ARCHIVE_MAX_READ_BYTES },
{ timeoutMs: CHUNK_TIMEOUT_MS, signal: input.signal }
)
if (page.bytesRead > 0) {
const bytes = Buffer.from(page.contentBase64, 'base64')
if (received + bytes.byteLength > FOLDER_ARCHIVE_MAX_ZIP_BYTES) {
throw new RuntimeDownloadError('too_large')
}
await input.sink.write(bytes, 0, bytes.byteLength, received)
received += bytes.byteLength
}
input.onProgress({
receivedBytes: received,
totalBytes: started.totalBytes,
processedBytes: page.processedBytes,
fileCount: started.fileCount
})
if (page.eof) {
finished = true
return {
bytes: received,
skippedCount: page.skippedCount,
excludedDirCount: started.excludedDirCount,
fileCount: started.fileCount
}
}
}
} finally {
if (!finished) {
releaseArchive(input.call, started.archiveId)
}
}
}
/** Best effort: the host's idle timer frees the session if this never arrives. */
function releaseArchive(call: RuntimeDownloadCall, archiveId: string): void {
void call(
'files.archiveCancel',
{ archiveId },
{ timeoutMs: 10_000, signal: new AbortController().signal }
).catch(() => {})
}
/** Rejects with `canceled` as soon as `signal` aborts; a result that lands later goes to `onLate`. */
function raceAbort<T>(
request: Promise<T>,
signal: AbortSignal,
onLate: (value: T) => void
): Promise<T> {
if (signal.aborted) {
void request.then(onLate, () => {})
return Promise.reject(new RuntimeDownloadError('canceled'))
}
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
void request.then(onLate, () => {})
reject(new RuntimeDownloadError('canceled'))
}
signal.addEventListener('abort', onAbort, { once: true })
request.then(
(value) => {
signal.removeEventListener('abort', onAbort)
if (signal.aborted) {
return
}
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error)
}
)
})
}
@@ -0,0 +1,51 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { app } from 'electron'
// Opened copies only need to outlive the app that opens them; a day covers a long session.
const OPEN_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000
// Under userData, not the OS temp dir: Linux /tmp is shared, and another local user could pre-create
// the folder and swap the file Orca is about to open.
export function runtimeOpenLocalCacheRoot(): string {
return join(app.getPath('userData'), 'opened-remote-files')
}
/** A fresh directory per open, so two opens of same-named files never clobber each other. */
export async function createRuntimeOpenLocalDirectory(root = runtimeOpenLocalCacheRoot()) {
await mkdir(root, { recursive: true, mode: 0o700 })
const directory = join(root, randomUUID())
// Not recursive: a fresh random name that already exists means something else made it.
await mkdir(directory, { mode: 0o700 })
return directory
}
/** Best effort: removes cached copies older than a day. Failures never block an open. */
export async function pruneRuntimeOpenLocalCache(
rootOverride?: string,
now = Date.now(),
maxAgeMs = OPEN_CACHE_MAX_AGE_MS
): Promise<void> {
let root: string
let names: string[]
try {
// Inside the try: before app ready (or in tests) userData may not resolve.
root = rootOverride ?? runtimeOpenLocalCacheRoot()
names = await readdir(root)
} catch {
return
}
await Promise.all(
names.map(async (name) => {
const path = join(root, name)
try {
if (now - (await stat(path)).mtimeMs > maxAgeMs) {
await rm(path, { recursive: true, force: true })
}
} catch {
// Another app instance may be pruning the same entry.
}
})
)
}
@@ -29,6 +29,7 @@ export type RuntimeFileCommandSurface = Pick<
| 'readFileExplorerPreview'
| 'readChatImagePreview'
| 'readFileExplorerChunk'
| 'resolveFileExplorerArchivePath'
| 'readDocPreviewFile'
| 'readDocPreviewChunk'
> &
@@ -91,6 +92,7 @@ export function installRuntimeFileCommandSurface(
readFileExplorerPreview: fileReads.readFileExplorerPreview.bind(fileReads),
readChatImagePreview: fileReads.readChatImagePreview.bind(fileReads),
readFileExplorerChunk: fileReads.readFileExplorerChunk.bind(fileReads),
resolveFileExplorerArchivePath: fileReads.resolveFileExplorerArchivePath.bind(fileReads),
readDocPreviewFile: fileReads.readDocPreviewFile.bind(fileReads),
readDocPreviewChunk: fileReads.readDocPreviewChunk.bind(fileReads),
writeFileExplorerFile: fileMutations.writeFileExplorerFile.bind(fileMutations),
@@ -0,0 +1,100 @@
import { constants } from 'node:fs'
import { open, readlink, realpath, type FileHandle } from 'node:fs/promises'
import { sep } from 'node:path'
import type { FolderArchiveEntry } from './folder-archive-walk'
/** Files up to this size are read whole, ahead of the zipper, so per-file open/stat/close overlaps. */
const SMALL_FILE_BYTES = 64 * 1024
export type SmallFileRead =
| { kind: 'bytes'; bytes: Uint8Array }
| { kind: 'skip'; error: unknown }
/** Grew past the small-file limit since the walk; stream it instead. */
| { kind: 'stream' }
export class ChangedSinceWalkError extends Error {}
export function isSmallFile(entry: FolderArchiveEntry): boolean {
return entry.kind === 'file' && entry.size <= SMALL_FILE_BYTES
}
// O_NONBLOCK: a file swapped for a FIFO after the walk must not park a libuv thread in open();
// fstat then refuses it. Regular files ignore the flag; Windows has neither flag.
const ARCHIVE_OPEN_FLAGS =
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)
/**
* Opens a planned file without following a final link, and refuses it if it is no longer the file
* the walk saw or if the opened file does not live under `rootRealPath`. A parent directory swapped
* for a link after the walk slips past O_NOFOLLOW; the fd's real path (or, off Linux, a re-resolve
* plus dev/ino) catches that, so nothing outside the root is ever read.
*/
export async function openUnchangedFile(
entry: FolderArchiveEntry,
rootRealPath: string
): Promise<FileHandle> {
const handle = await open(entry.absolutePath, ARCHIVE_OPEN_FLAGS)
try {
const opened = await handle.stat()
if (!opened.isFile() || opened.dev !== entry.dev || opened.ino !== entry.ino) {
throw new ChangedSinceWalkError('archive_entry_changed')
}
if (!isInsideRoot(await openedRealPath(handle, entry), rootRealPath)) {
throw new ChangedSinceWalkError('archive_entry_escaped')
}
return handle
} catch (error) {
await handle.close().catch(() => {})
throw error
}
}
async function openedRealPath(handle: FileHandle, entry: FolderArchiveEntry): Promise<string> {
if (process.platform === 'linux') {
try {
return await readlink(`/proc/self/fd/${handle.fd}`)
} catch {
// No /proc (unusual containers): fall through to the path check.
}
}
return realpath(entry.absolutePath)
}
function isInsideRoot(path: string, rootRealPath: string): boolean {
const root = rootRealPath.endsWith(sep) ? rootRealPath : `${rootRealPath}${sep}`
return process.platform === 'win32'
? path.toLowerCase().startsWith(root.toLowerCase())
: path.startsWith(root)
}
/** Never rejects: failures come back as `skip` so a look-ahead read cannot go unhandled. */
export async function readSmallFile(
entry: FolderArchiveEntry,
rootRealPath: string
): Promise<SmallFileRead> {
let handle: FileHandle
try {
handle = await openUnchangedFile(entry, rootRealPath)
} catch (error) {
return { kind: 'skip', error }
}
try {
// One spare byte tells a file that grew since the walk apart from one that matches.
const buffer = Buffer.allocUnsafe(Math.min(entry.size, SMALL_FILE_BYTES) + 1)
let filled = 0
for (;;) {
const { bytesRead } = await handle.read(buffer, filled, buffer.byteLength - filled, filled)
if (bytesRead === 0) {
return { kind: 'bytes', bytes: buffer.subarray(0, filled) }
}
filled += bytesRead
if (filled === buffer.byteLength) {
return { kind: 'stream' }
}
}
} catch (error) {
return { kind: 'skip', error }
} finally {
await handle.close().catch(() => {})
}
}
@@ -0,0 +1,281 @@
import { mkdtemp, mkdir, realpath, rename, rm, symlink, writeFile, chmod } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { unzipSync } from 'fflate'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FolderArchiveSessions } from './folder-archive-sessions'
import {
FolderArchiveLimitError,
planFolderArchive,
sanitizeArchiveSegment
} from './folder-archive-walk'
import { FolderArchiveZipStream, isContainedLinkTarget } from './folder-archive-zip-stream'
let root: string
beforeEach(async () => {
// Canonical, as the RPC hands it over (macOS tmpdir sits behind /var -> /private/var).
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-archive-test-')))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
async function zipEntries(stream: FolderArchiveZipStream) {
const parts: Buffer[] = []
for (;;) {
const page = await stream.read(1 << 20)
parts.push(page.bytes)
if (page.eof) {
return unzipSync(Buffer.concat(parts))
}
}
}
async function drain(sessions: FolderArchiveSessions, archiveId: string, ownerKey = 'device-a') {
const parts: Buffer[] = []
for (;;) {
const page = await sessions.read({ archiveId, ownerKey, maxBytes: 64 * 1024 })
parts.push(Buffer.from(page.contentBase64, 'base64'))
if (page.eof) {
return { bytes: Buffer.concat(parts), last: page }
}
}
}
describe('FolderArchiveSessions', () => {
it('zips a folder with nested files, empty dirs and the default exclusions applied', async () => {
const project = join(root, 'project')
await mkdir(join(project, 'src', 'empty'), { recursive: true })
await mkdir(join(project, 'node_modules', 'pkg'), { recursive: true })
await mkdir(join(project, '.git'), { recursive: true })
await writeFile(join(project, 'src', 'a.txt'), 'hello')
await writeFile(join(project, 'run.sh'), '#!/bin/sh\n')
await chmod(join(project, 'run.sh'), 0o755)
await writeFile(join(project, 'node_modules', 'pkg', 'index.js'), 'x')
await writeFile(join(project, '.git', 'HEAD'), 'ref')
const sessions = new FolderArchiveSessions()
const started = await sessions.start({
folderPath: project,
ownerKey: 'device-a',
excludeDefaultDirs: true
})
expect(started).toMatchObject({
suggestedName: 'project.zip',
fileCount: 2,
totalBytes: 15,
excludedDirCount: 2
})
const { bytes, last } = await drain(sessions, started.archiveId)
expect(last.processedBytes).toBe(15)
const files = unzipSync(bytes)
expect(Object.keys(files).sort()).toEqual([
'project/',
'project/run.sh',
'project/src/',
'project/src/a.txt',
'project/src/empty/'
])
expect(Buffer.from(files['project/src/a.txt']).toString()).toBe('hello')
expect(sessions.activeCount()).toBe(0)
})
it('includes dependency folders when exclusion is off', async () => {
await mkdir(join(root, 'node_modules'), { recursive: true })
await writeFile(join(root, 'node_modules', 'x.js'), 'x')
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: false })
expect(plan.entries.map((entry) => entry.archivePath)).toContain('r/node_modules/x.js')
})
it('keeps links that stay inside the folder and drops ones that point out of it', async () => {
const outside = join(root, 'outside-secret.txt')
await writeFile(outside, 'secret')
const project = join(root, 'p')
await mkdir(join(project, 'sub'), { recursive: true })
await writeFile(join(project, 'target.txt'), 'in')
await symlink(outside, join(project, 'abs-link'))
await symlink('../outside-secret.txt', join(project, 'rel-escape'))
await symlink(root, join(project, 'dir-link'))
await symlink('../target.txt', join(project, 'sub', 'inner-link'))
const plan = await planFolderArchive(project, 'p', { excludeDefaultDirs: true })
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath)
const files = await zipEntries(stream)
expect(Buffer.from(files['p/sub/inner-link']).toString()).toBe('../target.txt')
for (const dropped of ['p/abs-link', 'p/rel-escape', 'p/dir-link', 'p/dir-link/']) {
expect(files[dropped]).toBeUndefined()
}
expect(Object.keys(files).some((name) => name.includes('outside-secret'))).toBe(false)
expect(stream.skippedCount).toBe(3)
})
it('judges link targets against the archive root', () => {
expect(isContainedLinkTarget('p/a/l', '../b')).toBe(true)
expect(isContainedLinkTarget('p/a/l', '../../x')).toBe(false)
expect(isContainedLinkTarget('p/l', '/etc/passwd')).toBe(false)
expect(isContainedLinkTarget('p/l', 'C:x')).toBe(false)
expect(isContainedLinkTarget('p/a/b/l', 's/../../..')).toBe(false)
expect(isContainedLinkTarget('p/l', '..\\x')).toBe(false)
})
it('skips a file moved out of the folder and linked back in after the walk', async () => {
const project = join(root, 'p')
await mkdir(join(project, 'sub'), { recursive: true })
await writeFile(join(project, 'sub', 'a.txt'), 'moved-out')
const plan = await planFolderArchive(project, 'p', { excludeDefaultDirs: true })
// Same dev/ino as the walk saw, but now outside the root.
await rename(join(project, 'sub'), join(root, 'moved'))
await symlink(join(root, 'moved'), join(project, 'sub'))
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath)
const files = await zipEntries(stream)
expect(files['p/sub/a.txt']).toBeUndefined()
expect(stream.skippedCount).toBe(1)
})
it('never reads past the walked size of a file that grew', async () => {
await writeFile(join(root, 'log.bin'), Buffer.alloc(100 * 1024, 1))
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: true })
await writeFile(join(root, 'log.bin'), Buffer.alloc(900 * 1024, 1), { flag: 'a' })
const files = await zipEntries(new FolderArchiveZipStream(plan.entries, plan.rootRealPath))
expect(files['r/log.bin'].byteLength).toBe(100 * 1024)
})
it('gives case- and Unicode-folded twins distinct names', async () => {
await writeFile(join(root, 'Read.md'), 'a')
await writeFile(join(root, 'read.md'), 'b')
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: true })
const names = plan.entries.map((entry) => entry.archivePath).filter((p) => p.endsWith('.md'))
// macOS temp dirs fold case themselves; then there is only one file to name.
expect(new Set(names.map((name) => name.toLowerCase())).size).toBe(names.length)
})
it('skips a file whose parent directory was swapped for a link after the walk', async () => {
const outside = join(root, 'outside')
await mkdir(outside)
await writeFile(join(outside, 'a.txt'), 'secret')
const project = join(root, 'p')
await mkdir(join(project, 'sub'), { recursive: true })
await writeFile(join(project, 'sub', 'a.txt'), 'inside')
const plan = await planFolderArchive(project, 'p', { excludeDefaultDirs: true })
// Same name, different file: O_NOFOLLOW alone would follow the swapped parent.
await rename(join(project, 'sub'), join(root, 'moved'))
await symlink(outside, join(project, 'sub'))
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath)
const parts: Buffer[] = []
for (;;) {
const page = await stream.read(1 << 20)
parts.push(page.bytes)
if (page.eof) {
break
}
}
const files = unzipSync(Buffer.concat(parts))
expect(files['p/sub/a.txt']).toBeUndefined()
expect(stream.skippedCount).toBe(1)
})
it('rewrites backslashes and control characters so Windows extractors cannot escape', () => {
expect(sanitizeArchiveSegment('..\\..\\evil')).toBe('.._.._evil')
expect(sanitizeArchiveSegment('a\u0001b')).toBe('a_b')
expect(sanitizeArchiveSegment('..')).toBe('__')
expect(sanitizeArchiveSegment('CON')).toBe('_CON')
expect(sanitizeArchiveSegment('aux.txt')).toBe('_aux.txt')
expect(sanitizeArchiveSegment('a:b*c?.txt')).toBe('a_b_c_.txt')
expect(sanitizeArchiveSegment('notes. ')).toBe('notes__')
expect(sanitizeArchiveSegment('normal name.txt')).toBe('normal name.txt')
expect(sanitizeArchiveSegment('invoice\u202Etxt.exe')).toBe('invoicetxt.exe')
expect(sanitizeArchiveSegment('\u200B')).toBe('_')
})
it('refuses folders over the byte or entry limits', async () => {
await writeFile(join(root, 'a'), Buffer.alloc(10))
await writeFile(join(root, 'b'), Buffer.alloc(10))
await expect(
planFolderArchive(root, 'r', { excludeDefaultDirs: true, maxTotalBytes: 15 })
).rejects.toEqual(new FolderArchiveLimitError('archive_too_large'))
await expect(
planFolderArchive(root, 'r', { excludeDefaultDirs: true, maxEntries: 2 })
).rejects.toEqual(new FolderArchiveLimitError('archive_too_many_entries'))
})
it('refuses a folder that was swapped for a link after it was authorized', async () => {
await mkdir(join(root, 'real'))
await symlink(join(root, 'real'), join(root, 'swapped'))
await expect(
planFolderArchive(join(root, 'swapped'), 's', { excludeDefaultDirs: true })
).rejects.toThrow('archive_not_directory')
})
it('refuses to archive a file', async () => {
await writeFile(join(root, 'f'), 'x')
const sessions = new FolderArchiveSessions()
await expect(
sessions.start({ folderPath: join(root, 'f'), ownerKey: 'a', excludeDefaultDirs: true })
).rejects.toThrow('archive_not_directory')
})
it('hides an archive from other devices and frees it on cancel', async () => {
await writeFile(join(root, 'f'), 'x')
const sessions = new FolderArchiveSessions()
const { archiveId } = await sessions.start({
folderPath: root,
ownerKey: 'device-a',
excludeDefaultDirs: true
})
await expect(sessions.read({ archiveId, ownerKey: 'device-b', maxBytes: 10 })).rejects.toThrow(
'archive_not_found'
)
expect(sessions.cancel({ archiveId, ownerKey: 'device-b' })).toBe(false)
expect(sessions.cancel({ archiveId, ownerKey: 'device-a' })).toBe(true)
expect(sessions.activeCount()).toBe(0)
await expect(sessions.read({ archiveId, ownerKey: 'device-a', maxBytes: 10 })).rejects.toThrow(
'archive_not_found'
)
})
it('caps concurrent archives and releases idle ones', async () => {
const sessions = new FolderArchiveSessions(20, 1)
const first = await sessions.start({
folderPath: root,
ownerKey: 'a',
excludeDefaultDirs: true
})
await expect(
sessions.start({ folderPath: root, ownerKey: 'a', excludeDefaultDirs: true })
).rejects.toThrow('archive_busy')
await vi.waitFor(() => expect(sessions.activeCount()).toBe(0))
await expect(
sessions.read({ archiveId: first.archiveId, ownerKey: 'a', maxBytes: 10 })
).rejects.toThrow('archive_not_found')
})
it('limits archives per client so one client cannot hold every slot', async () => {
const sessions = new FolderArchiveSessions(60_000, 8, 2)
const start = (ownerKey: string) =>
sessions.start({ folderPath: root, ownerKey, excludeDefaultDirs: true })
// Concurrent starts reserve their slot before walking.
const results = await Promise.allSettled([start('a'), start('a'), start('a')])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(2)
await expect(start('b')).resolves.toMatchObject({ suggestedName: expect.any(String) })
})
it('pauses the producer at the high-water mark until the reader drains', async () => {
await writeFile(join(root, 'big.png'), Buffer.alloc(4 * 1024 * 1024, 7))
const plan = await planFolderArchive(root, 'r', { excludeDefaultDirs: true })
const stream = new FolderArchiveZipStream(plan.entries, plan.rootRealPath, 512 * 1024)
// The producer fills to the mark, then must stop until something is read.
await vi.waitFor(() => expect(stream.processedBytes).toBeGreaterThanOrEqual(512 * 1024))
expect(stream.processedBytes).toBeLessThanOrEqual(1024 * 1024)
let total = 0
for (;;) {
const page = await stream.read(256 * 1024)
total += page.bytes.byteLength
if (page.eof) {
break
}
}
expect(stream.processedBytes).toBe(4 * 1024 * 1024)
expect(total).toBeGreaterThan(4 * 1024 * 1024)
})
})
@@ -0,0 +1,213 @@
import { randomUUID } from 'node:crypto'
import { realpath } from 'node:fs/promises'
import { basename } from 'node:path'
import type {
FileArchiveReadResult,
FileArchiveStartResult
} from '../../../../shared/rpc-contract/files-archive-params'
import type { RpcContext } from '../../rpc/core'
import { planFolderArchive, sanitizeArchiveSegment } from './folder-archive-walk'
import { FolderArchiveZipStream } from './folder-archive-zip-stream'
// A lost cancel (closed laptop, dropped socket) must not pin host memory or file handles.
export const FOLDER_ARCHIVE_IDLE_TIMEOUT_MS = 2 * 60_000
/** Per client (paired device or connection), so one client can't hold every slot. */
const MAX_ARCHIVES_PER_OWNER = 2
/** Host-wide backstop across all clients. */
const MAX_ACTIVE_ARCHIVES = 8
type ArchiveSession = {
id: string
/** Only the connection that started an archive may read or cancel it. */
ownerKey: string
stream: FolderArchiveZipStream
reading: boolean
idleTimer: ReturnType<typeof setTimeout> | null
}
export class FolderArchiveSessions {
private readonly sessions = new Map<string, ArchiveSession>()
// Slots held by walks still running, so parallel starts can't all walk before the limit applies.
private readonly reservations = new Map<string, number>()
private reservedTotal = 0
constructor(
private readonly idleTimeoutMs = FOLDER_ARCHIVE_IDLE_TIMEOUT_MS,
private readonly maxActive = MAX_ACTIVE_ARCHIVES,
private readonly maxPerOwner = MAX_ARCHIVES_PER_OWNER
) {}
private ownerCount(ownerKey: string): number {
let count = this.reservations.get(ownerKey) ?? 0
for (const session of this.sessions.values()) {
if (session.ownerKey === ownerKey) {
count += 1
}
}
return count
}
private reserve(ownerKey: string): () => void {
if (
this.sessions.size + this.reservedTotal >= this.maxActive ||
this.ownerCount(ownerKey) >= this.maxPerOwner
) {
throw new Error('archive_busy')
}
this.reservations.set(ownerKey, (this.reservations.get(ownerKey) ?? 0) + 1)
this.reservedTotal += 1
let released = false
return () => {
if (released) {
return
}
released = true
this.reservedTotal -= 1
const left = (this.reservations.get(ownerKey) ?? 1) - 1
if (left > 0) {
this.reservations.set(ownerKey, left)
} else {
this.reservations.delete(ownerKey)
}
}
}
/** `folderPath` must already be confined to an allowed workspace root by the caller. */
async start(input: {
folderPath: string
ownerKey: string
excludeDefaultDirs: boolean
signal?: AbortSignal
}): Promise<FileArchiveStartResult> {
const releaseReservation = this.reserve(input.ownerKey)
let rootName: string
let plan: Awaited<ReturnType<typeof planFolderArchive>>
try {
rootName = sanitizeArchiveRootName(basename(await realpath(input.folderPath)))
plan = await planFolderArchive(input.folderPath, rootName, {
excludeDefaultDirs: input.excludeDefaultDirs,
...(input.signal ? { signal: input.signal } : {})
})
} finally {
// The session below takes over the slot synchronously, so no other start can slip in.
releaseReservation()
}
const session: ArchiveSession = {
id: randomUUID(),
ownerKey: input.ownerKey,
stream: new FolderArchiveZipStream(plan.entries, plan.rootRealPath),
reading: false,
idleTimer: null
}
this.sessions.set(session.id, session)
this.armIdleTimer(session)
return {
archiveId: session.id,
suggestedName: `${rootName}.zip`,
fileCount: plan.fileCount,
directoryCount: plan.directoryCount,
totalBytes: plan.totalBytes,
excludedDirCount: plan.excludedDirCount
}
}
async read(input: {
archiveId: string
ownerKey: string
maxBytes: number
}): Promise<FileArchiveReadResult> {
const session = this.requireSession(input.archiveId, input.ownerKey)
if (session.reading) {
throw new Error('archive_busy')
}
session.reading = true
this.clearIdleTimer(session)
try {
const page = await session.stream.read(input.maxBytes)
if (page.eof) {
this.release(session)
}
return {
contentBase64: page.bytes.toString('base64'),
bytesRead: page.bytes.byteLength,
eof: page.eof,
processedBytes: session.stream.processedBytes,
skippedCount: session.stream.skippedCount
}
} catch (error) {
this.release(session)
throw error
} finally {
session.reading = false
if (this.sessions.get(session.id) === session) {
this.armIdleTimer(session)
}
}
}
cancel(input: { archiveId: string; ownerKey: string }): boolean {
const session = this.sessions.get(input.archiveId)
if (!session || session.ownerKey !== input.ownerKey) {
return false
}
this.release(session)
return true
}
activeCount(): number {
return this.sessions.size
}
private requireSession(archiveId: string, ownerKey: string): ArchiveSession {
const session = this.sessions.get(archiveId)
// Another connection's archive looks the same as a missing one.
if (!session || session.ownerKey !== ownerKey) {
throw new Error('archive_not_found')
}
return session
}
private release(session: ArchiveSession): void {
this.clearIdleTimer(session)
if (this.sessions.get(session.id) === session) {
this.sessions.delete(session.id)
}
session.stream.cancel()
}
private armIdleTimer(session: ArchiveSession): void {
this.clearIdleTimer(session)
session.idleTimer = setTimeout(() => this.release(session), this.idleTimeoutMs)
session.idleTimer.unref?.()
}
private clearIdleTimer(session: ArchiveSession): void {
if (session.idleTimer) {
clearTimeout(session.idleTimer)
session.idleTimer = null
}
}
}
function sanitizeArchiveRootName(name: string): string {
// A filesystem root has no basename; zip readers choke on separators or control characters here.
// oxlint-disable-next-line no-control-regex
const cleaned = name.replace(/[\u0000-\u001f/\\]/g, '_').trim()
return cleaned === '' || cleaned === '.' || cleaned === '..'
? 'folder'
: sanitizeArchiveSegment(cleaned)
}
type ArchiveRuntime = RpcContext['runtime']
const sessionsByRuntime = new WeakMap<ArchiveRuntime, FolderArchiveSessions>()
/** One session table per runtime, so archives never outlive the runtime that started them. */
export function getFolderArchiveSessions(runtime: ArchiveRuntime): FolderArchiveSessions {
let sessions = sessionsByRuntime.get(runtime)
if (!sessions) {
sessions = new FolderArchiveSessions()
sessionsByRuntime.set(runtime, sessions)
}
return sessions
}
@@ -0,0 +1,274 @@
import { lstat, readdir, realpath } from 'node:fs/promises'
import { join } from 'node:path'
import {
FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES,
FOLDER_ARCHIVE_MAX_ENTRIES,
FOLDER_ARCHIVE_MAX_TOTAL_BYTES
} from '../../../../shared/rpc-contract/files-archive-params'
export type FolderArchiveEntry = {
/** Path inside the archive, `/`-separated, prefixed by the archived folder's name. */
archivePath: string
absolutePath: string
kind: 'file' | 'directory' | 'symlink'
size: number
mode: number
mtimeMs: number
/** Walk-time identity; the zip stream refuses to read a file whose dev/ino changed since. */
dev: number
ino: number
}
export type FolderArchivePlan = {
/** Canonical path of the archived folder; every read must land under it. */
rootRealPath: string
entries: FolderArchiveEntry[]
fileCount: number
directoryCount: number
totalBytes: number
excludedDirCount: number
}
export class FolderArchiveLimitError extends Error {
constructor(readonly code: 'archive_too_large' | 'archive_too_many_entries') {
super(code)
this.name = 'FolderArchiveLimitError'
}
}
export type FolderArchiveWalkOptions = {
excludeDefaultDirs: boolean
maxTotalBytes?: number
maxEntries?: number
signal?: AbortSignal
}
/**
* Lists a folder for archiving without ever following a symlink, so every entry stays under
* the root's real path. A directory swapped for a link mid-walk fails its realpath check and is skipped.
*/
export async function planFolderArchive(
rootPath: string,
archiveRootName: string,
options: FolderArchiveWalkOptions
): Promise<FolderArchivePlan> {
// The caller hands over the authorized, canonical path. If it no longer resolves to itself (the
// folder was swapped for a link after authorization), refuse rather than walk wherever it points.
const rootRealPath = await realpath(rootPath)
const sameRoot =
process.platform === 'win32'
? rootRealPath.toLowerCase() === rootPath.toLowerCase()
: rootRealPath === rootPath
if (!sameRoot) {
throw new Error('archive_not_directory')
}
const maxTotalBytes = options.maxTotalBytes ?? FOLDER_ARCHIVE_MAX_TOTAL_BYTES
const maxEntries = options.maxEntries ?? FOLDER_ARCHIVE_MAX_ENTRIES
const excluded = new Set<string>(
options.excludeDefaultDirs ? FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES : []
)
const rootStat = await lstat(rootRealPath)
if (!rootStat.isDirectory()) {
throw new Error('archive_not_directory')
}
const plan: FolderArchivePlan = {
rootRealPath,
entries: [
{
archivePath: `${archiveRootName}/`,
absolutePath: rootRealPath,
kind: 'directory',
size: 0,
mode: rootStat.mode,
mtimeMs: rootStat.mtimeMs,
dev: rootStat.dev,
ino: rootStat.ino
}
],
fileCount: 0,
directoryCount: 1,
totalBytes: 0,
excludedDirCount: 0
}
// Depth-first with an explicit stack: deep trees must not grow the JS call stack.
const pending: PendingDirectory[] = [
{
absolutePath: rootRealPath,
archivePath: `${archiveRootName}/`,
dev: rootStat.dev,
ino: rootStat.ino
}
]
while (pending.length > 0) {
if (options.signal?.aborted) {
throw new Error('archive_canceled')
}
const dir = pending.pop()!
if (!(await isUnmovedDirectory(dir))) {
continue
}
let names: string[]
try {
names = await readdir(dir.absolutePath)
} catch (error) {
if (isVanishedOrDenied(error)) {
continue
}
throw error
}
names.sort()
const stats = await Promise.all(
names.map(async (name) => {
try {
return { name, stat: await lstat(join(dir.absolutePath, name)) }
} catch (error) {
if (isVanishedOrDenied(error)) {
return null
}
throw error
}
})
)
// A directory swapped for a link while it was listed would have its children lstat'ed outside
// the root; check it again and drop the whole listing if it moved.
if (!(await isUnmovedDirectory(dir))) {
continue
}
const childDirs: PendingDirectory[] = []
// ZIP extractors on macOS and Windows fold case and Unicode form, so `A` and `a` (or NFC and
// NFD spellings) would land on one path; later twins get a ` (2)` suffix instead.
const usedNames = new Set<string>()
for (const item of stats) {
if (!item) {
continue
}
const { name, stat } = item
const absolutePath = join(dir.absolutePath, name)
const archivePath = `${dir.archivePath}${uniqueArchiveSegment(sanitizeArchiveSegment(name), usedNames)}`
if (stat.isDirectory()) {
if (excluded.has(name)) {
plan.excludedDirCount += 1
continue
}
plan.directoryCount += 1
plan.entries.push({
archivePath: `${archivePath}/`,
absolutePath,
kind: 'directory',
size: 0,
mode: stat.mode,
mtimeMs: stat.mtimeMs,
dev: stat.dev,
ino: stat.ino
})
childDirs.push({
absolutePath,
archivePath: `${archivePath}/`,
dev: stat.dev,
ino: stat.ino
})
} else if (stat.isFile() || stat.isSymbolicLink()) {
const kind = stat.isFile() ? 'file' : 'symlink'
if (kind === 'file') {
plan.fileCount += 1
plan.totalBytes += stat.size
if (plan.totalBytes > maxTotalBytes) {
throw new FolderArchiveLimitError('archive_too_large')
}
}
plan.entries.push({
archivePath,
absolutePath,
kind,
size: kind === 'file' ? stat.size : 0,
mode: stat.mode,
mtimeMs: stat.mtimeMs,
dev: stat.dev,
ino: stat.ino
})
}
// Sockets, FIFOs and devices have no archivable content.
if (plan.entries.length > maxEntries) {
throw new FolderArchiveLimitError('archive_too_many_entries')
}
}
// Reverse so the stack pops children in sorted order.
for (let index = childDirs.length - 1; index >= 0; index -= 1) {
pending.push(childDirs[index])
}
}
return plan
}
/**
* One path segment as stored in the ZIP. Linux allows `\` and control characters in names; Windows
* extractors treat `\` as a separator, so a file named `..\..\x` would escape the extract folder.
*/
export function sanitizeArchiveSegment(name: string): string {
// Bidi overrides and zero-width characters let `exe.txt` display as `txt.exe`; drop them.
const visible = name.replace(INVISIBLE_FORMAT_CHARS, '')
// Windows refuses these characters (`:` can even open an alternate data stream) and drops
// trailing dots and spaces, so map them as local download names are mapped. `.`/`..` become `_`/`__`.
const cleaned = visible
// oxlint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f\\:*?"<>|]/g, '_')
.replace(/[. ]+$/, (tail) => '_'.repeat(tail.length))
if (cleaned === '') {
return '_'
}
return WINDOWS_RESERVED_NAME.test(cleaned) ? `_${cleaned}` : cleaned
}
// Superscript digits written as escapes: the orcad bundle must stay ASCII.
const WINDOWS_RESERVED_NAME =
/^(?:con|prn|aux|nul|conin\$|conout\$|com[0-9\u00B9\u00B2\u00B3]|lpt[0-9\u00B9\u00B2\u00B3])(?:\..*)?$/i
const INVISIBLE_FORMAT_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g
function uniqueArchiveSegment(segment: string, used: Set<string>): string {
const fold = (value: string) => value.normalize('NFC').toLowerCase()
let candidate = segment
const dot = segment.lastIndexOf('.')
const stem = dot > 0 ? segment.slice(0, dot) : segment
const extension = dot > 0 ? segment.slice(dot) : ''
for (let copy = 2; used.has(fold(candidate)); copy += 1) {
candidate = `${stem} (${copy})${extension}`
}
used.add(fold(candidate))
return candidate
}
type PendingDirectory = { absolutePath: string; archivePath: string; dev: number; ino: number }
async function isUnmovedDirectory(dir: PendingDirectory): Promise<boolean> {
try {
const resolved = await realpath(dir.absolutePath)
// Windows paths compare case-insensitively; realpath may report a different casing.
const samePath =
process.platform === 'win32'
? resolved.toLowerCase() === dir.absolutePath.toLowerCase()
: resolved === dir.absolutePath
if (!samePath) {
return false
}
const current = await lstat(dir.absolutePath)
return current.isDirectory() && current.dev === dir.dev && current.ino === dir.ino
} catch (error) {
if (isVanishedOrDenied(error)) {
return false
}
throw error
}
}
export function isVanishedOrDenied(error: unknown): boolean {
const code =
typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined
return (
code === 'ENOENT' ||
code === 'ENOTDIR' ||
code === 'EACCES' ||
code === 'EPERM' ||
code === 'ELOOP'
)
}
@@ -0,0 +1,324 @@
import { readlink, type FileHandle } from 'node:fs/promises'
import { extname, isAbsolute, posix } from 'node:path'
import { Zip, ZipDeflate, ZipPassThrough, type ZipInputFile } from 'fflate'
import { isVanishedOrDenied, type FolderArchiveEntry } from './folder-archive-walk'
import {
ChangedSinceWalkError,
isSmallFile,
openUnchangedFile,
readSmallFile,
type SmallFileRead
} from './folder-archive-file-reads'
const FILE_READ_BYTES = 256 * 1024
/** Small files read ahead at once; bounds look-ahead memory to about 2 MiB. */
const PREFETCH_WINDOW = 32
/** Encoded bytes buffered ahead of the reader before the producer pauses. */
const DEFAULT_HIGH_WATER_BYTES = 8 * 1024 * 1024
// Level 4 keeps pure-JS deflate fast enough not to starve a fast link while still shrinking text.
const DEFLATE_LEVEL = 4
const UNIX_OS = 3
const S_IFLNK = 0o120000
// Recompressing these costs CPU for no size win.
const STORED_EXTENSIONS = new Set([
'.7z',
'.avif',
'.br',
'.bz2',
'.dmg',
'.gif',
'.gz',
'.heic',
'.jar',
'.jpeg',
'.jpg',
'.m4a',
'.mkv',
'.mov',
'.mp3',
'.mp4',
'.ogg',
'.png',
'.rar',
'.tgz',
'.webm',
'.webp',
'.whl',
'.woff',
'.woff2',
'.xz',
'.zip',
'.zst'
])
/**
* Turns a planned entry list into ZIP bytes on demand. The producer runs ahead of the reader by at
* most `highWaterBytes`, so a slow link never makes the host buffer a whole folder.
*/
export class FolderArchiveZipStream {
private readonly chunks: Uint8Array[] = []
// Head index instead of shift(): a folder of small files leaves thousands of tiny chunks queued.
private chunkHead = 0
private bufferedBytes = 0
private finished = false
private failure: Error | null = null
private canceled = false
private wakeReader: (() => void) | null = null
private wakeProducer: (() => void) | null = null
private readonly zip: Zip
processedBytes = 0
skippedCount = 0
constructor(
private readonly entries: readonly FolderArchiveEntry[],
private readonly rootRealPath: string,
private readonly highWaterBytes = DEFAULT_HIGH_WATER_BYTES
) {
this.zip = new Zip((error, data, final) => {
if (error) {
this.fail(error)
return
}
if (data.byteLength > 0) {
this.chunks.push(data)
this.bufferedBytes += data.byteLength
}
if (final) {
this.finished = true
}
this.notifyReader()
})
void this.produce()
}
/** Resolves with up to `maxBytes` once that much is buffered or the archive ends. */
async read(maxBytes: number): Promise<{ bytes: Buffer; eof: boolean }> {
while (!this.failure && !this.finished && this.bufferedBytes < maxBytes) {
await new Promise<void>((resolve) => {
this.wakeReader = resolve
})
}
if (this.failure) {
throw this.failure
}
const parts: Uint8Array[] = []
let taken = 0
while (this.chunkHead < this.chunks.length && taken < maxBytes) {
const head = this.chunks[this.chunkHead]
const room = maxBytes - taken
if (head.byteLength <= room) {
parts.push(head)
taken += head.byteLength
this.chunkHead += 1
} else {
parts.push(head.subarray(0, room))
this.chunks[this.chunkHead] = head.subarray(room)
taken += room
}
}
if (this.chunkHead > 1024 || this.chunkHead === this.chunks.length) {
this.chunks.splice(0, this.chunkHead)
this.chunkHead = 0
}
this.bufferedBytes -= taken
this.notifyProducer()
return {
bytes: Buffer.concat(parts, taken),
eof: this.finished && this.chunkHead >= this.chunks.length
}
}
cancel(): void {
this.canceled = true
this.zip.terminate()
this.chunks.length = 0
this.chunkHead = 0
this.bufferedBytes = 0
this.fail(new Error('archive_canceled'))
this.notifyProducer()
}
private async produce(): Promise<void> {
const prefetched = new Map<number, Promise<SmallFileRead>>()
try {
for (let index = 0; index < this.entries.length; index += 1) {
if (this.canceled) {
return
}
// Keep the next few small files' reads in flight while this entry is zipped.
for (
let ahead = index;
ahead < index + PREFETCH_WINDOW && ahead < this.entries.length;
ahead += 1
) {
const next = this.entries[ahead]
if (!prefetched.has(ahead) && isSmallFile(next)) {
prefetched.set(ahead, readSmallFile(next, this.rootRealPath))
}
}
const small = prefetched.get(index)
prefetched.delete(index)
await this.addEntry(this.entries[index], small ? await small : undefined)
}
this.zip.end()
} catch (error) {
this.fail(error instanceof Error ? error : new Error(String(error)))
}
}
private async addEntry(entry: FolderArchiveEntry, small?: SmallFileRead): Promise<void> {
if (entry.kind === 'directory') {
const file = withAttributes(new ZipPassThrough(entry.archivePath), entry, entry.mode)
this.zip.add(file)
file.push(new Uint8Array(0), true)
return
}
if (entry.kind === 'symlink') {
let target: string
try {
target = await readlink(entry.absolutePath)
} catch (error) {
return this.skip(error)
}
// Stored as a link, never dereferenced. A link that points outside the archived folder would
// let an extractor write through it (zip-slip), so only links that stay inside are kept.
if (!isContainedLinkTarget(entry.archivePath, target)) {
this.skippedCount += 1
return
}
const file = withAttributes(
new ZipPassThrough(entry.archivePath),
entry,
S_IFLNK | (entry.mode & 0o777)
)
this.zip.add(file)
file.push(new TextEncoder().encode(target), true)
return
}
if (small?.kind === 'skip') {
return this.skip(small.error)
}
if (small?.kind === 'bytes') {
await this.waitForRoom()
if (this.canceled) {
return
}
const file = this.addFileEntry(entry)
this.processedBytes += small.bytes.byteLength
file.push(small.bytes, true)
return
}
let handle: FileHandle
try {
handle = await openUnchangedFile(entry, this.rootRealPath)
} catch (error) {
return this.skip(error)
}
try {
const file = this.addFileEntry(entry)
const buffer = Buffer.allocUnsafe(FILE_READ_BYTES)
// Never past the walked size: a file that keeps growing (a log, /dev/zero behind a bind
// mount) cannot push the archive past the limits the walk enforced.
let remaining = entry.size
for (;;) {
await this.waitForRoom()
if (this.canceled) {
return
}
const { bytesRead } =
remaining > 0
? await handle.read(buffer, 0, Math.min(FILE_READ_BYTES, remaining), null)
: { bytesRead: 0 }
if (bytesRead === 0) {
file.push(new Uint8Array(0), true)
return
}
remaining -= bytesRead
this.processedBytes += bytesRead
// fflate may keep a reference until it deflates, so hand it a copy.
file.push(new Uint8Array(buffer.subarray(0, bytesRead)), false)
}
} finally {
await handle.close().catch(() => {})
}
}
private addFileEntry(entry: FolderArchiveEntry): ZipPassThrough | ZipDeflate {
const file = withAttributes(
STORED_EXTENSIONS.has(extname(entry.archivePath).toLowerCase())
? new ZipPassThrough(entry.archivePath)
: new ZipDeflate(entry.archivePath, { level: DEFLATE_LEVEL }),
entry,
entry.mode
)
this.zip.add(file)
return file
}
private skip(error: unknown): void {
if (!(error instanceof ChangedSinceWalkError) && !isVanishedOrDenied(error)) {
throw error
}
this.skippedCount += 1
}
private async waitForRoom(): Promise<void> {
while (!this.canceled && this.bufferedBytes >= this.highWaterBytes) {
await new Promise<void>((resolve) => {
this.wakeProducer = resolve
})
}
}
private fail(error: Error): void {
this.failure ??= error
this.notifyReader()
}
private notifyReader(): void {
const wake = this.wakeReader
this.wakeReader = null
wake?.()
}
private notifyProducer(): void {
const wake = this.wakeProducer
this.wakeProducer = null
wake?.()
}
}
/** True when a link stored at `archivePath` resolves inside the archive's top folder. */
export function isContainedLinkTarget(archivePath: string, target: string): boolean {
if (target === '' || isAbsolute(target) || target.includes('\\') || /^[a-zA-Z]:/.test(target)) {
return false
}
// A `..` after a normal segment may climb through another stored link (`s/../..`), which the
// lexical check below can't see; keep only targets whose `..` steps all come first.
const segments = target.split('/')
const firstNormal = segments.findIndex((segment) => segment !== '..' && segment !== '.')
if (firstNormal !== -1 && segments.slice(firstNormal).includes('..')) {
return false
}
const rootName = archivePath.slice(0, archivePath.indexOf('/'))
const resolved = posix.normalize(posix.join(posix.dirname(archivePath), target))
return resolved === rootName || resolved.startsWith(`${rootName}/`)
}
function withAttributes<T extends ZipInputFile>(
file: T,
entry: FolderArchiveEntry,
mode: number
): T {
file.os = UNIX_OS
file.attrs = (mode & 0xffff) << 16
file.mtime = clampZipMtime(entry.mtimeMs)
return file
}
// DOS timestamps cover 1980-2107; fflate throws outside that range.
function clampZipMtime(mtimeMs: number): number {
const min = Date.UTC(1980, 0, 2)
const max = Date.UTC(2107, 11, 30)
return Math.min(Math.max(mtimeMs, min), max)
}
+10
View File
@@ -198,6 +198,16 @@ export class RuntimeFileReads {
return readLocalFileRange(filePath, offset, length)
}
/** A workspace folder's real path for archiving, under the same containment as chunk reads. */
async resolveFileExplorerArchivePath(
worktreeSelector: string,
relativePath: string
): Promise<string> {
const target = await resolveFileExplorerPath(this.host, worktreeSelector, relativePath)
requireLocalRuntimeFileRoute(target)
return resolveRuntimeAuthorizedPath(target.path, this.host.requireStore())
}
/** A host image a chat names, under desktop's chat-image rule; the path must be on this machine. */
async readChatImagePreview(
worktreeSelector: string,
@@ -0,0 +1,58 @@
import { defineMethod, type RpcContext } from '../../core'
import {
FileArchiveCancel,
FileArchiveRead,
FileArchiveStart
} from '../../../../../shared/rpc-contract/files-archive-params'
import { getFolderArchiveSessions } from '../../../files/archive/folder-archive-sessions'
// Why device identity: one-shot calls from the same desktop may arrive on different sockets.
function archiveOwnerKey(
context: Pick<RpcContext, 'pairedDeviceId' | 'clientId' | 'connectionId'>
) {
return context.pairedDeviceId ?? context.clientId ?? context.connectionId ?? 'in-process'
}
/** Folder downloads: the host zips a workspace folder and the client pages the bytes. */
export const FILE_ARCHIVE_METHODS = [
defineMethod({
name: 'files.archiveStart',
permission: 'workspace',
params: FileArchiveStart,
handler: async (params, context) => {
// Same containment as every files.* read: workspace-relative, then authorized-root checked.
const folderPath = await context.runtime.resolveFileExplorerArchivePath(
params.worktree,
params.relativePath
)
return getFolderArchiveSessions(context.runtime).start({
folderPath,
ownerKey: archiveOwnerKey(context),
excludeDefaultDirs: params.excludeDefaultDirs !== false,
...(context.signal ? { signal: context.signal } : {})
})
}
}),
defineMethod({
name: 'files.archiveRead',
permission: 'workspace',
params: FileArchiveRead,
handler: async (params, context) =>
getFolderArchiveSessions(context.runtime).read({
archiveId: params.archiveId,
ownerKey: archiveOwnerKey(context),
maxBytes: params.maxBytes
})
}),
defineMethod({
name: 'files.archiveCancel',
permission: 'workspace',
params: FileArchiveCancel,
handler: async (params, context) => ({
released: getFolderArchiveSessions(context.runtime).cancel({
archiveId: params.archiveId,
ownerKey: archiveOwnerKey(context)
})
})
})
]
@@ -1,5 +1,6 @@
import { defineMethod, defineStreamingMethod } from '../../core'
import { runFileWatchStream } from './file-watch-stream-lifecycle'
import { FILE_ARCHIVE_METHODS } from './files-archive-methods'
import { FILE_MUTATION_METHODS } from './files-mutation-methods'
import { dirListingPageCache } from './files-read-dir-page'
import { FILE_STAGED_SAVE_METHODS } from './files-staged-save-methods'
@@ -210,6 +211,7 @@ export const FILE_METHODS = [
}),
...FILE_MUTATION_METHODS,
...FILE_STAGED_SAVE_METHODS,
...FILE_ARCHIVE_METHODS,
defineMethod({
name: 'files.search',
permission: 'workspace',
+3
View File
@@ -71,6 +71,7 @@ import type { ShellApi } from './api/shell-api'
import type { SpeechApi } from './api/speech-api'
import type { SshApi } from './api/ssh-api'
import type { PlainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
import type { RuntimeDownloadsApi } from './api/runtime-downloads-bridge'
import type { LegacyRelayBridgeApi } from './api/legacy-relay-bridge-api'
import type { DiagnosticsApi, MemoryApi, StatsApi, TelemetryApi } from './api/telemetry-api'
import type { UiCommandEventApi } from './api/ui-command-event-api'
@@ -180,6 +181,8 @@ export type PreloadApi = {
ssh: SshApi
/** Desktop only: an unmanaged ssh login to a host whose move is blocked. */
plainSsh?: PlainSshTerminalApi
/** Desktop only: streams files and folders from a paired runtime host to this machine. */
runtimeDownloads?: RuntimeDownloadsApi
/** Desktop only: the API keys Orca hands to agent launches. */
agentApiKeys?: AgentApiKeysApi
/** Desktop only: Orca Cloud API keys, org settings and routines (hosted builds). */
+6
View File
@@ -212,6 +212,12 @@ export type BrowserApi = {
) => () => void
/** Escape pressed in a focused page; the page also receives it unless a grab pick is running. */
onGuestEscape: (callback: (args: { browserPageId: string }) => void) => () => void
/** A browser page became (or stopped being) a remote desktop, or gained or lost the keyboard. */
onRemoteDesktopState: (
callback: (args: { browserPageId: string; desktop: boolean; captured: boolean }) => void
) => () => void
/** The release chord was pressed in a remote desktop: give the keyboard back to Orca. */
onRemoteDesktopRelease: (callback: (args: { browserPageId: string }) => void) => () => void
sessionListProfiles: () => Promise<BrowserSessionProfile[]>
onSessionProfilesChanged: (callback: () => void) => () => void
/** Resolves once the SSH workspace's partition is bound and proxy-verified; the webview must wait for it. */
@@ -168,6 +168,22 @@ export const browserPageInteractionAndSessionsApi = {
ipcRenderer.on('browser:guestEscape', listener)
return () => ipcRenderer.removeListener('browser:guestEscape', listener)
},
onRemoteDesktopState: (
callback: (args: { browserPageId: string; desktop: boolean; captured: boolean }) => void
): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
data: { browserPageId: string; desktop: boolean; captured: boolean }
) => callback(data)
ipcRenderer.on('browser:remoteDesktopState', listener)
return () => ipcRenderer.removeListener('browser:remoteDesktopState', listener)
},
onRemoteDesktopRelease: (callback: (args: { browserPageId: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: { browserPageId: string }) =>
callback(data)
ipcRenderer.on('browser:remoteDesktopRelease', listener)
return () => ipcRenderer.removeListener('browser:remoteDesktopRelease', listener)
},
sessionListProfiles: () => ipcRenderer.invoke('browser:session:listProfiles'),
onSessionProfilesChanged: (callback: () => void): (() => void) => {
const listener = (): void => callback()
@@ -0,0 +1,24 @@
import { ipcRenderer } from 'electron'
import type {
RuntimeDownloadProgressEvent,
RuntimeDownloadResult,
RuntimeDownloadStartArgs
} from '../../shared/runtime-downloads/runtime-download-contract'
export type RuntimeDownloadsApi = {
/** Resolves when the download finishes, fails or is canceled. */
start: (args: RuntimeDownloadStartArgs) => Promise<RuntimeDownloadResult>
cancel: (args: { downloadId: string }) => Promise<{ canceled: boolean }>
onProgress: (callback: (event: RuntimeDownloadProgressEvent) => void) => () => void
}
export const runtimeDownloadsApi: RuntimeDownloadsApi = {
start: (args) => ipcRenderer.invoke('runtimeDownloads:start', args),
cancel: (args) => ipcRenderer.invoke('runtimeDownloads:cancel', args),
onProgress: (callback) => {
const listener = (_event: Electron.IpcRendererEvent, payload: RuntimeDownloadProgressEvent) =>
callback(payload)
ipcRenderer.on('runtimeDownloads:progress', listener)
return () => ipcRenderer.removeListener('runtimeDownloads:progress', listener)
}
}
+2
View File
@@ -91,6 +91,7 @@ import { grokAccountsApi } from './api/grok-accounts-bridge'
import { cursorAccountsApi } from './api/cursor-accounts-bridge'
import { sshApi } from './api/ssh-bridge'
import { plainSshTerminalApi } from './api/plain-ssh-terminal-bridge'
import { runtimeDownloadsApi } from './api/runtime-downloads-bridge'
import { agentApiKeysApi } from './api/agent-api-keys-bridge'
import { cloudApiBridge } from './api/cloud-api-bridge'
import { cloudSecretsApi } from './api/cloud-secrets-bridge'
@@ -205,6 +206,7 @@ const api = {
cursorAccounts: cursorAccountsApi,
ssh: sshApi,
plainSsh: plainSshTerminalApi,
runtimeDownloads: runtimeDownloadsApi,
agentApiKeys: agentApiKeysApi,
cloudApi: cloudApiBridge,
cloudSecrets: cloudSecretsApi,
@@ -42,29 +42,25 @@ export function TeamPlanCard({
const discounted =
interval === 'year' && monthly !== null && fullMonthly !== null && monthly < fullMonthly
// Same bullets as the marketing site's /cloud pricing card; the overage line is in-app only.
const features = [
t(
'auto.components.billing.plans.featureHours4',
'{{value0}} sandbox hours per seat each month, pooled across your team',
{ value0: String(team.includedHoursPerSeat) }
),
t('auto.components.billing.plans.featureStandard', 'Standard (4 vCPU · 8 GB)'),
t(
'auto.components.billing.plans.featureLarge3',
'Standard (4 vCPU · 8 GB) sandboxes; Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate',
{
value0: String(team.largeMachineWeight)
}
'auto.components.billing.plans.featureLarge4',
'Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate',
{ value0: String(team.largeMachineWeight) }
),
t(
'auto.components.billing.plans.featureConcurrency2',
'{{value0}} sandboxes running at once per seat',
{
value0: String(team.concurrentSandboxesPerSeat)
}
'auto.components.billing.plans.featureSleep3',
'Sleeps after {{value0}} minutes idle, so quiet time is free',
{ value0: String(team.idleSleepMinutes) }
),
t('auto.components.billing.plans.featureSleep2', 'Sleeps after {{value0}} minutes idle', {
value0: String(team.idleSleepMinutes)
}),
t('auto.components.billing.plans.featureReady', 'Ready in seconds, nothing to set up or host'),
t(
'auto.components.billing.plans.featureOverage3',
'Optional overage at {{value0}}/hour ({{value1}}/hour on Large), capped at a monthly spend cap you set ({{value2}} by default)',
@@ -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 />
</>
@@ -1,6 +1,8 @@
// Copy for control-plane error codes from the team workspace registry.
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import { getLastBillingStatus } from '@/components/billing/billing-summary-store'
import { sandboxLimitFromBilling } from '@/components/worktree-creation/vm-build-failure-copy'
export function describeError(error: string, reason?: string, t: Translate = translate): string {
if (error === 'not_found') {
@@ -10,10 +12,23 @@ export function describeError(error: string, reason?: string, t: Translate = tra
)
}
if (error === 'entitlement_denied') {
return reason === 'concurrency_limit'
if (reason === 'concurrency_limit') {
const limit = sandboxLimitFromBilling(getLastBillingStatus())
return limit
? t(
'auto.components.cloudWorkspaces.errorConcurrencyLimitCount2',
'Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again.',
{ value0: String(limit.team), value1: String(limit.perSeat) }
)
: t(
'auto.components.cloudWorkspaces.errorConcurrencyLimit',
"You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again."
)
}
return reason === 'member_concurrency_limit'
? t(
'auto.components.cloudWorkspaces.errorConcurrencyLimit',
"You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again."
'auto.components.cloudWorkspaces.errorMemberConcurrencyLimit',
"You're at your limit of running sandboxes. Put one to sleep or delete it, then try again."
)
: t(
'auto.components.cloudWorkspaces.errorEntitlementDenied',
@@ -0,0 +1,252 @@
import { Copy, Download, File, MonitorDown, Pencil, Search, SquareTerminal } from 'lucide-react'
import { FixtureCodeBackdrop } from '../../../../component-fixtures/FixtureCodeBackdrop'
import { defineFixture } from '../../../../component-fixtures/component-fixture'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '../../../ui/dropdown-menu'
import { RuntimeDownloadToast, type RuntimeDownloadToastState } from './runtime-download-toast'
const noop = (): void => {}
const MB = 1024 * 1024
function toastFixture(state: RuntimeDownloadToastState) {
return defineFixture({
width: 380,
render: () => (
<div className="bg-background p-3">
<RuntimeDownloadToast
state={state}
revealLabel="Reveal in Finder"
onAction={noop}
onDismiss={noop}
/>
</div>
)
})
}
// Mirrors the file explorer row menu around the new entries (a dropdown, since a context menu
// cannot be held open in a fixture).
function menuFixture(kind: 'file' | 'folder') {
return defineFixture({
width: 300,
render: () => (
<div className="relative h-[250px] overflow-hidden bg-background">
<FixtureCodeBackdrop />
<DropdownMenu open modal={false}>
<DropdownMenuTrigger className="absolute top-2 left-2 size-px" />
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuItem>
<SquareTerminal />
Open in Terminal
</DropdownMenuItem>
{kind === 'file' ? (
<DropdownMenuItem>
<File />
View File
</DropdownMenuItem>
) : null}
<DropdownMenuItem>
<Download />
{kind === 'file' ? 'Download…' : 'Download as ZIP…'}
</DropdownMenuItem>
{kind === 'file' ? (
<DropdownMenuItem>
<MonitorDown />
Open on This Computer
</DropdownMenuItem>
) : (
<DropdownMenuItem>
<Search />
Find in Folder
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem>
<Copy />
Copy Path
</DropdownMenuItem>
<DropdownMenuItem>
<Pencil />
Rename
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
})
}
export const menuFile = menuFixture('file')
export const menuFolder = menuFixture('folder')
export const fileProgress = toastFixture({
kind: 'progress',
intent: 'save',
itemKind: 'file',
name: 'random-200mb.bin',
snapshot: {
downloadId: 'f',
phase: 'transferring',
receivedBytes: 87 * MB,
totalBytes: 200 * MB,
processedBytes: 87 * MB,
bytesPerSecond: 11.5 * MB,
rateWindowMs: 7_500
}
})
export const folderCollecting = toastFixture({
kind: 'progress',
intent: 'save',
itemKind: 'folder',
name: 'smallfiles.zip',
snapshot: null
})
export const folderProgress = toastFixture({
kind: 'progress',
intent: 'save',
itemKind: 'folder',
name: 'smallfiles.zip',
snapshot: {
downloadId: 'd',
phase: 'transferring',
receivedBytes: 1.1 * MB,
totalBytes: 2.4 * MB,
processedBytes: 1.6 * MB,
fileCount: 5000,
bytesPerSecond: 1.2 * MB,
rateWindowMs: 1_200
}
})
export const openProgress = toastFixture({
kind: 'progress',
intent: 'open',
itemKind: 'file',
name: 'build-output.zip',
snapshot: {
downloadId: 'o',
phase: 'transferring',
receivedBytes: 0.6 * MB,
totalBytes: 1.9 * MB,
processedBytes: 0.6 * MB,
bytesPerSecond: 5.4 * MB,
rateWindowMs: 400
}
})
export const fileDone = toastFixture({
kind: 'done',
itemKind: 'file',
name: 'random-200mb.bin',
bytes: 200 * MB,
elapsedMs: 17_400
})
export const folderDone = toastFixture({
kind: 'done',
itemKind: 'folder',
name: 'smallfiles.zip',
bytes: 0.86 * MB,
elapsedMs: 2_900,
fileCount: 5000,
excludedDefaultDirs: true
})
export const folderDoneSkipped = toastFixture({
kind: 'done',
itemKind: 'folder',
name: 'logs.zip',
bytes: 3.2 * MB,
elapsedMs: 1_800,
fileCount: 1,
skippedCount: 2
})
export const errorDisconnected = toastFixture({
kind: 'failed',
intent: 'save',
itemKind: 'file',
name: 'random-200mb.bin',
code: 'disconnected',
place: 'sandbox'
})
export const errorTooLarge = toastFixture({
kind: 'failed',
intent: 'save',
itemKind: 'folder',
name: 'datasets',
code: 'too_large',
place: 'sandbox'
})
export const errorTooLargeToOpen = toastFixture({
kind: 'failed',
intent: 'open',
itemKind: 'file',
name: 'model.safetensors',
code: 'too_large',
place: 'sandbox'
})
export const errorOldSandbox = toastFixture({
kind: 'failed',
intent: 'save',
itemKind: 'folder',
name: 'src',
code: 'host_update_required',
place: 'sandbox'
})
export const errorNotFound = toastFixture({
kind: 'failed',
intent: 'save',
itemKind: 'file',
name: 'report.pdf',
code: 'not_found',
place: 'sandbox'
})
export const errorNoApp = toastFixture({ kind: 'no_app', name: 'trace.perfetto' })
export const errorOpenRefused = toastFixture({
kind: 'failed',
intent: 'open',
itemKind: 'file',
name: 'install.sh',
code: 'open_refused',
place: 'sandbox'
})
export const errorFileGrew = toastFixture({
kind: 'failed',
intent: 'save',
itemKind: 'file',
name: 'server.log',
code: 'too_large',
place: 'sandbox'
})
export const errorTooManyItems = toastFixture({
kind: 'failed',
intent: 'save',
itemKind: 'folder',
name: 'node_cache',
code: 'too_many_entries',
place: 'sandbox'
})
const LONG_NAME = 'a-very-long-generated-artifact-name-2026-11-09-build-output.tar.gz'
export const longNameProgress = toastFixture({
kind: 'progress',
intent: 'save',
itemKind: 'file',
name: LONG_NAME,
snapshot: {
downloadId: 'l',
phase: 'transferring',
receivedBytes: 412 * MB,
totalBytes: 980 * MB,
processedBytes: 412 * MB,
bytesPerSecond: 12.1 * MB,
rateWindowMs: 30_000
}
})
export const longNameDone = toastFixture({
kind: 'done',
itemKind: 'file',
name: LONG_NAME,
bytes: 980 * MB,
elapsedMs: 83_000
})
@@ -1,5 +1,6 @@
import type { RuntimeFileOperationArgs } from '@/runtime/files/runtime-file-client-types'
import type { TreeNode } from '../tree/file-explorer-types'
import { canOpenRuntimeDownloadLocally } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
export function shouldShowCollapseFolderAction(node: TreeNode, isExpanded: boolean): boolean {
return node.isDirectory && isExpanded
@@ -22,11 +23,13 @@ export function shouldShowRemoteDownloadAction(
connectionId?: string | null,
runtimeDownloadContext?: RuntimeFileOperationArgs | null,
// Require advertised SFTP recursive transfer; unknown and system-SSH folder downloads stay hidden.
supportsFolderDownload = false
supportsFolderDownload = false,
// Desktop main can stream from the runtime host directly, including host-zipped folders.
canStreamFromRuntime = false
): boolean {
// Native save dialogs require desktop; runtime/system-SSH folders lack recursive transfer contracts.
// Native save dialogs require desktop; system-SSH folders lack a recursive transfer contract.
const hasDownloadCapability = node.isDirectory
? Boolean(connectionId && supportsFolderDownload)
? Boolean(connectionId ? supportsFolderDownload : canStreamFromRuntime)
: Boolean(connectionId || runtimeDownloadContext)
return (
hasDownloadCapability &&
@@ -34,6 +37,23 @@ export function shouldShowRemoteDownloadAction(
)
}
/**
* Fetch a runtime-hosted file into a temp copy and open it with the local default app. Hidden for
* files the OS would run (scripts, installers, apps): those can only be downloaded.
*/
export function shouldShowOpenLocallyAction(
node: TreeNode,
connectionId: string | null | undefined,
canStreamFromRuntime: boolean
): boolean {
return (
!node.isDirectory &&
!connectionId &&
canStreamFromRuntime &&
canOpenRuntimeDownloadLocally(node.name)
)
}
export function shouldShowCopyFileAction(
node: TreeNode,
connectionId?: string | null,
@@ -11,6 +11,7 @@ import {
FolderPlus,
Globe,
ListCollapse,
MonitorDown,
Pencil,
Search,
SquareTerminal,
@@ -43,11 +44,13 @@ import {
shouldShowCopyFileAction,
shouldShowFindInFolderAction,
shouldShowOpenInTerminalAction,
shouldShowOpenLocallyAction,
shouldShowRemoteDownloadAction,
shouldShowViewFileAction
} from './file-explorer-row-action-visibility'
import { copyFileToOsClipboard, downloadRemoteFile } from './file-explorer-row-file-transfer'
import { fileExplorerNodeTabRelativePath } from '../tree/file-explorer-directory-listing'
import { canStreamRuntimeDownload, startRuntimeDownload } from './runtime-download-actions'
const isMac = navigator.userAgent.includes('Mac')
@@ -107,11 +110,19 @@ export function FileExplorerRowContextMenu({
const copyPathShortcutLabel = useShortcutLabel('fileExplorer.copyPath')
const copyRelativePathShortcutLabel = useShortcutLabel('fileExplorer.copyRelativePath')
const findInFolderShortcutLabel = useShortcutLabel('sidebar.search.toggle')
const canStreamFromRuntime =
!connectionId && canStreamRuntimeDownload(node, runtimeDownloadContext)
const showRemoteDownloadAction = shouldShowRemoteDownloadAction(
node,
connectionId,
runtimeDownloadContext,
supportsFolderDownload
supportsFolderDownload,
canStreamFromRuntime
)
const showOpenLocallyAction = shouldShowOpenLocallyAction(
node,
connectionId,
canStreamFromRuntime
)
const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize)
const revealOwner = useAppStore((s) =>
@@ -133,12 +144,21 @@ export function FileExplorerRowContextMenu({
}
}, [activeWorktreeId, node.path])
const handleDownload = useCallback(() => {
if (canStreamFromRuntime && runtimeDownloadContext) {
void startRuntimeDownload(node, runtimeDownloadContext, 'save')
return
}
const downloadTarget = connectionId || runtimeDownloadContext
if (!downloadTarget) {
return
}
void downloadRemoteFile(node, downloadTarget)
}, [connectionId, node, runtimeDownloadContext])
}, [canStreamFromRuntime, connectionId, node, runtimeDownloadContext])
const handleOpenLocally = useCallback(() => {
if (runtimeDownloadContext) {
void startRuntimeDownload(node, runtimeDownloadContext, 'open')
}
}, [node, runtimeDownloadContext])
const handleCopyFile = useCallback(() => {
void copyFileToOsClipboard(node, connectionId)
}, [connectionId, node])
@@ -237,8 +257,22 @@ export function FileExplorerRowContextMenu({
<ContextMenuItem onSelect={handleDownload}>
<Download />
{node.isDirectory
? t('auto.components.right.sidebar.FileExplorerRow.7ac885bd2f', 'Download Folder')
: t('auto.components.right.sidebar.FileExplorerRow.c2112579f6', 'Download')}
? canStreamFromRuntime
? t(
'auto.components.right.sidebar.FileExplorerRow.downloadFolderZip',
'Download as ZIP…'
)
: t('auto.components.right.sidebar.FileExplorerRow.7ac885bd2f', 'Download Folder')
: canStreamFromRuntime
? // Opens a save dialog, like "Download as ZIP…".
t('auto.components.right.sidebar.FileExplorerRow.downloadFileEllipsis', 'Download…')
: t('auto.components.right.sidebar.FileExplorerRow.c2112579f6', 'Download')}
</ContextMenuItem>
)}
{showOpenLocallyAction && (
<ContextMenuItem onSelect={handleOpenLocally}>
<MonitorDown />
{t('auto.components.right.sidebar.FileExplorerRow.openLocally', 'Open on This Computer')}
</ContextMenuItem>
)}
{canCollapseFolderSubtree && shouldShowCollapseFolderAction(node, isExpanded) && (
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeDownloadProgressEvent } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
import type { TreeNode } from '../tree/file-explorer-types'
import {
applyRuntimeDownloadProgress,
getRuntimeDownloadProgress,
subscribeRuntimeDownloadProgress,
trackRuntimeDownload
} from '@/runtime/files/downloads/runtime-download-progress-store'
import {
shouldShowOpenLocallyAction,
shouldShowRemoteDownloadAction
} from './file-explorer-row-action-visibility'
vi.mock('@/lib/cloud-remote-desktop/cloud-environment-ids', () => ({
isKnownCloudEnvironment: (id: string) => id === 'cloud-env',
refreshCloudEnvironmentIds: async () => {}
}))
const { outcomeState } = await import('./runtime-download-actions')
function node(name: string, isDirectory = false): TreeNode {
return { name, path: `/w/${name}`, relativePath: name, isDirectory, depth: 0 }
}
const base = { itemKind: 'file' as const, environmentId: 'cloud-env', elapsedMs: 1_000 }
describe('outcomeState', () => {
it('leaves nothing behind for a canceled download or a file that opened', () => {
expect(outcomeState({ status: 'canceled' }, { ...base, node: node('a'), intent: 'save' })).toBe(
null
)
expect(
outcomeState(
{ status: 'done', destinationPath: '/tmp/a', bytes: 1 },
{ ...base, node: node('a'), intent: 'open' }
)
).toBe(null)
})
it('reports a saved copy no app could open as a soft no-app state', () => {
expect(
outcomeState(
{ status: 'done', destinationPath: '/tmp/a.xyz', bytes: 1, openError: 'no app' },
{ ...base, node: node('a.xyz'), intent: 'open' }
)
).toEqual({ kind: 'no_app', name: 'a.xyz' })
})
it('names the sandbox only for cloud workspaces', () => {
const error = { status: 'error', code: 'not_found', message: 'x' } as const
expect(outcomeState(error, { ...base, node: node('a'), intent: 'save' })).toMatchObject({
kind: 'failed',
place: 'sandbox'
})
expect(
outcomeState(error, { ...base, environmentId: 'ssh-box', node: node('a'), intent: 'save' })
).toMatchObject({ kind: 'failed', place: 'host' })
})
it('never offers Open on the done card for files the OS would run', () => {
vi.stubGlobal('api', { platform: { get: () => ({ platform: 'darwin' }) } })
const done = { status: 'done', destinationPath: '/d/x', bytes: 3 } as const
expect(outcomeState(done, { ...base, node: node('setup.sh'), intent: 'save' })).toMatchObject({
kind: 'done',
canOpen: false
})
expect(outcomeState(done, { ...base, node: node('report.pdf'), intent: 'save' })).toMatchObject(
{ kind: 'done', canOpen: true }
)
})
})
describe('row action visibility', () => {
it('offers folder downloads on runtime hosts without an SSH connection', () => {
expect(shouldShowRemoteDownloadAction(node('src', true), null, null, false, true)).toBe(true)
expect(shouldShowRemoteDownloadAction(node('src', true), null, null, false, false)).toBe(false)
})
it('hides Open on This Computer for folders, SSH and runnable files', () => {
expect(shouldShowOpenLocallyAction(node('a.pdf'), null, true)).toBe(true)
expect(shouldShowOpenLocallyAction(node('src', true), null, true)).toBe(false)
expect(shouldShowOpenLocallyAction(node('a.pdf'), 'ssh-1', true)).toBe(false)
for (const name of ['run.sh', 'Setup.EXE', 'x.command', 'evil.bat. ']) {
expect(shouldShowOpenLocallyAction(node(name), null, true)).toBe(false)
}
})
})
describe('runtime download progress store', () => {
const unsubscribe = vi.fn()
afterEach(() => {
unsubscribe.mockReset()
})
function event(receivedBytes: number): RuntimeDownloadProgressEvent {
return {
downloadId: 'd1',
phase: 'transferring',
receivedBytes,
totalBytes: 100,
processedBytes: receivedBytes
}
}
it('smooths the rate, tracks its window and drops the IPC listener on release', () => {
const onProgress = vi.fn(() => unsubscribe)
vi.stubGlobal('api', { runtimeDownloads: { onProgress } })
const release = trackRuntimeDownload('d1')
expect(onProgress).toHaveBeenCalledOnce()
applyRuntimeDownloadProgress(event(0), 0)
applyRuntimeDownloadProgress(event(10), 1_000)
applyRuntimeDownloadProgress(event(30), 2_000)
const snapshot = getRuntimeDownloadProgress('d1')
// 10 B/s, then 20 B/s smoothed by 0.3.
expect(snapshot?.bytesPerSecond).toBeCloseTo(13)
expect(snapshot?.rateWindowMs).toBe(2_000)
release()
expect(unsubscribe).toHaveBeenCalledOnce()
expect(getRuntimeDownloadProgress('d1')).toBe(null)
})
it('does not bring back a released download when a late toast subscribes', () => {
const onProgress = vi.fn(() => unsubscribe)
vi.stubGlobal('api', { runtimeDownloads: { onProgress } })
const release = trackRuntimeDownload('d2')
release()
subscribeRuntimeDownloadProgress('d2', () => {})()
// A new download must start a fresh IPC listener: the old entry did not linger.
trackRuntimeDownload('d3')()
expect(onProgress).toHaveBeenCalledTimes(2)
expect(unsubscribe).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,259 @@
import { useCallback, useSyncExternalStore } from 'react'
import { toast } from 'sonner'
import { createNonSecureContextUuid } from '../../../../../../shared/non-secure-context-uuid'
import { getRevealInFileManagerLabel, revealInFileManager } from '@/lib/reveal-in-file-manager'
import {
isKnownCloudEnvironment,
refreshCloudEnvironmentIds
} from '@/lib/cloud-remote-desktop/cloud-environment-ids'
import { getRemoteFileArgs } from '@/runtime/files/runtime-file-routing'
import type { RuntimeFileOperationArgs } from '@/runtime/files/runtime-file-client-types'
import { captureRuntimeEnvironmentRequestRevision } from '@/runtime/rpc/environment-revisions/runtime-environment-revision'
import {
getRuntimeDownloadProgress,
subscribeRuntimeDownloadProgress,
trackRuntimeDownload
} from '@/runtime/files/downloads/runtime-download-progress-store'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
import {
canOpenRuntimeDownloadLocally,
type RuntimeDownloadResult
} from '../../../../../../shared/runtime-downloads/runtime-download-contract'
import { requestFileExplorerRefresh } from '../watching/file-explorer-refresh-requests'
import type { TreeNode } from '../tree/file-explorer-types'
import {
RuntimeDownloadToast,
type RuntimeDownloadToastAction,
type RuntimeDownloadToastIntent,
type RuntimeDownloadToastState
} from './runtime-download-toast'
export type RuntimeDownloadIntent = RuntimeDownloadToastIntent
// Long enough to reach "Reveal in Finder" after a long download, short enough not to pile up.
// Errors stay until dismissed: their one next action must not time out.
const DONE_TOAST_MS = 12_000
// One transfer per path and intent: "Open on This Computer" has no dialog, so a double click or a
// retry while the first copy still runs would otherwise fetch and open it twice.
const inFlight = new Set<string>()
/** Whether this desktop can stream the node straight from its runtime host. */
export function canStreamRuntimeDownload(
node: TreeNode,
context: RuntimeFileOperationArgs | null | undefined
): context is RuntimeFileOperationArgs {
return Boolean(
context && window.api.runtimeDownloads && getRemoteFileArgs(context, node.path) !== null
)
}
function localName(destinationPath: string): string {
const separator = window.api.platform.get().platform === 'win32' ? /[\\/]/ : /\//
return destinationPath.split(separator).pop() || destinationPath
}
/** Progress toast body bound to the live progress store. */
function LiveProgressToast({
downloadId,
state,
onAction
}: {
downloadId: string
state: Omit<Extract<RuntimeDownloadToastState, { kind: 'progress' }>, 'snapshot'>
onAction: (action: RuntimeDownloadToastAction) => void
}): React.JSX.Element {
// Stable identities: an inline subscribe would resubscribe on every progress tick.
const subscribe = useCallback(
(listener: () => void) => subscribeRuntimeDownloadProgress(downloadId, listener),
[downloadId]
)
const getSnapshot = useCallback(() => getRuntimeDownloadProgress(downloadId), [downloadId])
const snapshot = useSyncExternalStore(subscribe, getSnapshot)
return (
<RuntimeDownloadToast
state={{ ...state, snapshot }}
revealLabel=""
onAction={onAction}
onDismiss={() => {}}
/>
)
}
export async function startRuntimeDownload(
node: TreeNode,
context: RuntimeFileOperationArgs,
intent: RuntimeDownloadIntent,
options: { excludeDefaultDirs?: boolean } = {}
): Promise<void> {
const api = window.api.runtimeDownloads
const remote = getRemoteFileArgs(context, node.path)
if (!api || !remote) {
return
}
const environmentId = remote.target.environmentId
const flightKey = `${environmentId}\u0000${remote.worktreeSelector}\u0000${remote.relativePath}\u0000${intent}`
if (inFlight.has(flightKey)) {
return
}
inFlight.add(flightKey)
try {
await runRuntimeDownload(node, context, intent, options, remote)
} finally {
inFlight.delete(flightKey)
}
}
async function runRuntimeDownload(
node: TreeNode,
context: RuntimeFileOperationArgs,
intent: RuntimeDownloadIntent,
options: { excludeDefaultDirs?: boolean },
remote: NonNullable<ReturnType<typeof getRemoteFileArgs>>
): Promise<void> {
const api = window.api.runtimeDownloads
if (!api) {
return
}
const environmentId = remote.target.environmentId
// Error copy says "sandbox" for cloud workspaces; the list is usually primed already.
void refreshCloudEnvironmentIds()
const itemKind = node.isDirectory ? 'folder' : 'file'
const downloadId = createNonSecureContextUuid()
const toastId = `runtime-download:${downloadId}`
const release = trackRuntimeDownload(downloadId)
const excludeDefaultDirs = options.excludeDefaultDirs !== false
// Timed from the first progress event so the save dialog doesn't count.
let startedAt = performance.now()
const pending = api.start({
downloadId,
environmentId,
worktree: remote.worktreeSelector,
relativePath: remote.relativePath,
name: node.name,
kind: itemKind,
intent,
excludeDefaultDirs,
expectedEnvironmentPairingRevision: captureRuntimeEnvironmentRequestRevision(environmentId)
})
const progressName = itemKind === 'folder' ? `${node.name}.zip` : node.name
// The save dialog comes first; show progress only after the user has picked a place.
const showProgress = (): void => {
toast.custom(
() => (
<LiveProgressToast
downloadId={downloadId}
state={{ kind: 'progress', intent, itemKind, name: progressName }}
onAction={() => void api.cancel({ downloadId })}
/>
),
{ id: toastId, duration: Number.POSITIVE_INFINITY, dismissible: false }
)
}
// `preparing` is always the first event and is sent once the save dialog has closed, so the
// elapsed time never includes the dialog, even for a file too small to send any progress.
const stopProgressWatch = api.onProgress((event) => {
if (event.downloadId === downloadId) {
stopProgressWatch()
startedAt = performance.now()
showProgress()
}
})
let result: RuntimeDownloadResult
try {
result = await pending
} catch (error) {
result = {
status: 'error',
code: 'transfer_failed',
message: error instanceof Error ? error.message : String(error)
}
} finally {
stopProgressWatch()
release()
}
const elapsedMs = performance.now() - startedAt
const outcome = outcomeState(result, { node, itemKind, intent, environmentId, elapsedMs })
if (!outcome) {
toast.dismiss(toastId)
return
}
if (result.status === 'error') {
// Host details stay out of the toast (plain words only) but remain debuggable.
console.warn('[runtime-download] failed', result.code, result.message)
}
if (result.status === 'done' && result.openError) {
console.warn('[runtime-download] open failed', result.openError)
}
const destinationPath = result.status === 'done' ? result.destinationPath : null
const onAction = (action: RuntimeDownloadToastAction): void => {
toast.dismiss(toastId)
if ((action === 'reveal' || action === 'open') && destinationPath) {
void (action === 'reveal'
? revealInFileManager(destinationPath, LOCAL_EXECUTION_HOST_ID)
: window.api.shell.openPath(destinationPath, LOCAL_EXECUTION_HOST_ID))
} else if (action === 'retry') {
void startRuntimeDownload(node, context, intent, options)
} else if (action === 'download_instead') {
void startRuntimeDownload(node, context, 'save', options)
} else if (action === 'refresh') {
requestFileExplorerRefresh()
}
}
toast.custom(
() => (
<RuntimeDownloadToast
state={outcome}
revealLabel={getRevealInFileManagerLabel()}
onAction={onAction}
onDismiss={() => toast.dismiss(toastId)}
/>
),
{
id: toastId,
duration: outcome.kind === 'done' ? DONE_TOAST_MS : Number.POSITIVE_INFINITY,
dismissible: true
}
)
}
/** The toast to leave behind, or null when it should just go away (canceled, opened fine). */
export function outcomeState(
result: RuntimeDownloadResult,
input: {
node: Pick<TreeNode, 'name'>
itemKind: 'file' | 'folder'
intent: RuntimeDownloadIntent
environmentId: string
elapsedMs: number
}
): RuntimeDownloadToastState | null {
const { node, itemKind, intent } = input
if (result.status === 'canceled') {
return null
}
if (result.status === 'error') {
return {
kind: 'failed',
intent,
itemKind,
name: node.name,
code: result.code,
place: isKnownCloudEnvironment(input.environmentId) ? 'sandbox' : 'host'
}
}
if (intent === 'open') {
return result.openError ? { kind: 'no_app', name: node.name } : null
}
return {
kind: 'done',
itemKind,
name: localName(result.destinationPath),
bytes: result.bytes,
elapsedMs: input.elapsedMs,
...(result.fileCount !== undefined ? { fileCount: result.fileCount } : {}),
excludedDefaultDirs: (result.excludedDirCount ?? 0) > 0,
canOpen: canOpenRuntimeDownloadLocally(node.name),
...(result.skippedCount ? { skippedCount: result.skippedCount } : {})
}
}
@@ -0,0 +1,183 @@
import type { Translate } from '@/i18n/use-translate'
import type { RuntimeDownloadErrorCode } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
export type RuntimeDownloadFailureAction = 'retry' | 'download_instead' | 'refresh' | 'reveal'
export type RuntimeDownloadFailureCopy = {
title: string
detail: string
/** 'neutral' for limits and deliberate refusals; 'error' when something actually broke. */
tone: 'error' | 'neutral'
action: RuntimeDownloadFailureAction | null
}
/** Cloud workspaces live in a sandbox; other runtime hosts are named generically. */
export type RuntimeDownloadPlace = 'sandbox' | 'host'
/** Glossary error tone: what happened, whether anything was lost, at most one next action. */
export function runtimeDownloadFailureCopy(
t: Translate,
state: {
intent: 'save' | 'open'
itemKind: 'file' | 'folder'
name: string
code: RuntimeDownloadErrorCode
place: RuntimeDownloadPlace
}
): RuntimeDownloadFailureCopy {
const { name, place } = state
const title =
state.intent === 'open'
? t('components.runtimeDownload.openFailedTitle', 'Couldn’t open {{name}}', { name })
: t('components.runtimeDownload.failedTitle', 'Couldn’t download {{name}}', { name })
const transferFailed: RuntimeDownloadFailureCopy = {
title,
detail: t(
'components.runtimeDownload.transferFailed',
'Something went wrong while copying it. Nothing was saved on this computer.'
),
tone: 'error',
action: 'retry'
}
switch (state.code) {
case 'too_large':
if (state.itemKind === 'folder') {
return {
title,
detail: t(
'components.runtimeDownload.folderTooLarge',
'This folder is over 2 GB, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.'
),
tone: 'neutral',
action: null
}
}
return state.intent === 'open'
? {
title,
detail: t(
'components.runtimeDownload.tooLargeToOpen',
'Files over 1 GB can’t be opened directly. Download it instead.'
),
tone: 'neutral',
action: 'download_instead'
}
: {
title,
detail: t(
'components.runtimeDownload.fileGrew',
'The file grew while Orca was copying it. Nothing was saved on this computer.'
),
tone: 'error',
action: 'retry'
}
case 'too_many_entries':
return {
title,
detail: t(
'components.runtimeDownload.folderTooManyItems',
'This folder has more than 65,000 items, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.'
),
tone: 'neutral',
action: null
}
case 'open_refused':
return {
title,
detail: t(
'components.runtimeDownload.openRefused',
'Orca doesn’t open files that can run programs, like scripts, installers and apps. Download it to check it first. Nothing was saved on this computer.'
),
tone: 'neutral',
action: 'download_instead'
}
case 'host_update_required':
return {
title,
detail:
place === 'sandbox'
? t(
'components.runtimeDownload.sandboxUpdateRequired',
'This sandbox runs an older version of Orca. New cloud workspaces include downloads. Your files are safe.'
)
: t(
'components.runtimeDownload.hostUpdateRequired',
'The remote host runs an older version of Orca. Update Orca there, then try again.'
),
tone: 'error',
action: null
}
case 'host_busy':
return {
title,
detail: t(
'components.runtimeDownload.busy',
'Too many downloads are running. Wait for one to finish, then try again.'
),
tone: 'error',
action: 'retry'
}
case 'not_found':
return {
title,
detail:
place === 'sandbox'
? t(
'components.runtimeDownload.notFoundInSandboxNothingSaved',
'It was moved or deleted in the sandbox. Nothing was saved on this computer.'
)
: t(
'components.runtimeDownload.notFoundOnHostNothingSaved',
'It was moved or deleted on the remote host. Nothing was saved on this computer.'
),
tone: 'error',
action: 'refresh'
}
case 'not_a_file':
case 'not_a_directory':
return {
title,
detail: t(
'components.runtimeDownload.changed',
'It changed while Orca was reading it. Try again.'
),
tone: 'error',
action: 'retry'
}
case 'disconnected':
return {
title,
detail:
place === 'sandbox'
? t(
'components.runtimeDownload.sandboxDisconnected',
'The connection to the sandbox dropped. Nothing was saved on this computer.'
)
: t(
'components.runtimeDownload.hostDisconnected',
'The connection to the remote host dropped. Nothing was saved on this computer.'
),
tone: 'error',
action: 'retry'
}
case 'canceled':
// Unreachable: outcomeState drops canceled results. Never offer "Try again" for a cancel.
return { ...transferFailed, action: null }
case 'open_failed':
case 'transfer_failed':
return transferFailed
}
}
/** The file was saved, but nothing on this computer opens its type: a soft state, not a failure. */
export function runtimeDownloadNoAppCopy(t: Translate, name: string): RuntimeDownloadFailureCopy {
return {
title: t('components.runtimeDownload.savedName', 'Saved {{name}}', { name }),
detail: t(
'components.runtimeDownload.noAppForType',
'No app on this computer opens this file type. Reveal it to choose one.'
),
tone: 'neutral',
action: 'reveal'
}
}
@@ -0,0 +1,352 @@
import type React from 'react'
import { CircleCheck, Download, ExternalLink, FolderOpen, MonitorDown, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { InlineError } from '@/components/ui/inline-error'
import { Progress } from '@/components/ui/progress'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { uiNumberFormat } from '@/i18n/ui-intl-formatters'
import { formatElapsedTime } from '@/components/worktree-creation/worktree-creation-clock'
import { formatBytes } from '../../../../../../shared/format-bytes'
import type { RuntimeDownloadErrorCode } from '../../../../../../shared/runtime-downloads/runtime-download-contract'
import type { RuntimeDownloadProgressSnapshot } from '@/runtime/files/downloads/runtime-download-progress-store'
import {
runtimeDownloadFailureCopy,
runtimeDownloadNoAppCopy,
type RuntimeDownloadFailureAction,
type RuntimeDownloadPlace
} from './runtime-download-failure-copy'
export type { RuntimeDownloadPlace }
export type RuntimeDownloadItemKind = 'file' | 'folder'
export type RuntimeDownloadToastIntent = 'save' | 'open'
export type RuntimeDownloadToastState =
| {
kind: 'progress'
intent: RuntimeDownloadToastIntent
itemKind: RuntimeDownloadItemKind
/** Local name being written, e.g. `src.zip`. */
name: string
snapshot: RuntimeDownloadProgressSnapshot | null
}
| {
kind: 'done'
itemKind: RuntimeDownloadItemKind
/** Name of the saved file on this computer. */
name: string
bytes: number
elapsedMs: number
fileCount?: number
excludedDefaultDirs?: boolean
skippedCount?: number
/** False for files the OS would run: those are only revealed, never opened from the toast. */
canOpen?: boolean
}
| {
kind: 'failed'
intent: RuntimeDownloadToastIntent
itemKind: RuntimeDownloadItemKind
name: string
code: RuntimeDownloadErrorCode
place: RuntimeDownloadPlace
}
| { kind: 'no_app'; name: string }
export type RuntimeDownloadToastAction = 'cancel' | 'open' | RuntimeDownloadFailureAction
const formatCount = (value: number): string => uiNumberFormat({}).format(value)
// A time-left estimate from the first second of a transfer swings wildly; wait for the rate to settle.
const ETA_MIN_RATE_WINDOW_MS = 2_000
// Titles truncate at the end in CSS, which hides the extension; shorten long names in the middle.
const MAX_TITLE_NAME_CHARS = 40
export function middleTruncateName(name: string, max = MAX_TITLE_NAME_CHARS): string {
const chars = Array.from(name)
if (chars.length <= max) {
return name
}
const tail = Math.floor((max - 1) / 2)
const head = max - 1 - tail
return `${chars.slice(0, head).join('')}…${chars.slice(chars.length - tail).join('')}`
}
const CARD =
'relative w-[356px] rounded-lg border border-border bg-popover px-3 py-2.5 text-sm text-popover-foreground shadow-lg'
/** Fraction done, or null while the size is unknown (folder walk, empty file). */
export function runtimeDownloadFraction(
snapshot: RuntimeDownloadProgressSnapshot | null
): number | null {
if (!snapshot || snapshot.phase !== 'transferring' || !snapshot.totalBytes) {
return null
}
return Math.min(1, snapshot.processedBytes / snapshot.totalBytes)
}
function progressDetail(
t: Translate,
itemKind: RuntimeDownloadItemKind,
snapshot: RuntimeDownloadProgressSnapshot | null
): string {
if (!snapshot || snapshot.phase === 'preparing') {
return itemKind === 'folder'
? t('components.runtimeDownload.collectingFiles', 'Collecting files…')
: t('components.runtimeDownload.starting', 'Starting…')
}
if (itemKind === 'folder') {
// Counted in files, not bytes: the source size and the ZIP size differ, and the done card
// reports the ZIP's size.
return snapshot.fileCount === undefined
? formatBytes(snapshot.receivedBytes)
: t('components.runtimeDownload.folderFilesProgress', '{{files}} files', {
count: snapshot.fileCount,
files: formatCount(snapshot.fileCount),
defaultValue_one: '{{files}} file'
})
}
const amount = snapshot.totalBytes
? t('components.runtimeDownload.amountOfTotal', '{{done}} of {{total}}', {
done: formatBytes(snapshot.receivedBytes),
total: formatBytes(snapshot.totalBytes)
})
: formatBytes(snapshot.receivedBytes)
const rate = snapshot.bytesPerSecond
if (!rate || rate <= 0) {
return amount
}
if (snapshot.totalBytes && snapshot.rateWindowMs >= ETA_MIN_RATE_WINDOW_MS) {
const leftMs = (Math.max(0, snapshot.totalBytes - snapshot.receivedBytes) / rate) * 1000
return t('components.runtimeDownload.amountRateEta', '{{amount}} · {{rate}}/s · {{eta}} left', {
amount,
rate: formatBytes(rate),
eta: formatElapsedTime(leftMs)
})
}
return t('components.runtimeDownload.amountAndRate', '{{amount}} · {{rate}}/s', {
amount,
rate: formatBytes(rate)
})
}
function DismissButton({ onDismiss }: { onDismiss: () => void }): React.JSX.Element {
const t = useTranslate()
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t('components.runtimeDownload.dismiss', 'Dismiss')}
className="absolute top-1.5 right-1.5"
onClick={onDismiss}
>
<X aria-hidden />
</Button>
)
}
function ProgressCard({
state,
onAction
}: {
state: Extract<RuntimeDownloadToastState, { kind: 'progress' }>
onAction: (action: RuntimeDownloadToastAction) => void
}): React.JSX.Element {
const t = useTranslate()
const fraction = runtimeDownloadFraction(state.snapshot)
const percent = fraction === null ? null : Math.round(fraction * 100)
const detail = progressDetail(t, state.itemKind, state.snapshot)
const title =
state.intent === 'open'
? t('components.runtimeDownload.opening', 'Opening {{name}}', {
name: middleTruncateName(state.name)
})
: t('components.runtimeDownload.downloading', 'Downloading {{name}}', {
name: middleTruncateName(state.name)
})
const progressLabel = t('components.runtimeDownload.progressLabel', 'Download progress')
const Icon = state.intent === 'open' ? MonitorDown : Download
return (
<div className={CARD}>
<div className="flex items-center gap-3">
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
{/* Only the title is live: the numbers change several times a second and would chatter.
The progressbar's value text carries them when asked. */}
<p role="status" className="min-w-0 flex-1 truncate font-medium" title={state.name}>
{title}
</p>
<Button type="button" variant="outline" size="xs" onClick={() => onAction('cancel')}>
{t('components.runtimeDownload.cancel', 'Cancel')}
</Button>
</div>
<div className="mt-2 space-y-1.5 ps-7">
{percent === null ? (
// Unknown size: an honest sweep beats a faked percentage.
<span
role="progressbar"
aria-label={progressLabel}
aria-valuetext={detail}
className="block h-1.5 w-full overflow-hidden rounded-full bg-primary/20"
>
<span className="block h-full w-2/5 animate-[skill-update-slide_1.35s_ease-in-out_infinite] rounded-full bg-primary motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-40" />
</span>
) : (
<Progress
value={percent}
aria-label={progressLabel}
aria-valuetext={`${percent}%, ${detail}`}
className="h-1.5"
/>
)}
<p className="truncate text-xs tabular-nums text-muted-foreground">{detail}</p>
</div>
</div>
)
}
function DoneCard({
state,
revealLabel,
onAction,
onDismiss
}: {
state: Extract<RuntimeDownloadToastState, { kind: 'done' }>
revealLabel: string
onAction: (action: RuntimeDownloadToastAction) => void
onDismiss: () => void
}): React.JSX.Element {
const t = useTranslate()
const isFolder = state.itemKind === 'folder'
const size = isFolder
? t('components.runtimeDownload.zipSize', '{{size}} zip', { size: formatBytes(state.bytes) })
: formatBytes(state.bytes)
const parts =
isFolder && state.fileCount !== undefined
? [
t('components.runtimeDownload.fileCount', '{{files}} files', {
count: state.fileCount,
files: formatCount(state.fileCount),
defaultValue_one: '{{files}} file'
}),
size
]
: [size]
parts.push(formatElapsedTime(state.elapsedMs))
const notes: string[] = []
if (state.excludedDefaultDirs) {
notes.push(
t(
'components.runtimeDownload.defaultDirsNotIncluded',
'node_modules and .git weren’t included.'
)
)
}
if ((state.skippedCount ?? 0) > 0) {
notes.push(
t(
'components.runtimeDownload.skipped',
'{{count}} items couldn’t be read and were left out.',
{
count: state.skippedCount,
defaultValue_one: '{{count}} item couldn’t be read and was left out.'
}
)
)
}
return (
<div role="status" className={`${CARD} pe-9`}>
<div className="flex items-start gap-3">
<CircleCheck className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="truncate font-medium" title={state.name}>
{t('components.runtimeDownload.downloaded', 'Downloaded {{name}}', {
name: middleTruncateName(state.name)
})}
</p>
<p className="text-xs tabular-nums text-muted-foreground">{parts.join(' · ')}</p>
{notes.map((note) => (
<p key={note} className="text-xs text-muted-foreground">
{note}
</p>
))}
<div className="flex gap-2 pt-1.5">
<Button type="button" variant="outline" size="xs" onClick={() => onAction('reveal')}>
<FolderOpen aria-hidden />
{revealLabel}
</Button>
{state.itemKind === 'file' && state.canOpen !== false ? (
<Button type="button" variant="outline" size="xs" onClick={() => onAction('open')}>
<ExternalLink aria-hidden />
{t('components.runtimeDownload.open', 'Open')}
</Button>
) : null}
</div>
</div>
</div>
<DismissButton onDismiss={onDismiss} />
</div>
)
}
function actionLabel(t: Translate, action: RuntimeDownloadFailureAction, revealLabel: string) {
switch (action) {
case 'retry':
return t('components.runtimeDownload.retry', 'Try again')
case 'download_instead':
return t('components.runtimeDownload.downloadInstead', 'Download')
case 'refresh':
return t('components.runtimeDownload.refresh', 'Refresh')
case 'reveal':
return revealLabel
}
}
/**
* Toast for a download from a runtime host: live progress with Cancel, a done card whose actions
* open the result on this computer, or one error with at most one next action.
*/
export function RuntimeDownloadToast({
state,
revealLabel,
onAction,
onDismiss
}: {
state: RuntimeDownloadToastState
/** Platform name for revealing a path, e.g. "Reveal in Finder". */
revealLabel: string
onAction: (action: RuntimeDownloadToastAction) => void
onDismiss: () => void
}): React.JSX.Element {
const t = useTranslate()
if (state.kind === 'progress') {
return <ProgressCard state={state} onAction={onAction} />
}
if (state.kind === 'done') {
return (
<DoneCard state={state} revealLabel={revealLabel} onAction={onAction} onDismiss={onDismiss} />
)
}
const copy =
state.kind === 'no_app'
? runtimeDownloadNoAppCopy(t, state.name)
: runtimeDownloadFailureCopy(t, state)
const action = copy.action
return (
<div className="relative w-[356px] rounded-lg bg-popover shadow-lg">
<InlineError
layout="toast"
tone={copy.tone}
title={copy.title}
detail={copy.detail}
action={
action ? (
<Button type="button" variant="outline" size="xs" onClick={() => onAction(action)}>
{actionLabel(t, action, revealLabel)}
</Button>
) : undefined
}
/>
<DismissButton onDismiss={onDismiss} />
</div>
)
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { subscribeFileExplorerRefreshRequests } from '../watching/file-explorer-refresh-requests'
const REFRESH_SPINNER_DELAY_MS = 200
@@ -51,5 +52,8 @@ export function useFileExplorerManualRefresh(refreshTree: () => Promise<unknown>
})
}, [clearSpinnerTimer, refreshTree])
// A toast outside the tree (e.g. a download whose file was gone) can ask for the same refresh.
useEffect(() => subscribeFileExplorerRefreshRequests(handleRefresh), [handleRefresh])
return { isRefreshing, showRefreshSpinner, handleRefresh }
}
@@ -0,0 +1,16 @@
// Lets surfaces outside the explorer tree (e.g. a download toast that found its file gone) ask the
// mounted explorer to refresh, without threading a callback through every row.
const listeners = new Set<() => void>()
export function requestFileExplorerRefresh(): void {
for (const listener of listeners) {
listener()
}
}
export function subscribeFileExplorerRefreshRequests(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
@@ -3,7 +3,8 @@ import { cn } from '@/lib/utils'
import { useTranslate } from '@/i18n/use-translate'
import { VmBuildReveal } from './VmBuildReveal'
import { isAccountGate, type VmBuildFailure } from './vm-build-failure'
import { vmBuildFailureCopy } from './vm-build-failure-copy'
import { getLastBillingStatus } from '@/components/billing/billing-summary-store'
import { sandboxLimitFromBilling, vmBuildFailureCopy } from './vm-build-failure-copy'
import { FailureActionRow, type VmBuildFailureActions } from './VmBuildFailureActions'
import { CheckRow } from './VmBuildCheckRow'
@@ -54,7 +55,16 @@ export function VmBuildFailureCard({
}
const visible = failure ?? shown
const copy = visible
? vmBuildFailureCopy(visible, { cloud, canManageBilling, repoName }, t)
? vmBuildFailureCopy(
visible,
{
cloud,
canManageBilling,
repoName,
sandboxLimit: sandboxLimitFromBilling(getLastBillingStatus())
},
t
)
: null
const headingRef = React.useRef<HTMLHeadingElement>(null)
const cardRef = React.useRef<HTMLDivElement>(null)
@@ -1,18 +1,38 @@
import type { Translate } from '@/i18n/use-translate'
import type { OrcaBillingStatusResult } from '../../../../shared/orca-billing'
import type { VmBuildFailure } from './vm-build-failure'
/** The running-sandbox caps billing enforces: per member, and per team (per seat × seats). */
export type SandboxLimit = { perSeat: number; team: number }
/** Mirrors billing's checkStart limits; null until the plan has loaded. */
export function sandboxLimitFromBilling(
status: OrcaBillingStatusResult | null
): SandboxLimit | null {
if (status?.status !== 'ok') {
return null
}
const perSeat = status.entitlement.offers?.team.concurrentSandboxesPerSeat
if (!perSeat) {
return null
}
return { perSeat, team: perSeat * Math.max(1, status.entitlement.seats) }
}
export type VmBuildFailureCopyContext = {
cloud: boolean
/** How the user knows the repository: its GitHub owner/name, else its name in Orca. */
repoName: string
/** False for team members who can't change billing; they are told to ask an owner or admin. */
canManageBilling: boolean
/** States the cap on the limit cards; omitted or null falls back to copy without numbers. */
sandboxLimit?: SandboxLimit | null
}
/** The card's cause-specific title and one plain-language sentence on what to do next. */
export function vmBuildFailureCopy(
failure: VmBuildFailure,
{ cloud, canManageBilling, repoName }: VmBuildFailureCopyContext,
{ cloud, canManageBilling, repoName, sandboxLimit }: VmBuildFailureCopyContext,
t: Translate
): { title: string; body: string | null } {
switch (failure.kind) {
@@ -104,10 +124,16 @@ export function vmBuildFailureCopy(
'auto.components.worktree.creation.VmBuildFailureCard.teamLimitTitle',
'You’re at your team’s limit of running sandboxes'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
)
body: sandboxLimit
? t(
'auto.components.worktree.creation.VmBuildFailureCard.teamLimitBody',
'Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.',
{ value0: String(sandboxLimit.team), value1: String(sandboxLimit.perSeat) }
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
)
}
case 'memberConcurrency':
return {
@@ -115,10 +141,16 @@ export function vmBuildFailureCopy(
'auto.components.worktree.creation.VmBuildFailureCard.memberLimitTitle',
'You’re at your limit of running sandboxes'
),
body: t(
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
)
body: sandboxLimit
? t(
'auto.components.worktree.creation.VmBuildFailureCard.memberLimitBody2',
'You can run {{value0}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.',
{ value0: String(sandboxLimit.perSeat) }
)
: t(
'auto.components.worktree.creation.VmBuildFailureCard.limitBody',
'Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.'
)
}
case 'githubNotConnected':
return {
@@ -4,7 +4,8 @@ import type { Translate } from '@/i18n/use-translate'
import { openPlansPage } from '@/components/billing/plans/plans-page-store'
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
import { classifyVmBuildFailure, type VmBuildFailure } from './vm-build-failure'
import { vmBuildFailureCopy } from './vm-build-failure-copy'
import { getLastBillingStatus } from '@/components/billing/billing-summary-store'
import { sandboxLimitFromBilling, vmBuildFailureCopy } from './vm-build-failure-copy'
import { isOrcaCloudBuild } from './vm-build-steps'
import { orcaCloudRepoName } from './vm-build-repo-actions'
import type { Repo } from '../../../../shared/repo-types'
@@ -26,7 +27,11 @@ export function vmBuildFailureSummary(
recipe ? repos.find((repo) => repo.id === recipe.sourceRepoId) : undefined
)
// Unknown here; the card itself refines the copy for members once billing status loads.
return { failure, ...vmBuildFailureCopy(failure, { cloud, canManageBilling: true, repoName }, t) }
const sandboxLimit = sandboxLimitFromBilling(getLastBillingStatus())
return {
failure,
...vmBuildFailureCopy(failure, { cloud, canManageBilling: true, repoName, sandboxLimit }, t)
}
}
/** The build sheet's card as a toast, for a VM build that failed while its sheet was off screen. */
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { classifyVmBuildFailure, isAccountGate, type VmBuildFailure } from './vm-build-failure'
import { vmBuildFailureCopy } from './vm-build-failure-copy'
import { sandboxLimitFromBilling, vmBuildFailureCopy } from './vm-build-failure-copy'
import { orcaCloudRepoName } from './vm-build-repo-actions'
import type { Translate } from '@/i18n/use-translate'
@@ -104,6 +104,38 @@ describe('vmBuildFailureCopy', () => {
)
})
it('states the running-sandbox cap on the limit cards once the plan is known', () => {
const copy = (kind: VmBuildFailure, sandboxLimit: { perSeat: number; team: number } | null) =>
vmBuildFailureCopy(
kind,
{ cloud: true, canManageBilling: true, repoName: 'acme/web', sandboxLimit },
t
).body
expect(copy({ kind: 'concurrency' }, { perSeat: 3, team: 12 })).toContain(
'Your team can run 12 sandboxes at once (3 per seat). Put one to sleep or delete it'
)
expect(copy({ kind: 'memberConcurrency' }, { perSeat: 3, team: 12 })).toContain(
'You can run 3 sandboxes at once. Put one to sleep or delete it'
)
expect(copy({ kind: 'concurrency' }, null)).toMatch(/^Put one to sleep or delete it/)
})
it('derives the caps the way billing enforces them', () => {
const status = (seats: number, perSeat: number | null) =>
({
status: 'ok',
entitlement: {
seats,
offers: perSeat === null ? null : { team: { concurrentSandboxesPerSeat: perSeat } }
}
}) as unknown as Parameters<typeof sandboxLimitFromBilling>[0]
expect(sandboxLimitFromBilling(status(4, 3))).toEqual({ perSeat: 3, team: 12 })
// Billing counts at least one seat.
expect(sandboxLimitFromBilling(status(0, 3))).toEqual({ perSeat: 3, team: 3 })
expect(sandboxLimitFromBilling(status(4, null))).toBeNull()
expect(sandboxLimitFromBilling(null)).toBeNull()
})
it('names the repository GitHub needs to reach', () => {
const { title } = vmBuildFailureCopy(
{ kind: 'githubNotConnected' },
+8
View File
@@ -2040,6 +2040,14 @@
"workers_one": "{{count}} worker",
"workers_other": "{{count}} workers"
},
"runtimeDownload": {
"fileCount_one": "{{files}} file",
"fileCount_other": "{{files}} files",
"folderFilesProgress_one": "{{files}} file",
"folderFilesProgress_other": "{{files}} files",
"skipped_one": "{{count}} item couldn’t be read and was left out.",
"skipped_other": "{{count}} items couldn’t be read and were left out."
},
"tab": {
"bar": {
"bulkClose": {
+71 -7
View File
@@ -4108,6 +4108,8 @@
"paymentFailedBodyMember": "New sandboxes can’t start until the payment goes through. Ask an owner or admin to update the payment method. Nothing was created, and you weren’t charged.",
"teamLimitTitle": "You’re at your team’s limit of running sandboxes",
"limitBody": "Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.",
"teamLimitBody": "Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.",
"memberLimitBody2": "You can run {{value0}} sandboxes at once. Put one to sleep or delete it, then try again. Nothing was created, and you weren’t charged.",
"memberLimitTitle": "You’re at your limit of running sandboxes",
"githubNotConnectedTitle": "GitHub isn’t connected for {{repo}}",
"githubNotConnectedBody": "Orca Cloud clones your code from GitHub and needs access to this repository first. Connect GitHub, then try again. Nothing was created, and you weren’t charged.",
@@ -14650,7 +14652,10 @@
"clipboardStagingUnavailable": "Could not copy the file because Orca's temporary storage is unavailable",
"revealInFinder": "Reveal in Finder",
"openContainingFolder": "Open Containing Folder",
"revealInFileExplorer": "Reveal in File Explorer"
"revealInFileExplorer": "Reveal in File Explorer",
"downloadFolderZip": "Download as ZIP…",
"openLocally": "Open on This Computer",
"downloadFileEllipsis": "Download…"
},
"FileExplorerToolbar": {
"d238264654": "Show Git Ignored Files",
@@ -20519,7 +20524,8 @@
"vpRunningBody2": "Sandboxes keep working while you are away and report back when they finish.",
"leftResets": "{{value0}} hours left · resets {{value1}}",
"retry": "Try again",
"featureSleep2": "Sleeps after {{value0}} minutes idle",
"featureSleep3": "Sleeps after {{value0}} minutes idle, so quiet time is free",
"featureReady": "Ready in seconds, nothing to set up or host",
"billedYearly": "Billed yearly",
"waiting": "Waiting for checkout…",
"fixPaymentAbove": "Update your payment method above to keep your plan active.",
@@ -20529,9 +20535,9 @@
"savePercent": "Save {{value0}}%",
"seatsPeriod": "{{value0}} · {{value1}}",
"was": "Was",
"featureConcurrency2": "{{value0}} sandboxes running at once per seat",
"featureHours4": "{{value0}} sandbox hours per seat each month, pooled across your team",
"featureLarge3": "Standard (4 vCPU · 8 GB) sandboxes; Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate",
"featureStandard": "Standard (4 vCPU · 8 GB)",
"featureLarge4": "Large (8 vCPU · 16 GB) uses sandbox hours at {{value0}}× the rate",
"featureOverage3": "Optional overage at {{value0}}/hour ({{value1}}/hour on Large), capped at a monthly spend cap you set ({{value2}} by default)",
"footTerms4": "Prices in USD, before tax. Overage is off until an owner or admin turns it on. Cancel anytime; your plan runs to the end of the billing period.",
"lockedBody2": "Only an owner or admin of your team can choose a plan. Ask one of them to open Plan & billing in Orca.",
@@ -20675,6 +20681,8 @@
"emptyTeamTitle": "No team workspaces yet",
"environmentName": "{{value0}} (cloud)",
"errorConcurrencyLimit": "You're at your team's limit of running sandboxes. Put one to sleep or delete it, then try again.",
"errorConcurrencyLimitCount2": "Your team can run {{value0}} sandboxes at once ({{value1}} per seat). Put one to sleep or delete it, then try again.",
"errorMemberConcurrencyLimit": "You're at your limit of running sandboxes. Put one to sleep or delete it, then try again.",
"errorConflict": "This workspace is still starting or going to sleep. Nothing was changed. Try again in a moment.",
"errorConflictInline": "This workspace is still starting or going to sleep. Nothing was changed.",
"errorEntitlementDenied": "Your team's plan can't start this workspace right now. Your files are safe. Ask an owner or admin to check the plan.",
@@ -22872,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.",
@@ -22882,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": {
@@ -22922,6 +22943,49 @@
"terminalOutput": "Saved output of {{title}}",
"noGit": "No source control state was saved."
}
},
"runtimeDownload": {
"collectingFiles": "Collecting files…",
"starting": "Starting…",
"amountOfTotal": "{{done}} of {{total}}",
"amountAndRate": "{{amount}} · {{rate}}/s",
"openFailedTitle": "Couldn’t open {{name}}",
"failedTitle": "Couldn’t download {{name}}",
"tooLargeToOpen": "Files over 1 GB can’t be opened directly. Download it instead.",
"sandboxUpdateRequired": "This sandbox runs an older version of Orca. New cloud workspaces include downloads. Your files are safe.",
"hostUpdateRequired": "The remote host runs an older version of Orca. Update Orca there, then try again.",
"busy": "Too many downloads are running. Wait for one to finish, then try again.",
"changed": "It changed while Orca was reading it. Try again.",
"sandboxDisconnected": "The connection to the sandbox dropped. Nothing was saved on this computer.",
"hostDisconnected": "The connection to the remote host dropped. Nothing was saved on this computer.",
"transferFailed": "Something went wrong while copying it. Nothing was saved on this computer.",
"dismiss": "Dismiss",
"opening": "Opening {{name}}",
"downloading": "Downloading {{name}}",
"progressLabel": "Download progress",
"cancel": "Cancel",
"skipped_one": "{{count}} item couldn’t be read and was left out.",
"skipped_other": "{{count}} items couldn’t be read and were left out.",
"downloaded": "Downloaded {{name}}",
"open": "Open",
"retry": "Try again",
"downloadInstead": "Download",
"fileCount_one": "{{files}} file",
"fileCount_other": "{{files}} files",
"amountRateEta": "{{amount}} · {{rate}}/s · {{eta}} left",
"folderFilesProgress_one": "{{files}} file",
"folderFilesProgress_other": "{{files}} files",
"zipSize": "{{size}} zip",
"defaultDirsNotIncluded": "node_modules and .git weren’t included.",
"refresh": "Refresh",
"folderTooLarge": "This folder is over 2 GB, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.",
"fileGrew": "The file grew while Orca was copying it. Nothing was saved on this computer.",
"folderTooManyItems": "This folder has more than 65,000 items, the most Orca can download at once. Download the folders inside it one at a time. Nothing was saved on this computer.",
"openRefused": "Orca doesn’t open files that can run programs, like scripts, installers and apps. Download it to check it first. Nothing was saved on this computer.",
"notFoundInSandboxNothingSaved": "It was moved or deleted in the sandbox. Nothing was saved on this computer.",
"notFoundOnHostNothingSaved": "It was moved or deleted on the remote host. Nothing was saved on this computer.",
"savedName": "Saved {{name}}",
"noAppForType": "No app on this computer opens this file type. Reveal it to choose one."
}
},
"dashboardPopout": {
@@ -33,6 +33,11 @@ function subscribe(listener: () => void): () => void {
return () => listeners.delete(listener)
}
/** Non-React read of the last loaded list; call refreshCloudEnvironmentIds first when it matters. */
export function isKnownCloudEnvironment(environmentId: string): boolean {
return ids.has(environmentId)
}
export function useIsCloudEnvironment(environmentId: string | null): boolean {
return useSyncExternalStore(subscribe, () => (environmentId ? ids.has(environmentId) : false))
}
@@ -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 })
@@ -0,0 +1,93 @@
import type { RuntimeDownloadProgressEvent } from '../../../../../shared/runtime-downloads/runtime-download-contract'
export type RuntimeDownloadProgressSnapshot = RuntimeDownloadProgressEvent & {
/** Recent receive rate, smoothed so the label does not jitter. */
bytesPerSecond: number | null
/** How long the rate has been sampled; a time-left estimate waits until it settles. */
rateWindowMs: number
}
type Entry = {
snapshot: RuntimeDownloadProgressSnapshot | null
listeners: Set<() => void>
lastSample: { at: number; bytes: number } | null
firstSampleAt: number | null
}
const entries = new Map<string, Entry>()
let unsubscribeIpc: (() => void) | null = null
// Rate is averaged over this window; shorter windows swing with every page.
const RATE_SMOOTHING = 0.3
function entryFor(downloadId: string): Entry {
let entry = entries.get(downloadId)
if (!entry) {
entry = { snapshot: null, listeners: new Set(), lastSample: null, firstSampleAt: null }
entries.set(downloadId, entry)
}
return entry
}
export function applyRuntimeDownloadProgress(
event: RuntimeDownloadProgressEvent,
now = performance.now()
): void {
const entry = entries.get(event.downloadId)
if (!entry) {
return
}
let bytesPerSecond = entry.snapshot?.bytesPerSecond ?? null
if (entry.lastSample && now > entry.lastSample.at) {
const instant =
((event.receivedBytes - entry.lastSample.bytes) * 1000) / (now - entry.lastSample.at)
bytesPerSecond =
bytesPerSecond === null
? instant
: bytesPerSecond + RATE_SMOOTHING * (instant - bytesPerSecond)
}
entry.lastSample = { at: now, bytes: event.receivedBytes }
entry.firstSampleAt ??= now
entry.snapshot = { ...event, bytesPerSecond, rateWindowMs: now - entry.firstSampleAt }
for (const listener of entry.listeners) {
listener()
}
}
/** Starts tracking a download; returns a release that forgets it once its toast is done. */
export function trackRuntimeDownload(downloadId: string): () => void {
entryFor(downloadId)
if (!unsubscribeIpc && window.api.runtimeDownloads) {
unsubscribeIpc = window.api.runtimeDownloads.onProgress((event) =>
applyRuntimeDownloadProgress(event)
)
}
return () => {
entries.delete(downloadId)
if (entries.size === 0 && unsubscribeIpc) {
unsubscribeIpc()
unsubscribeIpc = null
}
}
}
export function subscribeRuntimeDownloadProgress(
downloadId: string,
listener: () => void
): () => void {
// Never creates an entry: a toast rendering after release() must not resurrect one, or the IPC
// listener would never be removed.
const entry = entries.get(downloadId)
if (!entry) {
return () => {}
}
entry.listeners.add(listener)
return () => {
entry.listeners.delete(listener)
}
}
export function getRuntimeDownloadProgress(
downloadId: string
): RuntimeDownloadProgressSnapshot | null {
return entries.get(downloadId)?.snapshot ?? null
}
@@ -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)
})
})
@@ -124,6 +124,7 @@ export function blockedPluginSaveAsExtension(name: string): string | null {
.split(/[\\/]/)
.at(-1) ?? ''
const dot = base.lastIndexOf('.')
const extension = dot > 0 ? base.slice(dot + 1).toLowerCase() : ''
// `>= 0`: Windows treats a bare `.hta` or `.bat` as that type and runs it.
const extension = dot !== -1 ? base.slice(dot + 1).toLowerCase() : ''
return PLUGIN_SAVE_AS_BLOCKED_EXTENSIONS.has(extension) ? extension : null
}
@@ -0,0 +1,54 @@
import { z } from 'zod'
import { FileTreePath } from './files-params'
/** Uncompressed bytes one folder archive may hold; ZIP without ZIP64 tops out at 4 GiB. */
export const FOLDER_ARCHIVE_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
/** Entries one folder archive may hold; a ZIP without ZIP64 counts entries in 16 bits. */
export const FOLDER_ARCHIVE_MAX_ENTRIES = 65_000
/** Largest archive page one read returns; base64 keeps the reply well under the 4 MiB frame budget. */
export const FOLDER_ARCHIVE_MAX_READ_BYTES = 2 * 1024 * 1024
/** Directory names skipped when the caller asks to leave out dependency and VCS folders. */
export const FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES = ['node_modules', '.git'] as const
export const FileArchiveStart = FileTreePath.extend({
/** Skip FOLDER_ARCHIVE_DEFAULT_EXCLUDED_NAMES at any depth. */
excludeDefaultDirs: z.boolean().optional()
})
export const FileArchiveRead = z.object({
archiveId: z.string().min(1).max(128),
maxBytes: z.number().int().positive().max(FOLDER_ARCHIVE_MAX_READ_BYTES)
})
export const FileArchiveCancel = z.object({
archiveId: z.string().min(1).max(128)
})
export type FileArchiveStartResult = {
archiveId: string
/** Archive name the host suggests, e.g. `src.zip`. */
suggestedName: string
fileCount: number
directoryCount: number
/** Sum of the regular files' sizes at walk time. */
totalBytes: number
/** Directories left out by `excludeDefaultDirs`. */
excludedDirCount: number
}
export type FileArchiveReadResult = {
contentBase64: string
bytesRead: number
eof: boolean
/** Uncompressed file bytes archived so far; compare with `totalBytes` for progress. */
processedBytes: number
/** Entries skipped because they vanished or became unreadable mid-archive. */
skippedCount: number
}
export type FileArchiveErrorCode =
| 'archive_not_directory'
| 'archive_too_large'
| 'archive_too_many_entries'
| 'archive_not_found'
| 'archive_busy'
+4
View File
@@ -238,6 +238,7 @@ import {
TypeParams
} from './emulator-params'
import { EnvironmentDoctorParams } from './environment-doctor-params'
import { FileArchiveCancel, FileArchiveRead, FileArchiveStart } from './files-archive-params'
import {
FileCommitUpload,
FileCopy,
@@ -1066,6 +1067,9 @@ export const RPC_PARAMS_BY_METHOD = {
'emulator.type': TypeParams,
'emulator.unregisterActive': EmulatorUnregisterActiveParams,
'environment.doctor': EnvironmentDoctorParams,
'files.archiveCancel': FileArchiveCancel,
'files.archiveRead': FileArchiveRead,
'files.archiveStart': FileArchiveStart,
'files.browseServerDir': ServerDirectoryBrowse,
'files.commitUpload': FileCommitUpload,
'files.copy': FileCopy,
@@ -0,0 +1,70 @@
/** Desktop downloads of files and folders that live on a paired runtime host. */
import { blockedPluginSaveAsExtension } from '../plugins/frames/plugin-save-as-extensions'
export type RuntimeDownloadErrorCode =
| 'canceled'
| 'not_a_file'
| 'not_a_directory'
| 'too_large'
| 'too_many_entries'
| 'host_update_required'
| 'host_busy'
| 'not_found'
| 'disconnected'
| 'open_failed'
/** "Open on this computer" refuses files the OS would run (scripts, installers, apps). */
| 'open_refused'
| 'transfer_failed'
/** Largest file "Open locally" will fetch into the temp cache. */
export const RUNTIME_OPEN_LOCALLY_MAX_BYTES = 1024 * 1024 * 1024
/**
* Whether Orca may hand a fetched copy straight to the OS opener. The host controls the bytes and the
* name, so anything Windows or macOS would run on a double-click is only ever saved, never opened.
*/
export function canOpenRuntimeDownloadLocally(name: string): boolean {
return blockedPluginSaveAsExtension(name) === null
}
export type RuntimeDownloadStartArgs = {
/** Caller-chosen id; progress events and cancel use it. */
downloadId: string
environmentId: string
worktree: string
relativePath: string
/** Display name of the remote entry, used for the suggested local name. */
name: string
kind: 'file' | 'folder'
/** `save` asks where to put it; `open` fetches into a temp cache and opens it. */
intent: 'save' | 'open'
excludeDefaultDirs?: boolean
expectedEnvironmentPairingRevision?: number
}
export type RuntimeDownloadProgressEvent = {
downloadId: string
phase: 'preparing' | 'transferring'
receivedBytes: number
totalBytes: number | null
processedBytes: number
/** Folder downloads: files in the archive, known once the host has listed the folder. */
fileCount?: number
/** Present once a save location is chosen. */
destinationPath?: string
}
export type RuntimeDownloadResult =
| { status: 'canceled' }
| {
status: 'done'
destinationPath: string
bytes: number
/** Folder downloads only. */
fileCount?: number
skippedCount?: number
excludedDirCount?: number
/** `open` intent: the OS refused to open the fetched file. */
openError?: string
}
| { status: 'error'; code: RuntimeDownloadErrorCode; message: string }
+38
View File
@@ -100,6 +100,44 @@ describe('runtime RPC call queue', () => {
await expect(Promise.all(reads)).resolves.toEqual(Array(6).fill('chunk'))
})
it('runs a download in its own four-wide lane beside preview reads', async () => {
const queue = new RuntimeRpcCallQueuePool(8, 2)
const previewRelease: (() => void)[] = []
const downloadRelease: (() => void)[] = []
const previews = Array.from({ length: 3 }, () =>
queue.enqueue('web-runtime', 'files.readChunk', async () => {
await new Promise<void>((resolve) => previewRelease.push(resolve))
return 'preview'
})
)
const downloads = Array.from({ length: 6 }, () =>
queue.enqueue(
'web-runtime',
'files.readChunk',
async () => {
await new Promise<void>((resolve) => downloadRelease.push(resolve))
return 'page'
},
0,
undefined,
'download'
)
)
await vi.waitFor(() => expect(downloadRelease).toHaveLength(4))
expect(previewRelease).toHaveLength(2)
for (let finished = 0; finished < 6; finished += 1) {
await vi.waitFor(() => expect(downloadRelease.length).toBeGreaterThan(0))
downloadRelease.shift()?.()
}
await expect(Promise.all(downloads)).resolves.toEqual(Array(6).fill('page'))
for (let finished = 0; finished < 3; finished += 1) {
await vi.waitFor(() => expect(previewRelease.length).toBeGreaterThan(0))
previewRelease.shift()?.()
}
await expect(Promise.all(previews)).resolves.toEqual(Array(3).fill('preview'))
})
it('frees the queue slot when a runtime call throws synchronously', async () => {
const queue = new RuntimeRpcCallQueuePool(1, 1)
const first = queue.enqueue('web-runtime', 'status.get', () => {
+23 -8
View File
@@ -4,6 +4,9 @@ import { REMOTE_RUNTIME_MAX_PREPARED_RPC_BYTES } from './remote-runtime/remote-r
const DEFAULT_REMOTE_RUNTIME_CALL_CONCURRENCY = 8
const DEFAULT_REMOTE_RUNTIME_BACKGROUND_CALL_CONCURRENCY = 2
const REMOTE_RUNTIME_BULK_READ_CONCURRENCY = 2
// Explicit file downloads pipeline more pages than media/preview reads: at ~65 ms RTT two 512 KiB
// pages in flight cap a 200 MB download near 9 MB/s, four clear 10 MB/s.
const REMOTE_RUNTIME_DOWNLOAD_CONCURRENCY = 4
export const RUNTIME_RPC_MAX_QUEUED_CALLS_PER_SELECTOR = 256
export const RUNTIME_RPC_MAX_QUEUED_CALLS_TOTAL = 2_048
export const RUNTIME_RPC_QUEUE_OVERLOAD_CODE = 'runtime_rpc_queue_overloaded'
@@ -63,11 +66,15 @@ function isLongWaitRuntimeMethod(method: string): boolean {
function isBulkReadRuntimeMethod(method: string): boolean {
return (
method === 'files.readChunk' ||
method === 'files.archiveRead' ||
method === 'files.readDocPreviewChunk' ||
method === 'files.readTerminalArtifactChunk'
)
}
/** `download`: a user-started transfer that gets its own lane, so it neither starves nor is starved by previews. */
export type RuntimeRpcCallLane = 'download'
export class RuntimeRpcCallQueuePool {
private readonly queues = new Map<string, RuntimeCallQueue>()
private queuedCallCount = 0
@@ -86,18 +93,22 @@ export class RuntimeRpcCallQueuePool {
method: string,
run: () => Promise<T>,
retainedBytes = 0,
signal?: AbortSignal
signal?: AbortSignal,
lane?: RuntimeRpcCallLane
): Promise<T> {
if (signal?.aborted) {
return Promise.reject(abortSignalReason(signal))
}
// Same concurrency bound, counted apart from the selector's other calls; global caps still apply.
const bulkRead = isBulkReadRuntimeMethod(method)
const queueKey = isLongWaitRuntimeMethod(method)
? `${selector}\u0000long-wait`
: bulkRead
? `${selector}\u0000bulk-read`
: selector
const download = lane === 'download'
const bulkRead = !download && isBulkReadRuntimeMethod(method)
const queueKey = download
? `${selector}\u0000download`
: isLongWaitRuntimeMethod(method)
? `${selector}\u0000long-wait`
: bulkRead
? `${selector}\u0000bulk-read`
: selector
if (this.queuedCallCount >= this.maxQueuedTotal) {
return Promise.reject(new RuntimeRpcCallQueueOverloadError('global'))
}
@@ -115,7 +126,11 @@ export class RuntimeRpcCallQueuePool {
const queue = this.getQueue(
queueKey,
bulkRead ? Math.min(this.concurrency, REMOTE_RUNTIME_BULK_READ_CONCURRENCY) : this.concurrency
download
? Math.min(this.concurrency, REMOTE_RUNTIME_DOWNLOAD_CONCURRENCY)
: bulkRead
? Math.min(this.concurrency, REMOTE_RUNTIME_BULK_READ_CONCURRENCY)
: this.concurrency
)
return new Promise<T>((resolve, reject) => {
const call: QueuedRuntimeCall<T> = {