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/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/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 9d409b52920..d26c98d3df2 100644 --- a/cloud/apps/sandbox-control/test/fake-vercel.ts +++ b/cloud/apps/sandbox-control/test/fake-vercel.ts @@ -168,7 +168,7 @@ export function createFakeVercel(now: () => number): FakeVercel { failEnvRefresh: false, sshKeysExit: 0, authorizedKeys: new Map(), - desktop: { exitCode: 0, stdout: '{"schemaVersion":1,"port":6080,"display":":1","path":"vnc.html","password":"Ab3dE6gH"}\n' }, + desktop: { exitCode: 0, stdout: `{"schemaVersion":2,"port":6080,"display":":1","path":"vnc.html","ticket":"${'t'.repeat(43)}","expiresInMs":60000}\n` }, failCreate: false, snapshotExpirations: new Map(), expire(name) { 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/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/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/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 016fa4feaa7..bc3e97db9ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -22880,7 +22880,7 @@ "retry": "Try again", "notCloudTitle": "Remote desktop is only available in cloud workspaces.", "notCloudDetail": "Create a cloud workspace to run apps with a screen.", - "asleepTitle": "This workspace is asleep.", + "asleepTitle": "This sandbox is asleep.", "asleepDetail": "Wake it, then open the remote desktop again. Your files are safe.", "notInstalledTitle": "This sandbox doesn’t have a remote desktop yet.", "notInstalledDetail": "It was created before remote desktop was available. New cloud workspaces include it. Your files are safe.", @@ -22890,12 +22890,25 @@ "forwardFailedDetail": "The desktop is running in the sandbox. Your files are safe.", "unavailableTitle": "Couldn’t start the remote desktop.", "unavailableDetail": "Orca Cloud didn’t respond in time. Your files are safe.", - "wake": "Wake workspace", + "wake": "Wake sandbox", "signIn": "Sign in", "createWorkspace": "Create workspace", "slowDetail": "The first start in a sandbox can take up to a minute.", "dismiss": "Dismiss", - "openMenuItem": "Open remote desktop" + "openMenuItem": "Open remote desktop", + "startingTitle": "The sandbox is still starting or waking.", + "archivedTitle": "This sandbox is archived.", + "archivedDetail": "Unarchive the workspace to use the remote desktop again. Your files are safe.", + "startingDetail": "Try again in a moment. Your files are safe.", + "keyboardCaptured": "Keys go to the remote desktop. Press {{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": { 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/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) }) })