diff --git a/.gitignore b/.gitignore index 2da5dd09f72..673abb3e7e7 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,6 @@ ci-shards/ /.build/windows-native-benchmark/ .local/static-analysis-research/ + +# Python bytecode (VM entry points tested locally) +__pycache__/ diff --git a/cloud/apps/sandbox-control/image/desktop-proxy.test.ts b/cloud/apps/sandbox-control/image/desktop-proxy.test.ts new file mode 100644 index 00000000000..6c10e304f33 --- /dev/null +++ b/cloud/apps/sandbox-control/image/desktop-proxy.test.ts @@ -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=&x=1 HTTP/1.1') + }) +}) diff --git a/cloud/apps/sandbox-control/image/golden-image.test.ts b/cloud/apps/sandbox-control/image/golden-image.test.ts index d278d13450c..f769080e5ed 100644 --- a/cloud/apps/sandbox-control/image/golden-image.test.ts +++ b/cloud/apps/sandbox-control/image/golden-image.test.ts @@ -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) + } } }) diff --git a/cloud/apps/sandbox-control/image/golden-image.ts b/cloud/apps/sandbox-control/image/golden-image.ts index ba3b56fb8ce..e5127f76843 100644 --- a/cloud/apps/sandbox-control/image/golden-image.ts +++ b/cloud/apps/sandbox-control/image/golden-image.ts @@ -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', diff --git a/cloud/apps/sandbox-control/image/provision-golden.sh b/cloud/apps/sandbox-control/image/provision-golden.sh index aad3a180861..f2bcd5bdc90 100755 --- a/cloud/apps/sandbox-control/image/provision-golden.sh +++ b/cloud/apps/sandbox-control/image/provision-golden.sh @@ -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" diff --git a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts index 26dd72ca55f..9c67077a7bc 100644 --- a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts +++ b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts @@ -2,8 +2,8 @@ import type { BuildInputs, GoldenImageManifest } from './golden-image' export const MANIFEST: GoldenImageManifest = {"imageVersion":"2026-11-09.1","image":"vercel/sandbox/node:24","region":"iad1","vcpus":4,"orcad":{"bundleUrl":null,"sha256":null},"gh":{"version":"2.103.3","sha256":"4c806978ffce1cec70853302a56d70ac6da4f19bae7adca4e1a8ab00633dd91d"},"chrome":{"version":"157.0.8103.38-1","sha256":"a62b277dc03da2cff50cc04589deb66f6875be339c066d46adb49b49eedb629d"},"agentCliPackages":["@anthropic-ai/claude-code@2.1.319","@openai/codex@0.166.0"],"desktop":{"novnc":{"version":"1.6.0","sha256":"5066103959ef4e9b10f37e5a148627360dd8414e4cf8a7db92bdbd022e728aaa"},"websockify":{"version":"0.13.0","sha256":"b6413e364efd04f3c92ec8c17747e3c4adc20157c2ef1c5d019a26d944a46df8"}}} -export const PROVISION_SCRIPT: string = "#!/usr/bin/env bash\n# Runs once inside the build sandbox (as the sandbox user, with sudo). Installs the pinned orcad\n# bundle, agent CLIs and Orca's VM entry points into /opt/orca, then smoke-tests orcad.\n# Inputs (env, from image/golden-image.manifest.json via build-golden-image.ts):\n# ORCAD_BUNDLE_URL ORCAD_BUNDLE_SHA256 [GH_VERSION GH_SHA256] [CHROME_VERSION CHROME_SHA256]\n# AGENT_NPM_PACKAGES (space-separated\n# name@version), [NOVNC_VERSION NOVNC_SHA256 WEBSOCKIFY_VERSION WEBSOCKIFY_SHA256] and ORCA_VM_SCRIPT_ (base64) for each /opt/orca/bin entry point.\nset -euo pipefail\nORCA_HOME=/opt/orca\nlog() { printf '[golden] %s\\n' \"$*\" >&2; }\n\nsudo mkdir -p \"$ORCA_HOME/bin\" \"$ORCA_HOME/runtime\" \"$ORCA_HOME/tools\"\nsudo chown -R \"$(id -u):$(id -g)\" \"$ORCA_HOME\"\n\nlog 'system packages'\nif command -v apt-get >/dev/null; then\n # Sandbox egress is TLS-only: plain-http apt mirrors time out at runtime (W2, wake on SSH).\n sudo sed -i 's#http://#https://#g' /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null || true\n sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq\n # Developer basics agents expect (Conductor parity): rg, tmux, vim, git-lfs alongside the build tools.\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n ca-certificates curl git git-lfs jq ripgrep tmux vim-nox less openssh-client openssh-server build-essential python3 \\\n unzip zip util-linux procps >/dev/null\n git lfs install --system --skip-repo >/dev/null\n # sshd is started on demand by the wake-on-SSH bridge (key-only, loopback); never at boot.\n sudo systemctl disable ssh.service ssh.socket >/dev/null 2>&1 || true\n # A locked password field (\"!\") makes sshd refuse even key logins; '*' = no password, not locked.\n sudo usermod -p '*' \"$(id -un)\"\nelif command -v dnf >/dev/null; then\n sudo dnf install -y -q ca-certificates curl-minimal git git-lfs jq tmux vim-enhanced gcc gcc-c++ make python3 \\\n unzip util-linux procps-ng >/dev/null\nelse\n log 'no supported package manager'; exit 1\nfi\n\n# Managed images have no /vercel/sandbox; the project lives at /vercel/sandbox/project on every image.\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" /vercel/sandbox\n\nfetch_verified() { # url sha256 dest\n curl -fsSL --retry 3 -o \"$3\" \"$1\"\n echo \"$2 $3\" | sha256sum -c --quiet -\n}\n\nlog 'orcad bundle'\nbundle=\"$(mktemp -d)/orcad.tgz\"\nfetch_verified \"$ORCAD_BUNDLE_URL\" \"$ORCAD_BUNDLE_SHA256\" \"$bundle\"\ntar -xzf \"$bundle\" -C \"$ORCA_HOME/runtime\"\nrm -rf \"$(dirname \"$bundle\")\"\n# Release tarballs (orca-server-linux-*) may nest the server; normalize to runtime/orcad + runtime/runtimes.\nif [ ! -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\" ]; then\n server=\"$(find \"$ORCA_HOME/runtime\" -name orcad-server.js -type f | head -n 1)\"\n [ -n \"$server\" ] || { log 'bundle has no orcad-server.js'; exit 1; }\n root=\"$(dirname \"$(dirname \"$server\")\")\"\n ln -sfn \"$(dirname \"$server\")\" \"$ORCA_HOME/runtime/orcad\"\n [ -d \"$ORCA_HOME/runtime/runtimes\" ] || ln -sfn \"$root/runtimes\" \"$ORCA_HOME/runtime/runtimes\"\nfi\ntest -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ntest -x \"$node\"\n\nif [ -n \"${GH_VERSION:-}\" ]; then\n log \"gh $GH_VERSION\"\n fetch_verified \"https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz\" \\\n \"$GH_SHA256\" /tmp/gh.tgz\n tar -xzf /tmp/gh.tgz -C /tmp\n mkdir -p \"$ORCA_HOME/tools/bin\"\n install -m 0755 \"/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh\" \"$ORCA_HOME/tools/bin/gh\"\n rm -rf /tmp/gh.tgz \"/tmp/gh_${GH_VERSION}_linux_amd64\"\nfi\n\nif [ -n \"${CHROME_VERSION:-}\" ] && command -v apt-get >/dev/null; then\n log \"google-chrome $CHROME_VERSION (headless browser for agents and Orca browser automation)\"\n deb=\"$(mktemp -d)/chrome.deb\"\n fetch_verified \"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb\" \\\n \"$CHROME_SHA256\" \"$deb\"\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \"$deb\" >/dev/null\n rm -rf \"$(dirname \"$deb\")\"\n # The .deb adds Google's apt source; the image stays pinned, so drop it.\n sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/cron.daily/google-chrome\n google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom 'data:text/html,

ok

' 2>/dev/null | grep -q '

ok

'\nfi\n\nlog 'agent CLIs'\n# The image's system npm installs them; agents later run on whatever node orcad puts on PATH.\n# shellcheck disable=SC2086 # word splitting is the package list\nnpm install --global --no-fund --no-audit --prefix \"$ORCA_HOME/tools\" $AGENT_NPM_PACKAGES\n\nlog 'openssh server (binaries only)'\n# For `orca ssh`: an unprivileged sshd on loopback (see vm/orca-sshd). Only the server binaries,\n# kept out of /usr: the packages would pull in systemd units, SELinux policy (~38 MB on AL2023) and\n# system host keys a user-run sshd never uses. Its libraries are already in the base image, except\n# tcp-wrappers on Ubuntu.\nmkdir -p \"$ORCA_HOME/openssh\"\npkgs=\"$(mktemp -d)\"\nif command -v dnf >/dev/null; then\n sudo dnf install -y -q --setopt=install_weak_deps=False cpio >/dev/null\n sudo dnf install -y -q --downloadonly --downloaddir=\"$pkgs\" openssh-server >/dev/null\n (cd \"$pkgs\" && rpm2cpio openssh-server-[0-9]*.rpm | cpio -idm --quiet)\n sudo dnf clean all -q >/dev/null 2>&1 || true\n libexec=\"$pkgs/usr/libexec/openssh\"\nelse\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libwrap0 >/dev/null\n (cd \"$pkgs\" && apt-get download -qq openssh-server openssh-sftp-server >/dev/null)\n for deb in \"$pkgs\"/*.deb; do dpkg-deb -x \"$deb\" \"$pkgs\"; done\n libexec=\"$pkgs/usr/lib/openssh\"\nfi\ninstall -m 0755 \"$pkgs/usr/sbin/sshd\" \"$libexec/sshd-session\" \"$libexec/sftp-server\" \"$ORCA_HOME/openssh/\"\n# OpenSSH 10 splits authentication into its own binary.\nif [ -x \"$libexec/sshd-auth\" ]; then install -m 0755 \"$libexec/sshd-auth\" \"$ORCA_HOME/openssh/\"; fi\nsudo rm -rf \"$pkgs\"\n\"$ORCA_HOME/openssh/sshd\" -V 2>&1 | head -n 1 >&2 || true\n# sshd refuses a locked account ('!') even for key logins; '*' unlocks it with no usable password.\nsudo usermod -p '*' \"$(id -un)\"\n\ninstall_desktop() {\n # Remote desktop (~80 MB): Xvnc + openbox + xterm from the distro, noVNC + websockify pinned\n # tarballs. Only orca-desktop-start runs it, on demand; nothing starts at boot. Every step says\n # `|| return 1`: errexit does not apply inside an `if` condition.\n command -v apt-get >/dev/null || { log 'remote desktop: apt only'; return 1; }\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n tigervnc-standalone-server tigervnc-tools openbox xterm x11-xserver-utils >/dev/null || return 1\n local tmp dir=\"$ORCA_HOME/desktop\"\n tmp=\"$(mktemp -d)\" || return 1\n fetch_verified \"https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz\" \\\n \"$NOVNC_SHA256\" \"$tmp/novnc.tgz\" || return 1\n fetch_verified \"https://github.com/novnc/websockify/archive/refs/tags/v${WEBSOCKIFY_VERSION}.tar.gz\" \\\n \"$WEBSOCKIFY_SHA256\" \"$tmp/websockify.tgz\" || return 1\n rm -rf \"$dir\" && mkdir -p \"$dir/novnc\" \"$dir/websockify\" || return 1\n tar -xzf \"$tmp/novnc.tgz\" -C \"$dir/novnc\" --strip-components 1 || return 1\n tar -xzf \"$tmp/websockify.tgz\" -C \"$dir/websockify\" --strip-components 1 || return 1\n # Orca tab fit: title \"Remote desktop\" (Xvnc's -desktop name), no noVNC favicon, control bar on\n # the right (away from Orca's sidebar edge); a stale per-boot password ends the session with one\n # plain message instead of a reconnect loop. \"Unencrypted\" is noVNC's view of plain ws:// to\n # localhost; the hop to the sandbox rides the end-to-end encrypted runtime connection.\n sed -i -e 's/document.title = e.detail.name + \" - \" + PAGE_TITLE;/document.title = e.detail.name;/' \\\n -e \"s/WebUtil.readSetting('controlbar_pos') === 'right'/WebUtil.readSetting('controlbar_pos', 'right') === 'right'/\" \\\n -e 's/securityFailed(e) {/securityFailed(e) { UI.inhibitReconnect = true; UI.showStatus(\"This remote desktop session ended. Reopen it from the workspace menu.\", \"error\"); return;/' \\\n -e 's/_(\"Connected (unencrypted) to \")/_(\"Connected to \")/' \\\n \"$dir/novnc/app/ui.js\" || return 1\n sed -i '/rel=\"icon\"/d; /rel=\"apple-touch-icon\"/d' \"$dir/novnc/vnc.html\" || return 1\n rm -rf \"$tmp\"\n PYTHONPATH=\"$dir/websockify\" python3 -P -c 'import websockify' >/dev/null || return 1\n command -v Xvnc >/dev/null && test -s \"$dir/novnc/vnc.html\"\n}\ndesktop=false\nif [ -n \"${NOVNC_VERSION:-}\" ]; then\n log 'remote desktop'\n # Optional: a failure leaves an image without the desktop, never a failed build.\n if install_desktop; then\n desktop=true\n else\n log 'remote desktop install failed; continuing without it'\n rm -rf \"$ORCA_HOME/desktop\"\n fi\nfi\n\nlog 'Orca entry points'\nfor 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\n var=\"ORCA_VM_SCRIPT_${name//[-.]/_}\"\n printf '%s' \"${!var}\" | base64 -d >\"$ORCA_HOME/bin/$name\"\n chmod 0755 \"$ORCA_HOME/bin/$name\"\ndone\n# The orca CLI (agents drive worktrees, terminals and the browser through it) talks to this VM's orcad.\ncli=\"$(find \"$ORCA_HOME/runtime\" -name orca-cli.cjs -type f | head -n 1)\"\nif [ -n \"$cli\" ]; then\n printf '#!/usr/bin/env bash\\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\\nexec \"%s\" \"%s\" \"$@\"\\n' \"$node\" \"$cli\" >\"$ORCA_HOME/bin/orca\"\n chmod 0755 \"$ORCA_HOME/bin/orca\"\nelse\n log 'bundle has no orca CLI (orca-cli.cjs): agents will not have `orca` on PATH'\nfi\n# The image's node/npm/npx live only on the build command's PATH; append them so agents (codex's\n# `env node` shebang) and stdio MCP servers launched with npx resolve them in Orca's login shells.\nsystem_node=\"$(command -v npx || command -v node || true)\"\nif [ -z \"$system_node\" ]; then\n echo 'no node/npx on the build PATH' >&2\n exit 1\nfi\nprintf 'export PATH=%s/tools/bin:%s/bin:$PATH:%s\\n' \"$ORCA_HOME\" \"$ORCA_HOME\" \"$(dirname \"$system_node\")\" |\n sudo tee /etc/profile.d/orca.sh >/dev/null\n# Every new shell (login or not: /etc/bashrc sources profile.d) re-reads the session env file.\nsudo install -m 0644 \"$ORCA_HOME/bin/orca-session-env.sh\" /etc/profile.d/zz-orca-session-env.sh\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu's bash.bashrc does not source profile.d for non-login interactive shells (AL2023's bashrc does).\n grep -q zz-orca-session-env /etc/bash.bashrc ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/bash.bashrc >/dev/null\nfi\nsudo chown -R root:root \"$ORCA_HOME\"\n\nlog 'smoke: orcad reaches ready on loopback (and warms the V8 compile cache: ~1.5 s off every boot)'\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/compile-cache\"\nsmoke_data=\"$(mktemp -d)\"\nNODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_USER_DATA=\"$smoke_data\" timeout 60 \"$node\" \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6799 --json >/tmp/orcad-smoke.out 2>/tmp/orcad-smoke.err &\nsmoke_pid=$!\nready=0\nfor _ in $(seq 1 600); do\n if grep -q '\"orca_server_ready\"' /tmp/orcad-smoke.out 2>/dev/null; then ready=1; break; fi\n sleep 0.1\ndone\nkill \"$smoke_pid\" 2>/dev/null || true\nwait \"$smoke_pid\" 2>/dev/null || true\nrm -rf \"$smoke_data\" /tmp/orcad-smoke.out\nif [ \"$ready\" != 1 ]; then\n tail -n 40 /tmp/orcad-smoke.err >&2; exit 1\nfi\n\nlog 'versions'\nversions=\"{}\"\nfor bin in claude codex opencode gemini gh; do\n if [ -x \"$ORCA_HOME/tools/bin/$bin\" ]; then\n v=\"$(\"$ORCA_HOME/tools/bin/$bin\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$bin\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\nfor sys in rg vim git-lfs google-chrome; do\n if command -v \"$sys\" >/dev/null; then\n v=\"$(\"$sys\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$sys\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\ncommand -v tmux >/dev/null && versions=\"$(jq -c --arg v \"$(tmux -V)\" '. + {tmux: $v}' <<<\"$versions\")\"\n[ -x \"$ORCA_HOME/bin/orca\" ] && versions=\"$(jq -c '. + {orca: \"bundled\"}' <<<\"$versions\")\"\njq -c --arg node \"$(\"$node\" --version)\" --arg disk \"$(df -h / | awk 'NR==2{print $2}')\" \\\n --argjson desktop \"$desktop\" '{node: $node, disk: $disk, tools: ., desktop: $desktop}' <<<\"$versions\"\n" -export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"}\\n' \"$STATE/ready.json\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.onorca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\nfor _ in $(seq 1 1200); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.05\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\ndir=\"/dev/shm/orca-env-$(id -u)\"\nif [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex provider block (no secret values: env_key names the variable) ----\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop both managed blocks\n awk '/^# >>> orca-cloud managed/{skip=1} !skip{print} /^# <<< orca-cloud managed/{skip=0}' \"$1\"\n}\nif [ -n \"$openai_base_url\" ] || grep -q '^# >>> orca-cloud managed' \"$codex_config\" 2>/dev/null; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then\n # Top-level keys must precede every table; skip ours if the user already chose a provider/model.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n {\n printf '# >>> orca-cloud managed (Orca Cloud provider; edit in Orca settings)\\n'\n if ! grep -Eq '^[[:space:]]*model_provider[[:space:]]*=' <<<\"$head_keys\"; then\n printf 'model_provider = \"orca_cloud\"\\n'\n fi\n if [ -n \"$openai_model\" ] && ! grep -Eq '^[[:space:]]*model[[:space:]]*=' <<<\"$head_keys\"; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n } >\"$next\"\n else\n cat \"$user_part\" >\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and prints one JSON line:\n# {\"schemaVersion\":1,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"password\":\"<8 chars>\"}\n# Xvnc (X server + VNC in one) listens on a unix socket only; websockify serves noVNC and bridges\n# WebSockets to that socket on 127.0.0.1 only. Nothing is reachable from outside the VM: the\n# desktop reaches it through Orca's port forward. The forward is a localhost port on the user's\n# computer, which any local process or web page can reach, so VNC auth with a per-boot random\n# password guards it (Xvnc blacklists repeated failures). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\ncommand -v vncpasswd >/dev/null || fail 'not installed in this image (vncpasswd missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nready_line() {\n printf '{\"schemaVersion\":1,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"password\":\"%s\"}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$(cat \"$STATE/password\")\"\n}\nlistening() { # websockify answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -f \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\nif alive xvnc && alive websockify && listening && [ -s \"$STATE/password\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ ! -s \"$STATE/password\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # VNC auth uses 8 password bytes; 8 random alphanumerics (~48 bits) plus Xvnc's blacklist.\n password=\"$(LC_ALL=C tr -dc 'A-Za-z0-9' \"$STATE/passwd\"\n printf '%s' \"$password\" >\"$STATE/password\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket for websockify.\n start xvnc Xvnc \":$DISPLAY_NUM\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes VncAuth -PasswordFile \"$STATE/passwd\" \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n start xterm xterm -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11\n fi\nfi\n\nif ! alive websockify || ! listening; then\n if alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\n PYTHONPATH=\"$DESKTOP/websockify\" start websockify python3 -P -W ignore::UserWarning -m websockify \\\n --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" \"127.0.0.1:$PORT\"\n for _ in $(seq 1 100); do\n listening && break\n alive websockify || { tail -n 20 \"$STATE/websockify.log\" >&2; fail 'websockify exited'; }\n sleep 0.05\n done\n listening || fail \"websockify is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" \\\n \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.onorca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=/usr/bin/git\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x /opt/orca/bin/orca-git-as ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec /opt/orca/bin/orca-git-as \"$@\" 2> >(grep -v '^orca-git-as: no teammate identity' >&2)\n fi\n ;;\nesac\nexec \"$real\" \"$@\"\n"} +export const PROVISION_SCRIPT: string = "#!/usr/bin/env bash\n# Runs once inside the build sandbox (as the sandbox user, with sudo). Installs the pinned orcad\n# bundle, agent CLIs and Orca's VM entry points into /opt/orca, then smoke-tests orcad.\n# Inputs (env, from image/golden-image.manifest.json via build-golden-image.ts):\n# ORCAD_BUNDLE_URL ORCAD_BUNDLE_SHA256 [GH_VERSION GH_SHA256] [CHROME_VERSION CHROME_SHA256]\n# AGENT_NPM_PACKAGES (space-separated\n# name@version), [NOVNC_VERSION NOVNC_SHA256 WEBSOCKIFY_VERSION WEBSOCKIFY_SHA256] and ORCA_VM_SCRIPT_ (base64) for each /opt/orca/bin entry point.\nset -euo pipefail\nORCA_HOME=/opt/orca\nlog() { printf '[golden] %s\\n' \"$*\" >&2; }\n\nsudo mkdir -p \"$ORCA_HOME/bin\" \"$ORCA_HOME/runtime\" \"$ORCA_HOME/tools\"\nsudo chown -R \"$(id -u):$(id -g)\" \"$ORCA_HOME\"\n\nlog 'system packages'\nif command -v apt-get >/dev/null; then\n # Sandbox egress is TLS-only: plain-http apt mirrors time out at runtime (W2, wake on SSH).\n sudo sed -i 's#http://#https://#g' /etc/apt/sources.list.d/*.sources /etc/apt/sources.list 2>/dev/null || true\n sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq\n # Developer basics agents expect (Conductor parity): rg, tmux, vim, git-lfs alongside the build tools.\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n ca-certificates curl git git-lfs jq ripgrep tmux vim-nox less openssh-client openssh-server build-essential python3 \\\n unzip zip util-linux procps >/dev/null\n git lfs install --system --skip-repo >/dev/null\n # sshd is started on demand by the wake-on-SSH bridge (key-only, loopback); never at boot.\n sudo systemctl disable ssh.service ssh.socket >/dev/null 2>&1 || true\n # A locked password field (\"!\") makes sshd refuse even key logins; '*' = no password, not locked.\n sudo usermod -p '*' \"$(id -un)\"\nelif command -v dnf >/dev/null; then\n sudo dnf install -y -q ca-certificates curl-minimal git git-lfs jq tmux vim-enhanced gcc gcc-c++ make python3 \\\n unzip util-linux procps-ng >/dev/null\nelse\n log 'no supported package manager'; exit 1\nfi\n\n# Managed images have no /vercel/sandbox; the project lives at /vercel/sandbox/project on every image.\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" /vercel/sandbox\n\nfetch_verified() { # url sha256 dest\n curl -fsSL --retry 3 -o \"$3\" \"$1\"\n echo \"$2 $3\" | sha256sum -c --quiet -\n}\n\nlog 'orcad bundle'\nbundle=\"$(mktemp -d)/orcad.tgz\"\nfetch_verified \"$ORCAD_BUNDLE_URL\" \"$ORCAD_BUNDLE_SHA256\" \"$bundle\"\ntar -xzf \"$bundle\" -C \"$ORCA_HOME/runtime\"\nrm -rf \"$(dirname \"$bundle\")\"\n# Release tarballs (orca-server-linux-*) may nest the server; normalize to runtime/orcad + runtime/runtimes.\nif [ ! -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\" ]; then\n server=\"$(find \"$ORCA_HOME/runtime\" -name orcad-server.js -type f | head -n 1)\"\n [ -n \"$server\" ] || { log 'bundle has no orcad-server.js'; exit 1; }\n root=\"$(dirname \"$(dirname \"$server\")\")\"\n ln -sfn \"$(dirname \"$server\")\" \"$ORCA_HOME/runtime/orcad\"\n [ -d \"$ORCA_HOME/runtime/runtimes\" ] || ln -sfn \"$root/runtimes\" \"$ORCA_HOME/runtime/runtimes\"\nfi\ntest -s \"$ORCA_HOME/runtime/orcad/orcad-server.js\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ntest -x \"$node\"\n\nif [ -n \"${GH_VERSION:-}\" ]; then\n log \"gh $GH_VERSION\"\n fetch_verified \"https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz\" \\\n \"$GH_SHA256\" /tmp/gh.tgz\n tar -xzf /tmp/gh.tgz -C /tmp\n mkdir -p \"$ORCA_HOME/tools/bin\"\n install -m 0755 \"/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh\" \"$ORCA_HOME/tools/bin/gh\"\n rm -rf /tmp/gh.tgz \"/tmp/gh_${GH_VERSION}_linux_amd64\"\nfi\n\nif [ -n \"${CHROME_VERSION:-}\" ] && command -v apt-get >/dev/null; then\n log \"google-chrome $CHROME_VERSION (headless browser for agents and Orca browser automation)\"\n deb=\"$(mktemp -d)/chrome.deb\"\n fetch_verified \"https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_amd64.deb\" \\\n \"$CHROME_SHA256\" \"$deb\"\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \"$deb\" >/dev/null\n rm -rf \"$(dirname \"$deb\")\"\n # The .deb adds Google's apt source; the image stays pinned, so drop it.\n sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/cron.daily/google-chrome\n google-chrome --headless=new --no-sandbox --disable-gpu --dump-dom 'data:text/html,

ok

' 2>/dev/null | grep -q '

ok

'\nfi\n\nlog 'agent CLIs'\n# The image's system npm installs them; agents later run on whatever node orcad puts on PATH.\n# shellcheck disable=SC2086 # word splitting is the package list\nnpm install --global --no-fund --no-audit --prefix \"$ORCA_HOME/tools\" $AGENT_NPM_PACKAGES\n\nlog 'openssh server (binaries only)'\n# For `orca ssh`: an unprivileged sshd on loopback (see vm/orca-sshd). Only the server binaries,\n# kept out of /usr: the packages would pull in systemd units, SELinux policy (~38 MB on AL2023) and\n# system host keys a user-run sshd never uses. Its libraries are already in the base image, except\n# tcp-wrappers on Ubuntu.\nmkdir -p \"$ORCA_HOME/openssh\"\npkgs=\"$(mktemp -d)\"\nif command -v dnf >/dev/null; then\n sudo dnf install -y -q --setopt=install_weak_deps=False cpio >/dev/null\n sudo dnf install -y -q --downloadonly --downloaddir=\"$pkgs\" openssh-server >/dev/null\n (cd \"$pkgs\" && rpm2cpio openssh-server-[0-9]*.rpm | cpio -idm --quiet)\n sudo dnf clean all -q >/dev/null 2>&1 || true\n libexec=\"$pkgs/usr/libexec/openssh\"\nelse\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends libwrap0 >/dev/null\n (cd \"$pkgs\" && apt-get download -qq openssh-server openssh-sftp-server >/dev/null)\n for deb in \"$pkgs\"/*.deb; do dpkg-deb -x \"$deb\" \"$pkgs\"; done\n libexec=\"$pkgs/usr/lib/openssh\"\nfi\ninstall -m 0755 \"$pkgs/usr/sbin/sshd\" \"$libexec/sshd-session\" \"$libexec/sftp-server\" \"$ORCA_HOME/openssh/\"\n# OpenSSH 10 splits authentication into its own binary.\nif [ -x \"$libexec/sshd-auth\" ]; then install -m 0755 \"$libexec/sshd-auth\" \"$ORCA_HOME/openssh/\"; fi\nsudo rm -rf \"$pkgs\"\n\"$ORCA_HOME/openssh/sshd\" -V 2>&1 | head -n 1 >&2 || true\n# sshd refuses a locked account ('!') even for key logins; '*' unlocks it with no usable password.\nsudo usermod -p '*' \"$(id -un)\"\n\ninstall_desktop() {\n # Remote desktop (~80 MB): Xvnc + openbox + xterm from the distro, noVNC + websockify pinned\n # tarballs. Only orca-desktop-start runs it, on demand; nothing starts at boot. Every step says\n # `|| return 1`: errexit does not apply inside an `if` condition.\n command -v apt-get >/dev/null || { log 'remote desktop: apt only'; return 1; }\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \\\n tigervnc-standalone-server tigervnc-tools openbox xterm x11-xserver-utils >/dev/null || return 1\n local tmp dir=\"$ORCA_HOME/desktop\"\n tmp=\"$(mktemp -d)\" || return 1\n fetch_verified \"https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz\" \\\n \"$NOVNC_SHA256\" \"$tmp/novnc.tgz\" || return 1\n fetch_verified \"https://github.com/novnc/websockify/archive/refs/tags/v${WEBSOCKIFY_VERSION}.tar.gz\" \\\n \"$WEBSOCKIFY_SHA256\" \"$tmp/websockify.tgz\" || return 1\n rm -rf \"$dir\" && mkdir -p \"$dir/novnc\" \"$dir/websockify\" || return 1\n tar -xzf \"$tmp/novnc.tgz\" -C \"$dir/novnc\" --strip-components 1 || return 1\n tar -xzf \"$tmp/websockify.tgz\" -C \"$dir/websockify\" --strip-components 1 || return 1\n # Orca tab fit: title \"Remote desktop\" (Xvnc's -desktop name), no noVNC favicon, control bar on\n # the right (away from Orca's sidebar edge); a stale per-boot password ends the session with one\n # plain message instead of a reconnect loop. \"Unencrypted\" is noVNC's view of plain ws:// to\n # localhost; the hop to the sandbox rides the end-to-end encrypted runtime connection.\n sed -i -e 's/document.title = e.detail.name + \" - \" + PAGE_TITLE;/document.title = e.detail.name;/' \\\n -e \"s/WebUtil.readSetting('controlbar_pos') === 'right'/WebUtil.readSetting('controlbar_pos', 'right') === 'right'/\" \\\n -e 's/securityFailed(e) {/securityFailed(e) { UI.inhibitReconnect = true; UI.showStatus(\"This remote desktop session ended. Reopen it from the workspace menu.\", \"error\"); return;/' \\\n -e 's/_(\"Connected (unencrypted) to \")/_(\"Connected to \")/' \\\n \"$dir/novnc/app/ui.js\" || return 1\n sed -i '/rel=\"icon\"/d; /rel=\"apple-touch-icon\"/d' \"$dir/novnc/vnc.html\" || return 1\n rm -rf \"$tmp\"\n PYTHONPATH=\"$dir/websockify\" python3 -P -c 'import websockify' >/dev/null || return 1\n command -v Xvnc >/dev/null && test -s \"$dir/novnc/vnc.html\"\n}\ndesktop=false\nif [ -n \"${NOVNC_VERSION:-}\" ]; then\n log 'remote desktop'\n # Optional: a failure leaves an image without the desktop, never a failed build.\n if install_desktop; then\n desktop=true\n else\n log 'remote desktop install failed; continuing without it'\n rm -rf \"$ORCA_HOME/desktop\"\n fi\nfi\n\nlog 'Orca entry points'\nfor 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\n var=\"ORCA_VM_SCRIPT_${name//[-.]/_}\"\n printf '%s' \"${!var}\" | base64 -d >\"$ORCA_HOME/bin/$name\"\n chmod 0755 \"$ORCA_HOME/bin/$name\"\ndone\n# The orca CLI (agents drive worktrees, terminals and the browser through it) talks to this VM's orcad.\ncli=\"$(find \"$ORCA_HOME/runtime\" -name orca-cli.cjs -type f | head -n 1)\"\nif [ -n \"$cli\" ]; then\n printf '#!/usr/bin/env bash\\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\\nexec \"%s\" \"%s\" \"$@\"\\n' \"$node\" \"$cli\" >\"$ORCA_HOME/bin/orca\"\n chmod 0755 \"$ORCA_HOME/bin/orca\"\nelse\n log 'bundle has no orca CLI (orca-cli.cjs): agents will not have `orca` on PATH'\nfi\n# The image's node/npm/npx live only on the build command's PATH; append them so agents (codex's\n# `env node` shebang) and stdio MCP servers launched with npx resolve them in Orca's login shells.\nsystem_node=\"$(command -v npx || command -v node || true)\"\nif [ -z \"$system_node\" ]; then\n echo 'no node/npx on the build PATH' >&2\n exit 1\nfi\nprintf 'export PATH=%s/tools/bin:%s/bin:$PATH:%s\\n' \"$ORCA_HOME\" \"$ORCA_HOME\" \"$(dirname \"$system_node\")\" |\n sudo tee /etc/profile.d/orca.sh >/dev/null\n# Every new shell (login or not: /etc/bashrc sources profile.d) re-reads the session env file.\nsudo install -m 0644 \"$ORCA_HOME/bin/orca-session-env.sh\" /etc/profile.d/zz-orca-session-env.sh\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu's bash.bashrc does not source profile.d for non-login interactive shells (AL2023's bashrc does).\n grep -q zz-orca-session-env /etc/bash.bashrc ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/bash.bashrc >/dev/null\nfi\nsudo chown -R root:root \"$ORCA_HOME\"\n\nlog 'smoke: orcad reaches ready on loopback (and warms the V8 compile cache: ~1.5 s off every boot)'\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/compile-cache\"\nsmoke_data=\"$(mktemp -d)\"\nNODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_USER_DATA=\"$smoke_data\" timeout 60 \"$node\" \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6799 --json >/tmp/orcad-smoke.out 2>/tmp/orcad-smoke.err &\nsmoke_pid=$!\nready=0\nfor _ in $(seq 1 600); do\n if grep -q '\"orca_server_ready\"' /tmp/orcad-smoke.out 2>/dev/null; then ready=1; break; fi\n sleep 0.1\ndone\nkill \"$smoke_pid\" 2>/dev/null || true\nwait \"$smoke_pid\" 2>/dev/null || true\nrm -rf \"$smoke_data\" /tmp/orcad-smoke.out\nif [ \"$ready\" != 1 ]; then\n tail -n 40 /tmp/orcad-smoke.err >&2; exit 1\nfi\n\nlog 'versions'\nversions=\"{}\"\nfor bin in claude codex opencode gemini gh; do\n if [ -x \"$ORCA_HOME/tools/bin/$bin\" ]; then\n v=\"$(\"$ORCA_HOME/tools/bin/$bin\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$bin\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\nfor sys in rg vim git-lfs google-chrome; do\n if command -v \"$sys\" >/dev/null; then\n v=\"$(\"$sys\" --version 2>/dev/null | head -n 1 || echo unknown)\"\n versions=\"$(jq -c --arg k \"$sys\" --arg v \"$v\" '. + {($k): $v}' <<<\"$versions\")\"\n fi\ndone\ncommand -v tmux >/dev/null && versions=\"$(jq -c --arg v \"$(tmux -V)\" '. + {tmux: $v}' <<<\"$versions\")\"\n[ -x \"$ORCA_HOME/bin/orca\" ] && versions=\"$(jq -c '. + {orca: \"bundled\"}' <<<\"$versions\")\"\njq -c --arg node \"$(\"$node\" --version)\" --arg disk \"$(df -h / | awk 'NR==2{print $2}')\" \\\n --argjson desktop \"$desktop\" '{node: $node, disk: $disk, tools: ., desktop: $desktop}' <<<\"$versions\"\n" +export const VM_SCRIPT_SOURCES: BuildInputs['vmScripts'] = {"orca-sandbox-start":"#!/usr/bin/env bash\n# Starts orcad for this boot (idempotent) and prints its recipe line:\n# {\"schemaVersion\":1,\"pairingCode\":\"orca://pair?...\",\"projectRoot\":\"/vercel/sandbox/project\"}\n# Env from the control plane: ORCA_SANDBOX_ID, ORCA_PUBLIC_WSS, optional ORCA_REPO_URL/ORCA_REPO_REF,\n# ORCA_GITHUB_PLACEHOLDER, ORCA_SECRET_ENV (`NAME=` lines: org/personal/repo secrets for\n# orcad and its agents), ORCA_SHARE_PROXY_KEY (this session's share-link proxy key, port 6769).\n# Never prints anything else on stdout's last line.\n# With ORCA_READY_VIA=file it prints {\"schemaVersion\":1,\"readyFile\":\"\"} instead and the control\n# plane reads the offer with the files API: the provider keeps command stdout as logs, not files.\n# Managed env for every process orcad starts: ORCA_IS_CLOUD=1, ORCA_SANDBOX_ID, ORCA_ROOT_PATH,\n# ORCA_WORKTREE_PATH, ORCA_WORKSPACE_PATH, ORCA_WORKSPACE_NAME, ORCA_DEFAULT_BRANCH, ORCA_PORT and the\n# CONDUCTOR_* names imported Conductor scripts read.\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nPROJECT=/vercel/sandbox/project\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 60 9\n# Background children close fd 9 (`9>&-`): an orcad that inherited it would hold the lock for its whole\n# life and every later start (resume of a running sandbox, a retried create) would wait out the 60 s.\n\n# orcad only lists a project once a client registers it (the desktop does on connect), so API, MCP,\n# routine and phone sessions saw no repo. Register the workspace project at every start, in the\n# background (ready is not delayed); `repo add` of an already-registered path is a no-op error.\nregister_project() {\n [ -x \"$ORCA_HOME/bin/orca\" ] && [ -d \"$PROJECT\" ] || return 0\n local kind=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"}\\n' \"$STATE/ready.json\"\n else\n tail -n 1 \"$STATE/ready.json\"\n fi\n}\n# Command stderr is kept by the provider: strip injected values and credential shapes from log tails.\nredact_log() {\n local text kv value\n text=\"$(cat)\"\n for kv in ${agent_env[@]+\"${agent_env[@]}\"}; do\n value=\"${kv#*=}\"\n if [ \"${#value}\" -ge 8 ]; then\n text=\"${text//\"$value\"/[REDACTED]}\"\n fi\n done\n printf '%s\\n' \"$text\" | sed -E \\\n -e 's#orca://[^[:space:]]+#[REDACTED_PAIRING]#g' \\\n -e 's#(gh[pousr]_|github_pat_|sk-|vc[kpiar]_)[A-Za-z0-9._-]{16,}#[REDACTED_TOKEN]#g' \\\n -e 's#eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}#[REDACTED_JWT]#g' \\\n -e 's#(Authorization|authorization):[[:space:]]*[A-Za-z]+[[:space:]]+[^[:space:]]+#\\1: [REDACTED]#g'\n}\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n\n# Share-link proxy (port 6769): answers only requests the control plane signed with this session's\n# key, so its public route is useless on its own. The key goes in on stdin (printf is a builtin), so\n# it never appears in any argv or in the proxy's /proc//environ.\nstart_share_proxy() {\n [ -n \"${ORCA_SHARE_PROXY_KEY:-}\" ] && [ -x \"$ORCA_HOME/bin/orca-share-proxy\" ] || return 0\n if [ \"$(cat \"$STATE/share-proxy.boot\" 2>/dev/null || true)\" = \"$boot_id\" ] \\\n && kill -0 \"$(cat \"$STATE/share-proxy.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n return 0\n fi\n printf '%s' \"$ORCA_SHARE_PROXY_KEY\" | env -i HOME=\"$HOME\" LANG=C.UTF-8 PATH=/usr/bin:/bin \\\n setsid \"$node\" \"$ORCA_HOME/bin/orca-share-proxy\" >\"$STATE/share-proxy.log\" 2>&1 9>&- &\n echo $! >\"$STATE/share-proxy.pid\"\n printf '%s' \"$boot_id\" >\"$STATE/share-proxy.boot\"\n}\n\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nstart_share_proxy\nunset ORCA_SHARE_PROXY_KEY\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" = \"$boot_id\" ] && [ -s \"$STATE/ready.json\" ] \\\n && kill -0 \"$(cat \"$STATE/orcad.pid\" 2>/dev/null || echo 0)\" 2>/dev/null; then\n print_ready\n exit 0\nfi\n# Resume = reboot from disk: locks and pids from the previous session name dead processes.\nrm -f \"$STATE/ready.json\" \"$STATE/orcad.pid\"\nprintf '%s' \"$boot_id\" >\"$STATE/boot-id\"\n\nif [ -n \"${ORCA_GIT_EXTRAHEADER:-}\" ]; then\n # Every github.com request carries the placeholder; the egress policy swaps in a 1h repo token.\n git config --global http.https://github.com/.extraheader \"$ORCA_GIT_EXTRAHEADER\"\nfi\n# Commits outside a teammate's turn (or before any member connected GitHub) need an author too.\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.onorca.dev'\ngit config --global user.name >/dev/null 2>&1 || git config --global user.name 'Orca Cloud'\n# First boot clones while orcad starts (orcad only needs the directory to exist); ready waits for both.\nclone_pid=''\nif [ ! -d \"$PROJECT\" ]; then\n mkdir -p \"$PROJECT\"\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n git clone --quiet ${ORCA_REPO_REF:+--branch \"$ORCA_REPO_REF\"} -- \"$ORCA_REPO_URL\" \"$PROJECT\" 2>\"$STATE/clone.log\" 9>&- &\n clone_pid=$!\n fi\nfi\n\n# The clone may still be running here, so the name comes from the URL; the default branch is\n# filled in lazily by the session-env shell hook once the clone exists.\nif [ -n \"${ORCA_REPO_URL:-}\" ]; then\n workspace_name=\"$(basename \"${ORCA_REPO_URL%/}\" .git)\"\nelse\n workspace_name=\"$(basename \"$(git -C \"$PROJECT\" remote get-url origin 2>/dev/null || echo \"$PROJECT\")\" .git)\"\nfi\n# One workspace per VM: its preview ports start at 3000 (Conductor allots CONDUCTOR_PORT..+9).\nmanaged_env=(\n ORCA_IS_CLOUD=1\n ORCA_ROOT_PATH=\"$PROJECT\" ORCA_WORKTREE_PATH=\"$PROJECT\" ORCA_WORKSPACE_PATH=\"$PROJECT\"\n ORCA_WORKSPACE_NAME=\"$workspace_name\" ORCA_PORT=3000\n CONDUCTOR_IS_CLOUD=1\n CONDUCTOR_ROOT_PATH=\"$PROJECT\" CONDUCTOR_WORKSPACE_PATH=\"$PROJECT\"\n CONDUCTOR_WORKSPACE_NAME=\"$workspace_name\" CONDUCTOR_PORT=3000\n)\n\nagent_env=()\nif command -v google-chrome >/dev/null; then\n # Orca's browser automation and agent browser tools use the image's headless Chrome.\n agent_env+=(\"ORCA_BROWSER_EXECUTABLE=$(command -v google-chrome)\")\nfi\nif [ -n \"${ORCA_GITHUB_PLACEHOLDER:-}\" ]; then\n # The egress layer swaps the placeholder for a short-lived GitHub token; none lives in the VM.\n agent_env+=(\"GH_TOKEN=$ORCA_GITHUB_PLACEHOLDER\" \"GITHUB_TOKEN=$ORCA_GITHUB_PLACEHOLDER\")\nfi\n\nsecret_names=()\nif [ -n \"${ORCA_SECRET_ENV:-}\" ]; then\n # Secrets live only in memory: orcad's process env and a tmpfs file new shells re-read\n # (orca-sandbox-env), never under $HOME or the project, so the stop snapshot never carries them.\n # Resolved per start (and per edit) by the control plane: no image rebuild, no restart.\n while IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\" # not IFS='=': read would strip base64 padding\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n agent_env+=(\"$name=$(printf '%s' \"$b64\" | base64 -d)\")\n secret_names+=(\"$name\")\n done < <(printf '%s\\n' \"$ORCA_SECRET_ENV\") # a pipe, not a here-string temp file\nfi\n# Always rewrite the file, so a resume with fewer secrets drops the old names too.\n\"$ORCA_HOME/bin/orca-sandbox-env\" >/dev/null || echo 'session env file unavailable; secrets apply at boot only' >&2\nunset ORCA_SECRET_ENV\n\n# Agent CLIs installed with the image's npm (codex, gemini, ...) are `#!/usr/bin/env node` scripts:\n# terminals need that node on PATH, not only orcad's bundled runtime.\nsystem_node=\"$(command -v node 2>/dev/null || echo \"$node\")\"\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \\\n PATH=\"$ORCA_HOME/tools/bin:$ORCA_HOME/bin:$(dirname \"$system_node\"):/usr/local/bin:/usr/bin:/bin\" \\\n ORCA_USER_DATA=\"$HOME/.orcad-data\" ORCA_SANDBOX_ID=\"$ORCA_SANDBOX_ID\" \"${managed_env[@]}\" \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/runtime/orcad/orcad-server.js\" \\\n --port 6768 --bind 0.0.0.0 --pairing-address \"$ORCA_PUBLIC_WSS\" \\\n --recipe-json --project-root \"$PROJECT\" \\\n >\"$STATE/ready.json\" 2>\"$STATE/orcad.log\" &- &\necho $! >\"$STATE/orcad.pid\"\n\n# sshd for `orca ssh` (loopback only). Best effort: a workspace without it still serves orcad.\n\"$ORCA_HOME/bin/orca-sshd\" >/dev/null 2>\"$STATE/sshd-start.err\" || true\n\nfor _ in $(seq 1 1200); do\n if grep -q '\"pairingCode\"' \"$STATE/ready.json\" 2>/dev/null; then\n if [ -n \"$clone_pid\" ] && ! wait \"$clone_pid\"; then\n echo 'repository clone failed' >&2\n tail -n 5 \"$STATE/clone.log\" | redact_log >&2\n exit 1\n fi\n register_project\n print_ready\n exit 0\n fi\n if ! kill -0 \"$(cat \"$STATE/orcad.pid\")\" 2>/dev/null; then\n echo 'orcad exited before ready' >&2\n tail -n 40 \"$STATE/orcad.log\" | redact_log >&2\n exit 1\n fi\n sleep 0.05\ndone\necho 'orcad start timed out' >&2\nexit 1\n","orca-sandbox-drain":"#!/usr/bin/env bash\n# Asks orcad to quit before the VM stops: its shutdown turns working agent chats into durable\n# restart offers and flushes terminal restore hints, which the desktop continues after resume.\nset -euo pipefail\nSTATE=\"$HOME/.orca-sandbox\"\nWAIT_SECONDS=\"${1:-280}\"\npid=\"$(cat \"$STATE/orcad.pid\" 2>/dev/null || true)\"\nif [ -z \"$pid\" ] || ! kill -0 \"$pid\" 2>/dev/null; then\n exit 0\nfi\nkill -TERM \"$pid\"\nfor _ in $(seq 1 $((WAIT_SECONDS * 10))); do\n if ! kill -0 \"$pid\" 2>/dev/null; then\n rm -f \"$STATE/orcad.pid\" \"$STATE/ready.json\"\n exit 0\n fi\n sleep 0.1\ndone\n# Leave it running: the provider stop ends it, and the drain already had its window.\necho 'orcad still draining at deadline' >&2\nexit 0\n","orca-sandbox-env":"#!/usr/bin/env bash\n# Writes this session's secret env where new shells and agent launches read it (orca-session-env.sh).\n# Input: ORCA_SECRET_ENV (`NAME=` lines) from the control plane, at boot (orca-sandbox-start)\n# and whenever a secret changes (POST /v1/sandboxes/:id/env/refresh). The file lives on tmpfs only:\n# never on the sandbox disk, so the stop snapshot never carries it. Prints {\"names\":N} on success.\n# Also keeps Codex pointed at a configured OpenAI-compatible provider (OPENAI_BASE_URL), since Codex\n# does not read that variable: a managed block in ~/.codex/config.toml naming only the env var.\nset -euo pipefail\numask 077\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\ndir=\"/dev/shm/orca-env-$(id -u)\"\nif [ \"$(stat -f -c %T /dev/shm 2>/dev/null || true)\" != tmpfs ]; then\n # No memory-only place to keep it: refuse rather than write secrets to disk.\n echo 'no tmpfs for the session env' >&2\n exit 3\nfi\nmkdir -p \"$dir\"\nchmod 700 \"$dir\"\n# Writes are ordered by the control plane's resolve time: an older refresh that lost a race is dropped.\ngeneration=\"${ORCA_ENV_GENERATION:-0}\"\ncase \"$generation\" in '' | *[!0-9]*) generation=0 ;; esac\ncurrent=\"$(cat \"$dir/generation\" 2>/dev/null || echo 0)\"\ncase \"$current\" in '' | *[!0-9]*) current=0 ;; esac\nif [ \"$generation\" -lt \"$current\" ]; then\n printf '{\"stale\":true}\\n'\n exit 0\nfi\ntmp=\"$(mktemp \"$dir/.session.env.XXXXXX\")\"\ncount=0\nopenai_base_url=''\nopenai_model=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n # Values stay base64 in the file; the shell hook decodes them.\n printf '%s=%s\\n' \"$name\" \"$b64\" >>\"$tmp\"\n count=$((count + 1))\n case \"$name\" in\n OPENAI_BASE_URL) openai_base_url=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n OPENAI_MODEL) openai_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex provider block (no secret values: env_key names the variable) ----\ncodex_config=\"$HOME/.codex/config.toml\"\ntoml_string() { # TOML basic string; the control plane only accepts https URLs and plain model ids\n local value=\"${1//\\\\/\\\\\\\\}\"\n printf '\"%s\"' \"${value//\\\"/\\\\\\\"}\"\n}\nstrip_managed() { # drop both managed blocks\n awk '/^# >>> orca-cloud managed/{skip=1} !skip{print} /^# <<< orca-cloud managed/{skip=0}' \"$1\"\n}\nif [ -n \"$openai_base_url\" ] || grep -q '^# >>> orca-cloud managed' \"$codex_config\" 2>/dev/null; then\n mkdir -p \"$HOME/.codex\"\n user_part=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n if [ -f \"$codex_config\" ]; then strip_managed \"$codex_config\" >\"$user_part\"; fi\n next=\"$(mktemp \"$HOME/.codex/.config.XXXXXX\")\"\n openai_host=\"${openai_base_url#https://}\"\n openai_host=\"${openai_host%%/*}\"\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then\n # Top-level keys must precede every table; skip ours if the user already chose a provider/model.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n {\n printf '# >>> orca-cloud managed (Orca Cloud provider; edit in Orca settings)\\n'\n if ! grep -Eq '^[[:space:]]*model_provider[[:space:]]*=' <<<\"$head_keys\"; then\n printf 'model_provider = \"orca_cloud\"\\n'\n fi\n if [ -n \"$openai_model\" ] && ! grep -Eq '^[[:space:]]*model[[:space:]]*=' <<<\"$head_keys\"; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_cloud]\\nname = \"Orca Cloud\"\\n'\n printf 'base_url = %s\\nenv_key = \"OPENAI_API_KEY\"\\nwire_api = \"responses\"\\n' \"$(toml_string \"$openai_base_url\")\"\n printf '# <<< orca-cloud managed\\n'\n } >\"$next\"\n else\n cat \"$user_part\" >\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\nprintf '{\"names\":%d}\\n' \"$count\"\n","orca-session-env.sh":"# Orca Cloud: sourced by every new bash/zsh (via /etc/profile.d) so terminals and agents started\n# after a secret edit get the current values with no restart. The file is written by\n# /opt/orca/bin/orca-sandbox-env on tmpfs; names it no longer lists are unset. Cloud secrets win\n# over same-named values already in the environment.\n__orca_env_file=\"/dev/shm/orca-env-$(id -u 2>/dev/null)/session.env\"\nif [ -r \"$__orca_env_file\" ]; then\n # $(...) word-splits in both bash and zsh.\n for __orca_n in $(printf '%s' \"${ORCA_SESSION_ENV_NAMES-}\"); do\n unset \"$__orca_n\" 2>/dev/null || true\n done\n __orca_names=''\n while IFS= read -r __orca_line || [ -n \"$__orca_line\" ]; do\n __orca_n=\"${__orca_line%%=*}\"\n case \"$__orca_n\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n export \"$__orca_n=$(printf '%s' \"${__orca_line#*=}\" | base64 -d 2>/dev/null)\"\n __orca_names=\"$__orca_names $__orca_n\"\n done <\"$__orca_env_file\"\n export ORCA_SESSION_ENV_NAMES=\"${__orca_names# }\"\nfi\n# Default branch of the cloud workspace repo, once its first-boot clone exists (computed once per shell tree).\nif [ -n \"${ORCA_WORKSPACE_PATH-}\" ] && [ -z \"${ORCA_DEFAULT_BRANCH-}\" ]; then\n __orca_branch=\"$(git -C \"$ORCA_WORKSPACE_PATH\" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)\"\n if [ -n \"$__orca_branch\" ]; then\n export ORCA_DEFAULT_BRANCH=\"${__orca_branch#origin/}\" CONDUCTOR_DEFAULT_BRANCH=\"${__orca_branch#origin/}\"\n fi\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-egress-probe":"#!/usr/bin/env bash\n# Bounded Node fetch against each credential host. Vercel transform rules have made undici hang on\n# response bodies (no content-length / transfer-encoding); report it instead of letting agents hang.\n# Prints one JSON line: {\"host\":\"api.github.com\",\"status\":\"ok\"|\"egress_framing_hang\"|\"error\"}.\nset -euo pipefail\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\nfor host in \"$@\"; do\n \"$node\" -e '\n const host = process.argv[1]\n const timer = setTimeout(() => { console.log(JSON.stringify({ host, status: \"egress_framing_hang\" })); process.exit(0) }, 8000)\n fetch(`https://${host}/`, { headers: { authorization: process.env.ORCA_PROBE_AUTHORIZATION ?? \"\" } })\n .then((r) => r.arrayBuffer())\n .then(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"ok\" })) })\n .catch(() => { clearTimeout(timer); console.log(JSON.stringify({ host, status: \"error\" })) })\n ' \"$host\"\ndone\n","orca-image-wipe":"#!/usr/bin/env bash\n# Runs last before the golden snapshot. Every sandbox cloned from the image must start without an\n# orcad identity, device registry, agent sign-in or shell history (clone-safe identity, risk #1).\nset -euo pipefail\npkill -f orcad-server.js 2>/dev/null || true\nfor path in \\\n \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\" \"$HOME/.orca-desktop\" \"$HOME/.config/orca\" \"$HOME/.config/orca-dev\" \\\n \"$HOME/.claude\" \"$HOME/.claude.json\" \"$HOME/.codex\" \"$HOME/.config/gh\" \"$HOME/.gemini\" \\\n \"$HOME/.local/share/opencode\" \"$HOME/.npm\" \"$HOME/.cache\" \"$HOME/.config/google-chrome\" \"$HOME/.bash_history\" \\\n /vercel/sandbox/project; do\n rm -rf -- \"$path\"\ndone\nsudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true\nfor leftover in \"$HOME/.orcad-data\" \"$HOME/.claude\" \"$HOME/.codex\" \"$HOME/.config/gh\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-share-proxy":"#!/usr/bin/env node\n// Share-link proxy for this VM. Listens on 0.0.0.0:6769 (its public *.vercel.run route) and only\n// serves requests the control plane's share gateway signed with this session's key:\n// x-orca-share: v2.....\n// A request must start before `expiresAt` (60s windows); its response or WebSocket is closed at\n// `until` (the link's expiry, at most 30 minutes out), so a revoked or expired link also loses open\n// streams. Signed requests are reverse-proxied to 127.0.0.1:: streaming both ways, no\n// buffering, WebSocket upgrades piped raw. Anything unsigned gets a bare 404.\n// The key arrives on stdin (orca-sandbox-start pipes it), so it is never in this process's\n// environment or argv; ORCA_SHARE_PROXY_KEY in the env is accepted for tests. Optional\n// ORCA_SHARE_PROXY_PORT (default 6769). Plain Node, no dependencies.\n// getBuiltinModule (Node >= 22.3) works whether node loads this extensionless file as CommonJS\n// (in the VM) or as an ES module (under this repo's type:module package, in tests).\nconst crypto = process.getBuiltinModule('node:crypto')\nconst fs = process.getBuiltinModule('node:fs')\nconst http = process.getBuiltinModule('node:http')\nconst net = process.getBuiltinModule('node:net')\n\nconst LISTEN_PORT = Number(process.env.ORCA_SHARE_PROXY_PORT || 6769)\nconst KEY = Buffer.from((process.env.ORCA_SHARE_PROXY_KEY || fs.readFileSync(0, 'utf8')).trim(), 'base64url')\ndelete process.env.ORCA_SHARE_PROXY_KEY\n// Never proxy to the VM's sshd, orcad or this proxy itself.\n// 6080: the remote desktop (VNC behind a password) is for its owner's forward only.\nconst BLOCKED_PORTS = new Set([2222, 6080, 6768, LISTEN_PORT])\nconst MAX_SKEW_SECONDS = 300\nconst MAX_CONNECTION_SECONDS = 3600\nconst HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade']\n\nif (KEY.length < 32) {\n console.error('orca-share-proxy: share proxy key missing or short')\n process.exit(2)\n}\n\n/** The target port and close-by time (ms) for a validly signed request, else null. */\nfunction verify(header, nowMs) {\n if (typeof header !== 'string') {\n return null\n }\n const match = /^v2\\.(\\d{1,5})\\.(\\d{1,12})\\.(\\d{1,12})\\.([0-9a-f]{32})\\.([A-Za-z0-9_-]{43})$/.exec(header)\n if (!match) {\n return null\n }\n const now = Math.floor(nowMs / 1000)\n const port = Number(match[1])\n const expiresAt = Number(match[2])\n const until = Number(match[3])\n if (port < 1 || port > 65535 || BLOCKED_PORTS.has(port)) {\n return null\n }\n if (expiresAt < now || expiresAt > now + MAX_SKEW_SECONDS || until <= now || until > now + MAX_CONNECTION_SECONDS) {\n return null\n }\n const signed = header.slice(0, header.lastIndexOf('.'))\n const expected = crypto.createHmac('sha256', KEY).update(signed).digest()\n const given = Buffer.from(match[5], 'base64url')\n return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { port, untilMs: until * 1000 } : null\n}\n\nfunction upstreamHeaders(raw, port) {\n const headers = { ...raw }\n const connectionTokens = String(raw.connection || '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n for (const name of [...HOP_BY_HOP, ...connectionTokens, 'x-orca-share']) {\n delete headers[name]\n }\n // Dev servers (Vite, Next) allow localhost hosts by default; the public host rides x-forwarded-host.\n headers.host = `localhost:${port}`\n return headers\n}\n\nfunction refuse(res) {\n res.writeHead(404, { 'content-type': 'text/plain', 'cache-control': 'no-store' })\n res.end('not found')\n}\n\n/** Ends `close` at the grant's deadline; the timer goes away with the connection. */\nfunction deadline(untilMs, close, ...emitters) {\n const timer = setTimeout(close, Math.max(0, untilMs - Date.now()))\n for (const emitter of emitters) {\n emitter.once('close', () => clearTimeout(timer))\n }\n}\n\nconst server = http.createServer((req, res) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n return refuse(res)\n }\n const { port } = grant\n const upstream = http.request(\n { host: '127.0.0.1', port, method: req.method, path: req.url, headers: upstreamHeaders(req.headers, port) },\n (answer) => {\n const headers = { ...answer.headers }\n for (const name of HOP_BY_HOP) {\n delete headers[name]\n }\n // Marks the answer as the app's, so the gateway can tell it from a route with no proxy behind it.\n headers['x-orca-share-proxy'] = '1'\n res.writeHead(answer.statusCode || 502, answer.statusMessage, headers)\n answer.pipe(res)\n }\n )\n deadline(grant.untilMs, () => {\n upstream.destroy()\n res.destroy()\n }, res)\n upstream.on('error', (error) => {\n if (res.headersSent) {\n res.destroy()\n return\n }\n // The gateway renders its own page for this; the code tells it why. Status 200 on purpose: a 5xx\n // from the VM reaches a Worker as Vercel's own 502 with our headers gone (found live).\n res.writeHead(200, { 'content-type': 'text/plain', 'x-orca-share-proxy': '1', 'x-orca-share-error': error.code === 'ECONNREFUSED' ? 'no_listener' : 'upstream_failed' })\n res.end('upstream unavailable')\n })\n req.on('aborted', () => upstream.destroy())\n req.pipe(upstream)\n})\n\nserver.on('upgrade', (req, socket, head) => {\n const grant = verify(req.headers['x-orca-share'], Date.now())\n if (grant === null) {\n socket.end('HTTP/1.1 404 Not Found\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n return\n }\n const { port } = grant\n const headers = upstreamHeaders(req.headers, port)\n const target = net.connect(port, '127.0.0.1', () => {\n const lines = [`${req.method} ${req.url} HTTP/1.1`, `connection: upgrade`, `upgrade: ${req.headers.upgrade || 'websocket'}`]\n for (const [name, value] of Object.entries(headers)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n lines.push(`${name}: ${item}`)\n }\n }\n target.write(`${lines.join('\\r\\n')}\\r\\n\\r\\n`)\n if (head && head.length > 0) {\n target.write(head)\n }\n socket.pipe(target)\n target.pipe(socket)\n })\n const close = () => {\n socket.destroy()\n target.destroy()\n }\n deadline(grant.untilMs, close, socket)\n target.on('error', () => {\n if (socket.writable) {\n socket.end('HTTP/1.1 200 OK\\r\\nx-orca-share-proxy: 1\\r\\nx-orca-share-error: no_listener\\r\\nconnection: close\\r\\ncontent-length: 0\\r\\n\\r\\n')\n }\n close()\n })\n socket.on('error', close)\n target.on('close', () => socket.destroy())\n socket.on('close', () => target.destroy())\n})\n\nserver.keepAliveTimeout = 65_000\nserver.headersTimeout = 70_000\n// No request timeout: previews can stream (SSE, long polls) until the grant's deadline.\nserver.requestTimeout = 0\nserver.listen(LISTEN_PORT, '0.0.0.0', () => {\n console.log(`orca-share-proxy listening on ${LISTEN_PORT}`)\n})\n","orca-sshd":"#!/usr/bin/env bash\n# Starts this boot's unprivileged sshd on 127.0.0.1:2222 (idempotent). Nothing outside the VM can\n# reach that port: the desktop tunnels to it over orcad's runtime connection (`orca ssh-proxy`).\n# Key-only, single user (the sandbox user), no root. Its host key and authorized_keys live under\n# ~/.orca-sandbox/ssh on the persistent disk, so the host key stays the same across stop/resume;\n# orca-image-wipe removes that directory, so every VM cloned from the golden image makes its own.\n# Prints {\"port\":2222,\"user\":\"\",\"hostKey\":\"ssh-ed25519 AAAA...\"} on success.\nset -euo pipefail\numask 077\nOPENSSH=/opt/orca/openssh\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\nPORT=2222\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 8>\"$SSH_DIR/sshd.lock\"\nflock -w 30 8\n\nif [ ! -x \"$OPENSSH/sshd\" ]; then\n echo 'sshd is not installed in this image' >&2\n exit 3\nfi\nif [ ! -s \"$SSH_DIR/ssh_host_ed25519_key\" ]; then\n rm -f \"$SSH_DIR/ssh_host_ed25519_key\" \"$SSH_DIR/ssh_host_ed25519_key.pub\"\n ssh-keygen -q -t ed25519 -N '' -C \"orca-cloud-host\" -f \"$SSH_DIR/ssh_host_ed25519_key\"\nfi\ntouch \"$SSH_DIR/authorized_keys\"\nchmod 0600 \"$SSH_DIR/authorized_keys\"\n\n# Rewritten every start so an image update's settings apply to existing workspaces. PAM stays at\n# its compiled-in default (off): an unprivileged sshd cannot use it, and this build warns on `no`.\ncat >\"$SSH_DIR/sshd_config\" </dev/null || echo 0)\"\n# Resume reboots from disk, so a recorded pid can name a dead process or an unrelated one.\nif ! { kill -0 \"$pid\" 2>/dev/null && grep -q sshd \"/proc/$pid/cmdline\" 2>/dev/null; }; then\n rm -f \"$SSH_DIR/sshd.pid\"\n : >\"$SSH_DIR/sshd.log\"\n \"$OPENSSH/sshd\" -t -f \"$SSH_DIR/sshd_config\"\n \"$OPENSSH/sshd\" -f \"$SSH_DIR/sshd_config\" -E \"$SSH_DIR/sshd.log\"\n for _ in $(seq 1 100); do\n [ -s \"$SSH_DIR/sshd.pid\" ] && break\n sleep 0.02\n done\n [ -s \"$SSH_DIR/sshd.pid\" ] || { echo 'sshd did not start' >&2; tail -n 20 \"$SSH_DIR/sshd.log\" >&2; exit 1; }\nfi\n\nhost_key=\"$(cut -d' ' -f1-2 \"$SSH_DIR/ssh_host_ed25519_key.pub\")\"\nprintf '{\"port\":%s,\"user\":\"%s\",\"hostKey\":\"%s\"}\\n' \"$PORT\" \"$(id -un)\" \"$host_key\"\n","orca-ssh-keys":"#!/usr/bin/env bash\n# `orca-ssh-keys add|remove`: adds or removes one desktop's key in the Orca-managed authorized_keys\n# of this VM's sshd, then makes sure sshd runs. The control plane passes the key in\n# ORCA_SSH_PUBLIC_KEY (`ssh-ed25519 `), never on the command line. Keys are\n# matched by their base64 blob, so a renamed comment cannot keep a revoked key alive.\n# Prints orca-sshd's {\"port\":...,\"user\":...,\"hostKey\":...} line on success.\nset -euo pipefail\numask 077\nSSH_DIR=\"$HOME/.orca-sandbox/ssh\"\naction=\"${1:-}\"\nkey=\"${ORCA_SSH_PUBLIC_KEY:-}\"\n# grep matches per line: a second line could carry key options past the check below.\ncase \"$key\" in *$'\\n'* | *$'\\r'*) echo 'invalid public key' >&2; exit 2 ;; esac\n# One line, ed25519 only, a plain comment: nothing that sshd would read as key options.\nif ! printf '%s' \"$key\" | grep -Eq '^ssh-ed25519 [A-Za-z0-9+/]{68} [A-Za-z0-9@._:+-]{1,200}$'; then\n echo 'invalid public key' >&2\n exit 2\nfi\nblob=\"$(printf '%s' \"$key\" | cut -d' ' -f2)\"\nmkdir -p \"$SSH_DIR\"\nchmod 0700 \"$HOME/.orca-sandbox\" \"$SSH_DIR\"\nexec 7>\"$SSH_DIR/keys.lock\"\nflock -w 30 7\ntouch \"$SSH_DIR/authorized_keys\"\nnext=\"$(mktemp \"$SSH_DIR/authorized_keys.XXXXXX\")\"\n# Drop the key wherever it appears (with or without options), then append it on add.\nawk -v blob=\"$blob\" '{ for (i = 1; i <= NF; i++) if ($i == blob) next; print }' \\\n \"$SSH_DIR/authorized_keys\" >\"$next\"\ncase \"$action\" in\n add) printf '%s\\n' \"$key\" >>\"$next\" ;;\n remove) ;;\n *) rm -f \"$next\"; echo 'usage: orca-ssh-keys add|remove' >&2; exit 2 ;;\nesac\nchmod 0600 \"$next\"\nmv -f \"$next\" \"$SSH_DIR/authorized_keys\"\nexec /opt/orca/bin/orca-sshd\n","orca-desktop-start":"#!/usr/bin/env bash\n# Starts the on-demand remote desktop (idempotent) and mints one connection ticket. Prints one line:\n# {\"schemaVersion\":2,\"port\":6080,\"display\":\":1\",\"path\":\"vnc.html\",\"ticket\":\"<43 chars>\",\"expiresInMs\":60000}\n# Xvnc (X server + VNC in one) listens on a 0600 unix socket only, with no VNC auth: only this uid\n# can open it. orca-desktop-proxy (websockify + a ticket gate) serves noVNC on 127.0.0.1 and\n# bridges WebSockets to that socket. Nothing is reachable from outside the VM: the desktop app\n# reaches it through Orca's port forward, a localhost port on the user's computer that any local\n# process or web page can reach, so each WebSocket needs a ticket from here: 32 random bytes,\n# single use, valid for 60 s, stored only as its sha256 (the file name; its content is the expiry).\n# The ticket goes to stdout only (the control plane hands it to the desktop app). Never started at boot.\nset -euo pipefail\numask 077\nDESKTOP=/opt/orca/desktop\nSTATE=\"$HOME/.orca-desktop\"\nPORT=\"${ORCA_DESKTOP_PORT:-6080}\"\nDISPLAY_NUM=\"${ORCA_DESKTOP_DISPLAY:-1}\"\nGEOMETRY=\"${ORCA_DESKTOP_GEOMETRY:-1280x800}\"\nSOCKET=\"$STATE/vnc.sock\"\n# The parent's DISPLAY must not leak into the WM/xterm below; they use ours.\nexport DISPLAY=\":$DISPLAY_NUM\"\n\n# Exit 3 = this image has no desktop (built before it, or its optional install failed).\nfail() { printf 'orca-desktop: %s\\n' \"$1\" >&2; exit \"${2:-1}\"; }\ncase \"$PORT\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_PORT must be a number' ;; esac\ncase \"$DISPLAY_NUM\" in '' | *[!0-9]*) fail 'ORCA_DESKTOP_DISPLAY must be a number' ;; esac\ncase \"$GEOMETRY\" in [0-9]*x[0-9]*) ;; *) fail 'ORCA_DESKTOP_GEOMETRY must look like 1280x800' ;; esac\ncommand -v Xvnc >/dev/null || fail 'not installed in this image (Xvnc missing)' 3\n[ -s \"$DESKTOP/novnc/vnc.html\" ] || fail 'not installed in this image (noVNC missing)' 3\nPROXY=\"$(dirname \"$(readlink -f \"$0\")\")/orca-desktop-proxy\"\n[ -x \"$PROXY\" ] || fail 'orca-desktop-proxy missing next to this script'\nTICKETS=\"$STATE/tickets\"\nTICKET_TTL_S=60\n# Xvnc's mode: a previous version of this script ran it with VncAuth; restart it in this mode.\nMODE=ticket-v1\n\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/start.lock\"\nflock -w 30 9 || fail 'another start is in progress'\n\npid_of() { # never \"0\": kill 0 targets our own process group\n local pid\n pid=\"$(cat \"$STATE/$1.pid\" 2>/dev/null || true)\"\n case \"$pid\" in '' | 0 | *[!0-9]*) return 1 ;; esac\n printf '%s' \"$pid\"\n}\nalive() { local pid; pid=\"$(pid_of \"$1\")\" && kill -0 \"$pid\" 2>/dev/null; }\nmint_ticket() { # prints the ticket; stores only its sha256, with the expiry as content\n local ticket now digest\n mkdir -p \"$TICKETS\"\n now=\"$(date +%s)\"\n # Expired or spent-but-unswept tickets; keep at most 16 live ones (oldest go first).\n for file in \"$TICKETS\"/*; do\n [ -f \"$file\" ] || continue\n [ \"$(cat \"$file\" 2>/dev/null || echo 0)\" -ge \"$now\" ] 2>/dev/null || rm -f \"$file\"\n done\n # shellcheck disable=SC2012 # names are hex digests\n ls -1t \"$TICKETS\" 2>/dev/null | tail -n +16 | while read -r old; do rm -f \"$TICKETS/$old\"; done\n ticket=\"$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\\n')\"\n [ \"${#ticket}\" = 43 ] || fail 'could not generate a ticket'\n digest=\"$(printf '%s' \"$ticket\" | sha256sum | cut -d' ' -f1)\"\n printf '%s' \"$((now + TICKET_TTL_S))\" >\"$TICKETS/.$digest\" && mv \"$TICKETS/.$digest\" \"$TICKETS/$digest\"\n printf '%s' \"$ticket\"\n}\nready_line() {\n local ticket\n ticket=\"$(mint_ticket)\" || exit 1\n printf '{\"schemaVersion\":2,\"port\":%s,\"display\":\":%s\",\"path\":\"vnc.html\",\"ticket\":\"%s\",\"expiresInMs\":%s}\\n' \\\n \"$PORT\" \"$DISPLAY_NUM\" \"$ticket\" \"$((TICKET_TTL_S * 1000))\"\n}\nlistening() { # the proxy answers HTTP on loopback\n curl -fsS -o /dev/null --max-time 1 \"http://127.0.0.1:$PORT/vnc.html\" 2>/dev/null\n}\n\n# Pids from a previous boot (resume = reboot from disk) name dead or unrelated processes.\nboot_id=\"$(cat /proc/sys/kernel/random/boot_id)\"\nif [ \"$(cat \"$STATE/boot-id\" 2>/dev/null || true)\" != \"$boot_id\" ]; then\n rm -rf \"$STATE\"/*.pid \"$SOCKET\" \"$STATE/password\" \"$STATE/passwd\" \"$STATE/mode\" \"$TICKETS\"\n printf '%s' \"$boot_id\" >\"$STATE/boot-id\"\nfi\n\n# Same boot, older script: its websockify had no ticket gate and its Xvnc wanted a VNC password.\nrm -f \"$STATE/password\" \"$STATE/passwd\"\nif alive websockify; then kill \"$(pid_of websockify)\" 2>/dev/null || true; fi\nrm -f \"$STATE/websockify.pid\"\ncurrent_mode() { cat \"$STATE/mode\" 2>/dev/null || true; }\n\nif alive xvnc && alive proxy && listening && [ \"$(current_mode)\" = \"$MODE\" ]; then\n ready_line\n exit 0\nfi\n\nstart() { # name cmd... — detached, logs in $STATE/.log\n local name=$1; shift\n # 9>&-: children must not inherit the start lock, or the next call waits for them to exit.\n setsid nohup \"$@\" >\"$STATE/$name.log\" 2>&1 &- &\n echo $! >\"$STATE/$name.pid\"\n}\n\nif ! alive xvnc || [ \"$(current_mode)\" != \"$MODE\" ]; then\n if alive xvnc; then kill \"$(pid_of xvnc)\" 2>/dev/null || true; sleep 0.2; fi\n rm -f \"$SOCKET\"\n # -rfbport -1: no TCP VNC port at all, only a 0600 unix socket (in the 0700 state dir) for the\n # proxy. SecurityTypes None is safe only because of that: the ticket gate is in the proxy.\n # SendPrimary=0: only explicit copies (xterm copies its selection, below) leave the sandbox.\n # The X display itself takes a cookie from ~/.Xauthority (Xlib's default for this user), so only\n # this uid's processes (agents, apps they start on DISPLAY=:1) can open it.\n auth=()\n if command -v xauth >/dev/null && command -v mcookie >/dev/null; then\n xauth -q -f \"$HOME/.Xauthority\" add \":$DISPLAY_NUM\" . \"$(mcookie)\" 9>&- && auth=(-auth \"$HOME/.Xauthority\")\n fi\n start xvnc Xvnc \":$DISPLAY_NUM\" \"${auth[@]}\" -geometry \"$GEOMETRY\" -depth 24 -nolisten tcp \\\n -rfbport -1 -rfbunixpath \"$SOCKET\" -rfbunixmode 0600 \\\n -SecurityTypes None -SendPrimary=0 \\\n -AlwaysShared -AcceptSetDesktopSize -desktop 'Remote desktop'\n printf '%s' \"$MODE\" >\"$STATE/mode\"\n for _ in $(seq 1 100); do\n [ -S \"$SOCKET\" ] && break\n alive xvnc || { tail -n 20 \"$STATE/xvnc.log\" >&2; fail 'Xvnc exited'; }\n sleep 0.05\n done\n [ -S \"$SOCKET\" ] || fail 'Xvnc did not create its socket'\n if command -v xsetroot >/dev/null; then xsetroot -solid '#1e1f22' 9>&- || true; fi\n if command -v openbox >/dev/null; then\n # Dark, flat theme to match Orca; the first terminal stays maximized as the tab resizes the screen.\n printf '%s\\n' '' \\\n 'Onyx' \\\n 'yes' \\\n '' >\"$STATE/openbox.xml\"\n start wm openbox --config-file \"$STATE/openbox.xml\"\n # Let it manage the screen before the terminal maps, so its maximize rule applies.\n sleep 0.2\n elif command -v twm >/dev/null; then\n start wm twm\n fi\n if command -v xterm >/dev/null; then\n # selectToClipboard: a selection is a copy (to CLIPBOARD, which reaches this computer).\n start xterm xterm -maximized -bg '#1e1f22' -fg '#d4d4d4' -fa 'Liberation Mono,Monospace' -fs 11 \\\n -xrm 'XTerm*selectToClipboard: true'\n fi\nfi\n\nif ! alive proxy || ! listening; then\n if alive proxy; then kill \"$(pid_of proxy)\" 2>/dev/null || true; sleep 0.1; fi\n mkdir -p \"$TICKETS\"\n PYTHONPATH=\"$DESKTOP/websockify\" start proxy python3 -P -W ignore::UserWarning \"$PROXY\" \\\n --listen \"$PORT\" --web \"$DESKTOP/novnc\" --unix-target \"$SOCKET\" --tickets \"$TICKETS\"\n for _ in $(seq 1 100); do\n listening && break\n alive proxy || { tail -n 20 \"$STATE/proxy.log\" >&2; fail 'orca-desktop-proxy exited'; }\n sleep 0.05\n done\n listening || fail \"orca-desktop-proxy is not answering on 127.0.0.1:$PORT\"\nfi\nready_line\n","orca-desktop-proxy":"#!/usr/bin/env python3\n# Remote desktop gateway, started by orca-desktop-start: serves noVNC on 127.0.0.1 and bridges one\n# WebSocket per single-use ticket to Xvnc's 0600 unix socket (Xvnc itself has no VNC auth: only this\n# uid can open the socket). The port is reached only through Orca's localhost forward on the user's\n# computer, where any local process or web page could knock, so every WebSocket must carry an\n# unused, unexpired ticket minted by orca-desktop-start (see consume_ticket), from a page on the same\n# localhost origin. Tickets never reach a log: paths are scrubbed before websockify logs them.\n# orca-desktop-proxy --listen PORT --web DIR --unix-target SOCKET --tickets DIR\n# stdlib + websockify (pinned tarball in /opt/orca/desktop/websockify, on PYTHONPATH) only.\nimport argparse\nimport hashlib\nimport hmac\nimport logging\nimport os\nimport re\nimport sys\nimport time\nfrom urllib.parse import parse_qs, urlsplit\n\nfrom websockify import websocketproxy\n\nTICKET_RE = re.compile(r'^[A-Za-z0-9_-]{43}$') # 32 random bytes, base64url without padding\nSCRUB_RE = re.compile(r'([?&]ticket=)[^&\\s\\'\"]*')\nLOOPBACK_HOST_RE = re.compile(r'^(localhost|127\\.0\\.0\\.1|\\[::1\\])(:[0-9]{1,5})?$')\nWS_PATH = '/websockify'\nPAGE_SCRIPT = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'orca-desktop-page.js')\n# The page may only talk to its own origin (its WebSocket included): even a URL that tries to point\n# noVNC elsewhere cannot carry a ticket or the user's keys off this forward.\nPAGE_CSP = \"connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'\"\nPAGE_TAG = b'\\n'\n\n\ndef scrub(text):\n return SCRUB_RE.sub(r'\\1', str(text))\n\n\ndef ticket_digest(ticket):\n return hashlib.sha256(ticket.encode('ascii')).hexdigest()\n\n\ndef consume_ticket(directory, ticket, now=None):\n \"\"\"True once per minted ticket, within its lifetime. The ticket file (named by the ticket's\n sha256, holding its expiry) is unlinked on first use, so a replay or a racing second\n connection finds nothing; each request runs in its own forked process, so state is on disk.\"\"\"\n if not isinstance(ticket, str) or not TICKET_RE.match(ticket):\n return False\n digest = ticket_digest(ticket)\n match = None\n try:\n names = os.listdir(directory)\n except OSError:\n return False\n for name in names:\n # Compare every entry in constant time; never short-circuit on a prefix.\n if hmac.compare_digest(name.encode('ascii', 'replace'), digest.encode('ascii')):\n match = name\n if match is None:\n return False\n path = os.path.join(directory, match)\n try:\n with open(path, 'r', encoding='ascii') as handle:\n expires_at = int(handle.read().strip() or '0')\n except (OSError, ValueError):\n expires_at = 0\n try:\n os.unlink(path) # the consume: only one caller can win this\n except FileNotFoundError:\n return False\n return (now if now is not None else time.time()) <= expires_at\n\n\ndef same_loopback_origin(headers):\n \"\"\"The page and the WebSocket share one localhost origin (blocks other local sites and DNS\n rebinding). The port is the user's local forward, which may differ from ours, so compare the\n Origin with the Host the browser dialed rather than with a fixed port.\"\"\"\n host = (headers.get('Host') or '').strip().lower()\n origin = (headers.get('Origin') or '').strip().lower()\n return loopback_host(host) and origin == 'http://' + host\n\n\ndef loopback_host(host):\n return bool(LOOPBACK_HOST_RE.match(host or ''))\n\n\nclass TicketedProxyHandler(websocketproxy.ProxyRequestHandler):\n \"\"\"websockify's proxy handler plus the ticket + origin gate. Module level (not a closure) so\n websockify's per-connection multiprocessing can pickle it under any start method.\"\"\"\n\n def log_message(self, format, *args): # noqa: A002 (websockify's signature)\n super().log_message('%s', scrub(format % args if args else format))\n\n def reject(self, code, reason):\n self.send_response(code)\n self.send_header('Content-Length', '0')\n self.send_header('Connection', 'close')\n self.end_headers()\n self.close_connection = True\n raise self.server.EClose('desktop: websocket refused (%s)' % reason)\n\n def validate_connection(self):\n parts = urlsplit(self.path)\n ticket = (parse_qs(parts.query).get('ticket') or [''])[0]\n # Drop it before anything below (websockify's own logging included) can see self.path.\n self.path = parts.path\n if parts.path != WS_PATH:\n self.reject(404, 'path')\n if not same_loopback_origin(self.headers):\n self.reject(403, 'origin')\n if not consume_ticket(self.server.orca_tickets_dir, ticket):\n self.reject(403, 'ticket')\n\n def loopback_request(self):\n if loopback_host((self.headers.get('Host') or '').strip().lower()):\n return True\n self.send_error(403)\n return False\n\n def do_GET(self):\n if not self.loopback_request():\n return None\n path = urlsplit(self.path).path\n if path == '/orca-desktop-page.js':\n return self.send_bytes(read_file(PAGE_SCRIPT), 'text/javascript; charset=utf-8')\n if path == '/vnc.html':\n page = read_file(os.path.join(self.server.web, 'vnc.html'))\n return self.send_bytes(page.replace(b'', PAGE_TAG, 1), 'text/html; charset=utf-8', PAGE_CSP)\n return super().do_GET()\n\n def do_HEAD(self):\n if not self.loopback_request():\n return None\n return super().do_HEAD()\n\n def send_bytes(self, body, content_type, csp=None):\n self.send_response(200)\n self.send_header('Content-Type', content_type)\n if csp:\n self.send_header('Content-Security-Policy', csp)\n self.send_header('Content-Length', str(len(body)))\n self.send_header('Cache-Control', 'no-store')\n self.send_header('Referrer-Policy', 'no-referrer')\n self.end_headers()\n self.wfile.write(body)\n return None\n\n\ndef read_file(path):\n with open(path, 'rb') as handle:\n return handle.read()\n\n\ndef main(argv):\n parser = argparse.ArgumentParser()\n parser.add_argument('--listen', type=int, required=True)\n parser.add_argument('--web', required=True)\n parser.add_argument('--unix-target', required=True)\n parser.add_argument('--tickets', required=True)\n args = parser.parse_args(argv)\n logging.basicConfig(level=logging.INFO, format='%(message)s')\n server = websocketproxy.WebSocketProxy(\n RequestHandlerClass=TicketedProxyHandler,\n listen_host='127.0.0.1',\n listen_port=args.listen,\n web=args.web,\n file_only=True,\n unix_target=args.unix_target,\n )\n server.orca_tickets_dir = os.path.abspath(args.tickets)\n server.start_server()\n\n\nif __name__ == '__main__':\n main(sys.argv[1:])\n","orca-desktop-page.js":"// Orca's glue for noVNC, injected into vnc.html by orca-desktop-proxy. Orca (the desktop app's\n// main process) drives it through window.orcaDesktop:\n// - tickets: the page never connects by itself. Each WebSocket needs a fresh single-use ticket,\n// which Orca hands over with connect(ticket) after nextTicketRequest() resolves: 'ticket' on\n// load or after a session dropped, 'retry' after an attempt that never connected. Tickets live\n// only in memory here: never in the URL, storage or noVNC's settings.\n// - pause(message, label): Orca stopped retrying; show why, with a Reconnect button. resume()\n// (Orca reopened the desktop) or the button asks for a ticket again.\n// - fit: resize=remote makes Xvnc follow the tab; a server without SetDesktopSize gets scaling.\n// - clipboard: a copy inside the desktop right after user input there goes to this computer's\n// clipboard; paste(text, chord) sets the sandbox's clipboard first, then replays the paste key.\nimport UI from './app/ui.js';\nimport * as WebUtil from './app/webutil.js';\n\nconst WS_PATH = 'websockify';\nconst RESIZE_FALLBACK_MS = 1500;\n// A copy inside the desktop follows a copy key or a selection (drag or double-click) there; other\n// clipboard changes (an agent in the sandbox running xclip) must not overwrite this computer's\n// clipboard. Plain typing does not open the window.\nconst USER_COPY_WINDOW_MS = 1500;\nconst DRAG_PX = 4;\nconst MAX_CLIPBOARD_CHARS = 1 << 20;\nconst TICKET_RE = /^[A-Za-z0-9_-]{43}$/;\nconst XK = { v: 0x0076, V: 0x0056, Insert: 0xff63, Shift_L: 0xffe1, Super_L: 0xffeb, Super_R: 0xffec };\n\nlet state = 'idle'; // idle | connecting | connected | disconnected | paused\nlet attemptConnected = true;\nlet ticketWaiters = [];\nlet lastCopyIntent = 0;\nlet pointerStart = null;\n\nconst style = document.createElement('style');\n// Orca owns connecting and the chrome: noVNC's Connect button would only try without a ticket,\n// and its control bar duplicates what the Orca tab does (fit, clipboard, keyboard release).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { position: fixed; inset: 0; display: none; align-items: center; justify-content: center;\n background: rgba(30, 31, 34, 0.92); z-index: 1000; font: 13px/1.5 system-ui, sans-serif; }\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid #3a3b3f; border-radius: 8px;\n background: #26272b; color: #d4d4d4; text-align: center; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid #4a4b50; border-radius: 6px;\n background: transparent; color: #f2f2f2; font: inherit; cursor: pointer; }\n#orca_pause button:focus-visible { outline: 2px solid #6ea8fe; outline-offset: 2px; }\n/* The VM page cannot read Orca's --focus-ring token; this matches its dark value. */\n`;\ndocument.head.appendChild(style);\n\nconst overlay = document.createElement('div');\noverlay.id = 'orca_pause';\noverlay.innerHTML = '

';\noverlay.querySelector('button').addEventListener('click', () => resume());\ndocument.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));\n\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && !event.altKey && (event.key === 'c' || event.key === 'C' || event.key === 'Insert')) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerdown', (event) => {\n pointerStart = { x: event.clientX, y: event.clientY };\n}, true);\n// A double- or triple-click selects a word or line (pointer events carry no click count).\nwindow.addEventListener('mousedown', (event) => {\n if (event.detail >= 2) {\n lastCopyIntent = Date.now();\n }\n}, true);\nwindow.addEventListener('pointerup', (event) => {\n if (pointerStart && Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y) >= DRAG_PX) {\n lastCopyIntent = Date.now();\n }\n pointerStart = null;\n}, true);\n\nfunction requestTicket() {\n const answer = attemptConnected ? 'ticket' : 'retry';\n const waiters = ticketWaiters;\n ticketWaiters = [];\n waiters.forEach((resolve) => resolve(answer));\n}\n\nasync function whenStarted() {\n while (document.documentElement.classList.contains('noVNC_loading')) {\n await new Promise((resolve) => setTimeout(resolve, 25));\n }\n}\n\nfunction onConnect() {\n state = 'connected';\n attemptConnected = true;\n UI.hideStatus();\n setTimeout(() => {\n // Xvnc answers SetDesktopSize; anything that does not gets scaled into the tab instead.\n if (UI.rfb && state === 'connected' && !UI.rfb._supportsSetDesktopSize) {\n UI.rfb.scaleViewport = true;\n }\n }, RESIZE_FALLBACK_MS);\n}\n\nfunction onDisconnect() {\n if (state === 'paused') {\n return;\n }\n state = 'disconnected';\n // The ticket is spent: ask Orca for a new one (a dropped forward, a restart of the desktop).\n requestTicket();\n}\n\nfunction onClipboard(event) {\n const text = event.detail && event.detail.text;\n if (typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return;\n }\n if (Date.now() - lastCopyIntent > USER_COPY_WINDOW_MS || !document.hasFocus()) {\n return;\n }\n navigator.clipboard.writeText(text).catch(() => {});\n}\n\nasync function connect(ticket) {\n if (typeof ticket !== 'string' || !TICKET_RE.test(ticket)) {\n return 'invalid';\n }\n if (state === 'connecting' || state === 'connected' || state === 'paused') {\n return state;\n }\n await whenStarted();\n if (UI.rfb) {\n return 'connecting';\n }\n state = 'connecting';\n attemptConnected = false;\n // UI.connect reads the path setting synchronously and opens the WebSocket in RFB's constructor;\n // the in-memory setting is restored right after, so the ticket is never kept or shown.\n // Only ever this page's own origin: noVNC also reads host, port, encrypt, repeater and logging\n // from the URL, which must never send a ticketed session (and the user's keys) elsewhere.\n WebUtil.setSetting('host', '');\n WebUtil.setSetting('port', '');\n WebUtil.setSetting('encrypt', false);\n WebUtil.setSetting('repeaterID', '');\n WebUtil.setSetting('path', `${WS_PATH}?ticket=${ticket}`);\n try {\n UI.connect();\n } finally {\n WebUtil.setSetting('path', WS_PATH);\n }\n const rfb = UI.rfb;\n if (!rfb) {\n state = 'disconnected';\n requestTicket();\n return 'failed';\n }\n rfb.addEventListener('connect', onConnect);\n rfb.addEventListener('disconnect', onDisconnect);\n rfb.addEventListener('clipboard', onClipboard);\n return 'connecting';\n}\n\n/** Pastes this computer's clipboard: the sandbox's clipboard first, then the paste key itself. */\nfunction paste(text, chord) {\n const rfb = UI.rfb;\n if (!rfb || state !== 'connected' || typeof text !== 'string' || text.length > MAX_CLIPBOARD_CHARS) {\n return false;\n }\n rfb.clipboardPasteFrom(text);\n if (chord && chord.kind === 'mac') {\n // Cmd+V: Linux apps do not paste on Super+V, so release Super and send Shift+Insert.\n rfb.sendKey(XK.Super_L, 'MetaLeft', false);\n rfb.sendKey(XK.Super_R, 'MetaRight', false);\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', true);\n rfb.sendKey(XK.Insert, 'Insert');\n rfb.sendKey(XK.Shift_L, 'ShiftLeft', false);\n } else if (chord && chord.kind === 'replay') {\n // Ctrl+V, Ctrl+Shift+V, Shift+Insert: the held modifiers already reached the sandbox.\n const insert = chord.key === 'Insert';\n rfb.sendKey(insert ? XK.Insert : chord.key === 'V' ? XK.V : XK.v, insert ? 'Insert' : 'KeyV');\n }\n return true;\n}\n\nfunction pause(message, label, tone) {\n state = 'paused';\n if (UI.rfb) {\n UI.rfb.disconnect();\n }\n UI.hideStatus();\n overlay.querySelector('p').textContent = String(message || '');\n overlay.querySelector('button').textContent = String(label || 'Reconnect');\n // Asleep and starting are states, not errors: announce them politely.\n overlay.firstElementChild.setAttribute('role', tone === 'neutral' ? 'status' : 'alert');\n overlay.classList.add('open');\n overlay.querySelector('button').focus();\n return true;\n}\n\nfunction resume() {\n if (state !== 'paused') {\n return false;\n }\n overlay.classList.remove('open');\n state = 'disconnected';\n attemptConnected = true; // a person asked: start the retry budget over\n requestTicket();\n return true;\n}\n\nObject.defineProperty(window, 'orcaDesktop', {\n value: Object.freeze({\n version: 1,\n state: () => state,\n /** Resolves when the page needs a ticket: now if it is not connected, else after a drop. */\n nextTicketRequest: () => new Promise((resolve) => {\n ticketWaiters.push(resolve);\n if (state === 'idle' || state === 'disconnected') {\n requestTicket();\n }\n }),\n connect,\n paste,\n pause,\n resume\n })\n});\n","orca-sandbox-grant":"#!/usr/bin/env bash\n# Mints a fresh, single-device orcad pairing offer for one org member's device and seals it to that\n# device's X25519 key, so the control plane relays only ciphertext (the VM owner's E2EE model holds:\n# every member device gets its own revocable orcad credential, nobody else can read it).\n# Env from the control plane: ORCA_GRANT_ID, ORCA_GRANT_RECIPIENT_KEY (base64 raw X25519 public\n# key), ORCA_GRANT_KIND (desktop|mobile), ORCA_PUBLIC_WSS. Prints one JSON line:\n# {\"deviceId\":\"…\",\"sealed\":{\"v\":1,\"ephemeralPublicKeyB64\":\"…\",\"nonceB64\":\"…\",\"ciphertextB64\":\"…\"}}\n# Sealing: ECDH(ephemeral, recipient) -> HKDF-SHA256(salt = eph||recipient, info\n# \"orca-cloud-grant-v1\") -> AES-256-GCM, AAD \"orca-cloud-grant-v1:\".\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nSTATE=\"$HOME/.orca-sandbox\"\nDATA=\"$HOME/.orcad-data\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\n# One mint at a time: --rotate retires unredeemed offers, so two members never share a credential.\nflock -w 30 9\n\ncase \"${ORCA_GRANT_ID:-}\" in '' | *[!A-Za-z0-9_-]*) echo 'bad grant id' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_KIND:-}\" in desktop) scope=() ;; mobile) scope=(--mobile) ;; *) echo 'bad kind' >&2; exit 2 ;; esac\ncase \"${ORCA_GRANT_RECIPIENT_KEY:-}\" in '' | *[!A-Za-z0-9+/=]*) echo 'bad key' >&2; exit 2 ;; esac\ncase \"${ORCA_PUBLIC_WSS:-}\" in wss://*) ;; *) echo 'bad endpoint' >&2; exit 2 ;; esac\n\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\n# The workspace's project must exist on this orcad before a phone opens it: phones may not add\n# projects themselves (mobile RPC scope), and a phone may be the first client this VM ever sees.\nproject=/vercel/sandbox/project\nif [ -d \"$project\" ]; then\n kind=folder\n [ -d \"$project/.git\" ] && kind=git\n ORCA_USER_DATA_PATH=\"$DATA\" timeout 20 \"$node\" \"$cli\" repo add --path \"$project\" --kind \"$kind\" --json \\\n >/dev/null 2>&1 || true\nfi\noffer=\"$(ORCA_USER_DATA_PATH=\"$DATA\" timeout 30 \"$node\" \"$cli\" pair --json --rotate \"${scope[@]}\" \\\n --address \"$ORCA_PUBLIC_WSS\" 2>/dev/null)\" || { echo 'pair failed' >&2; exit 1; }\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" \\\n \"$node\" -e '\nconst crypto = require(\"node:crypto\")\nlet input = \"\"\nprocess.stdin.on(\"data\", (c) => (input += c)).on(\"end\", () => {\n const res = JSON.parse(input).result\n if (!res || res.available !== true || !res.pairingUrl || !res.deviceId) {\n console.error(\"offer unavailable\")\n process.exit(1)\n }\n const recipient = Buffer.from(process.env.ORCA_GRANT_RECIPIENT_KEY, \"base64\")\n if (recipient.length !== 32) { console.error(\"bad key length\"); process.exit(2) }\n const recipientKey = crypto.createPublicKey({ key: { kty: \"OKP\", crv: \"X25519\", x: recipient.toString(\"base64url\") }, format: \"jwk\" })\n const eph = crypto.generateKeyPairSync(\"x25519\")\n const ephRaw = Buffer.from(eph.publicKey.export({ format: \"jwk\" }).x, \"base64url\")\n const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })\n const key = Buffer.from(crypto.hkdfSync(\"sha256\", shared, Buffer.concat([ephRaw, recipient]), \"orca-cloud-grant-v1\", 32))\n const nonce = crypto.randomBytes(12)\n const cipher = crypto.createCipheriv(\"aes-256-gcm\", key, nonce)\n cipher.setAAD(Buffer.from(\"orca-cloud-grant-v1:\" + process.env.ORCA_GRANT_ID))\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope }))\n const ct = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()])\n process.stdout.write(JSON.stringify({ deviceId: res.deviceId, sealed: { v: 1, ephemeralPublicKeyB64: ephRaw.toString(\"base64\"), nonceB64: nonce.toString(\"base64\"), ciphertextB64: ct.toString(\"base64\") } }) + \"\\n\")\n})'\n","orca-sandbox-revoke":"#!/usr/bin/env bash\n# Revokes member devices from orcad's device registry. orcad adopts on-disk removals at every auth\n# (an admin revoke, device-registry-store.ts), so this works whether or not orcad is running; the\n# control plane also runs it at boot, before orcad starts, for revokes queued while suspended.\n# Env: ORCA_REVOKE_DEVICE_IDS (comma list of orcad device ids). Prints {\"revoked\":,\"live\":}.\nset -euo pipefail\numask 077\nSTATE=\"$HOME/.orca-sandbox\"\nREGISTRY=\"$HOME/.orcad-data/orca-devices.json\"\nmkdir -p \"$STATE\"\nexec 9>\"$STATE/grant.lock\"\nflock -w 30 9\n\nids=\"${ORCA_REVOKE_DEVICE_IDS:-}\"\ncase \"$ids\" in '' | *[!A-Za-z0-9,_-]*) echo 'bad device ids' >&2; exit 2 ;; esac\n# Live orcad first: `orca pair --revoke` also closes the device's open sockets now. Older orcad\n# builds lack it; the on-disk removal below still blocks their next sign-in.\nORCA_HOME=/opt/orca\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node 2>/dev/null | head -n 1)\"\ncli=\"$ORCA_HOME/runtime/orcad/out/cli/index.js\"\nlive=0\nif [ -n \"$node\" ] && [ -f \"$cli\" ] && [ -s \"$HOME/.orcad-data/orca-runtime.json\" ]; then\n for id in ${ids//,/ }; do\n if ORCA_USER_DATA_PATH=\"$HOME/.orcad-data\" timeout 15 \"$node\" \"$cli\" pair --revoke \"$id\" --json 2>/dev/null \\\n | jq -e '.ok == true and .result.revoked == true' >/dev/null 2>&1; then\n live=$((live + 1))\n fi\n done\nfi\nif [ ! -s \"$REGISTRY\" ]; then\n echo \"{\\\"revoked\\\":$live,\\\"live\\\":$live}\"\n exit 0\nfi\nids_json=\"$(jq -cn --arg ids \"$ids\" '$ids | split(\",\") | map(select(length > 0))')\"\nbefore=\"$(jq 'length' \"$REGISTRY\")\"\ntmp=\"$(mktemp \"$REGISTRY.revoke.XXXXXX\")\"\n# Removes the rows and any phones delegated through them (parentDeviceId), like orcad's own revoke.\njq --argjson ids \"$ids_json\" \\\n '[.[] | select((.deviceId as $d | $ids | index($d)) == null and ((.parentDeviceId // \"\") as $p | $ids | index($p)) == null)]' \\\n \"$REGISTRY\" >\"$tmp\"\nchmod 0600 \"$tmp\"\nsync \"$tmp\" 2>/dev/null || true\nmv -f \"$tmp\" \"$REGISTRY\"\nafter=\"$(jq 'length' \"$REGISTRY\")\"\necho \"{\\\"revoked\\\":$((live + before - after)),\\\"live\\\":$live}\"\n","orca-sandbox-members":"#!/usr/bin/env bash\n# Runs as root (control plane, sudo). Installs who-may-push-as-whom for `orca-git-as`:\n# /etc/orca/members/.gitconfig egress placeholder header + GitHub author identity\n# /etc/orca/members/devices.json {\"\": \"\"} (each member's paired devices)\n# Both root-only: agents never read another member's placeholder. `orca-git-identity` (root, via a\n# sudoers rule) hands an agent only the identity of the member whose device drove its turn, as orcad\n# attests it. No token lives here: the placeholder is swapped at egress, for this repository only.\n# Env: ORCA_MEMBERS_B64 = base64 JSON [{userId, name, email, authorization, deviceIds:[...]}]\n# The VM user agents run as is orcad's own user (detected). Prints {\"members\":,\"devices\":}.\nset -euo pipefail\numask 077\nDIR=/etc/orca/members\norcad_pid=\"$(pgrep -f orcad-server.js | head -n 1 || true)\"\nUSER_NAME=\"$( [ -n \"$orcad_pid\" ] && ps -o user= -p \"$orcad_pid\" | tr -d ' ' || true)\"\nUSER_NAME=\"${USER_NAME:-${ORCA_SANDBOX_USER:-${SUDO_USER:-}}}\"\ncase \"$USER_NAME\" in '' | *[!a-z0-9_-]*) echo 'bad user' >&2; exit 2 ;; esac\nmkdir -p /etc/orca\njson=\"$(printf '%s' \"${ORCA_MEMBERS_B64:-W10=}\" | base64 -d)\"\ntmp=\"$(mktemp -d /etc/orca/members.XXXXXX)\"\ncount=0\nwhile IFS= read -r row; do\n user=\"$(jq -r '.userId' <<<\"$row\")\"\n case \"$user\" in '' | *[!A-Za-z0-9_.:-]*) continue ;; esac\n file=\"$tmp/$(printf '%s' \"$user\" | tr ':' '_').gitconfig\"\n # An empty extraheader first resets the image-wide (app identity) header for this member's git.\n git config -f \"$file\" --add http.https://github.com/.extraheader ''\n git config -f \"$file\" --add http.https://github.com/.extraheader \"Authorization: $(jq -r '.authorization' <<<\"$row\")\"\n git config -f \"$file\" user.name \"$(jq -r '.name' <<<\"$row\")\"\n git config -f \"$file\" user.email \"$(jq -r '.email' <<<\"$row\")\"\n count=$((count + 1))\ndone < <(jq -c '.[]' <<<\"$json\")\njq -c '[.[] | select(.userId | test(\"^[A-Za-z0-9_.:-]+$\")) | .userId as $u | (.deviceIds // [])[] | select(test(\"^[A-Za-z0-9_-]{1,128}$\")) | {key: ., value: $u}] | from_entries' <<<\"$json\" >\"$tmp/devices.json\"\ndevices=\"$(jq 'length' \"$tmp/devices.json\")\"\nchmod 0700 \"$tmp\" && chmod 0600 \"$tmp\"/*\nrm -rf \"$DIR.old\"\n[ -d \"$DIR\" ] && mv \"$DIR\" \"$DIR.old\"\nmv \"$tmp\" \"$DIR\"\nrm -rf \"$DIR.old\"\n# Agents may ask for their own sender's identity, and only that (the helper checks with orcad).\nrule=\"$USER_NAME ALL=(root) NOPASSWD:SETENV: /opt/orca/bin/orca-git-identity\"\nprintf '%s\\n' \"$rule\" >/etc/sudoers.d/orca-git-identity.new\nchmod 0440 /etc/sudoers.d/orca-git-identity.new\nif visudo -cf /etc/sudoers.d/orca-git-identity.new >/dev/null; then\n mv -f /etc/sudoers.d/orca-git-identity.new /etc/sudoers.d/orca-git-identity\nelse\n rm -f /etc/sudoers.d/orca-git-identity.new\n echo 'sudoers rule rejected' >&2\n exit 1\nfi\n# Pre-attestation layout (agent-readable member files) must not linger.\nuser_home=\"$(getent passwd \"$USER_NAME\" | cut -d: -f6)\"\n[ -n \"$orcad_pid\" ] && user_home=\"$(tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n 's/^HOME=//p' | head -n 1)\"\n[ -n \"$user_home\" ] && rm -rf \"$user_home/.orca-sandbox/members\"\n# Commits outside a teammate's turn still need an author (images older than orca-sandbox-start's default).\nif [ -n \"$user_home\" ]; then\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.email 'cloud@users.noreply.onorca.dev'\n sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name >/dev/null 2>&1 \\\n || sudo -u \"$USER_NAME\" env HOME=\"$user_home\" git config --global user.name 'Orca Cloud'\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-git-as":"#!/usr/bin/env bash\n# Runs git as the teammate whose message started this agent's turn: their GitHub identity for\n# pushes/PRs (this repository only) and their commit author.\n# orca-git-as e.g. orca-git-as push origin HEAD\n# orca-git-as --as refuses unless that member really sent the message\n# Orca attests the sender from the paired device that sent the message, never from arguments or\n# message text. Without an attested sender (not run from an agent, or the owner's local session) it\n# runs plain git with the workspace identity.\nset -euo pipefail\nexpect=''\nif [ \"${1:-}\" = '--as' ]; then\n expect=\"${2:-}\"\n shift 2 || { echo 'usage: orca-git-as [--as ] ' >&2; exit 2; }\n case \"$expect\" in '' | *[!A-Za-z0-9_.:-]*) echo 'orca-git-as: bad member id' >&2; exit 2 ;; esac\nfi\nset +e\nfile=\"$(ORCA_GIT_AS=\"$expect\" sudo -n --preserve-env=ORCA_GIT_AS,ORCA_TERMINAL_HANDLE,ORCA_PANE_KEY,ORCA_AGENT_LAUNCH_TOKEN,ORCA_AGENT_SESSION_ID \\\n /opt/orca/bin/orca-git-identity)\"\nstatus=$?\nset -e\ncase \"$status\" in\n 0)\n trap 'rm -f \"$file\"' EXIT\n git -c \"include.path=$file\" \"$@\"\n ;;\n 3)\n echo 'orca-git-as: no teammate identity for this turn; using the workspace identity' >&2\n exec git \"$@\"\n ;;\n *) exit 4 ;;\nesac\n","orca-git-identity":"#!/usr/bin/env bash\n# Root helper behind `orca-git-as` (sudoers: the VM user may run only this). Asks orcad, as the\n# calling agent, which paired device sent the message that started the agent's latest turn\n# (`orca sender`: orcad identifies the agent from its launch evidence, never from arguments), maps\n# that device to a workspace member, and prints that member's git config file for the caller to\n# include. Optional ORCA_GIT_AS: the member the agent expects; any other answer is refused.\n# Exit codes: 0 printed a config path; 3 no member identity (use the workspace identity); 4 refused.\nset -euo pipefail\nDIR=/etc/orca/members\ncaller=\"${SUDO_USER:-}\"\ncase \"$caller\" in '' | root | *[!a-z0-9_-]*) echo 'orca-git-identity: run through orca-git-as' >&2; exit 4 ;; esac\n# orcad's own data dir and HOME (the agent's user may not use its passwd home, e.g. HOME=/vercel).\norcad_pid=\"$(pgrep -u \"$caller\" -f orcad-server.js | head -n 1 || true)\"\n[ -n \"$orcad_pid\" ] || exit 3\norcad_env() { tr '\\0' '\\n' <\"/proc/$orcad_pid/environ\" | sed -n \"s/^$1=//p\" | head -n 1; }\nhome=\"$(orcad_env HOME)\"\ndata=\"$(orcad_env ORCA_USER_DATA)\"\ndata=\"${data:-$home/.orcad-data}\"\nnode=\"$(find /opt/orca/runtime/runtimes -type f -name node | head -n 1)\"\ncli=/opt/orca/runtime/orcad/out/cli/index.js\n# Only the evidence variables orcad needs reach the query, which runs as the caller, not root.\nanswer=\"$(sudo -u \"$caller\" env -i HOME=\"$home\" PATH=/usr/bin:/bin ORCA_USER_DATA_PATH=\"$data\" \\\n ${ORCA_TERMINAL_HANDLE:+ORCA_TERMINAL_HANDLE=\"$ORCA_TERMINAL_HANDLE\"} \\\n ${ORCA_PANE_KEY:+ORCA_PANE_KEY=\"$ORCA_PANE_KEY\"} \\\n ${ORCA_AGENT_LAUNCH_TOKEN:+ORCA_AGENT_LAUNCH_TOKEN=\"$ORCA_AGENT_LAUNCH_TOKEN\"} \\\n ${ORCA_AGENT_SESSION_ID:+ORCA_AGENT_SESSION_ID=\"$ORCA_AGENT_SESSION_ID\"} \\\n timeout 15 \"$node\" \"$cli\" sender --json 2>/dev/null || true)\"\ndevice=\"$(jq -r '.result.sender.deviceId // empty' <<<\"$answer\" 2>/dev/null || true)\"\nif [ -z \"$device\" ] || [ ! -s \"$DIR/devices.json\" ]; then\n if [ -n \"${ORCA_GIT_AS:-}\" ]; then\n echo \"orca-git-as: Orca cannot confirm that $ORCA_GIT_AS sent this agent's latest message; refusing to push as them.\" >&2\n exit 4\n fi\n exit 3\nfi\nmember=\"$(jq -r --arg d \"$device\" '.[$d] // empty' \"$DIR/devices.json\")\"\nif [ -z \"$member\" ]; then\n [ -n \"${ORCA_GIT_AS:-}\" ] && { echo \"orca-git-as: the sender's device is not linked to a workspace member; refusing.\" >&2; exit 4; }\n exit 3\nfi\nif [ -n \"${ORCA_GIT_AS:-}\" ] && [ \"$ORCA_GIT_AS\" != \"$member\" ]; then\n echo \"orca-git-as: this agent's latest message came from $member, not $ORCA_GIT_AS; refusing to push as someone else.\" >&2\n exit 4\nfi\nfile=\"$DIR/$(printf '%s' \"$member\" | tr ':' '_').gitconfig\"\n[ -s \"$file\" ] || exit 3\n# The caller reads the identity through this one-shot copy (owned by them, removed by orca-git-as).\nout=\"$(sudo -u \"$caller\" mktemp)\"\ncat \"$file\" >\"$out\"\necho \"$out\"\n","git":"#!/usr/bin/env bash\n# git for agents in Orca Cloud workspaces (first on orcad's PATH). Commands that author or publish\n# work run as the teammate whose message started this agent's turn (orca-git-as: orcad attests the\n# sender's paired device; root-only member identities). Everything else is plain git, untouched.\nreal=/usr/bin/git\nsub=''\nskip=0\nfor arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n -*) ;;\n *) sub=\"$arg\"; break ;;\n esac\ndone\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n # Outside an agent turn (no attested sender) orca-git-as falls back to the workspace identity.\n if [ -n \"${ORCA_TERMINAL_HANDLE:-}${ORCA_AGENT_SESSION_ID:-}\" ] && [ -x /opt/orca/bin/orca-git-as ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec /opt/orca/bin/orca-git-as \"$@\" 2> >(grep -v '^orca-git-as: no teammate identity' >&2)\n fi\n ;;\nesac\nexec \"$real\" \"$@\"\n"} export const SOURCE_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Golden build from source, run by the Worker inside the build sandbox: orcad + the orca CLI from one\n# commit of the Orca repo (cloud/integration carries host RPCs no upstream release has), then the same\n# provision-golden.sh every image uses. Inputs (env):\n# ORCA_SRC_SHA (40-hex) and either ORCA_SRC_DIR (source already uploaded) or ORCA_SRC_REPO\n# (https://github.com//) + ORCA_SRC_AUTH (an\n# `Authorization: Basic …` header value for a 1 h read-only installation token; never printed),\n# ORCA_PROVISION_SCRIPT (base64 of provision-golden.sh) + everything provision-golden.sh reads.\nset -euo pipefail\n# Vercel can report a detached build command as still running after it exits, so the outcome is also\n# a file the caller polls: /tmp/source-build.exit (exit code), /tmp/source-build.report (the\n# provision report line).\nrm -f /tmp/source-build.exit /tmp/source-build.report\ntrap 'echo $? >/tmp/source-build.exit' EXIT\nlog() { printf '[source-build] %s\\n' \"$*\" >&2; }\nstart=$(date +%s)\n[[ \"$ORCA_SRC_SHA\" =~ ^[0-9a-f]{40}$ ]] || { log 'ORCA_SRC_SHA must be a full commit sha'; exit 2; }\n\nlog 'build tools'\nif command -v apt-get >/dev/null; then\n sudo apt-get update -qq\n sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends build-essential python3 git ca-certificates >/dev/null\nelse\n sudo dnf install -y -q gcc gcc-c++ make tar gzip python3 git >/dev/null\nfi\nsudo corepack enable >/dev/null 2>&1 || true\n\nsrc=\"$HOME/orca-src\"\nif [ -n \"${ORCA_SRC_DIR:-}\" ]; then\n # Operator rotation (image/rotate-golden-from-source.ts) uploaded `git archive ` here already.\n log \"uploaded source for $ORCA_SRC_SHA\"\n src=\"$ORCA_SRC_DIR\"\n cd \"$src\" && test -f package.json\n git init -q && git add -A && git -c user.email=build@orca -c user.name=build commit -qm \"$ORCA_SRC_SHA\"\nelse\n log \"fetch $ORCA_SRC_SHA\"\n rm -rf \"$src\" && mkdir -p \"$src\" && cd \"$src\"\n git init -q\n # The header lives only in this one command's config, not in .git/config.\n git -c \"http.https://github.com/.extraheader=$ORCA_SRC_AUTH\" fetch -q --depth 1 \"$ORCA_SRC_REPO\" \"$ORCA_SRC_SHA\"\n unset ORCA_SRC_AUTH\n git -c advice.detachedHead=false checkout -q FETCH_HEAD\n test \"$(git rev-parse HEAD)\" = \"$ORCA_SRC_SHA\"\nfi\n\nlog 'build orcad + CLI'\nexport CI=1 ELECTRON_SKIP_BINARY_DOWNLOAD=1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1\ncorepack pnpm install --frozen-lockfile >/tmp/src-install.log 2>&1 || { tail -40 /tmp/src-install.log >&2; exit 1; }\ncorepack pnpm -C mobile install --frozen-lockfile >/tmp/src-mobile.log 2>&1 || { tail -40 /tmp/src-mobile.log >&2; exit 1; }\ncorepack pnpm build:mobile-web >/tmp/src-mobile-web.log 2>&1 || { tail -40 /tmp/src-mobile-web.log >&2; exit 1; }\ncorepack pnpm build:orcad >/tmp/src-orcad.log 2>&1 || { tail -60 /tmp/src-orcad.log >&2; exit 1; }\ncorepack pnpm build:cli >/tmp/src-cli.log 2>&1 || { tail -40 /tmp/src-cli.log >&2; exit 1; }\nmkdir -p out/orca-cli\nnode_modules/.bin/esbuild out/cli/index.js --bundle --platform=node --target=node22 --format=cjs \\\n --outfile=out/orca-cli/orca-cli.cjs --log-level=error\nprintf '%s\\n' \"$ORCA_SRC_SHA\" >out/orcad/SOURCE_COMMIT\ntar czf /tmp/orcad.tgz -C out orcad runtimes orca-cli\nlog \"built in $(( $(date +%s) - start ))s\"\n\nexport ORCAD_BUNDLE_URL=file:///tmp/orcad.tgz\nORCAD_BUNDLE_SHA256=\"$(sha256sum /tmp/orcad.tgz | cut -d' ' -f1)\"\nexport ORCAD_BUNDLE_SHA256\ncd \"$HOME\"\nprintf '%s' \"$ORCA_PROVISION_SCRIPT\" | base64 -d >/tmp/provision-golden.sh\nunset ORCA_PROVISION_SCRIPT\nbash /tmp/provision-golden.sh | tee /tmp/source-build.report\n# Build leftovers stay out of the image.\nrm -rf \"$src\" \"$HOME/.local/share/pnpm\" \"$HOME/.cache/pnpm\" /tmp/orcad.tgz /tmp/provision-golden.sh /tmp/src-*.log\nlog \"done in $(( $(date +%s) - start ))s\"\n" export const ENVIRONMENT_BUILD_SCRIPT: string = "#!/usr/bin/env bash\n# Environment build (\"Cloud Computer\"), run by the control plane in a VM booted from the golden image.\n# Clones every repo, runs the org's install script once with `set -euo pipefail`, then records what it\n# built from (commit + lockfile blob ids per repo) so the control plane can tell when a rebuild is due.\n# Everything printed here streams live to the desktop build log.\n#\n# Env from the control plane (validated there): ORCA_BUILD_REPOS (`|||\n# ` lines: each private repo gets its own placeholder the egress swaps), ORCA_INSTALL_SCRIPT_B64, ORCA_LOCKFILES (space list), optional ORCA_GIT_EXTRAHEADER, plus\n# the org secrets the environment names. `::orca-step:: ` lines mark progress.\n# Exit codes: 10 clone, 20 install script, 30 recording the result.\nset -uo pipefail\numask 022\n# One stream: the provider replays stdout and stderr separately after the fact, so a failed build's\n# log would read out of order. `::orca-error::` lines carry Orca's own failure explanations.\nexec 2>&1\nOUT=/tmp/orca-build\nROOT=/vercel/sandbox/repos\nmkdir -p \"$OUT\" \"$ROOT\"\nstep() { printf '\\n::orca-step:: %s\\n' \"$1\"; }\nstarted=$(date +%s)\nexport PATH=\"/opt/orca/tools/bin:/opt/orca/bin:$PATH\"\nexport ORCA_REPOS_ROOT=\"$ROOT\" ORCA_ENVIRONMENT_BUILD=1 CI=1 GIT_TERMINAL_PROMPT=0\nrepos() { printf '%s\\n' \"$ORCA_BUILD_REPOS\"; }\n\n# Tell the script author (often an agent) what they are installing onto.\npm=$(for c in apt-get dnf yum apk; do command -v \"$c\" >/dev/null 2>&1 && { echo \"$c\"; break; }; done)\necho \"Base image: $(. /etc/os-release 2>/dev/null && echo \"$PRETTY_NAME\"), $(nproc) vCPU, package manager: ${pm:-none} (with sudo)\"\n\nstep clone\nfirst=\"\"\nwhile IFS='|' read -r url branch dir header; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n [ -n \"$first\" ] || first=\"$dest\"\n # The egress layer swaps the placeholder for a short-lived repo token; nothing real is on disk,\n # and the header is never written to the repo's config.\n git_auth=()\n [ -z \"${header:-}\" ] || git_auth=(-c \"http.https://github.com/.extraheader=$header\")\n echo \"Cloning $url${branch:+ ($branch)}\"\n rm -rf -- \"$dest\"\n # The clone's own error shows only when it really failed (an empty repo is recovered below).\n if ! git \"${git_auth[@]}\" clone --quiet ${branch:+--branch \"$branch\"} -- \"$url\" \"$dest\" 2>\"$OUT/clone.err\"; then\n refs=0\n git \"${git_auth[@]}\" ls-remote --exit-code -- \"$url\" >/dev/null 2>&1 || refs=$?\n if [ -n \"$branch\" ] && [ \"$refs\" -eq 2 ]; then\n # Reachable but no refs at all (ls-remote exits 2): a repo with no commits yet.\n echo \"$url has no commits yet; starting it empty on $branch.\"\n rm -rf -- \"$dest\"\n { git \"${git_auth[@]}\" clone --quiet -- \"$url\" \"$dest\" 2>/dev/null && git -C \"$dest\" checkout --quiet -b \"$branch\"; } || exit 10\n else\n cat \"$OUT/clone.err\"\n echo \"::orca-error:: Could not clone $url${branch:+ at $branch}. Check that the branch exists and Orca Cloud can reach the repository.\" \n exit 10\n fi\n fi\n git -C \"$dest\" log -1 --format=' at %h %s' 2>/dev/null || echo ' (no commits yet)'\ndone < <(repos)\n\nstep install\nprintf '%s' \"${ORCA_INSTALL_SCRIPT_B64:-}\" | base64 -d >\"$OUT/install.sh\"\nif [ -s \"$OUT/install.sh\" ]; then\n echo \"Running the install script in ${first:-$ROOT}\"\n (cd \"${first:-$ROOT}\" && exec bash -euo pipefail \"$OUT/install.sh\") /dev/null)\" || continue\n git -C \"$dest\" ls-files --others --ignored --exclude-standard --directory 2>/dev/null \\\n | sed 's#/$##' | grep -E \"$deps_re\" | head -n 500 >\"$common/orca-env-deps\" || true\n if [ -n \"${ORCA_DEPS_HOOK_B64:-}\" ]; then\n # A relative core.hooksPath (husky: .husky/_) names a gitignored folder a new worktree lacks; pin\n # it to the primary checkout so every worktree runs the same hooks, ours included.\n rel=\"$(git -C \"$dest\" config --get core.hooksPath 2>/dev/null || true)\"\n case \"$rel\" in '' | /*) ;; *) git -C \"$dest\" config core.hooksPath \"$dest/$rel\" ;; esac\n hooks=\"$(git -C \"$dest\" rev-parse --path-format=absolute --git-path hooks)\"\n mkdir -p \"$hooks\"\n if [ -e \"$hooks/post-checkout\" ] && ! grep -q 'orca-env-deps' \"$hooks/post-checkout\"; then\n mv -- \"$hooks/post-checkout\" \"$hooks/post-checkout.orca-chained\"\n fi\n printf '%s' \"$ORCA_DEPS_HOOK_B64\" | base64 -d >\"$hooks/post-checkout\" && chmod 0755 \"$hooks/post-checkout\"\n fi\n n=$(wc -l <\"$common/orca-env-deps\" | tr -d ' ')\n [ \"$n\" -gt 0 ] && echo \"$dir: $n installed folder(s) carried into new workspaces.\"\ndone < <(repos)\n{\n printf '{\"durationSec\":%d,\"repos\":[' \"$(( $(date +%s) - started ))\"\n sep=\"\"\n while IFS='|' read -r url branch dir _; do\n [ -n \"$url\" ] || continue\n dest=\"$ROOT/$dir\"\n commit=$(git -C \"$dest\" rev-parse --verify --quiet HEAD 2>/dev/null || true)\n head=$(git -C \"$dest\" symbolic-ref --quiet --short HEAD 2>/dev/null || true)\n printf '%s{\"url\":\"%s\",\"dir\":\"%s\",\"branch\":\"%s\",\"commit\":\"%s\",\"lockfiles\":{' \"$sep\" \"$url\" \"$dir\" \"$head\" \"$commit\"\n lsep=\"\"\n if [ -n \"$commit\" ]; then\n for f in ${ORCA_LOCKFILES:-}; do\n blob=$(git -C \"$dest\" rev-parse --verify --quiet \"HEAD:$f\" 2>/dev/null || true)\n if [ -n \"$blob\" ]; then printf '%s\"%s\":\"%s\"' \"$lsep\" \"$f\" \"$blob\"; lsep=\",\"; fi\n done\n fi\n printf '}}'\n sep=\",\"\n done < <(repos)\n printf '],\"diskUsedBytes\":%s,\"diskTotalBytes\":%s}\\n' \\\n \"$(df -B1 --output=used / | tail -n 1 | tr -d ' ')\" \"$(df -B1 --output=size / | tail -n 1 | tr -d ' ')\"\n} >\"$OUT/result.json\" || exit 30\n# Nothing from this VM's own session belongs in the image, only what the script installed.\nrm -rf -- \"$OUT/install.sh\" \"$HOME/.bash_history\" \"$HOME/.orcad-data\" \"$HOME/.orca-sandbox\"\necho \"Disk used: $(df -h --output=used / | tail -n 1 | tr -d ' ') of $(df -h --output=size / | tail -n 1 | tr -d ' ')\"\n" export const ENVIRONMENT_DEPS_HOOK: string = "#!/usr/bin/env bash\n# Installed by Orca environment builds as the repo's post-checkout hook. When git creates a new linked\n# worktree (`git worktree add` passes the null commit as the previous HEAD), give it the dependency\n# folders the build installed in the primary checkout, as hard links: near-instant, real paths (not\n# symlinks, so bundlers and native modules behave). Hard links share file contents: tools that rewrite\n# a file in place inside these folders change it for every worktree on this VM (one workspace per VM).\n# Any post-checkout hook the repo already had runs afterwards, unchanged.\nprev=\"${1:-}\"\nhere=\"$(dirname \"$0\")\"\nif [ \"$prev\" = \"0000000000000000000000000000000000000000\" ] && [ \"${ORCA_SKIP_DEPS_LINK:-}\" != 1 ]; then\n common=\"$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)\"\n top=\"$(git rev-parse --show-toplevel 2>/dev/null || true)\"\n primary=\"$(dirname \"$common\")\"\n list=\"$common/orca-env-deps\"\n if [ -n \"$top\" ] && [ \"$top\" != \"$primary\" ] && [ -f \"$list\" ]; then\n started=$(date +%s%N)\n linked=0\n while IFS= read -r rel; do\n case \"$rel\" in '' | /* | *..*) continue ;; esac\n [ -e \"$primary/$rel\" ] && [ ! -e \"$top/$rel\" ] || continue\n mkdir -p \"$(dirname \"$top/$rel\")\"\n if [ -d \"$primary/$rel\" ]; then\n if ! cp -al -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null; then\n # A partial hard-link copy would make the fallback nest inside it: start clean.\n rm -rf -- \"$top/$rel\"\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || { rm -rf -- \"$top/$rel\"; continue; }\n fi\n else\n cp -a -- \"$primary/$rel\" \"$top/$rel\" 2>/dev/null || continue\n fi\n linked=$((linked + 1))\n done <\"$list\"\n if [ \"$linked\" -gt 0 ]; then\n echo \"Orca: reused $linked installed dependency folder(s) from the environment build ($(( ($(date +%s%N) - started) / 1000000 )) ms).\" >&2\n fi\n fi\nfi\nif [ -x \"$here/post-checkout.orca-chained\" ]; then\n if head -n 1 \"$here/post-checkout.orca-chained\" | grep -Eq '^#!.*(ba|da|z)?sh'; then\n # Sourced, so $0 still names `post-checkout` (husky v9 finds the user's hook by basename \"$0\").\n ( . \"$here/post-checkout.orca-chained\" \"$@\" )\n exit $?\n fi\n exec \"$here/post-checkout.orca-chained\" \"$@\"\nfi\nexit 0\n" diff --git a/cloud/apps/sandbox-control/image/vm/orca-desktop-page.js b/cloud/apps/sandbox-control/image/vm/orca-desktop-page.js new file mode 100644 index 00000000000..266c783636e --- /dev/null +++ b/cloud/apps/sandbox-control/image/vm/orca-desktop-page.js @@ -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 = '

'; +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 + }) +}); diff --git a/cloud/apps/sandbox-control/image/vm/orca-desktop-proxy b/cloud/apps/sandbox-control/image/vm/orca-desktop-proxy new file mode 100755 index 00000000000..f81c0cb6628 --- /dev/null +++ b/cloud/apps/sandbox-control/image/vm/orca-desktop-proxy @@ -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'\n' + + +def scrub(text): + return SCRUB_RE.sub(r'\1', 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'', 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:]) diff --git a/cloud/apps/sandbox-control/image/vm/orca-desktop-start b/cloud/apps/sandbox-control/image/vm/orca-desktop-start index 32335610aeb..588b31dfaa5 100644 --- a/cloud/apps/sandbox-control/image/vm/orca-desktop-start +++ b/cloud/apps/sandbox-control/image/vm/orca-desktop-start @@ -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/.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' "$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 'yes' \ '' >"$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 diff --git a/cloud/apps/sandbox-control/src/environment-builds.ts b/cloud/apps/sandbox-control/src/environment-builds.ts index 00c5e9019a0..08afb44b783 100644 --- a/cloud/apps/sandbox-control/src/environment-builds.ts +++ b/cloud/apps/sandbox-control/src/environment-builds.ts @@ -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 diff --git a/cloud/apps/sandbox-control/src/golden-image-provider.ts b/cloud/apps/sandbox-control/src/golden-image-provider.ts index 2af61a69069..adb13f6e0d9 100644 --- a/cloud/apps/sandbox-control/src/golden-image-provider.ts +++ b/cloud/apps/sandbox-control/src/golden-image-provider.ts @@ -22,7 +22,12 @@ export type GoldenImageProviderApi = { params: { command: string; args?: string[]; env?: Record; timeoutMs: number } ): Promise /** 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. diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 643718cdf6c..815a6a0a8b3 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -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)) diff --git a/cloud/apps/sandbox-control/src/sandbox-vm-commands.ts b/cloud/apps/sandbox-control/src/sandbox-vm-commands.ts index 101944915e0..ccb22a14084 100644 --- a/cloud/apps/sandbox-control/src/sandbox-vm-commands.ts +++ b/cloud/apps/sandbox-control/src/sandbox-vm-commands.ts @@ -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 { let result: Awaited> try { diff --git a/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts b/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts index ddc665a3948..e8715bcac9b 100644 --- a/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts +++ b/cloud/apps/sandbox-control/src/vercel-sandbox-api.ts @@ -64,7 +64,7 @@ export function createVercelSandboxApi( async function request( method: string, path: string, - options: { query?: Record; body?: unknown } = {} + options: { query?: Record; body?: unknown; signal?: AbortSignal } = {} ): Promise { 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 } }, diff --git a/cloud/apps/sandbox-control/test/desktop-route.test.ts b/cloud/apps/sandbox-control/test/desktop-route.test.ts index f536a946384..4ba76be7698 100644 --- a/cloud/apps/sandbox-control/test/desktop-route.test.ts +++ b/cloud/apps/sandbox-control/test/desktop-route.test.ts @@ -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 () => { diff --git a/cloud/apps/sandbox-control/test/fake-vercel.ts b/cloud/apps/sandbox-control/test/fake-vercel.ts index 9a654e8c342..d26c98d3df2 100644 --- a/cloud/apps/sandbox-control/test/fake-vercel.ts +++ b/cloud/apps/sandbox-control/test/fake-vercel.ts @@ -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) { diff --git a/cloud/apps/sandbox-control/test/vercel-sandbox-api.test.ts b/cloud/apps/sandbox-control/test/vercel-sandbox-api.test.ts index 429db260c30..320df9435a6 100644 --- a/cloud/apps/sandbox-control/test/vercel-sandbox-api.test.ts +++ b/cloud/apps/sandbox-control/test/vercel-sandbox-api.test.ts @@ -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((_, 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({ diff --git a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts index a51f774436b..e5d5d941082 100644 --- a/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts +++ b/src/main/browser/guest-lifecycle/input/shortcuts/browser-guest-shortcut-forwarding.ts @@ -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?.() diff --git a/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts b/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts index df201f6595b..51bc5119488 100644 --- a/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts +++ b/src/main/browser/guest-lifecycle/manager/browser-manager-bindings.ts @@ -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( diff --git a/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts b/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts index 805c75e1321..492c0bbacc0 100644 --- a/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts +++ b/src/main/browser/guest-lifecycle/manager/browser-manager-state.ts @@ -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 void>() protected readonly mouseWheelZoomCleanupByTabId = new Map void>() protected readonly targetUrlCleanupByTabId = new Map void>() + protected readonly remoteDesktopCleanupByTabId = new Map void>() protected readonly annotationViewportBridgeOpsByTabId = new Map>() protected readonly worktreeIdByTabId = new Map() protected readonly policyAttachedGuestIds = new Set() diff --git a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-registration.ts b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-registration.ts index 17fdd5904ef..32d4957fb65 100644 --- a/src/main/browser/guest-lifecycle/manager/registration/browser-manager-registration.ts +++ b/src/main/browser/guest-lifecycle/manager/registration/browser-manager-registration.ts @@ -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() } } diff --git a/src/main/hosted-sandbox/fake-hosted-control-plane.test-support.ts b/src/main/hosted-sandbox/fake-hosted-control-plane.test-support.ts index c1d961f5a82..dada1b8639b 100644 --- a/src/main/hosted-sandbox/fake-hosted-control-plane.test-support.ts +++ b/src/main/hosted-sandbox/fake-hosted-control-plane.test-support.ts @@ -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 } diff --git a/src/main/hosted-sandbox/hosted-remote-desktop.test.ts b/src/main/hosted-sandbox/hosted-remote-desktop.test.ts index b4651559727..0a876011489 100644 --- a/src/main/hosted-sandbox/hosted-remote-desktop.test.ts +++ b/src/main/hosted-sandbox/hosted-remote-desktop.test.ts @@ -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() diff --git a/src/main/hosted-sandbox/hosted-remote-desktop.ts b/src/main/hosted-sandbox/hosted-remote-desktop.ts index 7cbfa31882b..825028873fd 100644 --- a/src/main/hosted-sandbox/hosted-remote-desktop.ts +++ b/src/main/hosted-sandbox/hosted-remote-desktop.ts @@ -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> = { + 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 { 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 => { + 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) } } diff --git a/src/main/hosted-sandbox/remote-desktop-guest.test.ts b/src/main/hosted-sandbox/remote-desktop-guest.test.ts new file mode 100644 index 00000000000..c336278d881 --- /dev/null +++ b/src/main/hosted-sandbox/remote-desktop-guest.test.ts @@ -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 { + 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): { 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 => { + 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 } + }) + }) +}) diff --git a/src/main/hosted-sandbox/remote-desktop-guest.ts b/src/main/hosted-sandbox/remote-desktop-guest.ts new file mode 100644 index 00000000000..62bcea9f0ba --- /dev/null +++ b/src/main/hosted-sandbox/remote-desktop-guest.ts @@ -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 +} + +const guestsWithDesktop = new WeakSet() + +/** 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((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((resolve) => { + cancelInFlight = () => resolve(undefined) + }) + const cancelPageCalls = (): void => { + cancelInFlight() + cancelled = new Promise((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 => { + 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 { + 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 { + 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 + ): 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 => { + 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() + } + } +} diff --git a/src/main/hosted-sandbox/remote-desktop-keyboard.test.ts b/src/main/hosted-sandbox/remote-desktop-keyboard.test.ts new file mode 100644 index 00000000000..6ba43e34bc6 --- /dev/null +++ b/src/main/hosted-sandbox/remote-desktop-keyboard.test.ts @@ -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> = {} +): 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() + }) +}) diff --git a/src/main/hosted-sandbox/remote-desktop-keyboard.ts b/src/main/hosted-sandbox/remote-desktop-keyboard.ts new file mode 100644 index 00000000000..f585146e6ce --- /dev/null +++ b/src/main/hosted-sandbox/remote-desktop-keyboard.ts @@ -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 +} diff --git a/src/main/hosted-sandbox/remote-desktop-sessions.test.ts b/src/main/hosted-sandbox/remote-desktop-sessions.test.ts new file mode 100644 index 00000000000..6e8b2134e81 --- /dev/null +++ b/src/main/hosted-sandbox/remote-desktop-sessions.test.ts @@ -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) }) + }) +}) diff --git a/src/main/hosted-sandbox/remote-desktop-sessions.ts b/src/main/hosted-sandbox/remote-desktop-sessions.ts new file mode 100644 index 00000000000..9ca05977d0f --- /dev/null +++ b/src/main/hosted-sandbox/remote-desktop-sessions.ts @@ -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 + /** 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() + +/** 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 { + 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() +} diff --git a/src/main/ipc/cloud-remote-desktop.ts b/src/main/ipc/cloud-remote-desktop.ts index d0aae7d63fa..80a56079ef9 100644 --- a/src/main/ipc/cloud-remote-desktop.ts +++ b/src/main/ipc/cloud-remote-desktop.ts @@ -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 ) diff --git a/src/main/ipc/filesystem/filesystem.ts b/src/main/ipc/filesystem/filesystem.ts index 014f16a429a..688c3ae8b5f 100644 --- a/src/main/ipc/filesystem/filesystem.ts +++ b/src/main/ipc/filesystem/filesystem.ts @@ -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) diff --git a/src/main/ipc/filesystem/local-download-filename.ts b/src/main/ipc/filesystem/local-download-filename.ts index 2880cd9035a..24e271da139 100644 --- a/src/main/ipc/filesystem/local-download-filename.ts +++ b/src/main/ipc/filesystem/local-download-filename.ts @@ -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('') diff --git a/src/main/ipc/runtime-environments/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environments/runtime-environment-call-queue.ts index 558fa9e21d5..39c14a03593 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-call-queue.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-call-queue.ts @@ -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( selector: string, method: string, run: () => Promise, - signal?: AbortSignal + signal?: AbortSignal, + lane?: RuntimeRpcCallLane ): Promise { - return runtimeCallQueuePool.enqueue(selector, method, run, 0, signal) + return runtimeCallQueuePool.enqueue(selector, method, run, 0, signal, lane) } diff --git a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts index e0fec03129a..5d87ef7e91a 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-transport-routing.ts @@ -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> { 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') { diff --git a/src/main/runtime-downloads/runtime-download-errors.ts b/src/main/runtime-downloads/runtime-download-errors.ts new file mode 100644 index 00000000000..33244d65177 --- /dev/null +++ b/src/main/runtime-downloads/runtime-download-errors.ts @@ -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 = { + 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 }) +} diff --git a/src/main/runtime-downloads/runtime-download-handlers.ts b/src/main/runtime-downloads/runtime-download-handlers.ts new file mode 100644 index 00000000000..00bac9fe62b --- /dev/null +++ b/src/main/runtime-downloads/runtime-download-handlers.ts @@ -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() +// 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 + +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 ( + method: string, + params: unknown, + options: { timeoutMs: number; signal: AbortSignal } + ): Promise => { + 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) => { + 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 { + 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 { + 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 { + 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 => { + 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 } + }) +} diff --git a/src/main/runtime-downloads/runtime-download-transfer.test.ts b/src/main/runtime-downloads/runtime-download-transfer.test.ts new file mode 100644 index 00000000000..368f8e416e2 --- /dev/null +++ b/src/main/runtime-downloads/runtime-download-transfer.test.ts @@ -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

(impl: (method: string, params: P) => Promise): 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((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' }) + }) +}) diff --git a/src/main/runtime-downloads/runtime-download-transfer.ts b/src/main/runtime-downloads/runtime-download-transfer.ts new file mode 100644 index 00000000000..9a0e24f4eb9 --- /dev/null +++ b/src/main/runtime-downloads/runtime-download-transfer.ts @@ -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 = ( + method: string, + params: unknown, + options: { timeoutMs: number; signal: AbortSignal } +) => Promise + +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 => { + 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( + '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 { + // 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( + '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( + '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( + request: Promise, + signal: AbortSignal, + onLate: (value: T) => void +): Promise { + if (signal.aborted) { + void request.then(onLate, () => {}) + return Promise.reject(new RuntimeDownloadError('canceled')) + } + return new Promise((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) + } + ) + }) +} diff --git a/src/main/runtime-downloads/runtime-open-local-cache.ts b/src/main/runtime-downloads/runtime-open-local-cache.ts new file mode 100644 index 00000000000..f136dc5d917 --- /dev/null +++ b/src/main/runtime-downloads/runtime-open-local-cache.ts @@ -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 { + 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. + } + }) + ) +} diff --git a/src/main/runtime/construction/runtime-file-command-surface.ts b/src/main/runtime/construction/runtime-file-command-surface.ts index c975065c25f..87e3ca3f822 100644 --- a/src/main/runtime/construction/runtime-file-command-surface.ts +++ b/src/main/runtime/construction/runtime-file-command-surface.ts @@ -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), diff --git a/src/main/runtime/files/archive/folder-archive-file-reads.ts b/src/main/runtime/files/archive/folder-archive-file-reads.ts new file mode 100644 index 00000000000..72a979cf805 --- /dev/null +++ b/src/main/runtime/files/archive/folder-archive-file-reads.ts @@ -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 { + 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 { + 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 { + 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(() => {}) + } +} diff --git a/src/main/runtime/files/archive/folder-archive-sessions.test.ts b/src/main/runtime/files/archive/folder-archive-sessions.test.ts new file mode 100644 index 00000000000..0b064f961e4 --- /dev/null +++ b/src/main/runtime/files/archive/folder-archive-sessions.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/files/archive/folder-archive-sessions.ts b/src/main/runtime/files/archive/folder-archive-sessions.ts new file mode 100644 index 00000000000..c747dbdbcab --- /dev/null +++ b/src/main/runtime/files/archive/folder-archive-sessions.ts @@ -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 | null +} + +export class FolderArchiveSessions { + private readonly sessions = new Map() + // Slots held by walks still running, so parallel starts can't all walk before the limit applies. + private readonly reservations = new Map() + 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 { + const releaseReservation = this.reserve(input.ownerKey) + let rootName: string + let plan: Awaited> + 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 { + 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() + +/** 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 +} diff --git a/src/main/runtime/files/archive/folder-archive-walk.ts b/src/main/runtime/files/archive/folder-archive-walk.ts new file mode 100644 index 00000000000..84d9fa6196b --- /dev/null +++ b/src/main/runtime/files/archive/folder-archive-walk.ts @@ -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 { + // 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( + 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() + 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 { + 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 { + 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' + ) +} diff --git a/src/main/runtime/files/archive/folder-archive-zip-stream.ts b/src/main/runtime/files/archive/folder-archive-zip-stream.ts new file mode 100644 index 00000000000..4b44cd1e93b --- /dev/null +++ b/src/main/runtime/files/archive/folder-archive-zip-stream.ts @@ -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((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 { + const prefetched = new Map>() + 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 { + 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 { + while (!this.canceled && this.bufferedBytes >= this.highWaterBytes) { + await new Promise((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( + 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) +} diff --git a/src/main/runtime/files/file-reads.ts b/src/main/runtime/files/file-reads.ts index f84056c724b..9c02e16c2c6 100644 --- a/src/main/runtime/files/file-reads.ts +++ b/src/main/runtime/files/file-reads.ts @@ -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 { + 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, diff --git a/src/main/runtime/rpc/methods/files/files-archive-methods.ts b/src/main/runtime/rpc/methods/files/files-archive-methods.ts new file mode 100644 index 00000000000..037b871ce55 --- /dev/null +++ b/src/main/runtime/rpc/methods/files/files-archive-methods.ts @@ -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 +) { + 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) + }) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/files/files.ts b/src/main/runtime/rpc/methods/files/files.ts index 56c8c93d755..671fa50c1f2 100644 --- a/src/main/runtime/rpc/methods/files/files.ts +++ b/src/main/runtime/rpc/methods/files/files.ts @@ -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', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 43b3290ac25..e4979e756be 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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). */ diff --git a/src/preload/api/browser/browser-api.ts b/src/preload/api/browser/browser-api.ts index d778966efc0..887ea272d3a 100644 --- a/src/preload/api/browser/browser-api.ts +++ b/src/preload/api/browser/browser-api.ts @@ -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 onSessionProfilesChanged: (callback: () => void) => () => void /** Resolves once the SSH workspace's partition is bound and proxy-verified; the webview must wait for it. */ diff --git a/src/preload/api/browser/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser/browser-bridge-page-interaction-and-sessions.ts index 75e453861ec..c6ce70adebb 100644 --- a/src/preload/api/browser/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser/browser-bridge-page-interaction-and-sessions.ts @@ -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() diff --git a/src/preload/api/runtime-downloads-bridge.ts b/src/preload/api/runtime-downloads-bridge.ts new file mode 100644 index 00000000000..1f4b3c25a35 --- /dev/null +++ b/src/preload/api/runtime-downloads-bridge.ts @@ -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 + 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) + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 6cc4d785126..96045137488 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index 2bbb0a43b13..7a2f993b7dc 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -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)', diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx index 2d7e2d0d844..ac1709ae187 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-viewport-overlays.tsx @@ -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} /> + {showFailureOverlay && browserTab.loadError ? ( {} @@ -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. +

+ +
+ ) + }) +} + +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) diff --git a/src/renderer/src/components/cloud-remote-desktop/CloudRemoteDesktopToast.tsx b/src/renderer/src/components/cloud-remote-desktop/CloudRemoteDesktopToast.tsx index f515a34a0f4..b5aa8f502c8 100644 --- a/src/renderer/src/components/cloud-remote-desktop/CloudRemoteDesktopToast.tsx +++ b/src/renderer/src/components/cloud-remote-desktop/CloudRemoteDesktopToast.tsx @@ -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({ + {kind === 'captured' ? ( + {{chord}} to use Orca shortcuts.', + { chord } + )} + components={{ + kbd: ( + + ) + }} + /> + ) : ( + t( + 'components.cloudRemoteDesktop.keyboardReleased', + 'Orca shortcuts are back. Click the desktop to type in it.' + ) + )} + + ) +} + +/** + * 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 +}): 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 ( + + ) +} diff --git a/src/renderer/src/components/cloud-remote-desktop/RemoteDesktopMenuEntry.tsx b/src/renderer/src/components/cloud-remote-desktop/RemoteDesktopMenuEntry.tsx index f5b3f17544b..437cdea6980 100644 --- a/src/renderer/src/components/cloud-remote-desktop/RemoteDesktopMenuEntry.tsx +++ b/src/renderer/src/components/cloud-remote-desktop/RemoteDesktopMenuEntry.tsx @@ -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 }): {t('components.cloudRemoteDesktop.openMenuItem', 'Open remote desktop')} + + {t('components.cloudRemoteDesktop.beta', 'Beta')} + diff --git a/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts b/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts index 8af2d1638aa..2d2b3dfdb54 100644 --- a/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts +++ b/src/renderer/src/components/cloud-workspaces/cloud-workspace-errors.ts @@ -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', diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/RuntimeDownloads.fixture.tsx b/src/renderer/src/components/right-sidebar/file-explorer/panel/RuntimeDownloads.fixture.tsx new file mode 100644 index 00000000000..11f43696e0e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/RuntimeDownloads.fixture.tsx @@ -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: () => ( +
+ +
+ ) + }) +} + +// 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: () => ( +
+ + + + + + + Open in Terminal + + {kind === 'file' ? ( + + + View File + + ) : null} + + + {kind === 'file' ? 'Download…' : 'Download as ZIP…'} + + {kind === 'file' ? ( + + + Open on This Computer + + ) : ( + + + Find in Folder + + )} + + + + Copy Path + + + + Rename + + + +
+ ) + }) +} + +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 +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-action-visibility.ts b/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-action-visibility.ts index 36d538db94c..f077b69bff4 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-action-visibility.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-action-visibility.ts @@ -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, diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-context-menu.tsx b/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-context-menu.tsx index ffeed794e48..33173a81421 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/file-explorer-row-context-menu.tsx @@ -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({ {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')} + + )} + {showOpenLocallyAction && ( + + + {t('auto.components.right.sidebar.FileExplorerRow.openLocally', 'Open on This Computer')} )} {canCollapseFolderSubtree && shouldShowCollapseFolderAction(node, isExpanded) && ( diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-actions.test.ts b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-actions.test.ts new file mode 100644 index 00000000000..155c7283fbe --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-actions.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-actions.tsx b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-actions.tsx new file mode 100644 index 00000000000..e7b266395ec --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-actions.tsx @@ -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() + +/** 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, '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 ( + {}} + /> + ) +} + +export async function startRuntimeDownload( + node: TreeNode, + context: RuntimeFileOperationArgs, + intent: RuntimeDownloadIntent, + options: { excludeDefaultDirs?: boolean } = {} +): Promise { + 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> +): Promise { + 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( + () => ( + 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( + () => ( + 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 + 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 } : {}) + } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-failure-copy.ts b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-failure-copy.ts new file mode 100644 index 00000000000..d62788c7caa --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-failure-copy.ts @@ -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' + } +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-toast.tsx b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-toast.tsx new file mode 100644 index 00000000000..a1fc94da1cb --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/runtime-download-toast.tsx @@ -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 ( + + ) +} + +function ProgressCard({ + state, + onAction +}: { + state: Extract + 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 ( +
+
+ + {/* Only the title is live: the numbers change several times a second and would chatter. + The progressbar's value text carries them when asked. */} +

+ {title} +

+ +
+
+ {percent === null ? ( + // Unknown size: an honest sweep beats a faked percentage. + + + + ) : ( + + )} +

{detail}

+
+
+ ) +} + +function DoneCard({ + state, + revealLabel, + onAction, + onDismiss +}: { + state: Extract + 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 ( +
+
+ +
+

+ {t('components.runtimeDownload.downloaded', 'Downloaded {{name}}', { + name: middleTruncateName(state.name) + })} +

+

{parts.join(' · ')}

+ {notes.map((note) => ( +

+ {note} +

+ ))} +
+ + {state.itemKind === 'file' && state.canOpen !== false ? ( + + ) : null} +
+
+
+ +
+ ) +} + +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 + } + if (state.kind === 'done') { + return ( + + ) + } + const copy = + state.kind === 'no_app' + ? runtimeDownloadNoAppCopy(t, state.name) + : runtimeDownloadFailureCopy(t, state) + const action = copy.action + return ( +
+ onAction(action)}> + {actionLabel(t, action, revealLabel)} + + ) : undefined + } + /> + +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer/panel/useFileExplorerManualRefresh.ts b/src/renderer/src/components/right-sidebar/file-explorer/panel/useFileExplorerManualRefresh.ts index 7ff95062fdc..093ae92945a 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer/panel/useFileExplorerManualRefresh.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer/panel/useFileExplorerManualRefresh.ts @@ -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 }) }, [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 } } diff --git a/src/renderer/src/components/right-sidebar/file-explorer/watching/file-explorer-refresh-requests.ts b/src/renderer/src/components/right-sidebar/file-explorer/watching/file-explorer-refresh-requests.ts new file mode 100644 index 00000000000..0b4b3200bf4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer/watching/file-explorer-refresh-requests.ts @@ -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) + } +} diff --git a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx index 3b34cc9768a..fd5245ced1b 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx @@ -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(null) const cardRef = React.useRef(null) diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts index 35f65e34928..3ec2bcc5a72 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts @@ -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 { diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts index 2651fff4057..993a1f3c61b 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts @@ -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. */ diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure.test.ts b/src/renderer/src/components/worktree-creation/vm-build-failure.test.ts index 849a60a64c3..e6dc41a329c 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure.test.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure.test.ts @@ -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[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' }, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1c01e2283e2..ce9b52a712c 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c7bc95a0e3f..bc3e97db9ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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 {{chord}} 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": { diff --git a/src/renderer/src/lib/cloud-remote-desktop/cloud-environment-ids.ts b/src/renderer/src/lib/cloud-remote-desktop/cloud-environment-ids.ts index 5740e280a8c..7b4ca0c192c 100644 --- a/src/renderer/src/lib/cloud-remote-desktop/cloud-environment-ids.ts +++ b/src/renderer/src/lib/cloud-remote-desktop/cloud-environment-ids.ts @@ -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)) } diff --git a/src/renderer/src/lib/cloud-remote-desktop/open-cloud-remote-desktop.tsx b/src/renderer/src/lib/cloud-remote-desktop/open-cloud-remote-desktop.tsx index f3d89db032a..a3e679bad2d 100644 --- a/src/renderer/src/lib/cloud-remote-desktop/open-cloud-remote-desktop.tsx +++ b/src/renderer/src/lib/cloud-remote-desktop/open-cloud-remote-desktop.tsx @@ -13,21 +13,20 @@ import type { CloudRemoteDesktopResult } from '../../../../shared/hosted-sandbox const SLOW_START_MS = 5_000 const opening = new Set() -/** 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 }) diff --git a/src/renderer/src/runtime/files/downloads/runtime-download-progress-store.ts b/src/renderer/src/runtime/files/downloads/runtime-download-progress-store.ts new file mode 100644 index 00000000000..8bf3aba084e --- /dev/null +++ b/src/renderer/src/runtime/files/downloads/runtime-download-progress-store.ts @@ -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() +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 +} diff --git a/src/renderer/src/web/preload-api/web-browser-api.ts b/src/renderer/src/web/preload-api/web-browser-api.ts index afb4d9020e3..ecd73eb7efd 100644 --- a/src/renderer/src/web/preload-api/web-browser-api.ts +++ b/src/renderer/src/web/preload-api/web-browser-api.ts @@ -77,6 +77,8 @@ export function createBrowserApi(): NonNullable['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. diff --git a/src/shared/hosted-sandbox/cloud-remote-desktop.ts b/src/shared/hosted-sandbox/cloud-remote-desktop.ts index 2309a2e4d3f..da5fba18935 100644 --- a/src/shared/hosted-sandbox/cloud-remote-desktop.ts +++ b/src/shared/hosted-sandbox/cloud-remote-desktop.ts @@ -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()}` } diff --git a/src/shared/hosted-sandbox/hosted-sandbox-wire.ts b/src/shared/hosted-sandbox/hosted-sandbox-wire.ts index 587871a76de..bf33c58134b 100644 --- a/src/shared/hosted-sandbox/hosted-sandbox-wire.ts +++ b/src/shared/hosted-sandbox/hosted-sandbox-wire.ts @@ -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) }) }) diff --git a/src/shared/plugins/frames/plugin-save-as-extensions.ts b/src/shared/plugins/frames/plugin-save-as-extensions.ts index fd9afbf7b47..e40b3fd59b3 100644 --- a/src/shared/plugins/frames/plugin-save-as-extensions.ts +++ b/src/shared/plugins/frames/plugin-save-as-extensions.ts @@ -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 } diff --git a/src/shared/rpc-contract/files-archive-params.ts b/src/shared/rpc-contract/files-archive-params.ts new file mode 100644 index 00000000000..04a7acb1253 --- /dev/null +++ b/src/shared/rpc-contract/files-archive-params.ts @@ -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' diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 3f59c0c86c6..8f1cbef09cf 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/src/shared/runtime-downloads/runtime-download-contract.ts b/src/shared/runtime-downloads/runtime-download-contract.ts new file mode 100644 index 00000000000..165afdf8847 --- /dev/null +++ b/src/shared/runtime-downloads/runtime-download-contract.ts @@ -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 } diff --git a/src/shared/runtime-rpc-call-queue.test.ts b/src/shared/runtime-rpc-call-queue.test.ts index 6c870dbdb3e..6c4c26c96d4 100644 --- a/src/shared/runtime-rpc-call-queue.test.ts +++ b/src/shared/runtime-rpc-call-queue.test.ts @@ -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((resolve) => previewRelease.push(resolve)) + return 'preview' + }) + ) + const downloads = Array.from({ length: 6 }, () => + queue.enqueue( + 'web-runtime', + 'files.readChunk', + async () => { + await new Promise((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', () => { diff --git a/src/shared/runtime-rpc-call-queue.ts b/src/shared/runtime-rpc-call-queue.ts index 532d6241358..82045c07183 100644 --- a/src/shared/runtime-rpc-call-queue.ts +++ b/src/shared/runtime-rpc-call-queue.ts @@ -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() private queuedCallCount = 0 @@ -86,18 +93,22 @@ export class RuntimeRpcCallQueuePool { method: string, run: () => Promise, retainedBytes = 0, - signal?: AbortSignal + signal?: AbortSignal, + lane?: RuntimeRpcCallLane ): Promise { 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((resolve, reject) => { const call: QueuedRuntimeCall = {