diff --git a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts index d52314674b6..64a1c7816bb 100644 --- a/cloud/apps/sandbox-control/image/vm-scripts.generated.ts +++ b/cloud/apps/sandbox-control/image/vm-scripts.generated.ts @@ -3,9 +3,9 @@ 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","opencode-ai@1.19.5","@google/gemini-cli@0.67.0","@github/copilot@1.0.108"],"cursorAgent":{"version":"2026.11.05-86047df","sha256":"170b77786cbf4c93064d64fff12b71a178b968730561c64afc2e8e752535442e"},"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), [CURSOR_AGENT_VERSION CURSOR_AGENT_SHA256], [NOVNC_VERSION NOVNC_SHA256 WEBSOCKIFY_VERSION WEBSOCKIFY_SHA256] and ORCA_VM_SCRIPT_ (base64) for each /opt/orca/bin entry point.\n# [ORCA_VM_SCRIPTS_STAMP] (VM_SCRIPTS_STAMP of those scripts; written to /opt/orca/bin/.scripts-stamp).\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 # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\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# opencode-ai installs every linux-x64 build variant (~205 MB each) and hardlinks the one its\n# postinstall picked into bin/opencode.exe, a standalone binary: drop the variants it did not pick.\nopencode_pkg=\"$ORCA_HOME/tools/lib/node_modules/opencode-ai\"\nif [ -x \"$opencode_pkg/bin/opencode.exe\" ]; then\n picked=\"$(stat -c %i \"$opencode_pkg/bin/opencode.exe\")\"\n for variant in \"$opencode_pkg\"/node_modules/opencode-*; do\n [ -d \"$variant\" ] || continue\n [ \"$(stat -c %i \"$variant/bin/opencode\" 2>/dev/null)\" = \"$picked\" ] || rm -rf \"$variant\"\n done\n \"$opencode_pkg/bin/opencode.exe\" --version >/dev/null\nfi\n\n# Copilot CLI prefers GH_TOKEN/GITHUB_TOKEN over the user's own /login. In Orca Cloud those hold the\n# workspace's GitHub App placeholder, which Copilot can't use (\"Resource not accessible by\n# integration\"), so a signed-in user could never get past it. The wrapper drops them for Copilot\n# only (COPILOT_GITHUB_TOKEN, a /login and BYOK still work), unless BYOK is set up, which needs no\n# GitHub sign-in and keeps them so `gh` in Copilot's shell still works.\ncopilot_loader=\"$ORCA_HOME/tools/lib/node_modules/@github/copilot/npm-loader.js\"\nif [ -f \"$copilot_loader\" ]; then\n rm -f \"$ORCA_HOME/tools/bin/copilot\"\n cat >\"$ORCA_HOME/tools/bin/copilot\" </dev/null; fi\ndone\n\nif [ -n \"${CURSOR_AGENT_VERSION:-}\" ]; then\n # Cursor's agent CLI is a pinned tarball (its own node + bundle), not an npm package. The image\n # copy lives under /opt/orca (root-owned) and its release channel is set to \"static\" at boot\n # (orca-sandbox-env), so it never replaces itself mid-session.\n log \"cursor-agent $CURSOR_AGENT_VERSION\"\n tgz=\"$(mktemp -d)/cursor-agent.tgz\"\n fetch_verified \"https://downloads.cursor.com/lab/${CURSOR_AGENT_VERSION}/linux/x64/agent-cli-package.tar.gz\" \\\n \"$CURSOR_AGENT_SHA256\" \"$tgz\"\n rm -rf \"$ORCA_HOME/tools/cursor-agent\"\n mkdir -p \"$ORCA_HOME/tools/cursor-agent\" \"$ORCA_HOME/tools/bin\"\n tar --strip-components=1 -xzf \"$tgz\" -C \"$ORCA_HOME/tools/cursor-agent\"\n rm -rf \"$(dirname \"$tgz\")\"\n ln -sfn ../cursor-agent/cursor-agent \"$ORCA_HOME/tools/bin/cursor-agent\"\n \"$ORCA_HOME/tools/bin/cursor-agent\" --version >/dev/null\nfi\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-env orca-egress-probe orca-image-wipe orca-agent-signout 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-sandbox-read-snapshot orca-read-snapshot.cjs orca-git-as orca-git-identity orcad-boot 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# Which entry points this image carries (VM_SCRIPTS_STAMP): a Worker with older scripts never\n# reinstalls over them. Absent (old callers) reads as 0, so any Worker may still update the image.\nrm -f \"$ORCA_HOME/bin/.scripts-stamp\" \"$ORCA_HOME\"/bin/.entry-points-*\nif [[ \"${ORCA_VM_SCRIPTS_STAMP:-}\" =~ ^[0-9]+$ ]]; then\n printf '%s\\n' \"$ORCA_VM_SCRIPTS_STAMP\" >\"$ORCA_HOME/bin/.scripts-stamp\"\nfi\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\nif [ -f /etc/bash.bashrc ]; then\n # Ubuntu: zsh reads /etc/zsh/zshenv for every shell, so new zsh shells match bash (`orca-env` covers\n # shells that were already open).\n sudo install -d -m 0755 /etc/zsh\n sudo touch /etc/zsh/zshenv\n grep -q zz-orca-session-env /etc/zsh/zshenv ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/zsh/zshenv >/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\"\n# The runtime is root-owned, so its startup-qualified marker lives here. The smoke run leaves one,\n# so every boot from the image skips orcad's disposable startup preflight (~0.6 s of wake).\nsudo install -d -o \"$(id -u)\" -g \"$(id -g)\" \"$ORCA_HOME/startup-qualification\"\nsmoke_data=\"$(mktemp -d)\"\n# orcad-boot also writes orcad's full code cache as the smoke run exits (~0.3 s more off every boot).\nNODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_USER_DATA=\"$smoke_data\" timeout 60 \"$node\" \"$ORCA_HOME/bin/orcad-boot\" \\\n --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" --write-code-cache \"$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\n# orcad writes the marker just after readiness; best effort, a boot without one only re-qualifies.\nfor _ in $(seq 1 30); do\n [ \"$ready\" = 1 ] && [ ! -s \"$ORCA_HOME/startup-qualification/.orcad-startup-qualified\" ] || break\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# Best effort: without it every boot loads orcad as before.\nif [ -s \"$ORCA_HOME/compile-cache/orcad-server.v8\" ]; then\n log \"orcad code cache: $(stat -c %s \"$ORCA_HOME/compile-cache/orcad-server.v8\") bytes\"\nelse\n log 'orcad code cache was not written; boots compile orcad without it'\n grep 'orcad-boot' /tmp/orcad-smoke.err >&2 || true\nfi\n\nlog 'versions'\nversions=\"{}\"\nfor bin in claude codex opencode gemini cursor-agent copilot 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# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\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\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\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=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\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)\"\n# CREATE-GIT-ASYNC: a create answers before member identities are installed; `git` holds commits and\n# pushes until orca-sandbox-members (or the control plane) marks this setup ready or failed. Marked\n# before the already-started exit below: a pooled VM's orcad may be up before its create claims it.\nif [ \"${ORCA_GIT_SETUP:-}\" = pending ]; then\n rm -f \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n date +%s >\"$STATE/git-setup.pending\"\nfi\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# Any other boot (wake, restart) installs identities before it answers: a resumed disk's marks are stale.\n[ \"${ORCA_GIT_SETUP:-}\" = pending ] || rm -f \"$STATE/git-setup.pending\" \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\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.\n# The fallback moved to orca.dev with the domain; a workspace still on the old default follows it.\n[ \"$(git config --global user.email 2>/dev/null || true)\" = 'cloud@users.noreply.onorca.dev' ] \\\n && git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.orca.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 # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\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\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\n# N-02: the host is a cloud terminal's source of truth across app reopens, so orcad and its\n# daemon keep a local terminal's depth (5000 rows) instead of the desktop daemon's 1000-row live\n# window. Scoped to the VM: local desktop daemons keep their default.\n# Ports panel: programs under $ORCA_HOME are the image's own services, and 23456 is Vercel's\n# sandbox-init (root, pid invisible to this user); neither is listed as the user's port.\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\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 ORCA_HOST_MANAGED_LISTENER_PATHS=\"$ORCA_HOME\" ORCA_HOST_MANAGED_UNOWNED_PORTS=23456 \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_DAEMON_SESSION_SCROLLBACK_ROWS=5000 \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --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\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); 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.01\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)}\"\n# ORCA_SESSION_ENV_DIR: tests only (a scratch dir off tmpfs); the control plane never sets it.\ndir=\"${ORCA_SESSION_ENV_DIR:-/dev/shm/orca-env-$(id -u)}\"\nif [ -z \"${ORCA_SESSION_ENV_DIR:-}\" ] && [ \"$(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=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\ncodex_placeholder=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n # Not for shells: the Codex account placeholder becomes ~/.codex/auth.json below.\n if [ \"$name\" = ORCA_CODEX_AUTH_PLACEHOLDER ]; then\n codex_placeholder=\"$(printf '%s' \"$b64\" | base64 -d)\"\n case \"$codex_placeholder\" in *[!A-Za-z0-9._-]*) codex_placeholder='' ;; esac\n fi\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 ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\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 the managed blocks and the blank lines written before them (no growth per refresh)\n awk '/^# >>> orca-cloud managed/{skip=1; blank=\"\"} skip{if (/^# <<< orca-cloud managed/) skip=0; next}\n /^[[:space:]]*$/{blank=blank $0 \"\\n\"; next} {printf \"%s\", blank; blank=\"\"; print}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; 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 custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n elif [ -n \"$codex_placeholder\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_chatgpt\"\\n'\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\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 elif [ -n \"$codex_placeholder\" ]; then\n # Codex via the Orca account: the ChatGPT backend over HTTPS. Its default Responses WebSocket can't\n # pass the egress proxy, where the placeholder becomes the real token (found live, Codex 0.166).\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_chatgpt]\\nname = \"OpenAI\"\\n'\n printf 'base_url = \"https://chatgpt.com/backend-api/codex\"\\nwire_api = \"responses\"\\nrequires_openai_auth = true\\nsupports_websockets = false\\n'\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Codex via the person's Orca account (contract D11): a placeholder ChatGPT login, no refresh token.\n# The egress proxy swaps the real access token on chatgpt.com; the real tokens never reach this VM. The\n# marker remembers which placeholder Orca wrote (a clone gets its own), so a login of the person's own\n# (kill-switch fallback, or `codex login` run here) is set aside while the account applies and put back after.\ncodex_auth=\"$HOME/.codex/auth.json\"\ncodex_saved=\"$HOME/.codex/auth.json.orca-saved\"\ncodex_marker=\"$HOME/.codex/.orca-account-placeholder\"\ncodex_ours=''\n# Ours: the marker's placeholder, or any Orca placeholder (all end in `.orca-placeholder`; a real token never does).\nif [ -f \"$codex_auth\" ] && { { [ -f \"$codex_marker\" ] && grep -qF \"\\\"$(cat \"$codex_marker\")\\\"\" \"$codex_auth\"; } || grep -q '\\.orca-placeholder\"' \"$codex_auth\"; }; then\n codex_ours=1\nfi\nif [ -n \"$codex_placeholder\" ]; then\n mkdir -p \"$HOME/.codex\"\n if [ -f \"$codex_auth\" ] && [ -z \"$codex_ours\" ]; then\n # Never overwrite a login already set aside: a second one gets its own name.\n if [ -e \"$codex_saved\" ]; then mv -f \"$codex_auth\" \"$codex_saved.$(date -u +%Y%m%d%H%M%S)\"; else mv -f \"$codex_auth\" \"$codex_saved\"; fi\n fi\n # Marker first: a run cut short after the swap still recognises the file as Orca's.\n printf '%s' \"$codex_placeholder\" >\"$codex_marker\"\n codex_tmp=\"$(mktemp \"$HOME/.codex/.auth.XXXXXX\")\"\n printf '{\\n \"OPENAI_API_KEY\": null,\\n \"tokens\": {\\n \"id_token\": \"%s\",\\n \"access_token\": \"%s\",\\n \"refresh_token\": \"\",\\n \"account_id\": null\\n },\\n \"last_refresh\": \"%s\"\\n}\\n' \\\n \"$codex_placeholder\" \"$codex_placeholder\" \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" >\"$codex_tmp\"\n chmod 600 \"$codex_tmp\"\n mv -f \"$codex_tmp\" \"$codex_auth\"\nelif [ -f \"$codex_marker\" ] || [ -n \"$codex_ours\" ]; then\n if [ -n \"$codex_ours\" ]; then rm -f \"$codex_auth\"; fi\n rm -f \"$codex_marker\"\n if [ -f \"$codex_saved\" ] && [ ! -f \"$codex_auth\" ]; then mv -f \"$codex_saved\" \"$codex_auth\"; fi\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n\n// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode\n// picks one and fails (\"Authorization header is badly formatted\" / opencode.ai unreachable) instead\n// of showing \"Connect a provider\". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the\n// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the\n// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json).\nconst ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"opencode\", \"auth.json\"))\nconst connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : [])\nconst ocDisabledLedger = path.join(ocDir, \".orca-managed-disabled-providers\")\nconst ocNow = readStrict(ocFile)\nif (isObject(ocNow) && !/\"disabled_providers\"\\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\") } catch { return \"\" } })())) {\n let ours = []\n try { ours = fs.readFileSync(ocDisabledLedger, \"utf8\").split(\"\\n\").filter(Boolean) } catch {}\n const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === \"string\" && !ours.includes(id))\n const managed = [\"github-copilot\", \"opencode\"].filter((id) => !connected.has(id) && !userList.includes(id))\n const next = [...userList, ...managed]\n const before = JSON.stringify(ocNow.disabled_providers ?? [])\n if (JSON.stringify(next) !== before) {\n const updated = { $schema: \"https://opencode.ai/config.json\", ...ocNow }\n if (next.length > 0) updated.disabled_providers = next\n else delete updated.disabled_providers\n write(ocFile, updated)\n }\n if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join(\"\\n\") + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocDisabledLedger, { force: true })\n}\n' || echo 'agent first-run state not seeded' >&2\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\n# CR-cmd-quiet: Orca's staged agent launch (` . '/tmp/orca-launch-….sh'`) stays out of zsh history.\n# zsh records a line before running it, so the hook must exist first; the staged script itself\n# drops the line from bash's history. eval: bash parses this file too.\nif [ -n \"${ZSH_VERSION-}\" ]; then\n eval '__orca_skip_launch_history() { [[ $1 == *orca-launch-* ]] && return 1; return 0 }\n (( ${zshaddhistory_functions[(Ie)__orca_skip_launch_history]} )) || zshaddhistory_functions+=(__orca_skip_launch_history)'\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-env":"#!/usr/bin/env bash\n# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets\n# saved after the shell started): eval \"$(orca-env)\" or, in fish: orca-env --fish | source\n# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and\n# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone.\n# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal.\nset -euo pipefail\nfish=''\ncase \"${1-}\" in\n '') ;;\n --fish) fish=1 ;;\n -h | --help)\n printf 'usage: eval \"$(orca-env)\" (fish: orca-env --fish | source)\\n'\n exit 0\n ;;\n *)\n printf 'orca-env: unknown option %s\\n' \"$1\" >&2\n exit 2\n ;;\nesac\n# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names.\nfile=\"${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}\"\nset -f # names are split below, never globbed\n# Not written yet (or not a cloud VM): nothing to change, like the shell hook.\n[ -r \"$file\" ] || exit 0\n\n# 'it'\\''s' for POSIX shells; fish single quotes escape only \\ and '.\nquote() {\n local v=\"$1\"\n if [ -n \"$fish\" ]; then\n v=\"${v//\\\\/\\\\\\\\}\"\n v=\"${v//\\'/\\\\\\'}\"\n printf \"'%s'\" \"$v\"\n else\n printf \"'%s'\" \"${v//\\'/\\'\\\\\\'\\'}\"\n fi\n}\n\n# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test\n# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes.\nskip_name() {\n case \"$1\" in\n '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;;\n LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;;\n PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \\\n BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \\\n NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \\\n CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \\\n PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \\\n GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \\\n GH_TOKEN | GITHUB_TOKEN) return 0 ;;\n # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the\n # Orca-account placeholder (contract D10), never a stored value.\n esac\n return 1\n}\n\nnames=' '\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n name=\"${line%%=*}\"\n skip_name \"$name\" && continue\n # $(...) drops trailing newlines; the x keeps them.\n value=\"$(printf '%s' \"${line#*=}\" | base64 -d 2>/dev/null; printf x)\"\n value=\"${value%x}\"\n if [ -n \"$fish\" ]; then\n printf 'set -gx %s %s\\n' \"$name\" \"$(quote \"$value\")\"\n else\n printf 'export %s=%s\\n' \"$name\" \"$(quote \"$value\")\"\n fi\n names=\"$names$name \"\ndone <\"$file\"\nfor name in ${ORCA_SESSION_ENV_NAMES-}; do\n skip_name \"$name\" && continue\n case \"$names\" in *\" $name \"*) continue ;; esac\n if [ -n \"$fish\" ]; then\n printf 'set -e %s\\n' \"$name\"\n else\n printf 'unset %s\\n' \"$name\"\n fi\ndone\nnames=\"${names# }\"\nnames=\"${names% }\"\nif [ -n \"$fish\" ]; then\n printf 'set -gx ORCA_SESSION_ENV_NAMES %s\\n' \"$(quote \"$names\")\"\nelse\n printf 'export ORCA_SESSION_ENV_NAMES=%s\\n' \"$(quote \"$names\")\"\nfi\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/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$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\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-agent-signout":"#!/usr/bin/env bash\n# Signs every agent out on this sandbox before Orca deletes it (A9): each agent's own logout, then\n# its credential files. Covers logins made inside the sandbox (the kill-switch fallback path).\n# Never fails: the sandbox is deleted next whatever happens. Values are never printed.\nset -u\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\nexport PATH=\"/opt/orca/tools/bin:$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}\"\nclaude_dir=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"\nclaude_json=\"${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json\"\ncodex_dir=\"${CODEX_HOME:-$HOME/.codex}\"\n\nlogged_out=0\n# Each logout gets a few seconds; a hung CLI must not hold up the delete.\nif command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\nif command -v codex >/dev/null 2>&1 && timeout 8 codex logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\n\n# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.\nrm -f -- \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\" 2>/dev/null\n# Orca-managed Codex homes keep their own auth.json under orcad's data dir.\nif [ -d \"$HOME/.orcad-data\" ]; then\n find \"$HOME/.orcad-data\" -path '*codex*' -type f -name auth.json -delete 2>/dev/null\nfi\n# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nfor file in \"$claude_json\" \"$HOME/.claude.json\"; do\n [ -f \"$file\" ] || continue\n if [ -n \"$node_bin\" ]; then\n ORCA_FILE=\"$file\" \"$node_bin\" -e '\nconst fs = require(\"fs\")\nconst file = process.env.ORCA_FILE\nlet c\ntry { c = JSON.parse(fs.readFileSync(file, \"utf8\")) } catch { process.exit(2) }\nif (!c || typeof c !== \"object\" || Array.isArray(c)) process.exit(2)\nlet changed = false\nfor (const key of Object.keys(c)) {\n if (/oauth/i.test(key) || key === \"primaryApiKey\") { delete c[key]; changed = true }\n}\nif (changed) {\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\n' 2>/dev/null || rm -f -- \"$file\"\n else\n # No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.\n rm -f -- \"$file\"\n fi\ndone\n\nleft=0\nfor file in \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\"; do\n [ -e \"$file\" ] && left=$((left + 1))\ndone\nprintf '{\"loggedOut\":%d,\"left\":%d}\\n' \"$logged_out\" \"$left\"\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).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\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# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\n# Granted devices are listed so orcad can keep owner-only features (local commands) from them.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n ORCA_GRANT_LEDGER=\"$STATE/grant-devices\" \"$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 if (process.env.ORCA_GRANT_LEDGER) require(\"node:fs\").appendFileSync(process.env.ORCA_GRANT_LEDGER, res.deviceId + \"\\n\", { mode: 0o600 })\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 signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\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.orca.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\n# CREATE-GIT-ASYNC: identities are in place, so `git` stops holding commits and pushes for this boot.\nif [ -n \"$user_home\" ] && [ -d \"$user_home/.orca-sandbox\" ]; then\n sudo -u \"$USER_NAME\" sh -c 'rm -f \"$1/git-setup.pending\" \"$1/git-setup.failed\" && date +%s >\"$1/git-setup.ready\"' \\\n _ \"$user_home/.orca-sandbox\" || true\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at),\n // \"::\": which checkout this chat works in.\n worktreePath: isObj(record.location) && typeof record.location.workspaceId === 'string' ? record.location.workspaceId.split('::').pop() : null\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/**\n * The checkout this workspace is about: the one its newest chat works in, else the most recently\n * active one (a workspace on its own branch is a separate worktree, not the base clone at\n * /vercel/sandbox/project), else the base clone.\n */\nfunction workspaceCheckout(worktrees, sessions) {\n const byPath = new Map(worktrees.filter((w) => typeof w.path === 'string').map((w) => [w.path, w]))\n const newest = sessions\n .filter((s) => s.worktreePath && byPath.has(s.worktreePath))\n .sort((a, b) => (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))[0]\n if (newest) {\n return byPath.get(newest.worktreePath)\n }\n const activity = (w) => Math.max(num(w.lastActivityAt) || 0, num(w.sortOrder) || 0)\n const ranked = [...byPath.values()].sort(\n (a, b) => activity(b) - activity(a) || Number(a.isMainWorktree === true) - Number(b.isMainWorktree === true)\n )\n return ranked[0] || null\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const stored = listSessions()\n const project = workspaceCheckout(worktrees, stored)\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of stored) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { workspaceCheckout, seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\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","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). 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 fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\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=\"${ORCA_REAL_GIT:-/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\n# CREATE-GIT-ASYNC: a new workspace answers before teammates' GitHub identities are installed. Until\n# they are (orca-sandbox-members writes git-setup.ready), commits and pushes wait up to 15 s rather\n# than go out under the workspace identity; a failed or stalled setup stops them with what to do.\n# Read-only git and fetches never wait (they use the workspace's own credentials, set before ready).\nhold_for_member_setup() {\n local state=\"${HOME:-/nonexistent}/.orca-sandbox\" ticks=0 since\n # A failed install on any boot (create or a later restart) refuses even with nothing pending.\n [ -f \"$state/git-setup.pending\" ] || [ -f \"$state/git-setup.failed\" ] || return 0\n since=\"$(cat \"$state/git-setup.pending\" 2>/dev/null || echo 0)\"\n case \"$since\" in '' | *[!0-9]*) since=0 ;; esac\n while [ -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.ready\" ] && [ ! -f \"$state/git-setup.failed\" ]; do\n # 15 s per command; a mark older than 2 min is a setup that died, not one still running.\n if [ \"$ticks\" -ge \"${ORCA_GIT_SETUP_WAIT_TICKS:-150}\" ] || [ $(($(date +%s) - since)) -gt 120 ]; then\n break\n fi\n sleep 0.1\n ticks=$((ticks + 1))\n done\n if [ -f \"$state/git-setup.ready\" ] || { [ ! -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.failed\" ]; }; then\n return 0\n fi\n if [ -f \"$state/git-setup.failed\" ]; then\n printf '%s\\n' \"orca: Orca couldn't set up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Restart this workspace (right-click it in the sidebar > Restart), then try again.\" >&2\n else\n printf '%s\\n' \"orca: Orca is still setting up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Try again in a few seconds. If this keeps happening, restart this workspace (right-click it in the sidebar > Restart).\" >&2\n fi\n exit 1\n}\n# A commit under the workspace identity (Orca Cloud's name) says once per session how to use your own\n# (design-squad). /dev/shm is per boot: a wake or restart says it again.\nnudge_once() {\n [ \"$sub\" = commit ] || return 0\n local mark=\"${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)\"\n [ -e \"$mark\" ] && return 0\n : >\"$mark\" 2>/dev/null || return 0\n printf '%s\\n' \"orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours.\" >&2\n}\n# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's\n# connected GitHub nor the App installation) must never fall through to git's own \"Username for\n# 'https://github.com':\" prompt. Prompts are off for that remote, and git's auth failure becomes one\n# plain line saying what to do.\ngithub_slug=''\ngithub_remote_slug() {\n local arg skip=0 seen=0 remote='' url\n for arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n if [ \"$seen\" = 0 ]; then\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n \"$sub\") seen=1 ;;\n esac\n continue\n fi\n case \"$arg\" in\n --repo=*) remote=\"${arg#--repo=}\"; break ;;\n -o | --push-option | --receive-pack | --upload-pack | --exec | --depth | --deepen | --shallow-since | --shallow-exclude | --refmap | --recurse-submodules-default | -j | --jobs) skip=1 ;;\n -*) ;;\n *) remote=\"$arg\"; break ;;\n esac\n done\n [ -n \"$remote\" ] || remote=\"$(\"$real\" config --get \"branch.$(\"$real\" symbolic-ref --short -q HEAD 2>/dev/null).remote\" 2>/dev/null || true)\"\n [ -n \"$remote\" ] || remote=origin\n case \"$remote\" in\n *://*) url=\"$remote\" ;;\n *) url=\"$(\"$real\" remote get-url \"$remote\" 2>/dev/null || printf '%s' \"$remote\")\" ;;\n esac\n case \"$url\" in\n https://github.com/* | https://*@github.com/*) url=\"${url#*github.com/}\" ;;\n *) return 0 ;;\n esac\n url=\"${url%.git}\"\n url=\"${url%/}\"\n case \"$url\" in */*/*) url=\"${url%%/*}/$(printf '%s' \"${url#*/}\" | cut -d/ -f1)\" ;; esac\n case \"$url\" in [A-Za-z0-9_.-]*/[A-Za-z0-9_.-]*) github_slug=\"$url\" ;; esac\n}\nno_access_line() {\n local owner=\"${github_slug%%/*}\" verb='push to'\n [ \"$sub\" = push ] || verb='read'\n printf '%s\\n' \"orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings › Account.\" >&2\n}\n# GitHub's \"not found\" is a typo'd name or a private repo it won't confirm: say which half fits whom.\nnot_found_line() {\n printf '%s\\n' \"orca: Orca Cloud can't find $github_slug. Check the name, or install the Orca GitHub App on ${github_slug%%/*} if it's private.\" >&2\n}\n# orca-git-as's own fallback line is replaced by the nudge; GitHub auth failures by the plain line.\nfallback_filter() {\n local line said=0\n while IFS= read -r line || [ -n \"$line\" ]; do\n case \"$line\" in\n 'orca-git-as: no teammate identity'*) nudge_once ;;\n 'remote: Repository not found.'* | \"fatal: repository 'https://github.com/\"*\"' not found\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || not_found_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *\"could not read Username for 'https://github.com'\"* | *\"could not read Password for 'https://\"*\"github.com'\"* | \\\n *\"Authentication failed for 'https://github.com/\"* | 'remote: Permission to '*' denied to '* | \\\n *\"The requested URL returned error: 403\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || no_access_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *) printf '%s\\n' \"$line\" ;;\n esac\n done\n}\ncase \"$sub\" in\n push | pull | fetch)\n github_remote_slug \"$@\"\n if [ -n \"$github_slug\" ]; then\n export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never\n fi\n ;;\nesac\ngit_as=\"${ORCA_GIT_AS:-/opt/orca/bin/orca-git-as}\"\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n hold_for_member_setup\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 \"$git_as\" ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec \"$git_as\" \"$@\" 2> >(fallback_filter >&2)\n fi\n # No terminal or agent turn to attribute it to: the workspace identity.\n nudge_once\n ;;\nesac\nif [ -n \"$github_slug\" ]; then\n exec \"$real\" \"$@\" 2> >(fallback_filter >&2)\nfi\nexec \"$real\" \"$@\"\n"} -export const VM_SCRIPTS_HASH: string = "9bc77cc5" -export const VM_SCRIPTS_STAMP: number = 1794530964 +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# With ORCA_READY_KEY too (64 hex chars, this call's key; env is not kept) the pointer also carries\n# \"sealedOffer\": \".\", which saves that round trip.\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\"\n# Only print_ready uses the key; nothing this script starts inherits it.\nready_key=\"${ORCA_READY_KEY:-}\"\nunset ORCA_READY_KEY\n[[ \"$ready_key\" =~ ^[0-9a-f]{64}$ ]] || ready_key=''\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=() name=()\n [ -e \"$PROJECT/.git\" ] || kind=(--kind folder)\n # The checkout lives at .../project; name it after the repository (owner/name.git -> name).\n if [ -n \"${ORCA_REPO_URL:-}\" ]; then\n local base=\"${ORCA_REPO_URL%/}\"\n base=\"${base##*/}\"\n base=\"${base%.git}\"\n [ -n \"$base\" ] && name=(--display-name \"$base\")\n fi\n setsid \"$ORCA_HOME/bin/orca\" repo add --path \"$PROJECT\" \"${kind[@]}\" \"${name[@]}\" --json \\\n >\"$STATE/register-project.log\" 2>&1 &- &\n}\n\nprint_ready() {\n if [ \"${ORCA_READY_VIA:-}\" = file ]; then\n local sealed='' iv\n if [ -n \"$ready_key\" ] && command -v openssl >/dev/null; then\n iv=\"$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')\"\n # Best effort: without it the control plane reads the file, as before.\n sealed=\"$(tail -n 1 \"$STATE/ready.json\" | openssl enc -aes-256-ctr -K \"$ready_key\" -iv \"$iv\" -nosalt | base64 -w0)\" &&\n sealed=\"$iv.$sealed\" || sealed=''\n fi\n printf '{\"schemaVersion\":1,\"readyFile\":\"%s\"%s}\\n' \"$STATE/ready.json\" \"${sealed:+,\\\"sealedOffer\\\":\\\"$sealed\\\"}\"\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)\"\n# CREATE-GIT-ASYNC: a create answers before member identities are installed; `git` holds commits and\n# pushes until orca-sandbox-members (or the control plane) marks this setup ready or failed. Marked\n# before the already-started exit below: a pooled VM's orcad may be up before its create claims it.\nif [ \"${ORCA_GIT_SETUP:-}\" = pending ]; then\n rm -f \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\n date +%s >\"$STATE/git-setup.pending\"\nfi\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# Any other boot (wake, restart) installs identities before it answers: a resumed disk's marks are stale.\n[ \"${ORCA_GIT_SETUP:-}\" = pending ] || rm -f \"$STATE/git-setup.pending\" \"$STATE/git-setup.ready\" \"$STATE/git-setup.failed\"\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.\n# The fallback moved to orca.dev with the domain; a workspace still on the old default follows it.\n[ \"$(git config --global user.email 2>/dev/null || true)\" = 'cloud@users.noreply.onorca.dev' ] \\\n && git config --global user.email 'cloud@users.noreply.orca.dev'\ngit config --global user.email >/dev/null 2>&1 || git config --global user.email 'cloud@users.noreply.orca.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 # Agent CLIs are pinned by the image (smoke-tested); a self-update mid-session can break a running agent.\n DISABLE_AUTOUPDATER=1 OPENCODE_DISABLE_AUTOUPDATE=1 COPILOT_AUTO_UPDATE=false\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\")\"\n# The egress proxy answers for hosts with credential rules (model APIs, api.github.com) with a\n# certificate from Vercel's CA, which this command's env trusts (Vercel sets these). Pure-Node clients\n# use Node's bundled roots otherwise: Gemini CLI retried forever (\"taking a bit longer\") in every\n# terminal while the same call worked here. Pass the provider's CA settings on to orcad and its shells.\nca_env=(NODE_USE_SYSTEM_CA=\"${NODE_USE_SYSTEM_CA:-1}\")\nfor name in NODE_EXTRA_CA_CERTS SSL_CERT_FILE; do\n if [ -n \"${!name:-}\" ]; then ca_env+=(\"$name=${!name}\"); fi\ndone\n# N-02: the host is a cloud terminal's source of truth across app reopens, so orcad and its\n# daemon keep a local terminal's depth (5000 rows) instead of the desktop daemon's 1000-row live\n# window. Scoped to the VM: local desktop daemons keep their default.\n# Ports panel: programs under $ORCA_HOME are the image's own services, and 23456 is Vercel's\n# sandbox-init (root, pid invisible to this user); neither is listed as the user's port.\nenv -i HOME=\"$HOME\" USER=\"$(id -un)\" LANG=C.UTF-8 TERM=xterm-256color \"${ca_env[@]}\" \\\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 ORCA_HOST_MANAGED_LISTENER_PATHS=\"$ORCA_HOME\" ORCA_HOST_MANAGED_UNOWNED_PORTS=23456 \\\n NODE_COMPILE_CACHE=\"$ORCA_HOME/compile-cache\" ORCA_ORCAD_STARTUP_QUALIFICATION_DIR=\"$ORCA_HOME/startup-qualification\" \\\n ORCA_DAEMON_SESSION_SCROLLBACK_ROWS=5000 \\\n ORCA_SESSION_ENV_NAMES=\"${secret_names[*]}\" \"${agent_env[@]}\" \\\n setsid nohup \"$node\" \"$ORCA_HOME/bin/orcad-boot\" --code-cache \"$ORCA_HOME/compile-cache/orcad-server.v8\" \\\n \"$ORCA_HOME/runtime/orcad/orcad-server.js\" --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\n# Polled every 10 ms (at least 60 s in all): ready is on the wake path, so detect it promptly.\nfor _ in $(seq 1 6000); 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.01\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)}\"\n# ORCA_SESSION_ENV_DIR: tests only (a scratch dir off tmpfs); the control plane never sets it.\ndir=\"${ORCA_SESSION_ENV_DIR:-/dev/shm/orca-env-$(id -u)}\"\nif [ -z \"${ORCA_SESSION_ENV_DIR:-}\" ] && [ \"$(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=''\nanthropic_model=''\nanthropic_key_tail=''\ngateway_key=''\ngemini_key=''\ncodex_placeholder=''\nwhile IFS= read -r line; do\n name=\"${line%%=*}\" b64=\"${line#*=}\"\n # Not for shells: the Codex account placeholder becomes ~/.codex/auth.json below.\n if [ \"$name\" = ORCA_CODEX_AUTH_PLACEHOLDER ]; then\n codex_placeholder=\"$(printf '%s' \"$b64\" | base64 -d)\"\n case \"$codex_placeholder\" in *[!A-Za-z0-9._-]*) codex_placeholder='' ;; esac\n fi\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 ANTHROPIC_MODEL) anthropic_model=\"$(printf '%s' \"$b64\" | base64 -d)\" ;;\n AI_GATEWAY_API_KEY) gateway_key=1 ;;\n GEMINI_API_KEY | GOOGLE_API_KEY) gemini_key=\"${gemini_key:-gemini-api-key}\" ;;\n GOOGLE_GENAI_USE_VERTEXAI) case \"$(printf '%s' \"$b64\" | base64 -d)\" in true | 1) gemini_key=vertex-ai ;; esac ;;\n # Claude Code asks once per key whether to use ANTHROPIC_API_KEY; it remembers the last 20 chars.\n ANTHROPIC_API_KEY) anthropic_key_tail=\"$(printf '%s' \"$b64\" | base64 -d | tail -c 20)\" ;;\n esac\ndone < <(printf '%s\\n' \"${ORCA_SECRET_ENV:-}\")\nmv -f \"$tmp\" \"$dir/session.env\"\nprintf '%s' \"$generation\" >\"$dir/generation\"\n\n# ---- Codex: trust the workspace and use the configured provider (no secret values) ----\nPROJECT=/vercel/sandbox/project\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 the managed blocks and the blank lines written before them (no growth per refresh)\n awk '/^# >>> orca-cloud managed/{skip=1; blank=\"\"} skip{if (/^# <<< orca-cloud managed/) skip=0; next}\n /^[[:space:]]*$/{blank=blank $0 \"\\n\"; next} {printf \"%s\", blank; blank=\"\"; print}' \"$1\"\n}\n# Always managed: an agent launched from the phone or the API has no one to answer Codex's\n# \"trust this folder?\" screen, so the workspace project is trusted up front.\nif true; 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 custom_provider=''\n if [ -n \"$openai_base_url\" ] && [ \"$openai_host\" != api.openai.com ]; then custom_provider=1; fi\n # Top-level keys must precede every table; each is skipped when the user already set it.\n head_keys=\"$(awk '/^\\[/{exit} {print}' \"$user_part\")\"\n has_key() { grep -Eq \"^[[:space:]]*$1[[:space:]]*=\" <<<\"$head_keys\"; }\n {\n printf '# >>> orca-cloud managed (Orca Cloud; edit in Orca settings)\\n'\n # The sandbox is the isolation boundary: Codex's own bwrap sandbox can't start in it (\"Unexpected\n # capabilities\"), and a prompt delivered from the phone or the API has no one to approve commands.\n has_key sandbox_mode || printf 'sandbox_mode = \"danger-full-access\"\\n'\n has_key approval_policy || printf 'approval_policy = \"never\"\\n'\n if [ -n \"$custom_provider\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_cloud\"\\n'\n if [ -n \"$openai_model\" ] && ! has_key model; then\n printf 'model = %s\\n' \"$(toml_string \"$openai_model\")\"\n fi\n elif [ -n \"$codex_placeholder\" ]; then\n has_key model_provider || printf 'model_provider = \"orca_chatgpt\"\\n'\n fi\n printf '# <<< orca-cloud managed\\n'\n cat \"$user_part\"\n if [ -n \"$custom_provider\" ]; then\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 elif [ -n \"$codex_placeholder\" ]; then\n # Codex via the Orca account: the ChatGPT backend over HTTPS. Its default Responses WebSocket can't\n # pass the egress proxy, where the placeholder becomes the real token (found live, Codex 0.166).\n printf '\\n# >>> orca-cloud managed provider\\n[model_providers.orca_chatgpt]\\nname = \"OpenAI\"\\n'\n printf 'base_url = \"https://chatgpt.com/backend-api/codex\"\\nwire_api = \"responses\"\\nrequires_openai_auth = true\\nsupports_websockets = false\\n'\n printf '# <<< orca-cloud managed\\n'\n fi\n } >\"$next\"\n if ! grep -qF \"[projects.\\\"$PROJECT\\\"]\" \"$user_part\"; then\n printf '\\n# >>> orca-cloud managed trust\\n[projects.\"%s\"]\\ntrust_level = \"trusted\"\\n# <<< orca-cloud managed\\n' \"$PROJECT\" >>\"$next\"\n fi\n chmod 600 \"$next\"\n mv -f \"$next\" \"$codex_config\"\n rm -f \"$user_part\"\nfi\n# ---- Codex via the person's Orca account (contract D11): a placeholder ChatGPT login, no refresh token.\n# The egress proxy swaps the real access token on chatgpt.com; the real tokens never reach this VM. The\n# marker remembers which placeholder Orca wrote (a clone gets its own), so a login of the person's own\n# (kill-switch fallback, or `codex login` run here) is set aside while the account applies and put back after.\ncodex_auth=\"$HOME/.codex/auth.json\"\ncodex_saved=\"$HOME/.codex/auth.json.orca-saved\"\ncodex_marker=\"$HOME/.codex/.orca-account-placeholder\"\ncodex_ours=''\n# Ours: the marker's placeholder, or any Orca placeholder (all end in `.orca-placeholder`; a real token never does).\nif [ -f \"$codex_auth\" ] && { { [ -f \"$codex_marker\" ] && grep -qF \"\\\"$(cat \"$codex_marker\")\\\"\" \"$codex_auth\"; } || grep -q '\\.orca-placeholder\"' \"$codex_auth\"; }; then\n codex_ours=1\nfi\nif [ -n \"$codex_placeholder\" ]; then\n mkdir -p \"$HOME/.codex\"\n if [ -f \"$codex_auth\" ] && [ -z \"$codex_ours\" ]; then\n # Never overwrite a login already set aside: a second one gets its own name.\n if [ -e \"$codex_saved\" ]; then mv -f \"$codex_auth\" \"$codex_saved.$(date -u +%Y%m%d%H%M%S)\"; else mv -f \"$codex_auth\" \"$codex_saved\"; fi\n fi\n # Marker first: a run cut short after the swap still recognises the file as Orca's.\n printf '%s' \"$codex_placeholder\" >\"$codex_marker\"\n codex_tmp=\"$(mktemp \"$HOME/.codex/.auth.XXXXXX\")\"\n printf '{\\n \"OPENAI_API_KEY\": null,\\n \"tokens\": {\\n \"id_token\": \"%s\",\\n \"access_token\": \"%s\",\\n \"refresh_token\": \"\",\\n \"account_id\": null\\n },\\n \"last_refresh\": \"%s\"\\n}\\n' \\\n \"$codex_placeholder\" \"$codex_placeholder\" \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" >\"$codex_tmp\"\n chmod 600 \"$codex_tmp\"\n mv -f \"$codex_tmp\" \"$codex_auth\"\nelif [ -f \"$codex_marker\" ] || [ -n \"$codex_ours\" ]; then\n if [ -n \"$codex_ours\" ]; then rm -f \"$codex_auth\"; fi\n rm -f \"$codex_marker\"\n if [ -f \"$codex_saved\" ] && [ ! -f \"$codex_auth\" ]; then mv -f \"$codex_saved\" \"$codex_auth\"; fi\nfi\n# ---- Claude Code, Gemini CLI, Cursor agent, Copilot CLI, OpenCode: first-run screens answered once\n# (Claude: theme picker, folder trust, bypass-mode warning, the \"use this API key?\" question; others:\n# folder trust, auth-method picker, self-update). A prompt delivered from the phone or the API has no one to click\n# through them, and the agent would sit there doing nothing. Merges; never touches credentials.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nif [ -n \"$node_bin\" ]; then\n ORCA_PROJECT=\"$PROJECT\" ORCA_KEY_TAIL=\"$anthropic_key_tail\" ORCA_GEMINI_KEY=\"$gemini_key\" \\\n ORCA_GATEWAY_MODEL=\"${gateway_key:+${anthropic_model:-$openai_model}}\" \"$node_bin\" -e '\nconst fs = require(\"fs\"), path = require(\"path\"), os = require(\"os\")\nconst home = os.homedir()\nconst readJson = (file) => { try { const v = JSON.parse(fs.readFileSync(file, \"utf8\")); return v && typeof v === \"object\" && !Array.isArray(v) ? v : {} } catch { return {} } }\nconst write = (file, value) => {\n fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\nconst claudeFile = path.join(process.env.CLAUDE_CONFIG_DIR || home, \".claude.json\")\nconst c = readJson(claudeFile)\nconst before = JSON.stringify(c)\nc.hasCompletedOnboarding = true\nc.theme = c.theme || \"dark\"\nc.bypassPermissionsModeAccepted = true\nc.projects = c.projects && typeof c.projects === \"object\" ? c.projects : {}\nconst p = process.env.ORCA_PROJECT\nc.projects[p] = { ...(c.projects[p] || {}), hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }\nconst tail = process.env.ORCA_KEY_TAIL\nif (tail) {\n const r = c.customApiKeyResponses && typeof c.customApiKeyResponses === \"object\" ? c.customApiKeyResponses : {}\n const approved = Array.isArray(r.approved) ? r.approved : []\n const rejected = Array.isArray(r.rejected) ? r.rejected.filter((t) => t !== tail) : []\n c.customApiKeyResponses = { ...r, approved: approved.includes(tail) ? approved : [...approved, tail], rejected }\n}\nif (JSON.stringify(c) !== before) write(claudeFile, c)\nconst settingsFile = path.join(home, \".claude\", \"settings.json\")\nconst s = readJson(settingsFile)\nif (s.skipDangerousModePermissionPrompt !== true) write(settingsFile, { ...s, skipDangerousModePermissionPrompt: true })\n\n// Each block below only fills in what is unset and leaves a file it cannot parse alone.\nconst readStrict = (file) => { try { return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, \"utf8\")) : {} } catch { return null } }\nconst isObject = (v) => v !== null && typeof v === \"object\" && !Array.isArray(v)\nconst section = (o, key) => (isObject(o[key]) ? o[key] : (o[key] = {}))\n\n// Gemini CLI: folder trust and the auth-method picker (only when a Gemini key is configured; a\n// Google sign-in picks its own method), no self-update, no usage stats (its host is not on the\n// egress allowlist, so every send would fail).\nconst geminiTrustFile = path.join(home, \".gemini\", \"trustedFolders.json\")\nconst geminiTrust = readStrict(geminiTrustFile)\nif (isObject(geminiTrust) && !geminiTrust[p]) write(geminiTrustFile, { ...geminiTrust, [p]: \"TRUST_FOLDER\" })\nconst geminiSettingsFile = path.join(home, \".gemini\", \"settings.json\")\nconst g = readStrict(geminiSettingsFile)\nif (isObject(g)) {\n const gBefore = JSON.stringify(g)\n const general = section(g, \"general\")\n general.disableAutoUpdate ??= true\n general.disableUpdateNag ??= true\n section(g, \"privacy\").usageStatisticsEnabled ??= false\n // gemini-api-key or vertex-ai (GOOGLE_GENAI_USE_VERTEXAI=true with an API key): headless runs\n // refuse to start without a method (\"Invalid auth method selected\").\n if (process.env.ORCA_GEMINI_KEY) section(section(g, \"security\"), \"auth\").selectedType ??= process.env.ORCA_GEMINI_KEY\n if (JSON.stringify(g) !== gBefore) write(geminiSettingsFile, g)\n}\n\n// Cursor agent: trust the workspace (cursor-agent keys trust by a slug of the path) and pin the\n// release channel to \"static\", which turns off its background self-update.\nconst cursorTrust = path.join(home, \".cursor\", \"projects\", p.replace(/^\\/+/, \"\").replace(/[^A-Za-z0-9]+/g, \"-\"), \".workspace-trusted\")\nif (!fs.existsSync(cursorTrust)) {\n fs.mkdirSync(path.dirname(cursorTrust), { recursive: true, mode: 0o700 })\n fs.writeFileSync(cursorTrust, JSON.stringify({ trustedAt: new Date().toISOString(), workspacePath: p }, null, 2) + \"\\n\", { mode: 0o600 })\n}\nconst cursorConfigFile = path.join(home, \".cursor\", \"cli-config.json\")\nconst cc = readStrict(cursorConfigFile)\nif (isObject(cc) && cc.channel === undefined) write(cursorConfigFile, { version: 1, ...cc, channel: \"static\" })\n\n// GitHub Copilot CLI: trusted folders live in ~/.copilot/config.json, which Copilot prefixes with\n// \"//\" comment lines; keep them.\nconst copilotFile = path.join(home, \".copilot\", \"config.json\")\nlet copilotHead = \"\"\nlet co = {}\ntry {\n const raw = fs.existsSync(copilotFile) ? fs.readFileSync(copilotFile, \"utf8\") : \"\"\n const lines = raw.split(\"\\n\")\n let i = 0\n while (i < lines.length && /^\\s*\\/\\//.test(lines[i])) i++\n copilotHead = lines.slice(0, i).map((l) => l + \"\\n\").join(\"\")\n const body = lines.slice(i).join(\"\\n\").trim()\n co = body ? JSON.parse(body) : {}\n} catch { co = null }\nif (isObject(co)) {\n const folders = Array.isArray(co.trustedFolders) ? co.trustedFolders : []\n if (!folders.includes(p)) {\n fs.mkdirSync(path.dirname(copilotFile), { recursive: true, mode: 0o700 })\n const tmp = `${copilotFile}.orca-${process.pid}`\n fs.writeFileSync(tmp, copilotHead + JSON.stringify({ ...co, trustedFolders: [...folders, p] }, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, copilotFile)\n }\n}\n\n// OpenCode: with the AI Gateway preset and a model secret (ANTHROPIC_MODEL, else OPENAI_MODEL), use\n// that model through the gateway unless the user picked one. A ledger remembers what Orca wrote, so a\n// changed model secret updates it and a model the user set is never touched.\nconst ocDir = path.join(home, \".config\", \"opencode\")\nconst ocFile = path.join(ocDir, \"opencode.json\")\nconst ocLedger = path.join(ocDir, \".orca-managed-model\")\nconst managedModel = process.env.ORCA_GATEWAY_MODEL ? `vercel/${process.env.ORCA_GATEWAY_MODEL}` : \"\"\nconst oc = readStrict(ocFile)\nlet jsoncHasModel = false\ntry { jsoncHasModel = /\"model\"\\s*:/.test(fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\")) } catch {}\nif (isObject(oc) && !jsoncHasModel) {\n let ours = \"\"\n try { ours = fs.readFileSync(ocLedger, \"utf8\").trim() } catch {}\n const userModel = typeof oc.model === \"string\" && oc.model !== ours\n if (!userModel && (oc.model ?? \"\") !== managedModel) {\n const next = { $schema: \"https://opencode.ai/config.json\", ...oc }\n if (managedModel) next.model = managedModel\n else delete next.model\n write(ocFile, next)\n }\n if (!userModel) {\n if (managedModel) fs.writeFileSync(ocLedger, managedModel + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocLedger, { force: true })\n }\n}\n\n// OpenCode: two built-in providers cannot work in Orca Cloud, and with no other credential OpenCode\n// picks one and fails (\"Authorization header is badly formatted\" / opencode.ai unreachable) instead\n// of showing \"Connect a provider\". GitHub Copilot reads GITHUB_TOKEN, which here is the stand-in for the\n// GitHub App token of the workspace, never a Copilot token; OpenCode Zen needs opencode.ai, which is not on the\n// egress allowlist. Both stay off unless the user connected them in OpenCode itself (auth.json).\nconst ocAuth = readStrict(path.join(process.env.XDG_DATA_HOME || path.join(home, \".local\", \"share\"), \"opencode\", \"auth.json\"))\nconst connected = new Set(isObject(ocAuth) ? Object.keys(ocAuth) : [])\nconst ocDisabledLedger = path.join(ocDir, \".orca-managed-disabled-providers\")\nconst ocNow = readStrict(ocFile)\nif (isObject(ocNow) && !/\"disabled_providers\"\\s*:/.test((() => { try { return fs.readFileSync(path.join(ocDir, \"opencode.jsonc\"), \"utf8\") } catch { return \"\" } })())) {\n let ours = []\n try { ours = fs.readFileSync(ocDisabledLedger, \"utf8\").split(\"\\n\").filter(Boolean) } catch {}\n const userList = (Array.isArray(ocNow.disabled_providers) ? ocNow.disabled_providers : []).filter((id) => typeof id === \"string\" && !ours.includes(id))\n const managed = [\"github-copilot\", \"opencode\"].filter((id) => !connected.has(id) && !userList.includes(id))\n const next = [...userList, ...managed]\n const before = JSON.stringify(ocNow.disabled_providers ?? [])\n if (JSON.stringify(next) !== before) {\n const updated = { $schema: \"https://opencode.ai/config.json\", ...ocNow }\n if (next.length > 0) updated.disabled_providers = next\n else delete updated.disabled_providers\n write(ocFile, updated)\n }\n if (managed.length > 0) fs.writeFileSync(ocDisabledLedger, managed.join(\"\\n\") + \"\\n\", { mode: 0o600 })\n else fs.rmSync(ocDisabledLedger, { force: true })\n}\n' || echo 'agent first-run state not seeded' >&2\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\n# CR-cmd-quiet: Orca's staged agent launch (` . '/tmp/orca-launch-….sh'`) stays out of zsh history.\n# zsh records a line before running it, so the hook must exist first; the staged script itself\n# drops the line from bash's history. eval: bash parses this file too.\nif [ -n \"${ZSH_VERSION-}\" ]; then\n eval '__orca_skip_launch_history() { [[ $1 == *orca-launch-* ]] && return 1; return 0 }\n (( ${zshaddhistory_functions[(Ie)__orca_skip_launch_history]} )) || zshaddhistory_functions+=(__orca_skip_launch_history)'\nfi\nunset __orca_env_file __orca_line __orca_n __orca_names __orca_branch\n","orca-env":"#!/usr/bin/env bash\n# Prints shell lines that bring the current shell up to date with this session's cloud env (secrets\n# saved after the shell started): eval \"$(orca-env)\" or, in fish: orca-env --fish | source\n# Reads /dev/shm/orca-env-/session.env (written by orca-sandbox-env, `NAME=` lines) and\n# prints an export for each name, plus an unset for each name in $ORCA_SESSION_ENV_NAMES that is gone.\n# Never writes a file. Values are single-quoted, so quotes, newlines and $ in them stay literal.\nset -euo pipefail\nfish=''\ncase \"${1-}\" in\n '') ;;\n --fish) fish=1 ;;\n -h | --help)\n printf 'usage: eval \"$(orca-env)\" (fish: orca-env --fish | source)\\n'\n exit 0\n ;;\n *)\n printf 'orca-env: unknown option %s\\n' \"$1\" >&2\n exit 2\n ;;\nesac\n# ORCA_SESSION_ENV_FILE is for tests; secrets can't set ORCA_* names.\nfile=\"${ORCA_SESSION_ENV_FILE:-/dev/shm/orca-env-$(id -u)/session.env}\"\nset -f # names are split below, never globbed\n# Not written yet (or not a cloud VM): nothing to change, like the shell hook.\n[ -r \"$file\" ] || exit 0\n\n# 'it'\\''s' for POSIX shells; fish single quotes escape only \\ and '.\nquote() {\n local v=\"$1\"\n if [ -n \"$fish\" ]; then\n v=\"${v//\\\\/\\\\\\\\}\"\n v=\"${v//\\'/\\\\\\'}\"\n printf \"'%s'\" \"$v\"\n else\n printf \"'%s'\" \"${v//\\'/\\'\\\\\\'\\'}\"\n fi\n}\n\n# The control plane's reserved names and prefixes (cloud/apps/sandbox-control/src/secrets.ts; a test\n# keeps this in sync): a session file never sets what would hijack the shell or Orca's own processes.\nskip_name() {\n case \"$1\" in\n '' | *[!A-Z0-9_]* | [0-9]*) return 0 ;;\n LD_* | DYLD_* | ORCA_* | VERCEL_* | BASH_FUNC_* | GIT_CONFIG*) return 0 ;;\n PATH | HOME | USER | LOGNAME | SHELL | PWD | OLDPWD | LANG | TERM | TMPDIR | IFS | ENV | \\\n BASH_ENV | PS4 | PROMPT_COMMAND | SHELLOPTS | BASHOPTS | NODE_OPTIONS | NODE_PATH | \\\n NODE_EXTRA_CA_CERTS | NODE_TLS_REJECT_UNAUTHORIZED | SSL_CERT_FILE | SSL_CERT_DIR | \\\n CURL_CA_BUNDLE | REQUESTS_CA_BUNDLE | HTTP_PROXY | HTTPS_PROXY | ALL_PROXY | NO_PROXY | \\\n PYTHONPATH | PYTHONSTARTUP | PERL5OPT | RUBYOPT | JAVA_TOOL_OPTIONS | GIT_SSH | \\\n GIT_SSH_COMMAND | GIT_ASKPASS | GIT_EXEC_PATH | GIT_PROXY_COMMAND | SSH_AUTH_SOCK | \\\n GH_TOKEN | GITHUB_TOKEN) return 0 ;;\n # CLAUDE_CODE_OAUTH_TOKEN is reserved for users but set by the control plane itself: only ever the\n # Orca-account placeholder (contract D10), never a stored value.\n esac\n return 1\n}\n\nnames=' '\nwhile IFS= read -r line || [ -n \"$line\" ]; do\n name=\"${line%%=*}\"\n skip_name \"$name\" && continue\n # $(...) drops trailing newlines; the x keeps them.\n value=\"$(printf '%s' \"${line#*=}\" | base64 -d 2>/dev/null; printf x)\"\n value=\"${value%x}\"\n if [ -n \"$fish\" ]; then\n printf 'set -gx %s %s\\n' \"$name\" \"$(quote \"$value\")\"\n else\n printf 'export %s=%s\\n' \"$name\" \"$(quote \"$value\")\"\n fi\n names=\"$names$name \"\ndone <\"$file\"\nfor name in ${ORCA_SESSION_ENV_NAMES-}; do\n skip_name \"$name\" && continue\n case \"$names\" in *\" $name \"*) continue ;; esac\n if [ -n \"$fish\" ]; then\n printf 'set -e %s\\n' \"$name\"\n else\n printf 'unset %s\\n' \"$name\"\n fi\ndone\nnames=\"${names# }\"\nnames=\"${names% }\"\nif [ -n \"$fish\" ]; then\n printf 'set -gx ORCA_SESSION_ENV_NAMES %s\\n' \"$(quote \"$names\")\"\nelse\n printf 'export ORCA_SESSION_ENV_NAMES=%s\\n' \"$(quote \"$names\")\"\nfi\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/.config/opencode\" \"$HOME/.cursor\" \"$HOME/.config/cursor\" \\\n \"$HOME/.local/share/cursor-agent\" \"$HOME/.copilot\" \"$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\" \"$HOME/.cursor\" \"$HOME/.copilot\" \"$HOME/.gemini\"; do\n if [ -e \"$leftover\" ]; then\n echo \"identity survived wipe: $leftover\" >&2\n exit 1\n fi\ndone\necho '{\"wiped\":true}'\n","orca-agent-signout":"#!/usr/bin/env bash\n# Signs every agent out on this sandbox before Orca deletes it (A9): each agent's own logout, then\n# its credential files. Covers logins made inside the sandbox (the kill-switch fallback path).\n# Never fails: the sandbox is deleted next whatever happens. Values are never printed.\nset -u\nHOME=\"${HOME:-$(getent passwd \"$(id -u)\" | cut -d: -f6)}\"\nexport PATH=\"/opt/orca/tools/bin:$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin:/opt/orca/bin:${PATH:-}\"\nclaude_dir=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"\nclaude_json=\"${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json\"\ncodex_dir=\"${CODEX_HOME:-$HOME/.codex}\"\n\nlogged_out=0\n# Each logout gets a few seconds; a hung CLI must not hold up the delete.\nif command -v claude >/dev/null 2>&1 && timeout 8 claude auth logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\nif command -v codex >/dev/null 2>&1 && timeout 8 codex logout /dev/null 2>&1; then\n logged_out=$((logged_out + 1))\nfi\n\n# The files go whether or not a logout ran: an older CLI, a missing binary or no network leaves them.\nrm -f -- \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\" 2>/dev/null\n# Orca-managed Codex homes keep their own auth.json under orcad's data dir.\nif [ -d \"$HOME/.orcad-data\" ]; then\n find \"$HOME/.orcad-data\" -path '*codex*' -type f -name auth.json -delete 2>/dev/null\nfi\n# ~/.claude.json keeps settings Orca wrote (onboarding, trust); only the sign-in fields go.\nnode_bin=\"$(command -v node || find /opt/orca/runtime/runtimes -type f -name node 2>/dev/null | head -n 1)\"\nfor file in \"$claude_json\" \"$HOME/.claude.json\"; do\n [ -f \"$file\" ] || continue\n if [ -n \"$node_bin\" ]; then\n ORCA_FILE=\"$file\" \"$node_bin\" -e '\nconst fs = require(\"fs\")\nconst file = process.env.ORCA_FILE\nlet c\ntry { c = JSON.parse(fs.readFileSync(file, \"utf8\")) } catch { process.exit(2) }\nif (!c || typeof c !== \"object\" || Array.isArray(c)) process.exit(2)\nlet changed = false\nfor (const key of Object.keys(c)) {\n if (/oauth/i.test(key) || key === \"primaryApiKey\") { delete c[key]; changed = true }\n}\nif (changed) {\n const tmp = `${file}.orca-${process.pid}`\n fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + \"\\n\", { mode: 0o600 })\n fs.renameSync(tmp, file)\n}\n' 2>/dev/null || rm -f -- \"$file\"\n else\n # No node to edit it safely: losing Orca's settings beats leaving a sign-in behind.\n rm -f -- \"$file\"\n fi\ndone\n\nleft=0\nfor file in \"$claude_dir\"/.credentials* \"$HOME/.claude\"/.credentials* \"$codex_dir/auth.json\" \"$HOME/.codex/auth.json\"; do\n [ -e \"$file\" ] && left=$((left + 1))\ndone\nprintf '{\"loggedOut\":%d,\"left\":%d}\\n' \"$logged_out\" \"$left\"\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).\n// The pause card follows Orca's theme: Orca sets Electron's theme source from its own setting, so\n// prefers-color-scheme here is Orca's light or dark, and it follows a switch while paused. The\n// page cannot read Orca's tokens, so these mirror them (popover, border, focus ring).\nstyle.textContent = `\n#noVNC_connect_dlg, #noVNC_control_bar_anchor { display: none !important; }\n#orca_pause { --orca-scrim: rgb(10 10 10 / 0.88); --orca-card: #171717; --orca-text: #f0f0f0;\n --orca-border: rgb(255 255 255 / 0.1); --orca-control: rgb(255 255 255 / 0.25);\n --orca-control-hover: rgb(255 255 255 / 0.08); --orca-focus: color-mix(in oklab, #f0f0f0 65%, #0a0a0a);\n color-scheme: dark; position: fixed; inset: 0; display: none; align-items: center;\n justify-content: center; padding: 16px; background: var(--orca-scrim); z-index: 1000;\n font: 13px/1.5 system-ui, sans-serif; }\n@media (prefers-color-scheme: light) {\n #orca_pause { --orca-scrim: rgb(255 255 255 / 0.88); --orca-card: #fff; --orca-text: #0a0a0a;\n --orca-border: #e5e5e5; --orca-control: #a3a3a3; --orca-control-hover: #f5f5f5;\n --orca-focus: color-mix(in oklab, #0a0a0a 55%, #fff); color-scheme: light; }\n}\n#orca_pause.open { display: flex; }\n#orca_pause > div { max-width: 360px; padding: 16px; border: 1px solid var(--orca-border);\n border-radius: 8px; background: var(--orca-card); color: var(--orca-text); text-align: center;\n box-shadow: 0 8px 24px rgb(0 0 0 / 0.16); }\n#orca_pause p { margin: 0; }\n#orca_pause button { margin-top: 12px; padding: 4px 12px; border: 1px solid var(--orca-control);\n border-radius: 6px; background: transparent; color: var(--orca-text); font: inherit; cursor: pointer; }\n#orca_pause button:hover { background: var(--orca-control-hover); }\n#orca_pause button:focus-visible { outline: 2px solid var(--orca-focus); outline-offset: 2px; }\n@media (prefers-reduced-motion: no-preference) {\n #orca_pause.open > div { animation: orca-pause-in 120ms ease-out; }\n}\n@keyframes orca-pause-in { from { opacity: 0; transform: translateY(4px); } }\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# This workspace's read-snapshot signer (public half), so the paired device pins it; best effort.\nsigner=\"$(\"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\" --signer-public-key 2>/dev/null || true)\"\n# The offer goes to the sealer on stdin (never argv or disk); only the sealed form is printed.\n# Granted devices are listed so orcad can keep owner-only features (local commands) from them.\nprintf '%s' \"$offer\" | ORCA_GRANT_ID=\"$ORCA_GRANT_ID\" ORCA_GRANT_RECIPIENT_KEY=\"$ORCA_GRANT_RECIPIENT_KEY\" ORCA_READ_SIGNER=\"$signer\" \\\n ORCA_GRANT_LEDGER=\"$STATE/grant-devices\" \"$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 if (process.env.ORCA_GRANT_LEDGER) require(\"node:fs\").appendFileSync(process.env.ORCA_GRANT_LEDGER, res.deviceId + \"\\n\", { mode: 0o600 })\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 signer = /^[A-Za-z0-9+/]{43}=$/.test(process.env.ORCA_READ_SIGNER || \"\") ? process.env.ORCA_READ_SIGNER : undefined\n const body = Buffer.from(JSON.stringify({ pairingUrl: res.pairingUrl, deviceId: res.deviceId, scope: res.scope, ...(signer ? { readSignerPublicKeyB64: signer } : {}) }))\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.orca.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\n# CREATE-GIT-ASYNC: identities are in place, so `git` stops holding commits and pushes for this boot.\nif [ -n \"$user_home\" ] && [ -d \"$user_home/.orca-sandbox\" ]; then\n sudo -u \"$USER_NAME\" sh -c 'rm -f \"$1/git-setup.pending\" \"$1/git-setup.failed\" && date +%s >\"$1/git-setup.ready\"' \\\n _ \"$user_home/.orca-sandbox\" || true\nfi\necho \"{\\\"members\\\":$count,\\\"devices\\\":$devices}\"\n","orca-sandbox-read-snapshot":"#!/usr/bin/env bash\n# Seals a read-only snapshot of this workspace (chats, agent status, git, PR) to the reader keys the\n# control plane passes, right before the drain. Only ciphertext is printed (one JSON line); see\n# orca-read-snapshot.cjs. Exit 3: orcad isn't running (nothing to read).\nset -euo pipefail\numask 077\nORCA_HOME=/opt/orca\nexport ORCA_USER_DATA_PATH=\"${ORCA_USER_DATA_PATH:-$HOME/.orcad-data}\"\nnode=\"$(find \"$ORCA_HOME/runtime/runtimes\" -type f -name node | head -n 1)\"\n[ -n \"$node\" ] || { echo 'node not found' >&2; exit 1; }\nexec timeout 35 \"$node\" \"$ORCA_HOME/bin/orca-read-snapshot.cjs\"\n","orca-read-snapshot.cjs":"#!/usr/bin/env node\n// Gathers what a sleeping workspace should still show (recent chats per agent session, agent\n// status, git status/diff summary, PR link) from the running orcad over its local socket, then seals\n// it to the reader keys the control plane passed, so only ciphertext leaves the VM.\n// Run by orca-sandbox-read-snapshot right before the drain. Env:\n// ORCA_READ_SNAPSHOT_ID rsn_<24 hex> ORCA_READ_WORKSPACE_ID sbx_<24 hex>\n// ORCA_READ_RECIPIENTS JSON [{keyId, publicKeyB64}] ORCA_READ_REASON sleep|rollover\n// ORCA_USER_DATA_PATH orcad's data dir (orca-runtime.json lives there)\n// Prints one JSON line: {v:1, snapshotId, workspaceId, nonceB64, ciphertextB64, recipients[], stats}.\n// Construction (keep in sync with src/shared/cloud-workspaces/read-snapshot.ts in the app):\n// body = AES-256-GCM(dek, nonce, JSON, AAD \"orca-cloud-read-v1::\")\n// wrapped = AES-256-GCM(HKDF-SHA256(X25519(eph, reader), eph||reader, \"orca-cloud-read-wrap-v1\"),\n// nonce_i, dek, AAD \"orca-cloud-read-wrap-v1:::\")\n'use strict'\nconst crypto = require('node:crypto')\nconst fs = require('node:fs')\nconst net = require('node:net')\nconst path = require('node:path')\n\nconst INFO = 'orca-cloud-read-v1'\nconst WRAP_INFO = 'orca-cloud-read-wrap-v1'\nconst PROJECT = '/vercel/sandbox/project'\nconst LIMITS = { sessions: 12, messagesPerSession: 60, messageChars: 4000, changes: 200, commits: 10 }\n/** Plaintext budget: sealed + base64 it stays under the control plane's 220 kB and the 256 kB stdout cap. */\nconst MAX_PLAIN_BYTES = 150_000\nconst CALL_TIMEOUT_MS = 5_000\nconst TOTAL_BUDGET_MS = 20_000\n\nfunction b64(bytes) {\n return Buffer.from(bytes).toString('base64')\n}\n\nfunction x25519PublicKey(raw) {\n return crypto.createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: Buffer.from(raw).toString('base64url') }, format: 'jwk' })\n}\n\nfunction gcmSeal(key, nonce, plain, aad) {\n const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)\n cipher.setAAD(Buffer.from(aad))\n return Buffer.concat([cipher.update(plain), cipher.final(), cipher.getAuthTag()])\n}\n\nconst SIG_INFO = 'orca-cloud-read-sig-v1'\n\n/**\n * This VM's snapshot signing key (Ed25519), created once and kept on the VM's own disk (0600, never\n * printed). Devices pin its public half per workspace, so a snapshot the control plane forged or\n * swapped doesn't verify. Same file for the grant script, which hands the public half to pairing devices.\n */\nfunction signingKey(stateDir) {\n if (!path.isAbsolute(stateDir)) {\n throw new Error('no state dir')\n }\n const file = path.join(stateDir, 'read-signing-key.pem')\n const load = () => {\n try {\n const privateKey = crypto.createPrivateKey(fs.readFileSync(file, 'utf8'))\n return privateKey.asymmetricKeyType === 'ed25519' ? privateKey : null\n } catch {\n return null\n }\n }\n let privateKey = load()\n if (!privateKey) {\n fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 })\n // Written whole to a temp file, then linked into place: atomic, and a concurrent first run\n // (grant + capture) keeps whichever key landed first. A broken file is replaced (devices that\n // pinned the old signer then see \"signer changed\" until they trust the new one).\n const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}`\n fs.writeFileSync(temp, crypto.generateKeyPairSync('ed25519').privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 })\n try {\n fs.linkSync(temp, file)\n } catch {\n if (!load()) {\n fs.renameSync(temp, file)\n }\n } finally {\n fs.rmSync(temp, { force: true })\n }\n privateKey = load()\n if (!privateKey) {\n throw new Error('signing key unavailable')\n }\n }\n const publicKeyB64 = Buffer.from(crypto.createPublicKey(privateKey).export({ format: 'jwk' }).x, 'base64url').toString('base64')\n return { privateKey, publicKeyB64 }\n}\n\n/** Signs the sealed body: \"orca-cloud-read-sig-v1::::\" || ciphertext bytes. */\nfunction sign(out, key) {\n const message = Buffer.concat([\n Buffer.from(`${SIG_INFO}:${out.workspaceId}:${out.snapshotId}:${out.nonceB64}:`),\n Buffer.from(out.ciphertextB64, 'base64')\n ])\n return { publicKeyB64: key.publicKeyB64, signatureB64: crypto.sign(null, message, key.privateKey).toString('base64') }\n}\n\n/** Seals `payload` (an object) to every recipient; a malformed key is skipped, not fatal. */\nfunction seal(payload, recipients, workspaceId, snapshotId) {\n const scope = `${workspaceId}:${snapshotId}`\n const dek = crypto.randomBytes(32)\n const nonce = crypto.randomBytes(12)\n const ciphertext = gcmSeal(dek, nonce, Buffer.from(JSON.stringify(payload)), `${INFO}:${scope}`)\n const sealedTo = []\n for (const recipient of recipients) {\n const reader = Buffer.from(String(recipient.publicKeyB64 || ''), 'base64')\n if (reader.length !== 32 || !/^rk_[0-9a-f]{24}$/.test(String(recipient.keyId || ''))) {\n continue\n }\n let shared\n const eph = crypto.generateKeyPairSync('x25519')\n try {\n shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: x25519PublicKey(reader) })\n } catch {\n continue\n }\n if (shared.every((byte) => byte === 0)) {\n continue\n }\n const ephRaw = Buffer.from(eph.publicKey.export({ format: 'jwk' }).x, 'base64url')\n const kek = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.concat([ephRaw, reader]), WRAP_INFO, 32))\n const wrapNonce = crypto.randomBytes(12)\n const wrapped = gcmSeal(kek, wrapNonce, dek, `${WRAP_INFO}:${scope}:${recipient.keyId}`)\n sealedTo.push({ keyId: recipient.keyId, ephemeralPublicKeyB64: b64(ephRaw), nonceB64: b64(wrapNonce), wrappedKeyB64: b64(wrapped) })\n }\n dek.fill(0)\n return { v: 1, snapshotId, workspaceId, nonceB64: b64(nonce), ciphertextB64: b64(ciphertext), recipients: sealedTo }\n}\n\n// --- gathering -------------------------------------------------------------------------------\n\n/** One request per connection, like the CLI. A streaming method resolves on its first frame. */\nfunction rpc(metadata, method, params, timeoutMs = CALL_TIMEOUT_MS) {\n return new Promise((resolve) => {\n const transport = (metadata.transports || [metadata.transport]).find((t) => t && (t.kind === 'unix' || t.kind === 'named-pipe'))\n if (!transport) {\n resolve(null)\n return\n }\n const socket = net.createConnection(transport.endpoint)\n const id = crypto.randomUUID()\n let buffer = ''\n let done = false\n const finish = (value) => {\n if (!done) {\n done = true\n clearTimeout(timer)\n socket.destroy()\n resolve(value)\n }\n }\n const timer = setTimeout(() => finish(null), timeoutMs)\n socket.setEncoding('utf8')\n socket.on('error', () => finish(null))\n socket.on('close', () => finish(null))\n socket.on('connect', () => socket.write(`${JSON.stringify({ id, authToken: metadata.authToken, method, params })}\\n`))\n socket.on('data', (chunk) => {\n buffer += chunk\n let newline\n while ((newline = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newline)\n buffer = buffer.slice(newline + 1)\n let frame\n try {\n frame = JSON.parse(line)\n } catch {\n continue\n }\n if (!frame || frame.id !== id) {\n continue\n }\n finish(frame.ok === true ? frame.result : null)\n return\n }\n })\n })\n}\n\nconst isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)\nconst str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : null)\nconst num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)\n\nfunction messagesOf(history) {\n const items = isObj(history) && isObj(history.page) && Array.isArray(history.page.items) ? history.page.items : []\n const out = []\n for (const item of items) {\n const body = isObj(item) && isObj(item.body) ? item.body : null\n if (!body || body.kind !== 'message' || !Array.isArray(body.blocks)) {\n continue\n }\n const role = body.role\n if (role !== 'user' && role !== 'assistant' && role !== 'system') {\n continue\n }\n const text = body.blocks\n .filter((b) => isObj(b) && b.type === 'text' && typeof b.text === 'string')\n .map((b) => b.text)\n .join('\\n')\n .trim()\n if (text) {\n out.push({ itemId: String(item.itemId).slice(0, 200), role, text: text.slice(0, LIMITS.messageChars), at: num(item.observedAt) ?? 0 })\n }\n }\n return { messages: out, hasOlder: isObj(history) && isObj(history.page) && history.page.hasOlder === true }\n}\n\nfunction gitOf(status, log) {\n if (!isObj(status) && !isObj(log)) {\n return null\n }\n const entries = isObj(status) && Array.isArray(status.entries) ? status.entries.filter(isObj) : []\n let added = 0\n let removed = 0\n let counted = false\n const changes = entries.slice(0, LIMITS.changes).map((e) => {\n const a = num(e.added)\n const r = num(e.removed)\n if (a !== null || r !== null) {\n counted = true\n }\n return { path: String(e.path).slice(0, 1024), status: str(e.status, 20) || 'modified', area: str(e.area, 20) || 'unstaged', added: a, removed: r }\n })\n for (const e of entries) {\n added += num(e.added) || 0\n removed += num(e.removed) || 0\n }\n const items = isObj(log) && Array.isArray(log.items) ? log.items.filter(isObj) : []\n return {\n branch: isObj(status) && typeof status.branch === 'string' ? status.branch.replace(/^refs\\/heads\\//, '') : null,\n head: isObj(status) ? str(status.head, 64) : null,\n changes,\n changeCount: entries.length,\n added: counted ? added : null,\n removed: counted ? removed : null,\n commits: items.slice(0, LIMITS.commits).map((c) => ({\n id: String(c.id).slice(0, 64),\n subject: String(c.subject ?? '').slice(0, 300),\n author: str(c.author, 120),\n at: num(c.timestamp)\n }))\n }\n}\n\nfunction prOf(linked) {\n if (!isObj(linked) || num(linked.number) === null || typeof linked.url !== 'string' || !linked.url.startsWith('https://')) {\n return null\n }\n return {\n number: linked.number,\n title: String(linked.title ?? '').slice(0, 300),\n state: str(linked.state, 20) || 'open',\n url: linked.url.slice(0, 500),\n checksStatus: str(linked.checksStatus, 20),\n checksSummary: str(linked.checksSummary, 200)\n }\n}\n\nconst STATUSES = new Set(['working', 'attention', 'idle'])\nconst PANE_PREFIX = 'structured-agent-session-'\n\n/**\n * Chats as orcad stores them (agent-session-journal.db, read-only): every session's provider, name\n * and last turn, open tabs first. The local socket can't stream agentSession.subscribeStatus, and\n * worktree.ps lists only agents that are live right now, so this is the complete list.\n */\nfunction storedSessions(dataDir) {\n let db\n try {\n const { DatabaseSync } = require('node:sqlite')\n db = new DatabaseSync(path.join(dataDir, 'agent-session-journal.db'), { readOnly: true })\n const open = new Set(db.prepare('SELECT session_id FROM agent_session_tabs').all().map((r) => r.session_id))\n const out = []\n for (const row of db.prepare('SELECT session_id, record_json FROM agent_session_records').all()) {\n let record\n try {\n record = JSON.parse(row.record_json)\n } catch {\n continue\n }\n const last = isObj(record.lastTurn) ? record.lastTurn : {}\n out.push({\n sessionId: row.session_id,\n agent: str(record.provider, 40),\n title: str(record.conversationName, 200) || str(last.prompt, 200),\n status: last.state === 'running' || last.state === 'submitted' ? 'working' : last.state === 'settled' ? 'idle' : 'unknown',\n turnOutcome: str(last.outcome, 40),\n updatedAt: num(record.updatedAt),\n open: open.has(row.session_id),\n lastPrompt: str(last.prompt, LIMITS.messageChars),\n lastReply: str(last.reply, LIMITS.messageChars),\n lastAt: num(last.at),\n // \"::\": which checkout this chat works in.\n worktreePath: isObj(record.location) && typeof record.location.workspaceId === 'string' ? record.location.workspaceId.split('::').pop() : null\n })\n }\n return out\n } catch {\n return []\n } finally {\n try {\n db && db.close()\n } catch {}\n }\n}\n\n/** Live agent rows from worktree.ps, keyed by session id (their status wins over the stored one). */\nfunction liveAgents(worktrees) {\n const out = new Map()\n for (const w of worktrees) {\n for (const a of Array.isArray(w.agents) ? w.agents.filter(isObj) : []) {\n const tab = typeof a.paneKey === 'string' ? a.paneKey.split(':')[0] : ''\n if (!tab.startsWith(PANE_PREFIX)) {\n continue\n }\n const state = a.mainAgent && typeof a.mainAgent.state === 'string' ? a.mainAgent.state : a.state\n out.set(tab.slice(PANE_PREFIX.length), {\n agent: str(a.agentType, 40),\n title: str(a.conversationName, 200) || str(a.taskTitle, 200) || str(a.displayName, 200),\n status: state === 'working' ? 'working' : state === 'blocked' || state === 'waiting' || state === 'attention' ? 'attention' : 'idle',\n updatedAt: num(a.updatedAt)\n })\n }\n }\n return out\n}\n\n/**\n * The checkout this workspace is about: the one its newest chat works in, else the most recently\n * active one (a workspace on its own branch is a separate worktree, not the base clone at\n * /vercel/sandbox/project), else the base clone.\n */\nfunction workspaceCheckout(worktrees, sessions) {\n const byPath = new Map(worktrees.filter((w) => typeof w.path === 'string').map((w) => [w.path, w]))\n const newest = sessions\n .filter((s) => s.worktreePath && byPath.has(s.worktreePath))\n .sort((a, b) => (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))[0]\n if (newest) {\n return byPath.get(newest.worktreePath)\n }\n const activity = (w) => Math.max(num(w.lastActivityAt) || 0, num(w.sortOrder) || 0)\n const ranked = [...byPath.values()].sort(\n (a, b) => activity(b) - activity(a) || Number(a.isMainWorktree === true) - Number(b.isMainWorktree === true)\n )\n return ranked[0] || null\n}\n\n/** `call(method, params)` -> result | null. Never throws: a missing piece is left out. */\nasync function gather(call, { workspaceId, snapshotId, reason, now, deadline, listSessions = () => [] }) {\n const timeLeft = () => deadline - Date.now()\n const ps = await call('worktree.ps', { limit: 20 })\n const worktrees = isObj(ps) && Array.isArray(ps.worktrees) ? ps.worktrees.filter(isObj) : []\n const stored = listSessions()\n const project = workspaceCheckout(worktrees, stored)\n const selector = project && typeof project.path === 'string' ? `path:${project.path}` : `path:${PROJECT}`\n const [status, log] = await Promise.all([\n call('git.status', { worktree: selector, includeLineStats: true }),\n call('git.history', { worktree: selector, limit: LIMITS.commits })\n ])\n const live = liveAgents(worktrees)\n const byId = new Map()\n for (const s of stored) {\n byId.set(s.sessionId, s)\n }\n for (const [sessionId, a] of live) {\n byId.set(sessionId, { ...(byId.get(sessionId) || { sessionId, open: true }), ...Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null)) })\n }\n // Open chats first, then newest activity.\n const picked = [...byId.values()]\n .filter((s) => typeof s.sessionId === 'string')\n .sort((a, b) => Number(Boolean(b.open)) - Number(Boolean(a.open)) || (num(b.updatedAt) || 0) - (num(a.updatedAt) || 0))\n .slice(0, LIMITS.sessions)\n const sessions = []\n for (const s of picked) {\n const history = timeLeft() > 1_000 ? await call('agentSession.history', { sessionId: s.sessionId, direction: 'tail', limit: LIMITS.messagesPerSession }) : null\n let { messages, hasOlder } = messagesOf(history)\n if (messages.length === 0) {\n // No readable history (or out of time): the stored last turn still says what happened.\n const at = s.lastAt || s.updatedAt || 0\n messages = [\n ...(s.lastPrompt ? [{ itemId: `${s.sessionId}:last-prompt`, role: 'user', text: s.lastPrompt, at }] : []),\n ...(s.lastReply ? [{ itemId: `${s.sessionId}:last-reply`, role: 'assistant', text: s.lastReply, at }] : [])\n ]\n hasOlder = hasOlder || messages.length > 0\n }\n sessions.push({\n sessionId: s.sessionId.slice(0, 200),\n agent: s.agent || null,\n title: s.title || null,\n status: STATUSES.has(s.status) ? s.status : 'unknown',\n turnOutcome: s.turnOutcome || null,\n updatedAt: num(s.updatedAt),\n messages: messages.slice(-LIMITS.messagesPerSession),\n truncated: hasOlder || messages.length > LIMITS.messagesPerSession\n })\n }\n return {\n v: 1,\n workspaceId,\n snapshotId,\n capturedAt: now,\n reason,\n git: gitOf(status, log),\n pr: prOf(project && project.linkedPR),\n sessions\n }\n}\n\n/** Cuts the oldest messages (then long texts, then sessions) until the JSON fits the budget. */\nfunction fitToBudget(payload, maxBytes = MAX_PLAIN_BYTES) {\n const size = () => Buffer.byteLength(JSON.stringify(payload))\n let guard = 0\n while (size() > maxBytes && guard++ < 10_000) {\n const longest = payload.sessions.reduce((best, s) => (!best || s.messages.length > best.messages.length ? s : best), null)\n if (longest && longest.messages.length > 4) {\n longest.messages.shift()\n longest.truncated = true\n continue\n }\n const longText = payload.sessions.flatMap((s) => s.messages).find((m) => m.text.length > 500)\n if (longText) {\n longText.text = `${longText.text.slice(0, 500)}…`\n continue\n }\n if (payload.git && payload.git.changes.length > 20) {\n payload.git.changes = payload.git.changes.slice(0, 20)\n continue\n }\n if (payload.sessions.length > 1) {\n payload.sessions.pop()\n continue\n }\n payload.sessions = []\n break\n }\n return payload\n}\n\n// Fails closed without HOME (signingKey refuses a relative path).\nconst stateDir = process.env.HOME ? path.join(process.env.HOME, '.orca-sandbox') : ''\n\nasync function main() {\n const started = Date.now()\n const env = process.env\n if (process.argv[2] === '--signer-public-key') {\n // For orca-sandbox-grant: pairing devices learn (and pin) this workspace's signer.\n process.stdout.write(`${signingKey(stateDir).publicKeyB64}\\n`)\n return\n }\n const snapshotId = env.ORCA_READ_SNAPSHOT_ID || ''\n const workspaceId = env.ORCA_READ_WORKSPACE_ID || ''\n if (!/^rsn_[0-9a-f]{24}$/.test(snapshotId) || !/^sbx_[0-9a-f]{24}$/.test(workspaceId)) {\n process.stderr.write('bad ids\\n')\n process.exit(2)\n }\n let recipients\n try {\n recipients = JSON.parse(env.ORCA_READ_RECIPIENTS || '[]')\n } catch {\n recipients = null\n }\n if (!Array.isArray(recipients) || recipients.length === 0) {\n process.stderr.write('no recipients\\n')\n process.exit(2)\n }\n const dataDir = env.ORCA_USER_DATA_PATH || path.join(env.HOME || '', '.orcad-data')\n let metadata\n try {\n metadata = JSON.parse(fs.readFileSync(path.join(dataDir, 'orca-runtime.json'), 'utf8'))\n } catch {\n metadata = null\n }\n if (!metadata || !metadata.authToken) {\n process.stderr.write('orcad not running\\n')\n process.exit(3)\n }\n const deadline = started + TOTAL_BUDGET_MS\n const call = (method, params) => rpc(metadata, method, params, Math.max(250, Math.min(CALL_TIMEOUT_MS, deadline - Date.now())))\n const payload = fitToBudget(\n await gather(call, { workspaceId, snapshotId, reason: (env.ORCA_READ_REASON || 'sleep').slice(0, 20), now: Date.now(), deadline, listSessions: () => storedSessions(dataDir) })\n )\n const out = seal(payload, recipients, workspaceId, snapshotId)\n out.signature = sign(out, signingKey(stateDir))\n // Timing only: counts and sizes would describe the plaintext to the control plane's logs.\n out.stats = { collectMs: Date.now() - started }\n process.stdout.write(`${JSON.stringify(out)}\\n`)\n}\n\nmodule.exports = { workspaceCheckout, seal, sign, signingKey, gather, fitToBudget, messagesOf, gitOf, prOf, rpc, storedSessions, liveAgents }\n\nif (require.main === module) {\n // A fixed message: an error text could carry workspace content into the control plane's logs.\n main().catch(() => {\n process.stderr.write('read snapshot failed\\n')\n process.exit(1)\n })\n}\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","orcad-boot":"#!/usr/bin/env node\n// Starts orcad from a V8 code cache taken after a full startup, so a boot skips compiling the\n// functions startup runs. NODE_COMPILE_CACHE only stores what V8 compiles before the bundle runs\n// (about 5 of its 15 MB); every other function compiled lazily on every boot (~300 ms of wake).\n// orcad-boot --code-cache [--write-code-cache] [orcad args...]\n// orcad sees the argv `node [orcad args...]`, as if started directly.\n// --write-code-cache (the image's smoke run) writes the cache when orcad exits. The cache is used\n// only when its header matches this bundle's bytes and this Node; otherwise orcad loads exactly as\n// before (plain require, which NODE_COMPILE_CACHE still serves). 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 fs = process.getBuiltinModule('node:fs')\nconst path = process.getBuiltinModule('node:path')\nconst vm = process.getBuiltinModule('node:vm')\nconst zlib = process.getBuiltinModule('node:zlib')\nconst { Module, createRequire } = process.getBuiltinModule('node:module')\n\nconst MAGIC = 'orcad-code-cache-v1'\n\nconst args = process.argv.slice(2)\nlet cacheFile = null\nlet writeCache = false\nwhile (args[0] === '--code-cache' || args[0] === '--write-code-cache') {\n if (args[0] === '--code-cache') {\n cacheFile = args[1]\n args.splice(0, 2)\n } else {\n writeCache = true\n args.shift()\n }\n}\nconst entry = args.shift()\nif (!entry || !cacheFile) {\n console.error('usage: orcad-boot --code-cache [--write-code-cache] [args...]')\n process.exit(2)\n}\n// As for `node `: argv[1] is the resolved path, __filename the real one.\nprocess.argv = [process.argv[0], path.resolve(entry), ...args]\nlet file = process.argv[1]\ntry {\n file = fs.realpathSync(file)\n} catch {\n // A missing bundle fails in require below, as it would under plain node.\n}\n\n// One line of identity: a cache from another bundle or Node must never run (V8 only checks length).\nfunction cacheHeader(bytes) {\n return `${MAGIC} ${process.version} ${process.arch} ${bytes.length} ${zlib.crc32(bytes)}\\n`\n}\n\nfunction readCache(header) {\n let data\n try {\n data = fs.readFileSync(cacheFile)\n } catch {\n return null\n }\n const headerLength = Buffer.byteLength(header)\n if (data.length <= headerLength || data.toString('latin1', 0, headerLength) !== header) {\n console.error('orcad-boot: code cache is for another orcad build; loading without it')\n return null\n }\n return data.subarray(headerLength)\n}\n\nfunction runCompiled(header, source, cachedData) {\n // A vm.Script can serialize what startup compiled; the CommonJS loader cannot. Same wrapper,\n // filename and require as the loader, and import() resolves from the bundle like it does there.\n const options = {\n filename: file,\n cachedData: cachedData ?? undefined,\n importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER\n }\n // Node flags the loader option experimental once, on the first import() through it. Spend that\n // one-time warning here, unprinted, so orcad's stderr stays as it was.\n const emitWarning = process.emitWarning\n process.emitWarning = (warning, ...rest) => {\n if (!String(warning?.message ?? warning).includes('USE_MAIN_CONTEXT_DEFAULT_LOADER')) {\n emitWarning.call(process, warning, ...rest)\n }\n }\n let script\n try {\n new vm.Script('import(\"node:path\")', {\n filename: file,\n importModuleDynamically: options.importModuleDynamically\n })\n .runInThisContext()\n .catch(() => {})\n script = new vm.Script(Module.wrap(source), options)\n } finally {\n process.emitWarning = emitWarning\n }\n if (cachedData && script.cachedDataRejected) {\n console.error('orcad-boot: V8 rejected the code cache (other V8 flags?); compiled without it')\n }\n if (writeCache) {\n process.on('exit', () => {\n try {\n const data = script.createCachedData()\n const temporary = `${cacheFile}.${process.pid}.tmp`\n fs.writeFileSync(temporary, Buffer.concat([Buffer.from(header, 'latin1'), data]))\n fs.renameSync(temporary, cacheFile)\n console.error(`orcad-boot: wrote ${data.length} bytes of code cache`)\n } catch (error) {\n console.error(`orcad-boot: could not write the code cache: ${error.message}`)\n }\n })\n }\n const module = new Module(file, null)\n module.filename = file\n module.paths = Module._nodeModulePaths(path.dirname(file))\n const compiled = script.runInThisContext()\n compiled.call(module.exports, module.exports, createRequire(file), module, file, path.dirname(file))\n}\n\n// Locals, so neither the bundle bytes nor the cache outlive the compile.\nfunction boot() {\n // Older Node (no crc32 or main-context import()) loads as before.\n if (typeof zlib.crc32 === 'function' && vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) {\n let bytes = null\n try {\n bytes = fs.readFileSync(file)\n } catch {\n // Missing or unreadable: require reports it as plain node would.\n }\n if (bytes) {\n const header = cacheHeader(bytes)\n const cachedData = readCache(header)\n if (cachedData || writeCache) {\n runCompiled(header, bytes.toString('utf8'), cachedData)\n return\n }\n }\n }\n createRequire(file)(file)\n}\n\nboot()\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=\"${ORCA_REAL_GIT:-/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\n# CREATE-GIT-ASYNC: a new workspace answers before teammates' GitHub identities are installed. Until\n# they are (orca-sandbox-members writes git-setup.ready), commits and pushes wait up to 15 s rather\n# than go out under the workspace identity; a failed or stalled setup stops them with what to do.\n# Read-only git and fetches never wait (they use the workspace's own credentials, set before ready).\nhold_for_member_setup() {\n local state=\"${HOME:-/nonexistent}/.orca-sandbox\" ticks=0 since\n # A failed install on any boot (create or a later restart) refuses even with nothing pending.\n [ -f \"$state/git-setup.pending\" ] || [ -f \"$state/git-setup.failed\" ] || return 0\n since=\"$(cat \"$state/git-setup.pending\" 2>/dev/null || echo 0)\"\n case \"$since\" in '' | *[!0-9]*) since=0 ;; esac\n while [ -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.ready\" ] && [ ! -f \"$state/git-setup.failed\" ]; do\n # 15 s per command; a mark older than 2 min is a setup that died, not one still running.\n if [ \"$ticks\" -ge \"${ORCA_GIT_SETUP_WAIT_TICKS:-150}\" ] || [ $(($(date +%s) - since)) -gt 120 ]; then\n break\n fi\n sleep 0.1\n ticks=$((ticks + 1))\n done\n if [ -f \"$state/git-setup.ready\" ] || { [ ! -f \"$state/git-setup.pending\" ] && [ ! -f \"$state/git-setup.failed\" ]; }; then\n return 0\n fi\n if [ -f \"$state/git-setup.failed\" ]; then\n printf '%s\\n' \"orca: Orca couldn't set up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Restart this workspace (right-click it in the sidebar > Restart), then try again.\" >&2\n else\n printf '%s\\n' \"orca: Orca is still setting up your GitHub identity in this sandbox, so it stopped \\`git $sub\\` rather than use the wrong identity. Try again in a few seconds. If this keeps happening, restart this workspace (right-click it in the sidebar > Restart).\" >&2\n fi\n exit 1\n}\n# A commit under the workspace identity (Orca Cloud's name) says once per session how to use your own\n# (design-squad). /dev/shm is per boot: a wake or restart says it again.\nnudge_once() {\n [ \"$sub\" = commit ] || return 0\n local mark=\"${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)\"\n [ -e \"$mark\" ] && return 0\n : >\"$mark\" 2>/dev/null || return 0\n printf '%s\\n' \"orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours.\" >&2\n}\n# PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's\n# connected GitHub nor the App installation) must never fall through to git's own \"Username for\n# 'https://github.com':\" prompt. Prompts are off for that remote, and git's auth failure becomes one\n# plain line saying what to do.\ngithub_slug=''\ngithub_remote_slug() {\n local arg skip=0 seen=0 remote='' url\n for arg in \"$@\"; do\n if [ \"$skip\" = 1 ]; then skip=0; continue; fi\n if [ \"$seen\" = 0 ]; then\n case \"$arg\" in\n -C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) skip=1 ;;\n \"$sub\") seen=1 ;;\n esac\n continue\n fi\n case \"$arg\" in\n --repo=*) remote=\"${arg#--repo=}\"; break ;;\n -o | --push-option | --receive-pack | --upload-pack | --exec | --depth | --deepen | --shallow-since | --shallow-exclude | --refmap | --recurse-submodules-default | -j | --jobs) skip=1 ;;\n -*) ;;\n *) remote=\"$arg\"; break ;;\n esac\n done\n [ -n \"$remote\" ] || remote=\"$(\"$real\" config --get \"branch.$(\"$real\" symbolic-ref --short -q HEAD 2>/dev/null).remote\" 2>/dev/null || true)\"\n [ -n \"$remote\" ] || remote=origin\n case \"$remote\" in\n *://*) url=\"$remote\" ;;\n *) url=\"$(\"$real\" remote get-url \"$remote\" 2>/dev/null || printf '%s' \"$remote\")\" ;;\n esac\n case \"$url\" in\n https://github.com/* | https://*@github.com/*) url=\"${url#*github.com/}\" ;;\n *) return 0 ;;\n esac\n url=\"${url%.git}\"\n url=\"${url%/}\"\n case \"$url\" in */*/*) url=\"${url%%/*}/$(printf '%s' \"${url#*/}\" | cut -d/ -f1)\" ;; esac\n case \"$url\" in [A-Za-z0-9_.-]*/[A-Za-z0-9_.-]*) github_slug=\"$url\" ;; esac\n}\nno_access_line() {\n local owner=\"${github_slug%%/*}\" verb='push to'\n [ \"$sub\" = push ] || verb='read'\n printf '%s\\n' \"orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings → Account.\" >&2\n}\n# GitHub's \"not found\" is a typo'd name or a private repo it won't confirm: say which half fits whom.\nnot_found_line() {\n printf '%s\\n' \"orca: Orca Cloud can't find $github_slug. Check the name, or install the Orca GitHub App on ${github_slug%%/*} if it's private.\" >&2\n}\n# orca-git-as's own fallback line is replaced by the nudge; GitHub auth failures by the plain line.\nfallback_filter() {\n local line said=0\n while IFS= read -r line || [ -n \"$line\" ]; do\n case \"$line\" in\n 'orca-git-as: no teammate identity'*) nudge_once ;;\n 'remote: Repository not found.'* | \"fatal: repository 'https://github.com/\"*\"' not found\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || not_found_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *\"could not read Username for 'https://github.com'\"* | *\"could not read Password for 'https://\"*\"github.com'\"* | \\\n *\"Authentication failed for 'https://github.com/\"* | 'remote: Permission to '*' denied to '* | \\\n *\"The requested URL returned error: 403\"*)\n if [ -n \"$github_slug\" ]; then\n [ \"$said\" = 1 ] || no_access_line\n said=1\n else\n printf '%s\\n' \"$line\"\n fi\n ;;\n *) printf '%s\\n' \"$line\" ;;\n esac\n done\n}\ncase \"$sub\" in\n push | pull | fetch)\n github_remote_slug \"$@\"\n if [ -n \"$github_slug\" ]; then\n export GIT_TERMINAL_PROMPT=0 GCM_INTERACTIVE=never\n fi\n ;;\nesac\ngit_as=\"${ORCA_GIT_AS:-/opt/orca/bin/orca-git-as}\"\ncase \"$sub\" in\n commit | push | merge | rebase | cherry-pick | revert | am | pull | tag)\n hold_for_member_setup\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 \"$git_as\" ]; then\n PATH=\"/usr/bin:/bin:$PATH\" exec \"$git_as\" \"$@\" 2> >(fallback_filter >&2)\n fi\n # No terminal or agent turn to attribute it to: the workspace identity.\n nudge_once\n ;;\nesac\nif [ -n \"$github_slug\" ]; then\n exec \"$real\" \"$@\" 2> >(fallback_filter >&2)\nfi\nexec \"$real\" \"$@\"\n"} +export const VM_SCRIPTS_HASH: string = "f4d18985" +export const VM_SCRIPTS_STAMP: number = 1794532639 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 # Transient mirror errors and dpkg lock contention made apt exit 100 (2 of ~10 golden builds):\n # retry downloads and wait for the lock instead of failing the build.\n printf 'Acquire::Retries \"5\";\\nDPkg::Lock::Timeout \"180\";\\n' | sudo tee /etc/apt/apt.conf.d/80-orca-retries >/dev/null\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\n# The image files come from this commit too (VM scripts, provision, manifest pins). The env the\n# caller passed is only a fallback for commits from before commit-provision-env.mjs existed.\nimage_dir=\"$src/cloud/apps/sandbox-control/image\"\nif [ -f \"$image_dir/commit-provision-env.mjs\" ]; then\n # The stamp must describe this commit's scripts, never the Worker's: a commit from before stamps\n # leaves it unset (the image then reads as stamp 0).\n unset ORCA_VM_SCRIPTS_STAMP\n node \"$image_dir/commit-provision-env.mjs\" \"$image_dir\" >/tmp/commit-provision.env\n # shellcheck disable=SC1091\n . /tmp/commit-provision.env\n rm -f /tmp/commit-provision.env\n log \"image files from $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 and ORCA_CLONE_FILTER, plus\n# ORCA_BUILD_SECRET_NAMES (space list of the team secrets the environment names; each value rides in\n# the variable of that name) and ORCA_BUILD_WITHHELD_SECRETS (names it lists that builds never get).\n# `::orca-step:: ` lines mark progress.\n# Exit codes: 10 clone, 20 install script, 30 recording the result, 40 a secret's value left on disk.\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\"\n\n# SEC-2: the snapshot of this VM is the team's saved environment, booted by every member's workspace,\n# so a secret's value must never be in it. Secrets are for the install script alone: take_secrets\n# moves them out of this script's environment (clones and Orca's own steps never see them), the\n# install step hands them to that one child, and scan_secrets searches the disk before the build\n# may succeed.\nsecret_names=()\nsecret_values=()\ntake_secrets() {\n local name\n for name in ${ORCA_BUILD_SECRET_NAMES:-}; do\n case \"$name\" in '' | *[!A-Z0-9_]* | [0-9]* | ORCA_* | PATH | HOME | LD_* | DYLD_*) continue ;; esac\n [ -n \"${!name:-}\" ] || continue\n secret_names+=(\"$name\")\n secret_values+=(\"${!name}\")\n unset \"$name\"\n done\n unset ORCA_BUILD_SECRET_NAMES\n # Only a file that changes after this marker can hold a value: the base image never saw one.\n : >\"$OUT/.scan-since\"\n}\n\n# Fails (non-zero) when a secret's value is in a file the build created or changed, anywhere on the\n# disk the snapshot captures. Plain text only: a value the script encoded, encrypted or compressed is\n# not found, and neither is one shorter than 8 characters (it would match ordinary text). A search\n# that cannot run fails too. Prints names and paths, never a value.\n# ORCA_SCAN_ROOT: tests only (a scratch tree); the control plane never sets it.\nscan_secrets() {\n [ \"${#secret_names[@]}\" -gt 0 ] || return 0\n local root=\"${ORCA_SCAN_ROOT:-/}\" i line pattern hit named=0\n local names=() patterns=() args=() newer=() sudo=()\n for i in \"${!secret_names[@]}\"; do\n # grep -F reads a newline as \"or\": a multi-line value (a PEM key) is searched by its longest line.\n pattern=\"\"\n while IFS= read -r line; do\n case \"$line\" in -----*) continue ;; esac\n if [ \"${#line}\" -gt \"${#pattern}\" ]; then pattern=\"$line\"; fi\n done < <(printf '%s\\n' \"${secret_values[$i]}\") # a pipe, not a here-string temp file\n [ \"${#pattern}\" -ge 8 ] || continue\n names+=(\"${secret_names[$i]}\")\n patterns+=(\"$pattern\")\n args+=(-e \"$pattern\")\n done\n [ \"${#patterns[@]}\" -gt 0 ] || return 0\n if ! command -v find >/dev/null 2>&1 || ! command -v grep >/dev/null 2>&1 || ! command -v xargs >/dev/null 2>&1; then\n echo \"::orca-error:: Orca couldn’t check the build for secrets, so the environment wasn’t saved.\"\n return 1\n fi\n # Root-owned files count too (`sudo npm config set` writes /root/.npmrc).\n if sudo -n true 2>/dev/null; then sudo=(sudo -n); fi\n # A missing marker (the install script removed it) means every file is searched.\n if [ -f \"$OUT/.scan-since\" ]; then newer=(-cnewer \"$OUT/.scan-since\"); fi\n changed_files() {\n ${sudo[@]+\"${sudo[@]}\"} find \"$root\" \\( -path /proc -o -path /sys -o -path /dev -o -path /run \\) -prune \\\n -o -type f ${newer[@]+\"${newer[@]}\"} -print0 2>/dev/null\n }\n # One pass for every value; the per-secret passes only run on a build that is about to fail.\n if [ -z \"$(changed_files | ${sudo[@]+\"${sudo[@]}\"} xargs -0 grep -lsF \"${args[@]}\" -- 2>/dev/null | head -n 1)\" ]; then\n return 0\n fi\n for i in \"${!names[@]}\"; do\n while IFS= read -r hit; do\n [ -n \"$hit\" ] || continue\n named=1\n echo \"::orca-error:: ${names[$i]} was left in $hit. Saved environments are shared with your team, so remove it before the install script ends.\"\n done < <(changed_files | ${sudo[@]+\"${sudo[@]}\"} xargs -0 grep -lsF -e \"${patterns[$i]}\" -- 2>/dev/null | head -n 5)\n done\n if [ \"$named\" -eq 0 ]; then\n # The file moved between the two passes: still a build that held a value on disk.\n echo \"::orca-error:: The install script left a secret on disk, so the environment wasn’t saved. Remove it before the script ends, then save again.\"\n fi\n return 1\n}\n\ntake_secrets\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 # ORCA_CLONE_FILTER (automatic environments: blob:none) keeps history and trees, not every old blob.\n if ! git \"${git_auth[@]}\" clone --quiet ${ORCA_CLONE_FILTER:+--filter=\"$ORCA_CLONE_FILTER\"} ${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 for name in ${ORCA_BUILD_WITHHELD_SECRETS:-}; do\n echo \"$name is a model key, so environment builds don’t get it. Agents in workspaces still do.\"\n done\n (\n cd \"${first:-$ROOT}\" || exit 1\n if [ \"${#secret_names[@]}\" -gt 0 ]; then\n for i in \"${!secret_names[@]}\"; do export \"${secret_names[$i]}=${secret_values[$i]}\"; done\n fi\n exec bash -euo pipefail \"$OUT/install.sh\"\n ) /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\" \"$OUT/.scan-since\" \"$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/git b/cloud/apps/sandbox-control/image/vm/git index 2baa0706287..bba55f41db4 100755 --- a/cloud/apps/sandbox-control/image/vm/git +++ b/cloud/apps/sandbox-control/image/vm/git @@ -48,7 +48,7 @@ nudge_once() { local mark="${ORCA_GIT_NUDGE_DIR:-/dev/shm}/orca-git-nudge-$(id -u)" [ -e "$mark" ] && return 0 : >"$mark" 2>/dev/null || return 0 - printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours." >&2 + printf '%s\n' "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours." >&2 } # PUSH-RAW-PROMPT: a push, pull or fetch to github.com that no credential covers (neither the person's # connected GitHub nor the App installation) must never fall through to git's own "Username for @@ -91,7 +91,7 @@ github_remote_slug() { no_access_line() { local owner="${github_slug%%/*}" verb='push to' [ "$sub" = push ] || verb='read' - printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings › Account." >&2 + printf '%s\n' "orca: Orca Cloud can't $verb $github_slug. Install the Orca GitHub App on $owner, or connect your GitHub in Settings → Account." >&2 } # GitHub's "not found" is a typo'd name or a private repo it won't confirm: say which half fits whom. not_found_line() { diff --git a/cloud/apps/sandbox-control/src/env.ts b/cloud/apps/sandbox-control/src/env.ts index c7a4a4322d4..d4586f574ab 100644 --- a/cloud/apps/sandbox-control/src/env.ts +++ b/cloud/apps/sandbox-control/src/env.ts @@ -99,6 +99,21 @@ export type Env = { SECRETS_KEK?: string SECRETS_KEK_ID?: string SECRETS_KEK_RETIRED?: string + /** + * `kms`: the KEKs are held encrypted by a Google Cloud KMS key and unwrapped once per isolate + * (src/secrets-kms.ts). Unset (default): SECRETS_KEK / SECRETS_KEK_RETIRED above, unchanged. + * With `kms`, SECRETS_KEK and SECRETS_KEK_RETIRED are ignored, and a KMS failure answers 503 + * `secrets_unavailable` (sessions do not start without their secrets). + * SECRETS_KMS_KEY projects/

/locations//keyRings//cryptoKeys/ + * SECRETS_KMS_SERVICE_ACCOUNT (secret) service-account JSON allowed to decrypt with that key only + * SECRETS_KEK_WRAPPED base64 KMS ciphertext of the active KEK's 32 bytes, AAD `orca-secrets-kek:` + * SECRETS_KEK_RETIRED_WRAPPED JSON {"":""} after a rotation + */ + SECRETS_KEK_SOURCE?: string + SECRETS_KMS_KEY?: string + SECRETS_KMS_SERVICE_ACCOUNT?: string + SECRETS_KEK_WRAPPED?: string + SECRETS_KEK_RETIRED_WRAPPED?: string /** * `v1` (default) | `v2`: the row format new secrets are sealed in (src/secrets-crypto.ts). With * v2 a KEK rotation re-wraps data keys only. Set it only once every Worker version that reads this diff --git a/cloud/apps/sandbox-control/src/index.ts b/cloud/apps/sandbox-control/src/index.ts index 83dac74951c..2a6ed4b9ce2 100644 --- a/cloud/apps/sandbox-control/src/index.ts +++ b/cloud/apps/sandbox-control/src/index.ts @@ -119,6 +119,7 @@ import { DEFAULT_RETENTION, type RetentionConfig } from './retention' import { reconcile } from './reconcile' import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress' import { SecretError, handleSecretsRoute, keyringFor, type SecretChange } from './secrets' +import { SecretsKmsError } from './secrets-kms' import { refreshAfterSecretChange, refreshSandboxEnv } from './env-refresh' import { ACCOUNT_PROVIDERS, accountProvider, isSharedWorkspace } from './account-providers' import { handleAccountUnauthorized, verifyAccountCredential } from './agent-credential-checks' @@ -880,7 +881,7 @@ export async function lifecycleDeps(env: Env, deps: Deps): Promise { +async function secretsState(env: Env): Promise> { const keyring = keyringFor(env) if (!keyring) { - return undefined + return {} } try { - return { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) } - } catch { + return { secrets: { keyring: await keyring, ...secretsEgressMode(env), accountProviders: providerEnabled(env) } } + } catch (error) { + if (error instanceof SecretsKmsError) { + // KMS holds the keys and is not handing them over. Routes that need no secrets keep working; + // a session start and the secrets routes answer 503 (keyringFor logged the reason). + return { secretsUnavailable: error.reason } + } // A malformed KEK must not block sandbox starts; the secrets routes surface it as 503. console.log(JSON.stringify({ event: 'secrets_kek_invalid' })) - return undefined + return {} } } @@ -1552,7 +1558,10 @@ async function routeRequest(request: Request, env: Env, deps: Deps): Promise { } } +/** The KMS-held secret keys cannot be fetched right now (SECRETS_KEK_SOURCE=kms). Retryable. */ +function secretsUnavailableResponse(): Response { + const response = fail('secrets_unavailable', 503) + response.headers.set('retry-after', '30') + return response +} + function errorResponse(error: unknown): Response { if (error instanceof SyntaxError) { return fail('invalid_json', 400) @@ -2354,6 +2370,9 @@ function errorResponse(error: unknown): Response { if (error instanceof BillingUnavailableError) { return fail('billing_unavailable', 503) } + if (error instanceof SecretsKmsError) { + return secretsUnavailableResponse() + } // Provider and VM failures are summarized; their bodies never reach the desktop. if (error instanceof ProviderError || error instanceof VmCommandError) { return fail('provider_unavailable', 502, error instanceof VmCommandError ? error.code : undefined) diff --git a/cloud/apps/sandbox-control/src/lifecycle.ts b/cloud/apps/sandbox-control/src/lifecycle.ts index 1886e9568be..2365a4cf5a8 100644 --- a/cloud/apps/sandbox-control/src/lifecycle.ts +++ b/cloud/apps/sandbox-control/src/lifecycle.ts @@ -24,6 +24,7 @@ import { ProviderError, type ProviderSandboxState, type SandboxProviderApi } fro import { egressAccountRoutes, egressSecretHosts, placeholderValues, secretPlaceholder } from './secret-egress' import { egressAllowHosts, sessionSecretEnv, type SessionSecrets } from './session-env' import type { Keyring } from './secrets-crypto' +import type { SecretsKmsReason } from './secrets-kms' import { captureReadSnapshot, deleteReadSnapshot } from './read-snapshots' import { ORCAD_PORT, @@ -89,6 +90,8 @@ export type LifecycleDeps = { * egressPlaceholders: every account (`true`) or only the listed account ids (staged rollout); * egressPlaceholdersOff: per-account kill switch that wins over both. */ + /** Set while the KMS-held keys cannot be fetched: a session start fails instead of running without its secrets. */ + secretsUnavailable?: SecretsKmsReason secrets?: { keyring: Keyring egressPlaceholders: boolean | ReadonlySet diff --git a/cloud/apps/sandbox-control/src/operator.ts b/cloud/apps/sandbox-control/src/operator.ts index f97598a7837..72c2b1f86ef 100644 --- a/cloud/apps/sandbox-control/src/operator.ts +++ b/cloud/apps/sandbox-control/src/operator.ts @@ -23,6 +23,7 @@ import { drainAll, housekeeping } from './retention' import { memberChangeTime, recordMemberChange } from './member-changes' import { memberEventActor, memberEventTarget, removeMember } from './member-removal' import type { Keyring } from './secrets-crypto' +import { SecretsKmsError } from './secrets-kms' import { kekStatus, rewrapBatch, RewrapError, REWRAP_MAX_LIMIT } from './secrets-rewrap' import { fairUseReport, FAIR_USE_WINDOW_DAYS, sweepSessionCpu } from './session-cpu' import { listByStatus } from './store' @@ -174,7 +175,10 @@ export async function handleOperator( let keyring: Keyring | null try { keyring = (await deps.keyring?.()) ?? null - } catch { + } catch (error) { + if (error instanceof SecretsKmsError) { + return Response.json({ error: 'secrets_unavailable', reason: error.reason }, { status: 503, headers: noStore }) + } // A malformed SECRETS_KEK* value. The cause is not echoed: it could quote key material. return Response.json({ error: 'secrets_misconfigured' }, { status: 503, headers: noStore }) } diff --git a/cloud/apps/sandbox-control/src/secrets-kms.ts b/cloud/apps/sandbox-control/src/secrets-kms.ts new file mode 100644 index 00000000000..ea11ce51ed2 --- /dev/null +++ b/cloud/apps/sandbox-control/src/secrets-kms.ts @@ -0,0 +1,143 @@ +// Google Cloud KMS unwrap of the secret store's KEKs (SECRETS_KEK_SOURCE=kms; off by default). +// The Worker then holds no plaintext KEK in its config: it holds each KEK encrypted by a KMS key +// (SECRETS_KEK_WRAPPED) and a service account that may only decrypt with that key. keyringFor +// (secrets.ts) unwraps once per isolate and keeps the imported keys in memory. +// Each wrapped KEK is bound to its key id through KMS additional authenticated data, so a blob +// deployed under the wrong id fails to unwrap instead of sealing rows under a mislabelled key. +// Nothing from a KMS or token response is logged or put in an error: only a reason and a status. +import { SignJWT, importPKCS8 } from 'jose' +import { z } from 'zod' + +export type SecretsKmsReason = + /** SECRETS_KEK_SOURCE=kms but a KMS setting is missing or malformed. */ + | 'config' + /** Google refused the service account or its permission on the key (401/403). */ + | 'denied' + /** KMS rejected the wrapped KEK: wrong key, wrong KEK id, or a corrupted blob (400/404). */ + | 'invalid' + /** KMS or the token endpoint could not be reached, timed out or answered 5xx. */ + | 'unavailable' + +/** The secret store cannot get its keys right now. Callers answer 503; they never fall back to another key. */ +export class SecretsKmsError extends Error { + constructor( + readonly reason: SecretsKmsReason, + readonly status?: number + ) { + super(`secrets_kms_${reason}`) + this.name = 'SecretsKmsError' + } +} + +const KEY_NAME = /^projects\/[a-z][a-z0-9-]{4,28}[a-z0-9]\/locations\/[a-z0-9-]{1,40}\/keyRings\/[A-Za-z0-9_-]{1,63}\/cryptoKeys\/[A-Za-z0-9_-]{1,63}$/ +// Fixed endpoints: the service-account file's own token_uri is not trusted. +const TOKEN_URL = 'https://oauth2.googleapis.com/token' +const KMS_URL = 'https://cloudkms.googleapis.com/v1' +const SCOPE = 'https://www.googleapis.com/auth/cloudkms' +const TIMEOUT_MS = 5000 +const ATTEMPTS = 2 + +const ServiceAccount = z.looseObject({ client_email: z.string().min(3).max(320), private_key: z.string().min(100) }) +const TokenResponse = z.looseObject({ access_token: z.string().min(1), expires_in: z.number().positive().optional() }) +const DecryptResponse = z.looseObject({ plaintext: z.string().min(1) }) + +/** The AAD a KEK is wrapped with (`gcloud kms encrypt --additional-authenticated-data-file`). */ +export function kekWrapAad(kekId: string): string { + return `orca-secrets-kek:${kekId}` +} + +export type KekUnwrapper = (wrapped: string, kekId: string) => Promise + +/** + * Returns `unwrap(wrapped, kekId)`: the base64 KEK inside a base64 KMS ciphertext. Throws + * SecretsKmsError; a 5xx or network failure is retried once. + */ +export function createKekUnwrapper(config: { keyName: string; serviceAccountJson: string; fetch?: typeof fetch; now?: () => number }): KekUnwrapper { + const doFetch = config.fetch ?? fetch + const now = config.now ?? Date.now + if (!KEY_NAME.test(config.keyName)) { + throw new SecretsKmsError('config') + } + let account: z.infer + try { + account = ServiceAccount.parse(JSON.parse(config.serviceAccountJson)) + } catch { + // The parse error could quote the credential; it is dropped. + throw new SecretsKmsError('config') + } + let signingKey: CryptoKey | null = null + let token: { value: string; expiresAt: number } | null = null + + async function send(url: string, init: RequestInit): Promise { + let failure = new SecretsKmsError('unavailable') + for (let attempt = 0; attempt < ATTEMPTS; attempt++) { + let response: Response + try { + response = await doFetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) }) + } catch { + continue + } + if (response.ok) { + return response + } + await response.body?.cancel().catch(() => {}) + if (response.status === 401 || response.status === 403) { + throw new SecretsKmsError('denied', response.status) + } + if (response.status < 500 && response.status !== 429) { + throw new SecretsKmsError('invalid', response.status) + } + failure = new SecretsKmsError('unavailable', response.status) + } + throw failure + } + + async function accessToken(): Promise { + if (token && token.expiresAt > now() + 60_000) { + return token.value + } + try { + signingKey ??= await importPKCS8(account.private_key, 'RS256') + } catch { + throw new SecretsKmsError('config') + } + const iat = Math.floor(now() / 1000) - 30 + const assertion = await new SignJWT({ scope: SCOPE }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(account.client_email) + .setAudience(TOKEN_URL) + .setIssuedAt(iat) + .setExpirationTime(iat + 600) + .sign(signingKey) + let response: Response + try { + response = await send(TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }).toString() + }) + } catch (error) { + // Google answers a bad or disabled service account with 400 invalid_grant: that is a refusal, not a bad KEK. + throw error instanceof SecretsKmsError && error.reason === 'invalid' ? new SecretsKmsError('denied', error.status) : error + } + const parsed = TokenResponse.safeParse(await response.json().catch(() => null)) + if (!parsed.success) { + throw new SecretsKmsError('unavailable') + } + token = { value: parsed.data.access_token, expiresAt: now() + (parsed.data.expires_in ?? 3600) * 1000 } + return token.value + } + + return async (wrapped, kekId) => { + const response = await send(`${KMS_URL}/${config.keyName}:decrypt`, { + method: 'POST', + headers: { authorization: `Bearer ${await accessToken()}`, 'content-type': 'application/json' }, + body: JSON.stringify({ ciphertext: wrapped, additionalAuthenticatedData: btoa(kekWrapAad(kekId)) }) + }) + const parsed = DecryptResponse.safeParse(await response.json().catch(() => null)) + if (!parsed.success) { + throw new SecretsKmsError('unavailable') + } + return parsed.data.plaintext + } +} diff --git a/cloud/apps/sandbox-control/src/secrets.ts b/cloud/apps/sandbox-control/src/secrets.ts index 51bb8bc379e..8bde55dc75e 100644 --- a/cloud/apps/sandbox-control/src/secrets.ts +++ b/cloud/apps/sandbox-control/src/secrets.ts @@ -9,7 +9,8 @@ import { z } from 'zod' import type { SandboxCaller } from './auth' import type { Env } from './env' -import { createKeyring, type Keyring, type SecretBinding } from './secrets-crypto' +import { createKeyring, type Keyring, type SealFormat, type SecretBinding } from './secrets-crypto' +import { createKekUnwrapper, SecretsKmsError } from './secrets-kms' import { AUTH_IAT_BACKDATE_MS } from './member-changes' import { BASE_URL_NAMES, baseUrlHost } from './provider-env' import type { SandboxRow } from './store' @@ -458,21 +459,80 @@ export async function effectiveSecrets(db: D1Database, caller: SandboxCaller, re }) } -let cachedKeyring: { source: string; keyring: Promise } | null = null +let cachedKeyring: { source: string; keyring: Promise; failedAt?: number } | null = null +/** How long a failed KMS unwrap is remembered, so an outage is not one KMS round trip per request. */ +export const KMS_RETRY_AFTER_MS = 10_000 -/** The Worker's keyring, or null when SECRETS_KEK is not configured. */ -export function keyringFor(env: Env): Promise | null { +function parseKeyMap(json: string | undefined): Record { + return json ? (JSON.parse(json) as Record) : {} +} + +/** + * SECRETS_KEK_SOURCE=kms: every KEK comes out of Cloud KMS (src/secrets-kms.ts), once per isolate. + * SECRETS_KEK is never read on this path, so a KMS failure cannot fall back to a plaintext key. + */ +function kmsKeyring(env: Env, sealFormat: SealFormat, fetcher: typeof fetch | undefined): Promise { + const activeId = env.SECRETS_KEK_ID ?? 'k1' + const source = `kms\n${activeId}\n${env.SECRETS_KMS_KEY ?? ''}\n${env.SECRETS_KEK_WRAPPED ?? ''}\n${env.SECRETS_KEK_RETIRED_WRAPPED ?? ''}\n${sealFormat}` + const cached = cachedKeyring + if (cached?.source === source && (cached.failedAt === undefined || Date.now() - cached.failedAt < KMS_RETRY_AFTER_MS)) { + return cached.keyring + } + const load = async (): Promise => { + if (!env.SECRETS_KMS_KEY || !env.SECRETS_KMS_SERVICE_ACCOUNT || !env.SECRETS_KEK_WRAPPED) { + throw new SecretsKmsError('config') + } + let retiredWrapped: Record + try { + retiredWrapped = parseKeyMap(env.SECRETS_KEK_RETIRED_WRAPPED) + } catch { + throw new SecretsKmsError('config') + } + const unwrap = createKekUnwrapper({ keyName: env.SECRETS_KMS_KEY, serviceAccountJson: env.SECRETS_KMS_SERVICE_ACCOUNT, fetch: fetcher }) + const active = await unwrap(env.SECRETS_KEK_WRAPPED, activeId) + const retired: Record = {} + for (const [id, wrapped] of Object.entries(retiredWrapped)) { + if (typeof wrapped !== 'string') { + throw new SecretsKmsError('config') + } + retired[id] = await unwrap(wrapped, id) + } + try { + return await createKeyring({ id: activeId, key: active }, retired, { sealFormat }) + } catch { + // KMS returned something that is not a 32-byte key, or an id is malformed. + throw new SecretsKmsError('invalid') + } + } + const entry: NonNullable = { source, keyring: load() } + entry.keyring.catch((error: unknown) => { + entry.failedAt = Date.now() + const kms = error instanceof SecretsKmsError ? error : null + // Reason and HTTP status only: never a response body, a token or a key. + secretLog('secrets_kms_unavailable', { reason: kms?.reason ?? 'error', ...(kms?.status ? { status: kms.status } : {}) }) + }) + cachedKeyring = entry + return entry.keyring +} + +/** + * The Worker's keyring, or null when the secret store is not configured. With SECRETS_KEK_SOURCE=kms + * the promise rejects with SecretsKmsError while KMS cannot hand over the keys. + */ +export function keyringFor(env: Env, fetcher?: typeof fetch): Promise | null { + // Anything but the exact opt-in stays v1, the format every deployed version reads. + const sealFormat = env.SECRETS_SEAL_FORMAT === 'v2' ? 'v2' : 'v1' + if (env.SECRETS_KEK_SOURCE === 'kms') { + return kmsKeyring(env, sealFormat, fetcher) + } if (!env.SECRETS_KEK) { return null } - // Anything but the exact opt-in stays v1, the format every deployed version reads. - const sealFormat = env.SECRETS_SEAL_FORMAT === 'v2' ? 'v2' : 'v1' const source = `${env.SECRETS_KEK_ID ?? 'k1'}\n${env.SECRETS_KEK}\n${env.SECRETS_KEK_RETIRED ?? ''}\n${sealFormat}` if (cachedKeyring?.source !== source) { - const retired = env.SECRETS_KEK_RETIRED ? (JSON.parse(env.SECRETS_KEK_RETIRED) as Record) : {} cachedKeyring = { source, - keyring: createKeyring({ id: env.SECRETS_KEK_ID ?? 'k1', key: env.SECRETS_KEK }, retired, { sealFormat }) + keyring: createKeyring({ id: env.SECRETS_KEK_ID ?? 'k1', key: env.SECRETS_KEK }, parseKeyMap(env.SECRETS_KEK_RETIRED), { sealFormat }) } } return cachedKeyring.keyring diff --git a/cloud/apps/sandbox-control/src/session-env.ts b/cloud/apps/sandbox-control/src/session-env.ts index db6140c18be..f384583fdf1 100644 --- a/cloud/apps/sandbox-control/src/session-env.ts +++ b/cloud/apps/sandbox-control/src/session-env.ts @@ -9,6 +9,7 @@ import { deriveProviderEnv, deriveProviderScopes, providerHosts } from './provid import { applySecretPlaceholders, encodeAccountRoute, encodeEgressSecretHosts } from './secret-egress' import { resolveSandboxSecrets } from './secrets' import type { Keyring } from './secrets-crypto' +import { SecretsKmsError } from './secrets-kms' import type { SandboxRow } from './store' import { secretLog } from './secret-log' @@ -80,9 +81,12 @@ function egressSwapOn(deps: Pick, row: Pick export type { AgentMode } export async function sessionSecretEnv( - deps: Pick, + deps: Pick, row: SandboxRow ): Promise { + if (deps.secretsUnavailable) { + throw new SecretsKmsError(deps.secretsUnavailable) + } if (!deps.secrets) { return EMPTY } diff --git a/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts b/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts index b328489de5d..e5cda0b3b75 100644 --- a/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts +++ b/cloud/apps/sandbox-control/test/git-shim-github-no-prompt.test.ts @@ -63,7 +63,7 @@ describe('the git shim never lets a github.com push prompt for credentials', () expect(err).not.toContain("Username for 'https://github.com'") expect(err).not.toContain('could not read Username') expect(err).toContain( - "orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings › Account." + "orca: Orca Cloud can't push to octocat/Hello-World. Install the Orca GitHub App on octocat, or connect your GitHub in Settings → Account." ) }) diff --git a/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts b/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts index 0c3c6518104..6a3a88fc090 100644 --- a/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts +++ b/cloud/apps/sandbox-control/test/git-shim-identity-nudge.test.ts @@ -1,5 +1,5 @@ // #503 follow-up (design-squad): a commit that goes out under the workspace identity (Orca Cloud's -// name) says once per session how to use your own: connect GitHub in Settings › Account. +// name) says once per session how to use your own: connect GitHub in Settings → Account. // Pushes and read-only git never say it; orca-git-as's own fallback line is replaced by it. import { spawnSync } from 'node:child_process' import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { afterEach, beforeEach, describe, expect, it } from 'vitest' const SHIM = fileURLToPath(new URL('../image/vm/git', import.meta.url).href) -const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings › Account to use yours." +const NUDGE = "orca: This commit uses Orca Cloud's name. Connect your GitHub in Settings → Account to use yours." let home = '' let cwd = '' diff --git a/cloud/apps/sandbox-control/test/secrets-kms.test.ts b/cloud/apps/sandbox-control/test/secrets-kms.test.ts new file mode 100644 index 00000000000..3b703981aae --- /dev/null +++ b/cloud/apps/sandbox-control/test/secrets-kms.test.ts @@ -0,0 +1,321 @@ +import { createCipheriv, createDecipheriv, generateKeyPairSync, randomBytes, type KeyObject } from 'node:crypto' +import { jwtVerify } from 'jose' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { START_COMMAND } from '../src/sandbox-vm-commands' +import { KMS_RETRY_AFTER_MS } from '../src/secrets' +import { createKeyring } from '../src/secrets-crypto' +import { createKekUnwrapper, kekWrapAad, SecretsKmsError } from '../src/secrets-kms' +import { createHarness } from './harness' + +// Fake keys and values only; the log check looks for every one of them. +const K1 = Buffer.alloc(32, 7).toString('base64') +const K2 = Buffer.alloc(32, 9).toString('base64') +const PLAIN = Buffer.alloc(32, 5).toString('base64') +const OP = 'o'.repeat(40) +const KEY_NAME = 'projects/onorca-cloud/locations/us/keyRings/orca-secrets/cryptoKeys/secrets-kek' +const VALUE = 'kms-secret-value-aaaa-1111' +const VALUE_2 = 'kms-secret-value-bbbb-2222' + +type FakeKms = ReturnType + +/** + * Google's token endpoint and Cloud KMS, in memory. The service-account assertion is verified against the + * account's public key, and encrypt/decrypt are real AES-256-GCM under a root key that never leaves this object. + */ +function createFakeKms() { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const root = randomBytes(32) + const email = 'orca-secrets@onorca-cloud.iam.gserviceaccount.com' + const state = { + tokens: 0, + decrypts: 0, + issued: [] as string[], + /** `down`: 503 from KMS. `denied`: 403 from KMS. `no-account`: token endpoint refuses. `offline`: fetch throws. */ + mode: 'up' as 'up' | 'down' | 'denied' | 'no-account' | 'offline' + } + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (state.mode === 'offline') { + throw new TypeError('fetch failed') + } + if (url === 'https://oauth2.googleapis.com/token') { + state.tokens++ + if (state.mode === 'no-account') { + return Response.json({ error: 'invalid_grant', error_description: 'account disabled' }, { status: 400 }) + } + const form = new URLSearchParams(String(init?.body)) + expect(form.get('grant_type')).toBe('urn:ietf:params:oauth:grant-type:jwt-bearer') + const { payload } = await jwtVerify(form.get('assertion') ?? '', publicKey as unknown as KeyObject, { + issuer: email, + audience: 'https://oauth2.googleapis.com/token' + }) + expect(payload.scope).toBe('https://www.googleapis.com/auth/cloudkms') + const token = `ya29.fake-${randomBytes(8).toString('hex')}` + state.issued.push(token) + return Response.json({ access_token: token, expires_in: 3600, token_type: 'Bearer' }) + } + if (url === `https://cloudkms.googleapis.com/v1/${KEY_NAME}:decrypt`) { + state.decrypts++ + if (state.mode === 'down') { + return Response.json({ error: { code: 503, message: 'backend unavailable' } }, { status: 503 }) + } + const bearer = new Headers(init?.headers).get('authorization')?.slice('Bearer '.length) ?? '' + if (state.mode === 'denied' || !state.issued.includes(bearer)) { + return Response.json({ error: { code: 403, message: 'Permission cloudkms.cryptoKeyVersions.useToDecrypt denied' } }, { status: 403 }) + } + const body = JSON.parse(String(init?.body)) as { ciphertext: string; additionalAuthenticatedData?: string } + try { + const bytes = Buffer.from(body.ciphertext, 'base64') + const decipher = createDecipheriv('aes-256-gcm', root, bytes.subarray(0, 12)) + decipher.setAAD(Buffer.from(body.additionalAuthenticatedData ?? '', 'base64')) + decipher.setAuthTag(bytes.subarray(bytes.length - 16)) + const plaintext = Buffer.concat([decipher.update(bytes.subarray(12, bytes.length - 16)), decipher.final()]) + return Response.json({ plaintext: plaintext.toString('base64') }) + } catch { + return Response.json({ error: { code: 400, message: 'Decryption failed: the ciphertext is invalid.' } }, { status: 400 }) + } + } + throw new Error(`unexpected fetch ${url}`) + }) as typeof fetch + return { + state, + fetch: fetcher, + serviceAccount: JSON.stringify({ + type: 'service_account', + client_email: email, + private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string, + // Not trusted by the client: the real endpoint is pinned. + token_uri: 'https://attacker.example/token' + }), + /** What `gcloud kms encrypt` does in the runbook: wrap a KEK's 32 bytes, bound to its id. */ + wrap(kekB64: string, kekId: string): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', root, iv) + cipher.setAAD(Buffer.from(kekWrapAad(kekId))) + return Buffer.concat([iv, cipher.update(Buffer.from(kekB64, 'base64')), cipher.final(), cipher.getAuthTag()]).toString('base64') + } + } +} + +let logs: string[] = [] +let kms: FakeKms +let clock = 1_800_000_000_000 +beforeEach(() => { + logs = [] + kms = createFakeKms() + // The keyring cache is per isolate (module state); a fresh fake means fresh wrapped blobs, so no test sees another's entry. + vi.stubGlobal('fetch', kms.fetch) + // Past any earlier test's remembered failure. + clock += 10 * KMS_RETRY_AFTER_MS + vi.spyOn(Date, 'now').mockImplementation(() => clock) + for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) { + vi.spyOn(console, method).mockImplementation((...args: unknown[]) => { + logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')) + }) + } +}) +afterEach(() => { + // No value, key, credential or access token reached a log line. + const privateKey = (JSON.parse(kms.serviceAccount) as { private_key: string }).private_key + for (const secret of [VALUE, VALUE_2, K1, K2, PLAIN, privateKey.split('\n')[1]!, ...kms.state.issued]) { + expect(logs.join('\n')).not.toContain(secret) + } + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +function kmsEnv(extra: Record = {}): Record { + return { + OPERATOR_TOKEN: OP, + SECRETS_KEK_SOURCE: 'kms', + SECRETS_KMS_KEY: KEY_NAME, + SECRETS_KMS_SERVICE_ACCOUNT: kms.serviceAccount, + SECRETS_KEK_WRAPPED: kms.wrap(K1, 'k1'), + ...extra + } +} + +const put = (h: Awaited>, bearer: string, name: string, value: string) => + h.call('PUT', `/v1/secrets/personal/${name}`, { bearer, body: { value } }) + +describe('KMS client', () => { + it('unwraps a KEK wrapped for its id, with a pinned token endpoint and one token for many calls', async () => { + const unwrap = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch }) + expect(await unwrap(kms.wrap(K1, 'k1'), 'k1')).toBe(K1) + expect(await unwrap(kms.wrap(K2, 'k2'), 'k2')).toBe(K2) + expect(kms.state).toMatchObject({ tokens: 1, decrypts: 2 }) + // A blob wrapped for another id, or a corrupted one, does not unwrap. + await expect(unwrap(kms.wrap(K1, 'k1'), 'k2')).rejects.toMatchObject({ reason: 'invalid', status: 400 }) + await expect(unwrap('AAAA', 'k1')).rejects.toMatchObject({ reason: 'invalid' }) + }) + + it('classifies failures and retries only the retryable ones', async () => { + const unwrap = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch }) + const wrapped = kms.wrap(K1, 'k1') + kms.state.mode = 'down' + await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ name: 'SecretsKmsError', reason: 'unavailable', status: 503 }) + expect(kms.state.decrypts).toBe(2) + kms.state.mode = 'denied' + await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ reason: 'denied', status: 403 }) + expect(kms.state.decrypts).toBe(3) + kms.state.mode = 'offline' + await expect(unwrap(wrapped, 'k1')).rejects.toMatchObject({ reason: 'unavailable' }) + const refused = createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson: kms.serviceAccount, fetch: kms.fetch }) + kms.state.mode = 'no-account' + await expect(refused(wrapped, 'k1')).rejects.toMatchObject({ reason: 'denied' }) + // Error text is a fixed code: nothing Google said is carried along. + const error = await refused(wrapped, 'k1').catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(SecretsKmsError) + expect(String(error)).toBe('SecretsKmsError: secrets_kms_denied') + }) + + it('refuses a malformed key name or credential without echoing it', () => { + for (const keyName of ['', 'projects/x', `${KEY_NAME}/../../other`, `${KEY_NAME}:encrypt`, 'https://evil.example/projects/a/locations/b/keyRings/c/cryptoKeys/d']) { + expect(() => createKekUnwrapper({ keyName, serviceAccountJson: kms.serviceAccount })).toThrow('secrets_kms_config') + } + for (const serviceAccountJson of ['', '{', '{"client_email":"a@b.c"}', 'not-json-PRIVATE']) { + expect(() => createKekUnwrapper({ keyName: KEY_NAME, serviceAccountJson })).toThrow('secrets_kms_config') + } + }) +}) + +describe('secret store with SECRETS_KEK_SOURCE=kms', () => { + it('is off unless the flag is exactly `kms`: the plaintext SECRETS_KEK path is untouched and KMS is never called', async () => { + for (const source of [undefined, '', 'KMS', 'env']) { + const h = await createHarness({ ...kmsEnv(), SECRETS_KEK: PLAIN, ...(source === undefined ? {} : { SECRETS_KEK_SOURCE: source }) }) + if (source === undefined) { + delete h.env.SECRETS_KEK_SOURCE + } + const alice = await h.token('alice') + expect((await put(h, alice, 'PLAIN_VAR', VALUE)).status).toBe(200) + const row = h.raw.prepare('SELECT kek_id, wrapped_dek, ciphertext FROM secrets').get() as { kek_id: string; wrapped_dek: string; ciphertext: string } + const plain = await createKeyring({ id: 'k1', key: PLAIN }) + expect(await plain.open({ scope: 'personal', ownerId: 'user:alice', name: 'PLAIN_VAR' }, { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext })).toBe(VALUE) + } + expect(kms.state).toMatchObject({ tokens: 0, decrypts: 0 }) + }) + + it('unwraps the KEK once per isolate and seals with it, ignoring any plaintext SECRETS_KEK', async () => { + const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN })) + const alice = await h.token('alice') + expect((await put(h, alice, 'FIRST_VAR', VALUE)).status).toBe(200) + expect((await put(h, alice, 'SECOND_VAR', VALUE_2)).status).toBe(200) + expect((await h.call('GET', '/v1/secrets?scope=personal', { bearer: alice })).body.secrets).toHaveLength(2) + expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k1', pending: 0, unknownKekIds: [] }) + // A session start decrypts them too. + expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200) + expect(h.vercel.commands.find((c) => c.command === START_COMMAND)!.env.ORCA_SECRET_ENV).toBeDefined() + // One token and one decrypt for all of it; an outage afterwards does not matter to a warm isolate. + expect(kms.state).toMatchObject({ tokens: 1, decrypts: 1 }) + kms.state.mode = 'down' + expect((await put(h, alice, 'THIRD_VAR', VALUE)).status).toBe(200) + expect(kms.state.decrypts).toBe(1) + + // The rows are under the KMS-held key, not the plaintext one that was also configured. + const row = h.raw.prepare(`SELECT kek_id, wrapped_dek, ciphertext FROM secrets WHERE name = 'FIRST_VAR'`).get() as { kek_id: string; wrapped_dek: string; ciphertext: string } + const sealed = { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext } + const binding = { scope: 'personal', ownerId: 'user:alice', name: 'FIRST_VAR' } + expect(await (await createKeyring({ id: 'k1', key: K1 })).open(binding, sealed)).toBe(VALUE) + await expect((await createKeyring({ id: 'k1', key: PLAIN })).open(binding, sealed)).rejects.toThrow() + }) + + it.each([ + ['down', 'unavailable'], + ['denied', 'denied'], + ['no-account', 'denied'], + ['offline', 'unavailable'] + ] as const)('answers a clear 503 when KMS is %s, without falling back to the plaintext key', async (mode, reason) => { + kms.state.mode = mode + const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN })) + const alice = await h.token('alice') + const refused = await put(h, alice, 'ANY_VAR', VALUE) + expect(refused).toEqual({ status: 503, body: { error: 'secrets_unavailable' } }) + expect((await h.send(new Request('https://sandbox.test/v1/secrets?scope=personal', { headers: { authorization: `Bearer ${alice}` } }))).headers.get('retry-after')).toBe('30') + expect(await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).toEqual({ status: 503, body: { error: 'secrets_unavailable', reason } }) + expect(await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: {} })).toEqual({ status: 503, body: { error: 'secrets_unavailable', reason } }) + // Nothing was stored under any key. + expect(h.raw.prepare('SELECT COUNT(*) AS n FROM secrets').get()).toEqual({ n: 0 }) + // A session does not start without its secrets, and no VM is left behind. + const created = await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice }) + expect(created).toEqual({ status: 503, body: { error: 'secrets_unavailable' } }) + expect(h.vercel.commands.filter((c) => c.command === START_COMMAND)).toHaveLength(0) + // Routes that need no secrets still answer. + expect((await h.call('GET', '/v1/internal/status', { bearer: OP })).status).toBe(200) + expect(logs.some((line) => line.includes('"secrets_kms_unavailable"') && line.includes(`"reason":"${reason}"`))).toBe(true) + }) + + it('remembers a failure briefly instead of calling KMS per request, then recovers', async () => { + kms.state.mode = 'down' + const h = await createHarness(kmsEnv()) + const alice = await h.token('alice') + expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(503) + const attempts = kms.state.decrypts + expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(503) + expect((await h.call('GET', '/v1/secrets?scope=personal', { bearer: alice })).status).toBe(503) + expect(kms.state.decrypts).toBe(attempts) + + kms.state.mode = 'up' + clock += KMS_RETRY_AFTER_MS + 1 + expect((await put(h, alice, 'ANY_VAR', VALUE)).status).toBe(200) + expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200) + }) + + it('answers 503 for an incomplete or mislabelled KMS config, never the plaintext key', async () => { + const cases: Record[] = [ + { SECRETS_KMS_KEY: '' }, + { SECRETS_KMS_SERVICE_ACCOUNT: '' }, + { SECRETS_KEK_WRAPPED: '' }, + { SECRETS_KMS_KEY: 'projects/onorca-cloud' }, + { SECRETS_KEK_RETIRED_WRAPPED: '{' }, + // The active blob was wrapped for k1 but is deployed as k2. + { SECRETS_KEK_ID: 'k2' }, + // KMS hands back something that is not a 32-byte key. + { SECRETS_KEK_WRAPPED: kms.wrap(Buffer.alloc(16, 1).toString('base64'), 'k1') } + ] + for (const broken of cases) { + const h = await createHarness(kmsEnv({ SECRETS_KEK: PLAIN, ...broken })) + expect(await put(h, await h.token('alice'), 'ANY_VAR', VALUE)).toEqual({ status: 503, body: { error: 'secrets_unavailable' } }) + expect(h.raw.prepare('SELECT COUNT(*) AS n FROM secrets').get()).toEqual({ n: 0 }) + } + }) + + it('rotates under KMS: new wrapped KEK active, old one retired, sweep, then the retired blob is dropped', async () => { + const h = await createHarness(kmsEnv()) + const alice = await h.token('alice') + await put(h, alice, 'FIRST_VAR', VALUE) + await put(h, alice, 'SECOND_VAR', VALUE_2) + + Object.assign(h.env, { + SECRETS_KEK_WRAPPED: kms.wrap(K2, 'k2'), + SECRETS_KEK_ID: 'k2', + SECRETS_KEK_RETIRED_WRAPPED: JSON.stringify({ k1: h.env.SECRETS_KEK_WRAPPED }) + }) + expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k2', retiredKekIds: ['k1'], pending: 2, retiredInUse: ['k1'], unknownKekIds: [] }) + expect((await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: { dryRun: true } })).body).toMatchObject({ scanned: 2, failed: 0 }) + expect((await h.call('POST', '/v1/internal/secrets/rewrap', { bearer: OP, body: {} })).body).toMatchObject({ resealed: 2, pending: 0, done: true }) + expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ pending: 0, retiredDroppable: ['k1'] }) + + delete h.env.SECRETS_KEK_RETIRED_WRAPPED + expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ retiredKekIds: [], pending: 0, unknownKekIds: [] }) + const onlyNew = await createKeyring({ id: 'k2', key: K2 }) + const rows = h.raw.prepare('SELECT name, kek_id, wrapped_dek, ciphertext FROM secrets ORDER BY name').all() as { name: string; kek_id: string; wrapped_dek: string; ciphertext: string }[] + const opened = await Promise.all( + rows.map((row) => onlyNew.open({ scope: 'personal', ownerId: 'user:alice', name: row.name }, { kekId: row.kek_id, wrappedDek: row.wrapped_dek, ciphertext: row.ciphertext })) + ) + expect(opened).toEqual([VALUE, VALUE_2]) + }) + + it('moves an existing plaintext-KEK store behind KMS without touching a row', async () => { + const h = await createHarness({ OPERATOR_TOKEN: OP, SECRETS_KEK: K1 }) + const alice = await h.token('alice') + await put(h, alice, 'FIRST_VAR', VALUE) + const before = h.raw.prepare('SELECT * FROM secrets').all() + // Same KEK, same id, now held by KMS; the Worker secret is removed. + Object.assign(h.env, kmsEnv()) + delete h.env.SECRETS_KEK + expect((await h.call('GET', '/v1/internal/secrets/kek', { bearer: OP })).body).toMatchObject({ activeKekId: 'k1', pending: 0, unknownKekIds: [] }) + expect((await h.call('POST', '/v1/sandboxes', { body: { workspaceId: 'ws-1', machineSize: 'standard' }, bearer: alice })).status).toBe(200) + expect(h.vercel.commands.find((c) => c.command === START_COMMAND)!.env.ORCA_SECRET_ENV).toBeDefined() + expect(h.raw.prepare('SELECT * FROM secrets').all()).toEqual(before) + }) +}) diff --git a/cloud/docs/sec-kms-design.md b/cloud/docs/sec-kms-design.md index dd5bbf2a6ac..dd570d0ca28 100644 --- a/cloud/docs/sec-kms-design.md +++ b/cloud/docs/sec-kms-design.md @@ -1,6 +1,7 @@ # SEC-3: moving the secrets-store KEK behind a KMS (design, not implemented) -Status: design and recommendation, 2026-11-12. Nothing here has been built or provisioned. No prod, no Terraform. +Status: phase 1 Worker code is implemented behind `SECRETS_KEK_SOURCE=kms` (off by default; `src/secrets-kms.ts`, tested against a fake KMS only, never against Google). No KMS resources exist: provisioning and turn-on are in the launch runbook, for Neil. Phase 2 is design only. +Differences from the plan below: the wrapped KEK is bound to its KEK id with KMS additional authenticated data; there is no cache window or stale-on-error (keys stay in memory for the isolate's life, so revocation needs a redeploy); a failed unwrap is retried at most every 10 seconds; with the flag on, `SECRETS_KEK` is never read (no fallback), and create/wake answer 503 `secrets_unavailable`. Prices, latencies and vendor feature claims are from memory of public docs and are **unmeasured**; check each before committing. ## Where we are after the SEC-3 PR diff --git a/src/main/cloud-api/cloud-api-client.test.ts b/src/main/cloud-api/cloud-api-client.test.ts index 6cae58ad6fb..dfb1bed7995 100644 --- a/src/main/cloud-api/cloud-api-client.test.ts +++ b/src/main/cloud-api/cloud-api-client.test.ts @@ -147,19 +147,87 @@ describe('teammate names', () => { expect(keys.map((key) => key.createdByName)).toEqual(['Ada Lovelace', null, undefined]) }) - it('reads audit actor names', async () => { - const entry = { - id: 'a1', - actor: { type: 'user', id: 'u_ada', userId: 'u_ada', userName: 'Ada Lovelace' }, - action: 'api_key.create', - target: null, - createdAt: 1 + it('reads the team audit log: filters go in the query, unknown kinds and categories still parse', async () => { + const event = { + id: 'aud_1', + at: 5, + category: 'something_new', + action: 'secret.updated', + actor: { kind: 'robot', userId: 'u_ada', name: 'Ada Lovelace', via: 'carrier_pigeon' }, + target: { type: 'secret', id: 'STRIPE_KEY', label: 'STRIPE_KEY' }, + detail: { scope: 'org' } } const fetchImpl = vi.fn(async () => - respond(200, { entries: [entry], nextBefore: null }) + respond(200, { + events: [event], + nextCursor: '5.aud_1', + actors: [event.actor], + categories: ['secrets'] + }) ) - const page = await client(fetchImpl).listAudit() - expect(page.entries[0]?.actor.userName).toBe('Ada Lovelace') + const page = await client(fetchImpl).listAuditLog({ + category: 'secrets', + actor: 'u_ada', + limit: 50 + }) + expect(String(fetchImpl.mock.calls[0]?.[0])).toBe( + 'https://cp.example.dev/v1/audit-log?category=secrets&actor=u_ada&limit=50' + ) + expect(page.nextCursor).toBe('5.aud_1') + expect(page.events[0]).toMatchObject({ + category: 'other', + actor: { kind: 'person', name: 'Ada Lovelace', via: null }, + target: { label: 'STRIPE_KEY' } + }) + }) + + it('tells a plan without the audit log apart from a missing role and an older control plane', async () => { + const answer = (status: number, body: unknown) => + codeOf(client(vi.fn(async () => respond(status, body))).listAuditLog()) + expect(await answer(403, { error: 'entitlement_denied', reason: 'audit_log' })).toBe( + 'plan-required' + ) + expect(await answer(403, { error: 'forbidden', reason: 'admin_required' })).toBe( + 'admin-required' + ) + expect(await answer(403, { error: 'insufficient_scope', reason: 'audit:read' })).toBe( + 'forbidden' + ) + expect(await answer(404, { error: 'not_found' })).toBe('not-available') + expect(await answer(503, { error: 'billing_unavailable' })).toBe('server') + const refused = await cloudApiErrorOf( + client( + vi.fn(async () => + respond(403, { error: 'entitlement_denied', reason: 'audit_log' }) + ) + ).listAuditLog() + ) + expect(refused.detail).toBe('audit_log') + }) + + it('exports the audit log as CSV text and says when the export was cut short', async () => { + const csv = 'time,category,action\r\n2026-11-12T00:00:00.000Z,secrets,secret.updated\r\n' + const fetchImpl = vi.fn( + async () => + new Response(csv, { + status: 200, + headers: { 'content-type': 'text/csv', 'x-orca-audit-truncated': 'true' } + }) + ) + const exported = await client(fetchImpl).exportAuditLogCsv({ category: 'secrets' }) + expect(String(fetchImpl.mock.calls[0]?.[0])).toBe( + 'https://cp.example.dev/v1/audit-log?category=secrets&format=csv' + ) + expect(exported).toEqual({ csv, truncated: true }) + expect( + await codeOf( + client( + vi.fn(async () => + respond(403, { error: 'entitlement_denied', reason: 'audit_log' }) + ) + ).exportAuditLogCsv() + ) + ).toBe('plan-required') }) }) diff --git a/src/main/cloud-api/cloud-api-client.ts b/src/main/cloud-api/cloud-api-client.ts index 7b2a1b83390..272b025d3b6 100644 --- a/src/main/cloud-api/cloud-api-client.ts +++ b/src/main/cloud-api/cloud-api-client.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { CloudApiApprovalSchema, - CloudApiAuditPageSchema, + CloudAuditLogPageSchema, CloudApiCreatedKeySchema, CloudApiGrantListSchema, CloudApiInstallationListSchema, @@ -20,6 +20,8 @@ import { RoutineUpdatedSchema, RoutineWebhookSchema, type CloudApiErrorCode, + type CloudAuditLogExport, + type CloudAuditLogQuery, type CreateCloudApiApprovalInput, type CreateCloudApiKeyInput, type RoutineInput, @@ -29,6 +31,8 @@ import { const REQUEST_TIMEOUT_MS = 20_000 const MAX_DETAIL_CHARS = 200 +// 5000 events is about 1.5 MB; anything far past that is not the export this client asked for. +const MAX_EXPORT_CHARS = 16 * 1024 * 1024 export class CloudApiError extends Error { constructor( @@ -80,6 +84,9 @@ export function classifyCloudApiResponse( return { code: 'signed-out' } } if (status === 403) { + if (error === 'entitlement_denied') { + return { code: 'plan-required', ...(reason ? { detail: reason } : {}) } + } if (reason === 'admin_required' || reason?.startsWith('admin_scope:')) { return { code: 'admin-required' } } @@ -104,12 +111,12 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { const fetchImpl = deps.fetchImpl ?? fetch const base = `${deps.controlPlaneUrl.replace(/\/+$/, '')}/` - async function request( + /** The authenticated call: a 2xx response, or a CloudApiError worded from the answer. */ + async function send( method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, - schema: S, options: RequestOptions = {} - ): Promise> { + ): Promise { let token: string try { token = await deps.getAccessToken() @@ -138,8 +145,8 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { } catch { throw new CloudApiError('network') } - const body: unknown = await response.json().catch(() => null) if (!response.ok) { + const body: unknown = await response.json().catch(() => null) const { code, detail } = classifyCloudApiResponse(response.status, body, options) const retryAfter = Number(response.headers.get('retry-after')) throw new CloudApiError( @@ -150,6 +157,17 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { : undefined ) } + return response + } + + async function request( + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + schema: S, + options: RequestOptions = {} + ): Promise> { + const response = await send(method, path, options) + const body: unknown = await response.json().catch(() => null) const parsed = schema.safeParse(body) if (!parsed.success) { throw new CloudApiError('invalid-response') @@ -183,8 +201,24 @@ export function createCloudApiClient(deps: CloudApiClientDeps) { getSettings: () => request('GET', 'v1/settings', CloudApiSettingsSchema), putSettings: (settings: { vmCliEnabled: boolean }) => request('PUT', 'v1/settings', CloudApiSettingsSchema, { body: settings }), - listAudit: (query: { limit?: number; before?: number } = {}) => - request('GET', 'v1/audit', CloudApiAuditPageSchema, { query }), + listAuditLog: (query: CloudAuditLogQuery = {}) => + request('GET', 'v1/audit-log', CloudAuditLogPageSchema, { + query, + notFoundMeansUnavailable: true + }), + exportAuditLogCsv: async ( + query: Pick = {} + ): Promise => { + const response = await send('GET', 'v1/audit-log', { + query: { ...query, format: 'csv' }, + notFoundMeansUnavailable: true + }) + const csv = await response.text().catch(() => null) + if (csv === null || csv.length > MAX_EXPORT_CHARS) { + throw new CloudApiError('invalid-response') + } + return { csv, truncated: response.headers.get('x-orca-audit-truncated') === 'true' } + }, listProjects: () => request('GET', 'v1/projects', CloudApiProjectListSchema), listInstallations: () => request('GET', 'v1/github/installations', CloudApiInstallationListSchema, { diff --git a/src/main/cloud-api/cloud-api-ipc.ts b/src/main/cloud-api/cloud-api-ipc.ts index 494cac86df7..abb599316eb 100644 --- a/src/main/cloud-api/cloud-api-ipc.ts +++ b/src/main/cloud-api/cloud-api-ipc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import { z } from 'zod' import { + CloudAuditLogQuerySchema, CreateCloudApiApprovalInputSchema, CreateCloudApiKeyInputSchema, RoutineInputSchema, @@ -16,12 +17,7 @@ import { } from './cloud-api-service' const Id = z.string().min(1).max(128) -const AuditQuery = z - .object({ - limit: z.number().int().min(1).max(200).optional(), - before: z.number().int().nonnegative().optional() - }) - .strict() +const AuditExportQuery = CloudAuditLogQuerySchema.pick({ category: true, actor: true }) const SettingsInput = z.object({ vmCliEnabled: z.boolean() }).strict() type Handler = (...args: unknown[]) => Promise> @@ -59,7 +55,12 @@ const HANDLERS: Record = { 'cloudApi:revokeGrant': call([Id], (c, id) => c.revokeGrant(id)), 'cloudApi:getSettings': call([], (c) => c.getSettings()), 'cloudApi:putSettings': call([SettingsInput], (c, input) => c.putSettings(input)), - 'cloudApi:listAudit': call([AuditQuery.optional()], (c, q) => c.listAudit(q ?? {})), + 'cloudApi:listAuditLog': call([CloudAuditLogQuerySchema.optional()], (c, q) => + c.listAuditLog(q ?? {}) + ), + 'cloudApi:exportAuditLogCsv': call([AuditExportQuery.optional()], (c, q) => + c.exportAuditLogCsv(q ?? {}) + ), 'cloudApi:listProjects': call([], (c) => c.listProjects()), 'cloudApi:listInstallations': call([], (c) => c.listInstallations()), 'cloudApi:listRoutines': call([], (c) => c.listRoutines()), diff --git a/src/preload/api/cloud-api-bridge.ts b/src/preload/api/cloud-api-bridge.ts index 918dafee590..cd0d514a037 100644 --- a/src/preload/api/cloud-api-bridge.ts +++ b/src/preload/api/cloud-api-bridge.ts @@ -1,7 +1,6 @@ import { ipcRenderer } from 'electron' import type { CloudApiApproval, - CloudApiAuditPage, CloudApiAvailability, CloudApiCreatedKey, CloudApiGrant, @@ -11,6 +10,9 @@ import type { CloudApiProject, CloudApiResult, CloudApiSettings, + CloudAuditLogExport, + CloudAuditLogPage, + CloudAuditLogQuery, CreateCloudApiApprovalInput, CreateCloudApiKeyInput, Routine, @@ -23,7 +25,7 @@ import type { RoutineUpdated } from '../../shared/cloud-api/cloud-api-types' -/** Desktop only: Orca Cloud API keys, org settings, audit and routines. The session token stays in main. */ +/** Desktop only: Orca Cloud API keys, org settings, the team audit log and routines. The session token stays in main. */ export type CloudApiBridge = { availability: () => Promise /** The desktop workspace running on this Orca Cloud sandbox, if this computer has it. */ @@ -37,10 +39,11 @@ export type CloudApiBridge = { revokeGrant: (id: string) => Promise> getSettings: () => Promise> putSettings: (input: { vmCliEnabled: boolean }) => Promise> - listAudit: (query?: { - limit?: number - before?: number - }) => Promise> + /** The team audit log: owners and admins of a team whose plan includes it (else `plan-required`). */ + listAuditLog: (query?: CloudAuditLogQuery) => Promise> + exportAuditLogCsv: ( + query?: Pick + ) => Promise> listProjects: () => Promise> listInstallations: () => Promise> listRoutines: () => Promise> @@ -69,7 +72,8 @@ export const cloudApiBridge: CloudApiBridge = { revokeGrant: invoke('cloudApi:revokeGrant'), getSettings: invoke('cloudApi:getSettings'), putSettings: invoke('cloudApi:putSettings'), - listAudit: invoke('cloudApi:listAudit'), + listAuditLog: invoke('cloudApi:listAuditLog'), + exportAuditLogCsv: invoke('cloudApi:exportAuditLogCsv'), listProjects: invoke('cloudApi:listProjects'), listInstallations: invoke('cloudApi:listInstallations'), listRoutines: invoke('cloudApi:listRoutines'), diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index a719d090fd6..058d0a0450c 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -280,7 +280,7 @@ export function PlanBillingView({

{hasPlan || onTrial ? null : ( )} {hasPlan && entitlement.canManageBilling && entitlement.payment.pastDueSince === null ? ( diff --git a/src/renderer/src/components/billing/team/TeamDialogHost.tsx b/src/renderer/src/components/billing/team/TeamDialogHost.tsx index 9db2b6353ce..3b2b8efccc1 100644 --- a/src/renderer/src/components/billing/team/TeamDialogHost.tsx +++ b/src/renderer/src/components/billing/team/TeamDialogHost.tsx @@ -231,7 +231,6 @@ export function TeamPageBody({ openTeammateProfile(userId) }} onDismissArrival={clearTeamPageArrival} - onContactSales={(url) => void window.api.shell.openUrl(url)} inline={inline} dangerZone={ org && teamDelete.check ? ( diff --git a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx index 7650919798d..67784fa9137 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.fixture.tsx @@ -89,8 +89,7 @@ const base: TeamPageViewProps = { onSeePlans: noop, onManageBilling: noop, onCreateWorkspace: noop, - onDismissArrival: noop, - onContactSales: noop + onDismissArrival: noop } function Page(props: Partial): React.JSX.Element { diff --git a/src/renderer/src/components/billing/team/TeamPageView.tsx b/src/renderer/src/components/billing/team/TeamPageView.tsx index c32779daf8f..427c63bb752 100644 --- a/src/renderer/src/components/billing/team/TeamPageView.tsx +++ b/src/renderer/src/components/billing/team/TeamPageView.tsx @@ -1,7 +1,6 @@ -import { useEffect, useId, useRef } from 'react' -import { CreditCard, ExternalLink, RefreshCw } from 'lucide-react' +import { useEffect, useRef } from 'react' +import { CreditCard, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' import { useTranslate } from '@/i18n/use-translate' @@ -52,8 +51,6 @@ export type TeamPageViewProps = { onManageBilling: () => void onCreateWorkspace: () => void onDismissArrival: () => void - /** Opens the Enterprise page (SSO/SCIM are Enterprise; nothing here pretends otherwise). */ - onContactSales: (url: string) => void /** Settings → Team renders the page inline: seats and invoices stay on Plan & billing (linked). */ inline?: { onOpenPlanBilling: () => void } /** Owners in Settings › Team: "Delete ", after "Leave " (TEAM-DELETE). */ @@ -83,7 +80,6 @@ function SkeletonRows(): React.JSX.Element { export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const t = useTranslate() const inviteInput = useRef(null) - const ssoTitleId = useId() const hasRoster = props.roster !== null const inline = props.inline ?? null useEffect(() => { @@ -109,7 +105,6 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { const roster = props.roster?.status === 'ok' ? props.roster.roster : null const entitlement = props.billing?.status === 'ok' ? props.billing.entitlement : null - const enterprise = entitlement?.offers?.enterprise ?? null const rows = roster ? teamRows(roster, props.viewerUserId) : [] const selfRow = rows.find((row) => row.self) const viewerRole = roster?.viewerRole ?? 'member' @@ -298,24 +293,8 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element { ) : null} - {(roster?.canManageMembers && enterprise) || props.dangerZone ? ( + {props.dangerZone ? (
- {roster?.canManageMembers && enterprise ? ( -
-

- {t('auto.components.billing.team.ssoTitle', 'Single sign-on and provisioning')} -

- props.onContactSales(enterprise.contactUrl)} - > - {t('auto.components.billing.enterpriseContactSales', 'Contact sales')} - - -
- ) : null} {props.dangerZone}
) : null} diff --git a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx index 35a6fd49a3d..0505dd8b28c 100644 --- a/src/renderer/src/components/billing/team/TeamPlanSummary.tsx +++ b/src/renderer/src/components/billing/team/TeamPlanSummary.tsx @@ -233,7 +233,7 @@ export function TeamUpgradeCard({
{entitlement.canManageBilling ? ( ) : null} diff --git a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx index c4ec26e7780..8ba8dbf77f3 100644 --- a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx +++ b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.test.tsx @@ -124,6 +124,10 @@ describe('the paired cloud workspace surface', () => { const { container } = render() await flush() expect(container.querySelector('[data-cloud-cover="waking"]')).not.toBeNull() + // PAIRED-WAKE-TYPE-THROUGH: while it wakes the pane underneath stays typeable, like owned. + expect(container.querySelector('[data-cloud-cover="waking"]')?.className).toContain( + 'pointer-events-none' + ) act(() => seedPaired('sleeping', { wake: { @@ -143,6 +147,10 @@ describe('the paired cloud workspace surface', () => { /Try again/.test(b.textContent ?? '') ) expect(retry).toBeDefined() + // A failed wake covers the pane again: nothing typed there would reach a terminal. + expect(container.querySelector('[data-cloud-cover="waking"]')?.className).not.toContain( + 'pointer-events-none' + ) fireEvent.click(retry!) expect(wakePaired).toHaveBeenCalledWith(ID) }) diff --git a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx index cc903cee8ad..f4831d6675d 100644 --- a/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx +++ b/src/renderer/src/components/orca-cloud/PairedCloudWorkspaceSurface.tsx @@ -27,6 +27,8 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import { coveredPaneHadFocus, useInertSiblings } from './cloud-cover-focus' import { CloudWakingOverlay } from './CloudWakingOverlay' import { useWakeOverlay } from './use-wake-overlay' +import { useWakeInput, useWakeInputNotices } from './use-wake-input' +import { clearWakeInput } from '@/components/terminal-pane/input/wake-input-buffer' import { useConfirmedRestart } from './use-cloud-panel-actions' import { useCloudWorkspaceView } from './use-cloud-workspace-view' import { useSleepCopyProgress } from './offline/CloudSleepCopyState' @@ -114,10 +116,15 @@ export function PairedCloudWorkspaceSurface({ const view = useCloudWorkspaceView(workspaceId, { pairedRows: true }) const name = useAppStore((state) => findWorktreeById(state, workspaceId)?.displayName ?? null) const now = useNow(1_000, view.wake !== undefined) + // PAIRED-WAKE-TYPE-THROUGH: typing is held while it wakes and delivered to the woken terminal, + // exactly as for an owned workspace (the store begins and ends the hold on wake progress). + const wakeInput = useWakeInput(workspaceId) + useWakeInputNotices(workspaceId) const overlay = useWakeOverlay( view.wake, Math.max(now, view.wake?.at ?? 0), - t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.') + t('orcaCloudWorkspace.overlay.stalled', 'Orca Cloud stopped responding while waking.'), + wakeInput.pending > 0 ) const copySaveEntry = useSleepCopySave(workspaceId) const wakeInFlight = Boolean( @@ -141,10 +148,12 @@ export function PairedCloudWorkspaceSurface({ const slowNow = useNow(30_000, layer === 'asleep') const restart = useConfirmedRestart(workspaceId) + // While it wakes the pane stays typeable under the overlay (a failed wake covers it again). + const typeThrough = layer === 'waking' && overlay.wake?.phase !== 'failed' const coverRef = useRef(null) const headingRef = useRef(null) const [takeFocus, setTakeFocus] = useState(false) - useInertSiblings(coverRef, layer !== null && !overlay.leaving) + useInertSiblings(coverRef, layer !== null && !typeThrough && !overlay.leaving) useLayoutEffect(() => { setTakeFocus(layer !== null && coveredPaneHadFocus(coverRef.current)) }, [layer]) @@ -175,7 +184,13 @@ export function PairedCloudWorkspaceSurface({ {layer ? (
@@ -185,6 +200,9 @@ export function PairedCloudWorkspaceSurface({ workspaceName={name} leaving={overlay.leaving} autoFocus={takeFocus} + typeThrough={typeThrough} + pendingInput={wakeInput.pending} + onClearPending={() => clearWakeInput(workspaceId)} onRetry={() => void wakePairedCloudWorkspace(workspaceId)} onRestart={restart} onDismiss={() => dismissCloudWake(workspaceId)} diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.test.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.test.tsx new file mode 100644 index 00000000000..c99a44680f2 --- /dev/null +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +// The Orca API keys pane for a team WITHOUT the Enterprise plan. Key activity lives only in the +// Enterprise audit log now, so this pane must never call an audit route (the control plane +// refuses those without the entitlement) and must never show an error because of one. +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + CloudApiKeyList, + CloudApiMe, + CloudApiResult +} from '../../../../../shared/cloud-api/cloud-api-types' +import { TooltipProvider } from '@/components/ui/tooltip' +import { CloudApiKeysPane } from './CloudApiKeysPane' + +beforeEach(() => { + vi.useFakeTimers({ now: NOW, toFake: ['Date'] }) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +// A fixed clock, so "5 minutes ago" never depends on when the test runs. +const NOW = Date.UTC(2026, 10, 12, 17, 30) +const ME: CloudApiMe = { + accountId: 'org:acme', + userId: 'u_ada', + orgId: 'acme', + role: 'owner', + scopes: ['workspaces:read', 'workspaces:write', 'audit:read'] +} +const KEYS: CloudApiKeyList = { + availableScopes: ['workspaces:read', 'workspaces:write', 'audit:read'], + keys: [ + { + id: 'key_ci', + name: 'GitHub Actions deploy', + prefix: 'orca_sk_Ab3x', + scopes: ['workspaces:read'], + createdBy: 'u_ada', + createdByName: 'Ada Lovelace', + createdAt: NOW - 3 * 86_400_000, + lastUsedAt: NOW - 5 * 60_000, + expiresAt: null + } + ] +} + +const ok = (data: T): Promise> => Promise.resolve({ ok: true, data }) + +/** + * The bridge as a non-Enterprise team's control plane answers it: everything the pane needs + * works, and ANY audit read is refused for the plan. Every call is recorded by name. + */ +function nonEnterpriseBridge(): { api: Record; calls: string[] } { + const calls: string[] = [] + const known: Record unknown> = { + availability: () => + Promise.resolve({ status: 'ready', controlPlaneUrl: 'https://api.orca.dev' }), + me: () => ok(ME), + listApiKeys: () => ok(KEYS), + getSettings: () => ok({ settings: { vmCliEnabled: true }, canEdit: true }), + listGrants: () => ok({ grants: [] }) + } + const api = new Proxy( + {}, + { + get: (_target, name) => { + if (typeof name !== 'string') { + return undefined + } + return (...args: unknown[]) => { + calls.push(name) + void args + return ( + known[name]?.() ?? + Promise.resolve({ ok: false, error: 'plan-required', detail: 'audit_log' }) + ) + } + } + } + ) + return { api, calls } +} + +it('lists the keys with their last use, makes no audit call and shows no error for a non-Enterprise team', async () => { + const { api, calls } = nonEnterpriseBridge() + // The pane reads window.api (happy-dom's window is the test global). + vi.stubGlobal('api', { cloudApi: api, shell: { openUrl: vi.fn() } }) + + const { container } = render( + + + + ) + await waitFor(() => expect(screen.getByText('GitHub Actions deploy')).toBeTruthy()) + + // The keys list and each key's "last used" stay. + expect(container.textContent).toMatch(/5 minutes ago/) + // No activity list, no way to ask for one, and nothing that reads as a failure. + expect(container.textContent).not.toMatch(/Recent activity|Show activity|Audit log/) + expect(container.querySelector('[role="alert"]')).toBeNull() + expect(container.textContent).not.toMatch(/doesn’t include|permission|Try again|problem/i) + // Not one audit read, gated or otherwise. + expect(calls.filter((name) => /audit/i.test(name))).toEqual([]) + expect([...new Set(calls)].sort()).toEqual([ + 'availability', + 'getSettings', + 'listApiKeys', + 'listGrants', + 'me' + ]) +}) diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx index 64ef2dda3bb..13e126d5b51 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysPane.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslate } from '@/i18n/use-translate' import type { - CloudApiAuditPage, CloudApiGrant, CloudApiKeyList, CloudApiMe, @@ -29,7 +28,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null { const [keys, setKeys] = useState>({ status: 'loading' }) const [settings, setSettings] = useState>({ status: 'loading' }) const [grants, setGrants] = useState>({ status: 'loading' }) - const [audit, setAudit] = useState>({ status: 'loading' }) const [now, setNow] = useState(() => Date.now()) const begin = useRequestGeneration() const [refreshing, setRefreshing] = useState(false) @@ -85,19 +83,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null { setNow(Date.now()) }, [api, begin, word]) - const loadAudit = useCallback(async (): Promise => { - if (!api) { - return - } - const isCurrent = begin('audit') - setAudit({ status: 'loading' }) - const result = await api.listAudit({ limit: 25 }) - if (isCurrent()) { - setAudit(toCloudLoadState(result, word)) - setNow(Date.now()) - } - }, [api, begin, word]) - useEffect(() => { if (availability?.status === 'ready') { void load() @@ -116,9 +101,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null { viewer={viewer} keys={keys} settings={settings} - audit={ - viewer && viewer.role !== 'member' && viewer.scopes.includes('audit:read') ? audit : null - } now={now} onRefresh={() => void load()} refreshing={refreshing} @@ -158,7 +140,6 @@ function CloudApiKeysPaneBody(): React.JSX.Element | null { setSettings({ status: 'ok', data: result.data }) return null }} - onLoadAudit={() => void loadAudit()} onCreateApproval={(input) => api.createApproval(input)} grants={grants} onRefreshGrants={() => { diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx index 9e4060a6536..5357e364674 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysSections.tsx @@ -1,20 +1,13 @@ import { useState } from 'react' -import { ExternalLink, History, ShieldCheck } from 'lucide-react' +import { ExternalLink, ShieldCheck } from 'lucide-react' import { Button } from '@/components/ui/button' import { InlineError } from '@/components/ui/inline-error' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' -import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' -import type { - CloudApiAuditEntry, - CloudApiAuditPage, - CloudApiMe, - CloudApiSettings -} from '../../../../../shared/cloud-api/cloud-api-types' +import { useTranslate } from '@/i18n/use-translate' +import type { CloudApiSettings } from '../../../../../shared/cloud-api/cloud-api-types' import { SettingsGroup } from '../section-layout/SettingsGroup' -import { PersonSentence } from './PersonName' -import { formatAbsoluteTime, formatRelativeFromNow } from './cloud-api-format' -import { CloudApiErrorNotice, CloudApiSkeletonRows, CopyValueButton } from './cloud-api-parts' +import { CopyValueButton } from './cloud-api-parts' import type { CloudLoadState } from './cloud-load-state' function LinkRow({ @@ -184,182 +177,3 @@ export function OrgSettings({ ) } - -const AUDIT_ACTIONS: Record = { - 'api_key.create': ['auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'], - 'api_key.revoke': ['auto.components.settings.cloudApi.audit.action.keyRevoke', 'Revoked API key'], - 'settings.update': [ - 'auto.components.settings.cloudApi.audit.action.settings', - 'Changed agent access' - ], - 'workspace.create': [ - 'auto.components.settings.cloudApi.audit.action.wsCreate', - 'Started a workspace' - ], - 'workspace.destroy': [ - 'auto.components.settings.cloudApi.audit.action.wsDestroy', - 'Deleted a workspace' - ], - 'workspace.sleep': [ - 'auto.components.settings.cloudApi.audit.action.wsSleep', - 'Put a workspace to sleep' - ], - 'workspace.wake': ['auto.components.settings.cloudApi.audit.action.wsWake', 'Woke a workspace'], - 'workspace.archive': [ - 'auto.components.settings.cloudApi.audit.action.wsArchive', - 'Archived a workspace' - ], - 'workspace.message': [ - 'auto.components.settings.cloudApi.audit.action.wsMessage', - 'Messaged an agent' - ], - 'project.create': [ - 'auto.components.settings.cloudApi.audit.action.projectCreate', - 'Created a project' - ], - 'project.update': [ - 'auto.components.settings.cloudApi.audit.action.projectUpdate', - 'Edited a project' - ], - 'project.delete': [ - 'auto.components.settings.cloudApi.audit.action.projectDelete', - 'Deleted a project' - ], - 'mcp.approval_create': [ - 'auto.components.settings.cloudApi.audit.action.approval', - 'Made an app approval code' - ] -} - -/** "Created API key “CI deploy”"; unknown actions fall back to their machine name. */ -function auditActionLabel(entry: CloudApiAuditEntry, t: Translate): string { - const known = AUDIT_ACTIONS[entry.action] - const base = known ? t(known[0], known[1]) : entry.action - const name = entry.detail?.name - return typeof name === 'string' && name.length > 0 - ? t('auto.components.settings.cloudApi.audit.actionNamed', '{{action}} “{{name}}”', { - action: base, - name: name.slice(0, 80) - }) - : base -} - -function ActorLabel({ - entry, - viewer -}: { - entry: CloudApiAuditEntry - viewer: CloudApiMe | null -}): React.JSX.Element { - const t = useTranslate() - if (entry.actor.type === 'workspace_token') { - return ( - <>{t('auto.components.settings.cloudApi.audit.viaAgent', 'Agent in a cloud workspace')} - ) - } - const mine = Boolean(entry.actor.userId) && entry.actor.userId === viewer?.userId - const personId = entry.actor.userId ?? entry.actor.id - const sentence = - entry.actor.type === 'api_key' - ? mine - ? t('auto.components.settings.cloudApi.audit.youViaKey', 'You via API key') - : t('auto.components.settings.cloudApi.audit.personViaKey', ' via API key') - : entry.actor.type === 'mcp' - ? mine - ? t('auto.components.settings.cloudApi.audit.youViaMcp', 'You via MCP') - : t('auto.components.settings.cloudApi.audit.personViaMcp', ' via MCP') - : mine - ? t('auto.components.settings.cloudApi.audit.you', 'You') - : '' - return mine ? ( - <>{sentence} - ) : ( - - ) -} - -export function AuditLog({ - audit, - now, - viewer, - initiallyOpen, - onLoad -}: { - audit: CloudLoadState - now: number - viewer: CloudApiMe | null - initiallyOpen: boolean - onLoad: () => void -}): React.JSX.Element { - const t = useTranslate() - const locale = useIntlLocale() - const [open, setOpen] = useState(initiallyOpen) - return ( - { - if (!open) { - onLoad() - } - setOpen(!open) - }} - > - - {open - ? t('auto.components.settings.cloudApi.audit.hide', 'Hide') - : t('auto.components.settings.cloudApi.audit.show', 'Show activity')} - - } - bodyClassName={open ? undefined : 'hidden'} - > - {!open ? null : audit.status === 'loading' ? ( - - ) : audit.status === 'error' ? ( -
- -
- ) : audit.data.entries.length === 0 ? ( -

- {t('auto.components.settings.cloudApi.audit.empty', 'Nothing has happened yet.')} -

- ) : ( -
    - {audit.data.entries.slice(0, 25).map((entry) => ( -
  • - - {auditActionLabel(entry, t)} - - - - - - {formatRelativeFromNow(entry.createdAt, now, locale)} - -
  • - ))} -
- )} -
- ) -} diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx index a31156a5809..b18f289c8f0 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.fixture.tsx @@ -1,7 +1,6 @@ import { defineFixture } from '../../../component-fixtures/component-fixture' import type { CloudApiGrant, - CloudApiAuditPage, CloudApiKeyList, CloudApiMe, CloudApiSettings @@ -65,34 +64,6 @@ const KEYS: CloudApiKeyList = { } const SETTINGS: CloudApiSettings = { settings: { vmCliEnabled: true }, canEdit: true } -const AUDIT: CloudApiAuditPage = { - nextBefore: null, - entries: [ - { - id: 'a1', - actor: { type: 'user', id: 'u_7f3k', userId: 'u_7f3k', userName: null }, - action: 'api_key.create', - target: { type: 'api_key', id: 'key_ci' }, - detail: { name: 'GitHub Actions deploy' }, - createdAt: NOW - 3 * DAY - }, - { - id: 'a2', - actor: { type: 'api_key', id: 'key_dash', userId: 'u_grace', userName: 'Grace Hopper' }, - action: 'workspace.create', - target: { type: 'workspace', id: 'sbx_7Hq2' }, - createdAt: NOW - 5 * MIN - }, - { - id: 'a3', - actor: { type: 'workspace_token', id: 'key_ws', userId: 'u_ada' }, - action: 'workspace.message', - target: { type: 'workspace', id: 'sbx_7Hq2' }, - createdAt: NOW - 2 * MIN - } - ] -} - const GRANTS: CloudApiGrant[] = [ { id: 'fam_claude', @@ -126,13 +97,11 @@ const base: CloudApiKeysViewProps = { viewer: ADMIN, keys: { status: 'ok', data: KEYS }, settings: { status: 'ok', data: SETTINGS }, - audit: { status: 'ok', data: AUDIT }, now: NOW, onRefresh: () => {}, onCreate: async () => ({ ok: false, error: 'server' }), onRevoke: async () => null, onToggleVmCli: async () => null, - onLoadAudit: () => {}, onCreateApproval: async () => ({ ok: false, error: 'server' }), grants: { status: 'ok', data: [] }, onRevokeGrant: async () => null, @@ -155,11 +124,6 @@ export const disconnectAppConfirm = defineFixture({ ) }) -export const listWithActivity = defineFixture({ - width: 760, - render: () => -}) - export const empty = defineFixture({ width: 760, render: () => ( @@ -180,7 +144,6 @@ export const loading = defineFixture({ keys={{ status: 'loading' }} grants={{ status: 'loading' }} settings={{ status: 'loading' }} - audit={null} /> ) }) @@ -196,7 +159,6 @@ export const error = defineFixture({ }} settings={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} grants={{ status: 'error', message: 'Couldn’t reach Orca Cloud.' }} - audit={null} /> ) }) @@ -212,7 +174,6 @@ export const memberView = defineFixture({ data: { availableScopes: MEMBER.scopes, keys: [{ ...KEYS.keys[0]!, createdBy: 'u_linus' }] } }} settings={{ status: 'ok', data: { settings: { vmCliEnabled: false }, canEdit: false } }} - audit={null} /> ) }) @@ -229,7 +190,6 @@ export const createDialogMemberCustom = defineFixture({ {...base} viewer={MEMBER} keys={{ status: 'ok', data: { availableScopes: MEMBER.scopes, keys: [] } }} - audit={null} initial={{ create: true }} /> ), diff --git a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx index 73a6718cbf7..67ba455d68c 100644 --- a/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx +++ b/src/renderer/src/components/settings/cloud-api/CloudApiKeysView.tsx @@ -7,7 +7,6 @@ import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/class-names' import type { CloudApiApproval, - CloudApiAuditPage, CloudApiCreatedKey, CloudApiGrant, CloudApiKey, @@ -20,7 +19,7 @@ import type { } from '../../../../../shared/cloud-api/cloud-api-types' import { SettingsGroup } from '../section-layout/SettingsGroup' import { ApproveAppDialog } from './ApproveAppDialog' -import { AccessLinks, AuditLog, OrgSettings } from './CloudApiKeysSections' +import { AccessLinks, OrgSettings } from './CloudApiKeysSections' import { CloudConfirmDialog } from './CloudConfirmDialog' import { CloudConnectedApps } from './CloudConnectedApps' import type { CloudLoadState } from './cloud-load-state' @@ -37,7 +36,6 @@ export type CloudApiKeysViewProps = { keys: CloudLoadState settings: CloudLoadState /** Null hides the activity log (members cannot read it). */ - audit: CloudLoadState | null now: number onRefresh: () => void /** A refresh is running while the current data stays on screen. */ @@ -46,7 +44,6 @@ export type CloudApiKeysViewProps = { /** Resolves to an error message or null. */ onRevoke: (key: CloudApiKey) => Promise onToggleVmCli: (enabled: boolean) => Promise - onLoadAudit: () => void grants: CloudLoadState onRevokeGrant: (grant: CloudApiGrant) => Promise onRefreshGrants: () => void @@ -58,7 +55,6 @@ export type CloudApiKeysViewProps = { create?: boolean created?: CloudApiCreatedKey revokeKeyId?: string - auditOpen?: boolean approve?: boolean approval?: CloudApiApproval revokeGrantId?: string @@ -252,16 +248,6 @@ export function CloudApiKeysView(props: CloudApiKeysViewProps): React.JSX.Elemen settings={props.settings} onToggle={props.onToggleVmCli} /> - - {props.audit ? ( - - ) : null}
[ ...translateSearchKeyword('auto.components.settings.cloudApi.keywordToken', 'token'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordMcp', 'mcp'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordCli', 'cli'), - ...translateSearchKeyword('auto.components.settings.cloudApi.keywordAudit', 'audit log'), ...translateSearchKeyword('auto.components.settings.cloudApi.keywordCloud', 'cloud') ] } diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx new file mode 100644 index 00000000000..a93d6afb4df --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseAuditLog.tsx @@ -0,0 +1,393 @@ +// Settings › Enterprise › Audit log: every recorded change for the team in one list, filtered by +// category and by person, with CSV export. Owners and admins only. A team whose plan does not +// include it sees the upsell instead; the control plane refuses the data either way. +import { Download, History, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { InlineError } from '@/components/ui/inline-error' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { useIntlLocale, useTranslate, type Translate } from '@/i18n/use-translate' +import { + CLOUD_AUDIT_CATEGORIES, + type CloudAuditActor, + type CloudAuditCategory, + type CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import { PersonName, PersonSentence } from '../cloud-api/PersonName' +import { formatAbsoluteTime, formatRelativeFromNow } from '../cloud-api/cloud-api-format' +import { + CloudApiEmptyState, + CloudApiErrorNotice, + CloudApiSkeletonRows +} from '../cloud-api/cloud-api-parts' +import { SettingsGroup } from '../section-layout/SettingsGroup' +import { EnterpriseUpsell, EnterpriseUpsellLine, type EnterpriseContact } from './EnterpriseUpsell' +import { auditCategoryLabel, auditEventSentence } from './enterprise-audit-copy' + +export type AuditLogFilters = { category: CloudAuditCategory | null; actor: string | null } + +export type AuditLogState = + | { status: 'loading' } + /** The team's plan does not include the audit log. */ + | { status: 'upsell' } + | { status: 'error'; message: string } + | { + status: 'ok' + events: CloudAuditEvent[] + /** More events exist past the ones loaded. */ + hasMore: boolean + loadingMore: boolean + /** A failed "Show more" keeps the list and says so under it. */ + moreError: string | null + /** A filter change is loading; the previous list stays on screen, dimmed. */ + refreshing: boolean + } + +export type AuditLogExportState = + | { status: 'idle' } + | { status: 'exporting' } + /** Saved; `truncated` means the file holds the newest events only. */ + | { status: 'done'; truncated: boolean } + | { status: 'error'; message: string } + +export type EnterpriseAuditLogProps = { + log: AuditLogState + filters: AuditLogFilters + /** Everyone who appears in the log, for the person filter. */ + actors: readonly CloudAuditActor[] + exportState: AuditLogExportState + now: number + viewerUserId: string | null + contact: EnterpriseContact + onFilter: (filters: AuditLogFilters) => void + onLoadMore: () => void + onRetry: () => void + onExport: () => void + onContactSales: (url: string) => void + /** False when the page banner already carries Contact sales: the upsell is then a plain line. */ + upsellButton?: boolean +} + +const ALL = '__all__' +const SYSTEM = 'system' + +function ActorCell({ + event, + viewerUserId +}: { + event: CloudAuditEvent + viewerUserId: string | null +}): React.JSX.Element { + const t = useTranslate() + const { actor } = event + if (actor.kind === 'system' || !actor.userId) { + return <>{t('settings.enterprise.audit.actor.system', 'Orca')} + } + const mine = actor.userId === viewerUserId + const sentence = + actor.via === 'api_key' + ? mine + ? t('auto.components.settings.cloudApi.audit.youViaKey', 'You via API key') + : t('auto.components.settings.cloudApi.audit.personViaKey', ' via API key') + : actor.via === 'mcp' + ? mine + ? t('auto.components.settings.cloudApi.audit.youViaMcp', 'You via MCP') + : t('auto.components.settings.cloudApi.audit.personViaMcp', ' via MCP') + : actor.via === 'agent' + ? mine + ? t('settings.enterprise.audit.actor.youViaAgent', 'Your agent') + : t('settings.enterprise.audit.actor.personViaAgent', '’s agent') + : actor.via === 'routine' + ? mine + ? t('settings.enterprise.audit.actor.youViaRoutine', 'Your routine') + : t('settings.enterprise.audit.actor.personViaRoutine', '’s routine') + : mine + ? t('auto.components.settings.cloudApi.audit.you', 'You') + : '' + if (mine) { + return <>{sentence} + } + return ( + <> + + {/* A named person who has left: say so, or they read as a current teammate. */} + {actor.kind === 'former_member' && actor.name ? ( + + {' · '} + {t('auto.components.settings.cloudApi.formerMember', 'Former member')} + + ) : null} + + ) +} + +function actorOptionLabel(actor: CloudAuditActor, viewerUserId: string | null, t: Translate) { + if (actor.userId && actor.userId === viewerUserId) { + return t('auto.components.settings.cloudApi.audit.you', 'You') + } + return ( + <> + + {actor.kind === 'former_member' && actor.name ? ( + + {t('auto.components.settings.cloudApi.formerMember', 'Former member')} + + ) : null} + + ) +} + +function Filters({ + filters, + actors, + viewerUserId, + disabled, + onFilter +}: Pick & { + disabled: boolean +}): React.JSX.Element { + const t = useTranslate() + return ( +
+ + +
+ ) +} + +export function EnterpriseAuditLog(props: EnterpriseAuditLogProps): React.JSX.Element { + const t = useTranslate() + const locale = useIntlLocale() + const { log, filters, exportState } = props + const filtered = filters.category !== null || filters.actor !== null + const canExport = log.status === 'ok' && log.events.length > 0 + return ( + (exportState.status === 'exporting' ? undefined : props.onExport())} + > + {exportState.status === 'exporting' ? ( + + ) : ( + + )} + {t('settings.enterprise.audit.export', 'Export CSV')} + + ) : undefined + } + > + {log.status === 'upsell' && props.upsellButton === false ? ( + + {t( + 'settings.enterprise.audit.upsellBody', + 'Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.' + )} + + ) : log.status === 'upsell' ? ( + + ) : log.status === 'loading' ? ( + + ) : log.status === 'error' ? ( +
+ +
+ ) : ( + <> + {/* Nothing to narrow down in a log with nothing in it. */} + {filtered || log.events.length > 0 ? ( + + ) : null} + {exportState.status === 'error' ? ( +
+ +
+ ) : exportState.status === 'done' && exportState.truncated ? ( +

+ {t( + 'settings.enterprise.audit.exportTruncated', + 'The file has the newest 5,000 events. Filter by activity or person to export the rest.' + )} +

+ ) : null} + {log.events.length === 0 ? ( +
+ props.onFilter({ category: null, actor: null })} + > + {t('settings.enterprise.audit.clearFilters', 'Clear filters')} + + ) : undefined + } + /> +
+ ) : ( +
    + {log.events.map((event) => ( +
  • + + {auditEventSentence(event, t)} + + + {auditCategoryLabel(event.category, t)} + + + + + + {formatRelativeFromNow(event.at, props.now, locale)} + +
  • + ))} +
+ )} + {log.moreError ? ( +
+ +
+ ) : log.hasMore ? ( +
+ +
+ ) : null} + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx new file mode 100644 index 00000000000..e86d1a4f38f --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsPane.tsx @@ -0,0 +1,65 @@ +// Settings › Enterprise, wired to the account: who is looking, billing's Enterprise contact page, +// and the audit log's data. Shown only to a team's owners and admins (the nav hides it otherwise, +// and the control plane refuses the data regardless). +import { useEffect } from 'react' +import { useOrcaAccountKey } from '@/hooks/use-orca-account-key' +import { useOrcaCloudApiAvailability } from '@/hooks/useOrcaCloudApiAvailability' +import { activeTeamRole } from '@/lib/orca-cloud/active-team-role' +import { useAppStore } from '@/store' +import { refreshBillingSummary, useBillingState } from '../../billing/billing-summary-store' +import { canManageMembers } from '../../billing/team/team-roles' +import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { ENTERPRISE_CONTACT_URL, type EnterpriseContact } from './EnterpriseUpsell' +import { useEnterpriseAuditLog } from './use-enterprise-audit-log' + +/** Billing's own name and contact page for Enterprise; the fixed page until billing has answered. */ +function useEnterpriseContact(): EnterpriseContact { + const billing = useBillingState() + useEffect(() => { + void refreshBillingSummary() + }, []) + const offer = + billing.result?.status === 'ok' ? billing.result.entitlement.offers?.enterprise : null + return { + planName: offer?.label || 'Enterprise', + contactUrl: offer?.contactUrl || ENTERPRISE_CONTACT_URL + } +} + +export function EnterpriseSettingsPane(): React.JSX.Element | null { + // Remount on a team switch so one team's log never shows under another. + return +} + +function EnterpriseSettingsPaneBody(): React.JSX.Element | null { + const availability = useOrcaCloudApiAvailability() + const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) + const role = activeTeamRole(authStatus) + const admin = role !== null && canManageMembers(role) + const contact = useEnterpriseContact() + const audit = useEnterpriseAuditLog(availability?.status === 'ready' && admin) + if (availability?.status !== 'ready' || !admin) { + return null + } + return ( + void window.api.shell.openUrl(url) + }} + /> + ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx new file mode 100644 index 00000000000..69d5d749a90 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.fixture.tsx @@ -0,0 +1,129 @@ +import { defineFixture } from '../../../component-fixtures/component-fixture' +import { SettingsSection } from '../section-layout/SettingsSection' +import { EnterpriseSettingsView } from './EnterpriseSettingsView' +import { fixtureAuditProps } from './enterprise-audit.fixtures' +import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog' + +const BARE_BODY = 'rounded-none border-0 bg-transparent p-0 shadow-none' + +/** The pane as Settings draws it: page title and description over the bare body. */ +function Page({ + audit, + ssoIncluded +}: { + audit: EnterpriseAuditLogProps + ssoIncluded?: boolean | null +}): React.JSX.Element { + return ( +
+ + + +
+ ) +} + +/** An Enterprise team's owner: the merged log, filters and export; single sign-on is theirs, so no upsell. */ +export const auditLog = defineFixture({ + width: 860, + render: () => +}) + +/** A team on neither: one banner with the page's only Contact sales, and a plain line per block. */ +export const upsell = defineFixture({ + width: 860, + render: () => +}) + +/** Entitled, nothing recorded yet. */ +export const empty = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** Filters that match nothing: say so and offer the way back. */ +export const emptyFiltered = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +export const loading = defineFixture({ + width: 860, + render: () => +}) + +export const loadFailed = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** After an export that hit the 5,000-event cap. */ +export const exportTruncated = defineFixture({ + width: 860, + render: () => ( + + ) +}) + +/** Only the audit log is missing (single sign-on is included): that block keeps its own button. */ +export const upsellAuditOnly = defineFixture({ + width: 860, + render: () => ( + + ) +}) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx new file mode 100644 index 00000000000..842a904c61e --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.test.tsx @@ -0,0 +1,371 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CloudAuditEvent } from '../../../../../shared/cloud-api/cloud-api-types' +import { + EnterpriseSettingsView as RawView, + type EnterpriseSettingsViewProps +} from './EnterpriseSettingsView' +import { FIXTURE_EVENTS, FIXTURE_NOW, fixtureAuditProps } from './enterprise-audit.fixtures' +import { auditCategoryLabel, auditEventSentence, isKnownAuditAction } from './enterprise-audit-copy' + +afterEach(cleanup) + +/** The view as the pane feeds it: the audit log's answer says which side of the plan the team is on. */ +function EnterpriseSettingsView( + props: Omit & { ssoIncluded?: boolean | null } +): React.JSX.Element { + const status = props.audit.log.status + const derived = status === 'ok' ? true : status === 'upsell' ? false : null + return ( + + ) +} + +const EMPTY = { + status: 'ok' as const, + events: [], + hasMore: false, + loadingMore: false, + moreError: null, + refreshing: false +} + +function rows(container: HTMLElement): string[] { + return [...container.querySelectorAll('ul[aria-label="Audit log"] > li')].map((row) => + [...row.children].map((cell) => cell.textContent).join(' | ') + ) +} + +describe('audit log', () => { + it('lists who did what to what and when, newest first, from every source', () => { + const { container } = render() + expect(rows(container)).toEqual([ + 'Changed secret STRIPE_SECRET_KEY | Secrets | Grace Hopper | 4 minutes ago', + 'Changed the role of Grace Hopper | Members | You | 38 minutes ago', + 'Shared a link to a port on workspace “checkout-redesign” | Workspaces | Grace Hopper | 3 hours ago', + 'Started workspace “nightly-evals” | Workspaces | Grace Hopper via API key | 5 hours ago', + 'Removed Linus Park from the team | Members | You | yesterday', + 'Turned off a routine | Routines | Orca | yesterday', + 'Created API key “GitHub Actions deploy” | API keys | Linus Park · Former member | 2 days ago', + 'Added secret ANTHROPIC_API_KEY | Secrets | You | 3 days ago', + 'Changed agent access | Settings | You | 6 days ago', + 'Connected an app | Connected apps | Grace Hopper via MCP | 9 days ago' + ]) + }) + + it('never renders a secret value, hint or raw user id inline; the id stays one hover away', () => { + const leaky: CloudAuditEvent = { + ...FIXTURE_EVENTS[0]!, + detail: { scope: 'org', hint: 'wxyz', value: 'sk-live-should-not-show' } + } + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/wxyz|should-not-show/) + expect(container.textContent).not.toContain('u_linus') + expect(container.querySelector('[title="u_linus"]')).not.toBeNull() + }) + + it('shows an action this version does not know by its machine name instead of hiding it', () => { + const unknown: CloudAuditEvent = { + ...FIXTURE_EVENTS[0]!, + id: 'aud_new', + category: 'security', + action: 'sso.went_live' + } + const { container } = render( + + ) + expect(rows(container)).toEqual(['sso.went_live | Security | Grace Hopper | 4 minutes ago']) + }) + + it('pages with Show more, and keeps the list when a page fails', () => { + const onLoadMore = vi.fn() + const { rerender, container } = render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Show more' })) + expect(onLoadMore).toHaveBeenCalledTimes(1) + rerender( + + ) + // Busy, not disabled: the button keeps focus, and a second click does nothing. + const busy = screen.getByRole('button', { name: 'Show more' }) + expect(busy.getAttribute('aria-busy')).toBe('true') + fireEvent.click(busy) + expect(onLoadMore).toHaveBeenCalledTimes(1) + rerender( + + ) + expect(rows(container)).toHaveLength(FIXTURE_EVENTS.length) + expect(screen.getByText('Couldn’t reach Orca Cloud.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(onLoadMore).toHaveBeenCalledTimes(2) + // The last page has no Show more. + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Show more' })).toBeNull() + }) + + it('offers both filters and the export, and exports once per click', () => { + const onExport = vi.fn() + const { rerender } = render() + expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe( + 'All activity' + ) + expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe('Everyone') + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })) + expect(onExport).toHaveBeenCalledTimes(1) + rerender( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Export CSV' })) + expect(onExport).toHaveBeenCalledTimes(1) + rerender( + + ) + expect(screen.getByRole('status').textContent).toContain('newest 5,000 events') + }) + + it('shows the chosen filters, and an empty filtered list offers the way back', () => { + const onFilter = vi.fn() + render( + + ) + expect(screen.getByRole('combobox', { name: 'Filter by activity' }).textContent).toBe('Secrets') + expect(screen.getByRole('combobox', { name: 'Filter by person' }).textContent).toBe( + 'Grace Hopper' + ) + expect(screen.getByText('Nothing matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })) + expect(onFilter).toHaveBeenCalledWith({ category: null, actor: null }) + // Nothing to export. + expect(screen.getByRole('button', { name: 'Export CSV' }).hasAttribute('disabled')).toBe(true) + }) + + it('says nothing is recorded yet when the log is empty and unfiltered', () => { + render() + expect(screen.getByText('Nothing recorded yet')).toBeTruthy() + expect(screen.queryByRole('combobox')).toBeNull() + expect(screen.queryByRole('button', { name: 'Clear filters' })).toBeNull() + }) + + it('words a load failure and retries', () => { + const onRetry = vi.fn() + render( + + ) + expect(screen.getByText('Orca Cloud is down.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull() + }) +}) + +describe('without the Enterprise plan', () => { + it('a team with neither gets ONE Contact sales, in a banner, and a plain line in each block', () => { + const onContactSales = vi.fn() + const { container } = render( + + ) + expect(screen.getByText('Single sign-on and the audit log are part of Enterprise')).toBeTruthy() + // The blocks keep their titles and say what they are, without a second button. + expect(screen.getByText('Single sign-on and provisioning')).toBeTruthy() + expect(screen.getByText('Audit log')).toBeTruthy() + expect(container.textContent).toContain('so your full history is here from the day you upgrade') + expect(screen.queryByText('Audit log is part of Enterprise')).toBeNull() + expect(rows(container)).toEqual([]) + expect(screen.queryByRole('combobox')).toBeNull() + expect(screen.queryByRole('button', { name: 'Export CSV' })).toBeNull() + const contact = screen.getAllByRole('button', { name: 'Contact sales' }) + expect(contact).toHaveLength(1) + fireEvent.click(contact[0]!) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + // The Copy link chevron sits beside it (the open-in-browser rule). + expect(screen.getAllByRole('button', { name: 'More link options' })).toHaveLength(1) + }) + + it('a team missing only the audit log keeps the button on that block, with no banner', () => { + const onContactSales = vi.fn() + render( + + ) + expect(screen.queryByText('Single sign-on and the audit log are part of Enterprise')).toBeNull() + expect(screen.getByText('Audit log is part of Enterprise')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact sales' })) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + }) + + it("uses billing's own plan name and contact page", () => { + const onContactSales = vi.fn() + render( + + ) + expect(screen.getByText('Single sign-on and the audit log are part of Scale')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact sales' })) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/scale') + }) +}) + +describe('on the Enterprise plan', () => { + it('never sends the team to sales: single sign-on is set up with us, with Contact us', () => { + const onContactSales = vi.fn() + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/part of Enterprise|Contact sales/) + expect(screen.getByText('Single sign-on is set up with us')).toBeTruthy() + expect(screen.getByText('To set up or change single sign-on, contact us.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Contact us' })) + expect(onContactSales).toHaveBeenCalledWith('https://orca.dev/enterprise') + }) + + it('says nothing about single sign-on until the plan is known', () => { + const { container } = render( + + ) + expect(container.textContent).not.toMatch(/Single sign-on|Contact/) + expect(screen.getByText('Audit log')).toBeTruthy() + }) +}) + +describe('slots', () => { + it('lets the SSO and policy owners replace or add their blocks, in order above the audit log', () => { + const { container } = render( + SSO} + policies={
Require MFA
} + /> + ) + expect(screen.queryByText('Single sign-on is set up with us')).toBeNull() + const order = [...container.querySelectorAll('[id^="cloud-enterprise-"]')].map( + (node) => node.id + ) + expect(order).toEqual([ + 'cloud-enterprise-sso', + 'cloud-enterprise-policies', + 'cloud-enterprise-audit-log' + ]) + }) +}) + +describe('copy', () => { + it('names every category and words every action the control plane records today', () => { + expect(auditCategoryLabel('api_keys')).toBe('API keys') + const recorded = [ + 'api_key.create', + 'api_key.revoke', + 'workspace_token.issue', + 'settings.update', + 'workspace.create', + 'workspace.destroy', + 'workspace.sleep', + 'workspace.wake', + 'workspace.archive', + 'workspace.unarchive', + 'workspace.rename', + 'workspace.share_create', + 'workspace.share_revoke', + 'workspace.message', + 'workspace.attachment', + 'workspace.session.create', + 'workspace.session.cancel', + 'workspace.session.rename', + 'sandbox.ssh_key_added', + 'sandbox.ssh_key_removed', + 'project.create', + 'project.update', + 'project.delete', + 'mcp.approval_create', + 'mcp.authorize', + 'mcp.grant_revoke', + 'mcp.code_replay', + 'mcp.refresh_replay', + 'secret.created', + 'secret.updated', + 'secret.deleted', + 'member.removed', + 'member.left', + 'member.role_changed', + 'member.role_synced', + 'routine.create', + 'routine.update', + 'routine.delete', + 'routine.disable', + 'routine.run', + 'routine.run.start', + 'routine.run.finish', + 'routine.rotate_webhook' + ] + expect(recorded.filter((action) => !isKnownAuditAction(action))).toEqual([]) + }) + + it('falls back to the plain sentence when the target has no name', () => { + const event: CloudAuditEvent = { + id: 'a', + at: FIXTURE_NOW, + category: 'members', + action: 'member.removed', + actor: { kind: 'system', userId: null, name: null, via: null }, + target: { type: 'member', id: 'u_x', label: null }, + detail: null + } + expect(auditEventSentence(event)).toBe('Removed a teammate') + expect( + auditEventSentence({ ...event, target: { type: 'member', id: 'u_x', label: 'Bo' } }) + ).toBe('Removed Bo from the team') + }) +}) diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx new file mode 100644 index 00000000000..b6ce0ee72ea --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseSettingsView.tsx @@ -0,0 +1,123 @@ +// Settings › Enterprise, for a team's owners and admins: single sign-on and directory sync, the +// team's sign-in policies, and the audit log. Each block shows its own controls when the team's +// plan includes it and the shared upsell (EnterpriseUpsell) when it does not. +// +// Contact sales appears once per page: a team with neither single sign-on nor the audit log gets +// one banner at the top and a plain line in each block. A team on Enterprise is never sent to sales. +// +// Slots for the blocks other owners build (pass a node; nothing renders until one is passed): +// singleSignOn replaces the built-in single sign-on block (sso-scim) +// policies sign-in policies, e.g. "Require MFA" (mfa) +import { ExternalLink } from 'lucide-react' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { useTranslate } from '@/i18n/use-translate' +import { SettingsGroup } from '../section-layout/SettingsGroup' +import { EnterpriseAuditLog, type EnterpriseAuditLogProps } from './EnterpriseAuditLog' +import { EnterpriseUpsell, EnterpriseUpsellLine } from './EnterpriseUpsell' + +export const ENTERPRISE_SSO_ANCHOR = 'cloud-enterprise-sso' +export const ENTERPRISE_POLICIES_ANCHOR = 'cloud-enterprise-policies' +export const ENTERPRISE_AUDIT_LOG_ANCHOR = 'cloud-enterprise-audit-log' + +export type EnterpriseSettingsViewProps = { + audit: EnterpriseAuditLogProps + /** + * Whether the team's plan includes single sign-on: true, false, or null while that is not known + * yet (the block waits rather than guess). Ignored once `singleSignOn` is passed. + */ + ssoIncluded: boolean | null + singleSignOn?: React.ReactNode + policies?: React.ReactNode +} + +export function EnterpriseSettingsView({ + audit, + ssoIncluded, + singleSignOn, + policies +}: EnterpriseSettingsViewProps): React.JSX.Element { + const t = useTranslate() + const { contact, onContactSales } = audit + const ssoLocked = singleSignOn === undefined && ssoIncluded === false + const auditLocked = audit.log.status === 'upsell' + // Neither: one banner carries the page's only Contact sales. + const banner = ssoLocked && auditLocked + const ssoUpsellBody = t( + 'settings.enterprise.sso.upsellBody', + 'Sign in with your identity provider over SAML, and add or remove teammates with SCIM.' + ) + return ( +
+ {banner ? ( + + + + ) : null} + {singleSignOn !== undefined ? ( +
{singleSignOn}
+ ) : ssoIncluded === null ? null : ( +
+ + {ssoIncluded ? ( + // On the plan: never an upsell. We set it up with the team until they can do it here. +
+
+

+ {t('settings.enterprise.sso.managedTitle', 'Single sign-on is set up with us')} +

+

+ {t( + 'settings.enterprise.sso.managedBody', + 'To set up or change single sign-on, contact us.' + )} +

+
+ onContactSales(contact.contactUrl)} + > + {t('settings.enterprise.contactUs', 'Contact us')} + + +
+ ) : banner ? ( + {ssoUpsellBody} + ) : ( + + )} +
+
+ )} + {policies ?
{policies}
: null} +
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx new file mode 100644 index 00000000000..4f85dd14f16 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/EnterpriseUpsell.tsx @@ -0,0 +1,56 @@ +// The one locked state for everything on Settings › Enterprise: what the feature is, that it is +// part of Enterprise, and Contact sales. The button opens the page; its chevron copies the link. +import { ExternalLink } from 'lucide-react' +import { OpenExternalSplitButton } from '@/components/open-external/OpenExternalSplitButton' +import { useTranslate } from '@/i18n/use-translate' + +/** Where Contact sales goes when billing has not answered yet (billing's own Enterprise page). */ +export const ENTERPRISE_CONTACT_URL = 'https://orca.dev/enterprise' + +/** Billing's name and contact page for the plan, so the upsell never drifts from the plans page. */ +export type EnterpriseContact = { planName: string; contactUrl: string } + +export function EnterpriseUpsell({ + title, + body, + contact, + onContactSales +}: { + /** "Audit log is part of Enterprise". */ + title: string + body: string + contact: EnterpriseContact + onContactSales: (url: string) => void +}): React.JSX.Element { + const t = useTranslate() + return ( +
+
+

{title}

+

{body}

+
+ onContactSales(contact.contactUrl)} + > + {t('auto.components.billing.enterpriseContactSales', 'Contact sales')} + + +
+ ) +} + +/** The locked state without a button, for a block under the page banner that carries Contact sales. */ +export function EnterpriseUpsellLine({ + children +}: { + children: React.ReactNode +}): React.JSX.Element { + return ( +

+ {children} +

+ ) +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts new file mode 100644 index 00000000000..c5915d61a87 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit-copy.ts @@ -0,0 +1,311 @@ +// Wording for the team audit log: each recorded action as a sentence an admin can scan, and the +// names of the categories they filter by. An action this version does not know is shown by its +// machine name rather than hidden, so nothing recorded is ever missing from the log. +import { translate } from '@/i18n/i18n' +import type { Translate } from '@/i18n/use-translate' +import type { + CloudAuditCategory, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' + +export function auditCategoryLabel(category: CloudAuditCategory, t: Translate = translate): string { + switch (category) { + case 'members': + return t('settings.enterprise.audit.category.members', 'Members') + case 'security': + return t('settings.enterprise.audit.category.security', 'Security') + case 'api_keys': + return t('settings.enterprise.audit.category.apiKeys', 'API keys') + case 'apps': + return t('settings.enterprise.audit.category.apps', 'Connected apps') + case 'workspaces': + return t('settings.enterprise.audit.category.workspaces', 'Workspaces') + case 'secrets': + return t('settings.enterprise.audit.category.secrets', 'Secrets') + case 'routines': + return t('settings.enterprise.audit.category.routines', 'Routines') + case 'settings': + return t('settings.enterprise.audit.category.settings', 'Settings') + case 'billing': + return t('settings.enterprise.audit.category.billing', 'Billing') + case 'other': + return t('settings.enterprise.audit.category.other', 'Other') + } +} + +type Sentence = (t: Translate) => string +/** `named` takes the thing's name ({{name}}): a key, a secret, a teammate, a workspace. */ +type ActionCopy = { plain: Sentence; named?: (t: Translate, name: string) => string } + +const ACTIONS: Record = { + 'api_key.create': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyCreate', 'Created API key'), + named: (t, name) => + t('settings.enterprise.audit.action.keyCreateNamed', 'Created API key “{{name}}”', { name }) + }, + 'api_key.revoke': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.keyRevoke', 'Revoked API key'), + named: (t, name) => + t('settings.enterprise.audit.action.keyRevokeNamed', 'Revoked API key “{{name}}”', { name }) + }, + 'workspace_token.issue': { + plain: (t) => + t('settings.enterprise.audit.action.workspaceToken', 'Gave an agent access to the Orca API') + }, + 'settings.update': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.settings', 'Changed agent access') + }, + 'workspace.create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsCreate', 'Started a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsCreateNamed', 'Started workspace “{{name}}”', { name }) + }, + 'workspace.destroy': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsDestroy', 'Deleted a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsDestroyNamed', 'Deleted workspace “{{name}}”', { name }) + }, + 'workspace.sleep': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsSleep', 'Put a workspace to sleep'), + named: (t, name) => + t('settings.enterprise.audit.action.wsSleepNamed', 'Put workspace “{{name}}” to sleep', { + name + }) + }, + 'workspace.wake': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsWake', 'Woke a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsWakeNamed', 'Woke workspace “{{name}}”', { name }) + }, + 'workspace.archive': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.wsArchive', 'Archived a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsArchiveNamed', 'Archived workspace “{{name}}”', { + name + }) + }, + 'workspace.unarchive': { + plain: (t) => t('settings.enterprise.audit.action.wsUnarchive', 'Restored a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsUnarchiveNamed', 'Restored workspace “{{name}}”', { + name + }) + }, + 'workspace.rename': { + plain: (t) => t('settings.enterprise.audit.action.wsRename', 'Renamed a workspace'), + named: (t, name) => + t('settings.enterprise.audit.action.wsRenameNamed', 'Renamed a workspace to “{{name}}”', { + name + }) + }, + 'workspace.share_create': { + plain: (t) => + t('settings.enterprise.audit.action.wsShare', 'Shared a link to a workspace port'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.wsShareNamed', + 'Shared a link to a port on workspace “{{name}}”', + { name } + ) + }, + 'workspace.share_revoke': { + plain: (t) => + t('settings.enterprise.audit.action.wsUnshare', 'Turned off a workspace share link'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.wsUnshareNamed', + 'Turned off a share link on workspace “{{name}}”', + { name } + ) + }, + 'workspace.message': { + plain: (t) => t('auto.components.settings.cloudApi.audit.action.wsMessage', 'Messaged an agent') + }, + 'workspace.attachment': { + plain: (t) => t('settings.enterprise.audit.action.wsAttachment', 'Sent a file to an agent') + }, + 'workspace.session.create': { + plain: (t) => t('settings.enterprise.audit.action.sessionCreate', 'Started an agent') + }, + 'workspace.session.cancel': { + plain: (t) => t('settings.enterprise.audit.action.sessionCancel', 'Stopped an agent') + }, + 'workspace.session.rename': { + plain: (t) => t('settings.enterprise.audit.action.sessionRename', 'Renamed an agent session') + }, + 'sandbox.ssh_key_added': { + plain: (t) => t('settings.enterprise.audit.action.sshAdd', 'Added an SSH key to a workspace'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.sshAddNamed', + 'Added an SSH key to workspace “{{name}}”', + { + name + } + ) + }, + 'sandbox.ssh_key_removed': { + plain: (t) => + t('settings.enterprise.audit.action.sshRemove', 'Removed an SSH key from a workspace'), + named: (t, name) => + t( + 'settings.enterprise.audit.action.sshRemoveNamed', + 'Removed an SSH key from workspace “{{name}}”', + { name } + ) + }, + 'project.create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectCreate', 'Created a project'), + named: (t, name) => + t('settings.enterprise.audit.action.projectCreateNamed', 'Created project “{{name}}”', { + name + }) + }, + 'project.update': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectUpdate', 'Edited a project') + }, + 'project.delete': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.projectDelete', 'Deleted a project') + }, + 'mcp.approval_create': { + plain: (t) => + t('auto.components.settings.cloudApi.audit.action.approval', 'Made an app approval code') + }, + 'mcp.authorize': { + plain: (t) => t('settings.enterprise.audit.action.mcpAuthorize', 'Connected an app') + }, + 'mcp.grant_revoke': { + plain: (t) => t('settings.enterprise.audit.action.mcpRevoke', 'Disconnected an app') + }, + 'mcp.code_replay': { + plain: (t) => + t( + 'settings.enterprise.audit.action.mcpCodeReplay', + 'Blocked a reused app sign-in code and disconnected the app' + ) + }, + 'mcp.refresh_replay': { + plain: (t) => + t( + 'settings.enterprise.audit.action.mcpRefreshReplay', + 'Blocked a reused app token and disconnected the app' + ) + }, + 'secret.created': { + plain: (t) => t('settings.enterprise.audit.action.secretCreate', 'Added a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretCreateNamed', 'Added secret {{name}}', { name }) + }, + 'secret.updated': { + plain: (t) => t('settings.enterprise.audit.action.secretUpdate', 'Changed a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretUpdateNamed', 'Changed secret {{name}}', { name }) + }, + 'secret.deleted': { + plain: (t) => t('settings.enterprise.audit.action.secretDelete', 'Deleted a secret'), + named: (t, name) => + t('settings.enterprise.audit.action.secretDeleteNamed', 'Deleted secret {{name}}', { name }) + }, + 'member.invited': { + plain: (t) => t('settings.enterprise.audit.action.memberInvite', 'Invited a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberInviteNamed', 'Invited {{name}}', { name }) + }, + 'member.invite_revoked': { + plain: (t) => t('settings.enterprise.audit.action.inviteRevoke', 'Canceled an invite'), + named: (t, name) => + t('settings.enterprise.audit.action.inviteRevokeNamed', 'Canceled the invite for {{name}}', { + name + }) + }, + 'member.removed': { + plain: (t) => t('settings.enterprise.audit.action.memberRemove', 'Removed a teammate'), + named: (t, name) => + t('settings.enterprise.audit.action.memberRemoveNamed', 'Removed {{name}} from the team', { + name + }) + }, + 'member.left': { + plain: (t) => t('settings.enterprise.audit.action.memberLeft', 'Left the team') + }, + 'member.role_changed': { + plain: (t) => t('settings.enterprise.audit.action.roleChange', 'Changed a teammate’s role'), + named: (t, name) => + t('settings.enterprise.audit.action.roleChangeNamed', 'Changed the role of {{name}}', { + name + }) + }, + 'member.role_synced': { + plain: (t) => + t( + 'settings.enterprise.audit.action.roleSync', + 'Updated a teammate’s API keys to match their role' + ) + }, + 'team.created': { + plain: (t) => t('settings.enterprise.audit.action.teamCreate', 'Created the team') + }, + 'team.renamed': { + plain: (t) => t('settings.enterprise.audit.action.teamRename', 'Renamed the team'), + named: (t, name) => + t('settings.enterprise.audit.action.teamRenameNamed', 'Renamed the team to “{{name}}”', { + name + }) + }, + 'routine.create': { + plain: (t) => t('settings.enterprise.audit.action.routineCreate', 'Created a routine') + }, + 'routine.update': { + plain: (t) => t('settings.enterprise.audit.action.routineUpdate', 'Edited a routine') + }, + 'routine.delete': { + plain: (t) => t('settings.enterprise.audit.action.routineDelete', 'Deleted a routine') + }, + 'routine.disable': { + plain: (t) => t('settings.enterprise.audit.action.routineDisable', 'Turned off a routine') + }, + 'routine.run': { + plain: (t) => t('settings.enterprise.audit.action.routineRun', 'Ran a routine') + }, + 'routine.run.start': { + plain: (t) => t('settings.enterprise.audit.action.routineRunStart', 'A routine run started') + }, + 'routine.run.finish': { + plain: (t) => t('settings.enterprise.audit.action.routineRunFinish', 'A routine run finished') + }, + 'routine.rotate_webhook': { + plain: (t) => + t('settings.enterprise.audit.action.routineWebhook', 'Replaced a routine’s webhook URL') + } +} + +/** The name to put in the sentence: what the control plane labelled the target, else a recorded name. */ +function targetName(event: CloudAuditEvent): string | null { + const recorded = event.detail?.name + const name = event.target?.label ?? (typeof recorded === 'string' ? recorded : null) + const clean = name?.trim() + return clean ? clean.slice(0, 80) : null +} + +/** "Created API key “CI deploy”"; an unknown action reads as its machine name. */ +export function auditEventSentence(event: CloudAuditEvent, t: Translate = translate): string { + const copy = ACTIONS[event.action] + if (!copy) { + return event.action + } + const name = targetName(event) + return name && copy.named ? copy.named(t, name) : copy.plain(t) +} + +/** Whether this version words the action (the rest show their machine name). */ +export function isKnownAuditAction(action: string): boolean { + return action in ACTIONS +} diff --git a/src/renderer/src/components/settings/enterprise/enterprise-audit.fixtures.ts b/src/renderer/src/components/settings/enterprise/enterprise-audit.fixtures.ts new file mode 100644 index 00000000000..96e5206f993 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/enterprise-audit.fixtures.ts @@ -0,0 +1,152 @@ +// Sample audit log for the Enterprise settings fixtures and tests: one event from every source the +// control plane merges (API keys, secrets, members, workspaces, settings, apps), by current +// teammates, a former teammate, an API key, an agent and Orca itself. Names only, never values. +import type { + CloudAuditActor, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import type { EnterpriseAuditLogProps } from './EnterpriseAuditLog' + +export const FIXTURE_NOW = Date.UTC(2026, 10, 12, 17, 30) +const MIN = 60_000 +const HOUR = 60 * MIN +const DAY = 24 * HOUR + +const ADA: CloudAuditActor = { kind: 'person', userId: 'u_ada', name: 'Ada Lovelace', via: null } +const GRACE: CloudAuditActor = { + kind: 'person', + userId: 'u_grace', + name: 'Grace Hopper', + via: null +} +const LINUS: CloudAuditActor = { + kind: 'former_member', + userId: 'u_linus', + name: 'Linus Park', + via: null +} +const SYSTEM: CloudAuditActor = { kind: 'system', userId: null, name: null, via: null } + +export const FIXTURE_ACTORS: CloudAuditActor[] = [ADA, GRACE, LINUS] + +export const FIXTURE_EVENTS: CloudAuditEvent[] = [ + { + id: 'sev_9', + at: FIXTURE_NOW - 4 * MIN, + category: 'secrets', + action: 'secret.updated', + actor: GRACE, + target: { type: 'secret', id: 'STRIPE_SECRET_KEY', label: 'STRIPE_SECRET_KEY' }, + detail: { scope: 'org' } + }, + { + id: 'aud_8', + at: FIXTURE_NOW - 38 * MIN, + category: 'members', + action: 'member.role_changed', + actor: ADA, + target: { type: 'member', id: 'u_grace', label: 'Grace Hopper' }, + detail: { role: 'admin' } + }, + { + id: 'aud_7', + at: FIXTURE_NOW - 3 * HOUR, + category: 'workspaces', + action: 'workspace.share_create', + actor: GRACE, + target: { type: 'workspace', id: 'sbx_7Hq2', label: 'checkout-redesign' }, + detail: { port: 3000, audience: 'team', expiresIn: '1d' } + }, + { + id: 'aud_6', + at: FIXTURE_NOW - 5 * HOUR, + category: 'workspaces', + action: 'workspace.create', + actor: { ...GRACE, via: 'api_key' }, + target: { type: 'workspace', id: 'sbx_2Lm9', label: 'nightly-evals' }, + detail: null + }, + { + id: 'aud_5', + at: FIXTURE_NOW - 1 * DAY, + category: 'members', + action: 'member.removed', + actor: ADA, + target: { type: 'member', id: 'u_linus', label: 'Linus Park' }, + detail: { keysRevoked: 2, devicesRevoked: 1 } + }, + { + id: 'aud_4', + at: FIXTURE_NOW - 1 * DAY - 2 * MIN, + category: 'routines', + action: 'routine.disable', + actor: SYSTEM, + target: { type: 'routine', id: 'rtn_41', label: null }, + detail: { reason: 'member_removed' } + }, + { + id: 'aud_3', + at: FIXTURE_NOW - 2 * DAY, + category: 'api_keys', + action: 'api_key.create', + actor: LINUS, + target: { type: 'api_key', id: 'key_ci', label: 'GitHub Actions deploy' }, + detail: { name: 'GitHub Actions deploy' } + }, + { + id: 'sev_2', + at: FIXTURE_NOW - 3 * DAY, + category: 'secrets', + action: 'secret.created', + actor: ADA, + target: { type: 'secret', id: 'ANTHROPIC_API_KEY', label: 'ANTHROPIC_API_KEY' }, + detail: { scope: 'repo', repo: 'github.com/acme/web' } + }, + { + id: 'aud_1', + at: FIXTURE_NOW - 6 * DAY, + category: 'settings', + action: 'settings.update', + actor: ADA, + target: { type: 'account', id: 'org:acme', label: null }, + detail: { vmCliEnabled: true } + }, + { + id: 'aud_0', + at: FIXTURE_NOW - 9 * DAY, + category: 'apps', + action: 'mcp.authorize', + actor: { ...GRACE, via: 'mcp' }, + target: { type: 'oauth_client', id: 'cl_4q', label: null }, + detail: null + } +] + +const noop = (): void => {} + +export function fixtureAuditProps( + patch: Partial = {} +): EnterpriseAuditLogProps { + return { + log: { + status: 'ok', + events: FIXTURE_EVENTS, + hasMore: true, + loadingMore: false, + moreError: null, + refreshing: false + }, + filters: { category: null, actor: null }, + actors: FIXTURE_ACTORS, + exportState: { status: 'idle' }, + now: FIXTURE_NOW, + viewerUserId: 'u_ada', + contact: { planName: 'Enterprise', contactUrl: 'https://orca.dev/enterprise' }, + onFilter: noop, + onLoadMore: noop, + onRetry: noop, + onExport: noop, + onContactSales: noop, + ...patch + } +} diff --git a/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts b/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts new file mode 100644 index 00000000000..8e2be99ccb5 --- /dev/null +++ b/src/renderer/src/components/settings/enterprise/use-enterprise-audit-log.ts @@ -0,0 +1,196 @@ +// Loads the team audit log for Settings › Enterprise: the first page, more pages, filter changes +// and the CSV export. The control plane decides access; `plan-required` becomes the upsell. +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslate } from '@/i18n/use-translate' +import type { + CloudAuditActor, + CloudAuditEvent +} from '../../../../../shared/cloud-api/cloud-api-types' +import { cloudApiErrorCopy } from '../cloud-api/cloud-api-copy' +import type { CloudApiFailure } from '../cloud-api/cloud-load-state' +import { useRequestGeneration } from '../cloud-api/use-request-generation' +import type { AuditLogExportState, AuditLogFilters, AuditLogState } from './EnterpriseAuditLog' + +const PAGE_SIZE = 50 +const NO_FILTERS: AuditLogFilters = { category: null, actor: null } + +function queryFor(filters: AuditLogFilters): { + category?: NonNullable + actor?: string +} { + return { + ...(filters.category ? { category: filters.category } : {}), + ...(filters.actor ? { actor: filters.actor } : {}) + } +} + +/** Hands the CSV to the browser's download path (Electron shows its save dialog). */ +export function saveAuditCsv(csv: string, at: number): void { + // A BOM so spreadsheet apps read names with accents as UTF-8. + const url = URL.createObjectURL(new Blob(['', csv], { type: 'text/csv;charset=utf-8' })) + const link = document.createElement('a') + link.href = url + link.download = `orca-audit-log-${new Date(at).toISOString().slice(0, 10)}.csv` + document.body.append(link) + link.click() + link.remove() + // Revoked after the click has been handled, or the download can start from a dead URL. + window.setTimeout(() => URL.revokeObjectURL(url), 10_000) +} + +export function useEnterpriseAuditLog(enabled: boolean): { + log: AuditLogState + filters: AuditLogFilters + actors: CloudAuditActor[] + exportState: AuditLogExportState + now: number + setFilters: (filters: AuditLogFilters) => void + loadMore: () => void + retry: () => void + exportCsv: () => void +} { + const t = useTranslate() + const api = window.api.cloudApi + const begin = useRequestGeneration() + const [log, setLog] = useState({ status: 'loading' }) + const [filters, setFiltersState] = useState(NO_FILTERS) + const [actors, setActors] = useState([]) + const [exportState, setExportState] = useState({ status: 'idle' }) + const [now, setNow] = useState(() => Date.now()) + const cursor = useRef(null) + const loaded = useRef([]) + + const word = useCallback( + (failure: CloudApiFailure) => cloudApiErrorCopy(failure.error, t, failure), + [t] + ) + + const loadFirst = useCallback( + async (next: AuditLogFilters): Promise => { + if (!api) { + return + } + const isCurrent = begin('audit-log') + // A filter change keeps the list on screen until the new one arrives. + setLog((current) => + current.status === 'ok' ? { ...current, refreshing: true } : { status: 'loading' } + ) + const result = await api.listAuditLog({ ...queryFor(next), limit: PAGE_SIZE }) + if (!isCurrent()) { + return + } + setNow(Date.now()) + if (!result.ok) { + cursor.current = null + loaded.current = [] + setLog( + result.error === 'plan-required' + ? { status: 'upsell' } + : { status: 'error', message: word(result) } + ) + return + } + cursor.current = result.data.nextCursor + loaded.current = result.data.events + if (result.data.actors) { + setActors(result.data.actors) + } + setLog({ + status: 'ok', + events: result.data.events, + hasMore: result.data.nextCursor !== null, + loadingMore: false, + moreError: null, + refreshing: false + }) + }, + [api, begin, word] + ) + + useEffect(() => { + if (enabled) { + void loadFirst(NO_FILTERS) + } + }, [enabled, loadFirst]) + + const setFilters = useCallback( + (next: AuditLogFilters): void => { + setFiltersState(next) + setExportState({ status: 'idle' }) + void loadFirst(next) + }, + [loadFirst] + ) + + const loadMore = useCallback((): void => { + const after = cursor.current + if (!api || !after) { + return + } + const isCurrent = begin('audit-log') + setLog((current) => + current.status === 'ok' ? { ...current, loadingMore: true, moreError: null } : current + ) + void api + .listAuditLog({ ...queryFor(filters), cursor: after, limit: PAGE_SIZE }) + .then((result) => { + if (!isCurrent()) { + return + } + if (!result.ok) { + setLog((current) => + current.status === 'ok' + ? { ...current, loadingMore: false, moreError: word(result) } + : current + ) + return + } + cursor.current = result.data.nextCursor + // A new event can shift a page boundary; never show one twice. + const seen = new Set(loaded.current.map((event) => event.id)) + loaded.current = [ + ...loaded.current, + ...result.data.events.filter((event) => !seen.has(event.id)) + ] + setLog({ + status: 'ok', + events: loaded.current, + hasMore: result.data.nextCursor !== null, + loadingMore: false, + moreError: null, + refreshing: false + }) + }) + }, [api, begin, filters, word]) + + const exportCsv = useCallback((): void => { + if (!api) { + return + } + const isCurrent = begin('audit-export') + setExportState({ status: 'exporting' }) + void api.exportAuditLogCsv(queryFor(filters)).then((result) => { + if (!isCurrent()) { + return + } + if (!result.ok) { + setExportState({ status: 'error', message: word(result) }) + return + } + saveAuditCsv(result.data.csv, Date.now()) + setExportState({ status: 'done', truncated: result.data.truncated }) + }) + }, [api, begin, filters, word]) + + return { + log, + filters, + actors, + exportState, + now, + setFilters, + loadMore, + retry: () => void loadFirst(filters), + exportCsv + } +} diff --git a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts index 7dcc261b2b9..5cea2db888b 100644 --- a/src/renderer/src/components/settings/navigation/settings-navigation-types.ts +++ b/src/renderer/src/components/settings/navigation/settings-navigation-types.ts @@ -48,6 +48,7 @@ const SETTINGS_NAV_TARGETS = [ 'orca-account', 'cloud-billing', 'cloud-team', + 'cloud-enterprise', 'cloud-secrets', 'cloud-environments', 'cloud-mcp', diff --git a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx index 064d183cd34..76147988a74 100644 --- a/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx +++ b/src/renderer/src/components/settings/orca-cloud/OrcaCloudSettingsPanes.tsx @@ -13,6 +13,7 @@ import { } from '../../cloud-environment/CloudEnvironmentSection' import { OrcaSignInButton, OrcaSignInWaitNotice } from '../../OrcaSignInProgress' import { CloudApiEmptyState } from '../cloud-api/cloud-api-parts' +import { EnterpriseSettingsPane } from '../enterprise/EnterpriseSettingsPane' import { CloudSecretsSection } from '../cloud-secrets/CloudSecretsSection' import { SettingsGroup } from '../section-layout/SettingsGroup' import { CloudPredictiveWakeSetting } from './CloudPredictiveWakeSetting' @@ -104,6 +105,11 @@ function OrcaCloudAccountGate({ ) } +/** Settings › Enterprise: single sign-on, sign-in policies and the team audit log. */ +export function OrcaCloudEnterprisePane(): React.JSX.Element { + return {() => } +} + export function OrcaCloudTeamPane(): React.JSX.Element { const t = useTranslate() return ( diff --git a/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts b/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts index 25c9981a208..b1ca6546f97 100644 --- a/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts +++ b/src/renderer/src/components/settings/orca-cloud/orca-cloud-settings-search.ts @@ -34,6 +34,24 @@ export const getOrcaCloudTeamSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getOrcaCloudEnterpriseSearchEntries = createLocalizedCatalog(() => [ + { + title: translate('settings.enterprise.title', 'Enterprise'), + description: translate( + 'settings.enterprise.searchDescription', + 'Single sign-on and directory sync, and the audit log of changes across your team.' + ), + keywords: [ + ...translateSearchKeyword('settings.enterprise.keywordAudit', 'audit log'), + ...translateSearchKeyword('settings.enterprise.keywordActivity', 'activity'), + ...translateSearchKeyword('settings.enterprise.keywordSso', 'sso'), + ...translateSearchKeyword('settings.enterprise.keywordSaml', 'saml'), + ...translateSearchKeyword('settings.enterprise.keywordScim', 'scim'), + ...translateSearchKeyword('settings.enterprise.keywordCompliance', 'compliance') + ] + } +]) + export const getOrcaCloudSecretsSearchEntries = createLocalizedCatalog(() => [ { title: translate('cloudSecrets.title', 'Cloud secrets'), diff --git a/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx b/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx index fc73b9264fe..d4d004e20e2 100644 --- a/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx +++ b/src/renderer/src/components/settings/page/settings-orca-cloud-section-renderers.tsx @@ -1,4 +1,5 @@ import { + OrcaCloudEnterprisePane, OrcaCloudEnvironmentsPane, OrcaCloudLocalCommandsSettingsPane, OrcaCloudMcpPane, @@ -23,8 +24,10 @@ export function renderOrcaCloudSettingsSections( return null } // Titles and descriptions come from the nav metadata so the pane and the sidebar never disagree. - const sections = [ + const sections: { id: string; pane: React.JSX.Element; navOnly?: boolean }[] = [ { id: 'cloud-team', pane: }, + // Listed in the nav for team owners and admins only; without the entry there is no page. + { id: 'cloud-enterprise', pane: , navOnly: true }, { id: 'cloud-secrets', pane: }, { id: 'cloud-skills', pane: }, { id: 'cloud-environments', pane: }, @@ -33,8 +36,11 @@ export function renderOrcaCloudSettingsSections( ] return ( <> - {sections.map(({ id, pane }) => { + {sections.map(({ id, pane, navOnly }) => { const meta = navigation.navSections.find((section) => section.id === id) + if (navOnly && !meta) { + return null + } return ( { expect(document.activeElement).toBe(button) }) -it('offers an owner See plans then Try again on no plan', () => { +it('offers an owner Compare plans then Try again on no plan', () => { const handlers = actions() render() const buttons = screen.getAllByRole('button').map((button) => button.textContent?.trim()) - expect(buttons.slice(0, 2)).toEqual(['See plans', 'Try again']) + expect(buttons.slice(0, 2)).toEqual(['Compare plans', 'Try again']) fireEvent.click(screen.getByRole('button', { name: /Try again/ })) expect(handlers.onRetry).toHaveBeenCalledOnce() }) @@ -106,7 +106,7 @@ it.each(['plan', 'hoursUsed', 'spendCap', 'payment'] as const)( const handlers = actions() render() expect( - screen.queryByRole('button', { name: /See plans|Raise spend cap|Turn on overage/ }) + screen.queryByRole('button', { name: /Compare plans|Raise spend cap|Turn on overage/ }) ).toBeNull() fireEvent.click(screen.getByRole('button', { name: /Use this computer instead/ })) expect(handlers.onCreateLocally).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx b/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx index 2292e4a3429..826d57bdaf9 100644 --- a/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx +++ b/src/renderer/src/components/worktree-creation/VmBuildSheetGitHub.fixture.tsx @@ -100,6 +100,6 @@ export const refusedPlan = refused('no_subscription') export const refusedHoursUsed = refused('included_hours_exhausted') export const refusedSpendCap = refused('overage_cap_reached') export const refusedPayment = refused('payment_past_due') -// A member can't fix billing: the card names who can, with no "See plans". +// A member can't fix billing: the card names who can, with no "Compare plans". export const refusedPlanMember = refused('no_subscription', false) export const refusedHoursUsedMember = refused('included_hours_exhausted', false) diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts index 4e1ac5825b1..655aff2010e 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-toast.ts @@ -62,7 +62,7 @@ export function showVmBuildFailureToast( ...(failure.kind === 'plan' ? { action: { - label: translate('auto.lib.orcaCloudSession.seePlans', 'See plans'), + label: translate('auto.components.billing.comparePlans', 'Compare plans'), onClick: () => openPlansPage('sandbox') } } diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index f72d0eb58f8..9f6cca5f58a 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -15,6 +15,8 @@ export type SettingsNavigationBuildOptions = { isLinearConnected: boolean /** Orca Cloud programmatic access (API keys, routines): only builds with a control plane. */ isOrcaCloudApiAvailable?: boolean + /** Owner or admin of the active Orca team: Settings › Enterprise is theirs alone. */ + isOrcaTeamAdmin?: boolean repos: readonly Repo[] projectGrouping?: ProjectGroupingModel } diff --git a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts index cb8b4328ccb..f2bc2b3b428 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.test.ts @@ -53,6 +53,33 @@ describe('Orca Cloud settings group', () => { expect(new Set(sections.map((section) => section.group))).toEqual(new Set(['orca-cloud'])) }) + it('adds Enterprise after Team for a team owner or admin only', () => { + const ids = (patch: Parameters[0]) => + buildOrcaCloudSettingsSections(options(patch)).map((section) => section.id) + expect(ids({})).not.toContain('cloud-enterprise') + expect(ids({ isOrcaTeamAdmin: false })).not.toContain('cloud-enterprise') + const admin = ids({ isOrcaTeamAdmin: true }) + expect(admin.slice(0, 4)).toEqual([ + 'cloud-billing', + 'cloud-team', + 'cloud-enterprise', + 'cloud-secrets' + ]) + const section = buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true })).find( + (entry) => entry.id === 'cloud-enterprise' + )! + expect(section).toMatchObject({ title: 'Enterprise', group: 'orca-cloud' }) + // The audit log is found by searching for it; it no longer lives under Orca API keys. + const keywords = (id: string) => + buildOrcaCloudSettingsSections(options({ isOrcaTeamAdmin: true })) + .find((entry) => entry.id === id)! + .searchEntries.flatMap((entry) => entry.keywords ?? []) + expect(keywords('cloud-enterprise')).toContain('audit log') + expect(keywords('cloud-api-keys')).not.toContain('audit log') + // Never without a control plane, whatever the role. + expect(ids({ isOrcaTeamAdmin: true, isOrcaCloudApiAvailable: false })).toEqual([]) + }) + it('is absent without a control plane and on the web client', () => { expect(buildOrcaCloudSettingsSections(options({ isOrcaCloudApiAvailable: false }))).toEqual([]) expect(buildOrcaCloudSettingsSections(options({ isWebClient: true }))).toEqual([]) diff --git a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts index 0d16b505b8e..edc2e5fc797 100644 --- a/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-orca-cloud-sections.ts @@ -4,6 +4,7 @@ import { } from '@/components/settings/cloud-api/cloud-api-search' import { getOrcaCloudBillingSearchEntries, + getOrcaCloudEnterpriseSearchEntries, getOrcaCloudEnvironmentsSearchEntries, getOrcaCloudLocalCommandsSearchEntries, getOrcaCloudMcpSearchEntries, @@ -13,11 +14,15 @@ import { } from '@/components/settings/orca-cloud/orca-cloud-settings-search' import { translate } from '@/i18n/i18n' import type { Translate } from '@/i18n/use-translate' -import type { SettingsNavSection, SettingsNavTarget } from '@/components/settings/navigation/settings-navigation-types' +import type { + SettingsNavSection, + SettingsNavTarget +} from '@/components/settings/navigation/settings-navigation-types' import type { OrcaProfileAuthStatus } from '../../../shared/orca-profiles' import { BookOpenText, Boxes, + Building2, CreditCard, KeySquare, LockKeyhole, @@ -31,7 +36,7 @@ import { CLOUD_ROUTINES_UI_ENABLED } from '../../../shared/cloud-api/cloud-routi /** One home for everything Orca Cloud; only builds with a control plane show it. */ export function buildOrcaCloudSettingsSections( - { isWebClient, isOrcaCloudApiAvailable }: SettingsNavigationBuildOptions, + { isWebClient, isOrcaCloudApiAvailable, isOrcaTeamAdmin }: SettingsNavigationBuildOptions, t: Translate = translate ): SettingsNavSection[] { if (isWebClient || !isOrcaCloudApiAvailable) { @@ -61,6 +66,22 @@ export function buildOrcaCloudSettingsSections( searchEntries: getOrcaCloudTeamSearchEntries(), group }, + // Team owners and admins only; members and personal accounts never see the entry. + ...(isOrcaTeamAdmin + ? [ + { + id: 'cloud-enterprise', + title: t('settings.enterprise.title', 'Enterprise'), + description: t( + 'settings.enterprise.description', + 'Single sign-on, sign-in policies and the audit log for your team.' + ), + icon: Building2, + searchEntries: getOrcaCloudEnterpriseSearchEntries(), + group + } + ] + : []), { id: 'cloud-secrets', title: t('cloudSecrets.title', 'Cloud secrets'), diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 4095d17fec1..ea7f3a896ba 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -15,6 +15,7 @@ import { useWindowsTerminalCapabilities } from '@/runtime/hosts/capabilities/windows-terminal-capabilities' import { defaultScopeHost } from '@/runtime/hosts/selection/default-creation-host' +import { isActiveTeamAdmin } from '@/lib/orca-cloud/active-team-role' import { useAppStore } from '@/store/index' import { useProjectHostSetupProjection } from '@/store/projects/use-project-host-setup-projection' import { @@ -62,6 +63,7 @@ export function buildSettingsNavigationMetadata( isDev = import.meta.env.DEV, isLinearConnected = false, isOrcaCloudApiAvailable = false, + isOrcaTeamAdmin = false, repos, projectGrouping }: { @@ -77,6 +79,7 @@ export function buildSettingsNavigationMetadata( isDev?: boolean isLinearConnected?: boolean isOrcaCloudApiAvailable?: boolean + isOrcaTeamAdmin?: boolean repos: readonly Repo[] projectGrouping?: ProjectGroupingModel }, @@ -112,6 +115,7 @@ export function buildSettingsNavigationMetadata( isDev, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectGrouping } @@ -153,6 +157,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const isWebClient = isWebClientLocation() const isLinearConnected = useLinearProviderConnected() const isOrcaCloudApiAvailable = useOrcaCloudApiAvailable() + const isOrcaTeamAdmin = useAppStore((state) => isActiveTeamAdmin(state.orcaProfileAuthStatus)) const windowsTerminalCapabilityOwnerKey = useWindowsTerminalCapabilityOwnerKey( settings?.activeRuntimeEnvironmentId ) @@ -197,6 +202,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { isDev: import.meta.env.DEV, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectGrouping: { projects: projectHostSetupProjection.projects, @@ -218,6 +224,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, isOrcaCloudApiAvailable, + isOrcaTeamAdmin, repos, projectHostSetupProjection, languageRevision, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index da425f3ca4c..6b0d2b66f8e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1478,6 +1478,124 @@ "title2": "Account & billing", "accountOnly": "Account", "accountOnlyDescription": "Your Orca account and what’s included with it." + }, + "enterprise": { + "audit": { + "actor": { + "system": "Orca", + "youViaAgent": "Your agent", + "personViaAgent": "’s agent", + "youViaRoutine": "Your routine", + "personViaRoutine": "’s routine" + }, + "filter": { + "categoryLabel": "Filter by activity", + "allActivity": "All activity", + "actorLabel": "Filter by person", + "everyone": "Everyone" + }, + "title": "Audit log", + "description": "Member, key, secret, workspace and settings changes across your team. Only owners and admins can see it.", + "export": "Export CSV", + "upsellTitle": "Audit log is part of {{plan}}", + "upsellBody": "Orca records your team’s changes on every plan, so your full history is here from the day you upgrade.", + "loading": "Loading the audit log", + "exportTruncated": "The file has the newest 5,000 events. Filter by activity or person to export the rest.", + "emptyFilteredTitle": "Nothing matches", + "emptyTitle": "Nothing recorded yet", + "emptyFilteredBody": "No recorded change matches these filters.", + "emptyBody": "Changes to members, API keys, secrets, workspaces and settings will show up here.", + "clearFilters": "Clear filters", + "showMore": "Show more", + "category": { + "members": "Members", + "security": "Security", + "apiKeys": "API keys", + "apps": "Connected apps", + "workspaces": "Workspaces", + "secrets": "Secrets", + "routines": "Routines", + "settings": "Settings", + "billing": "Billing", + "other": "Other" + }, + "action": { + "keyCreateNamed": "Created API key “{{name}}”", + "keyRevokeNamed": "Revoked API key “{{name}}”", + "workspaceToken": "Gave an agent access to the Orca API", + "wsCreateNamed": "Started workspace “{{name}}”", + "wsDestroyNamed": "Deleted workspace “{{name}}”", + "wsSleepNamed": "Put workspace “{{name}}” to sleep", + "wsWakeNamed": "Woke workspace “{{name}}”", + "wsArchiveNamed": "Archived workspace “{{name}}”", + "wsUnarchive": "Restored a workspace", + "wsUnarchiveNamed": "Restored workspace “{{name}}”", + "wsRename": "Renamed a workspace", + "wsRenameNamed": "Renamed a workspace to “{{name}}”", + "wsShare": "Shared a link to a workspace port", + "wsShareNamed": "Shared a link to a port on workspace “{{name}}”", + "wsUnshare": "Turned off a workspace share link", + "wsUnshareNamed": "Turned off a share link on workspace “{{name}}”", + "wsAttachment": "Sent a file to an agent", + "sessionCreate": "Started an agent", + "sessionCancel": "Stopped an agent", + "sessionRename": "Renamed an agent session", + "sshAdd": "Added an SSH key to a workspace", + "sshAddNamed": "Added an SSH key to workspace “{{name}}”", + "sshRemove": "Removed an SSH key from a workspace", + "sshRemoveNamed": "Removed an SSH key from workspace “{{name}}”", + "projectCreateNamed": "Created project “{{name}}”", + "mcpAuthorize": "Connected an app", + "mcpRevoke": "Disconnected an app", + "mcpCodeReplay": "Blocked a reused app sign-in code and disconnected the app", + "mcpRefreshReplay": "Blocked a reused app token and disconnected the app", + "secretCreate": "Added a secret", + "secretCreateNamed": "Added secret {{name}}", + "secretUpdate": "Changed a secret", + "secretUpdateNamed": "Changed secret {{name}}", + "secretDelete": "Deleted a secret", + "secretDeleteNamed": "Deleted secret {{name}}", + "memberInvite": "Invited a teammate", + "memberInviteNamed": "Invited {{name}}", + "inviteRevoke": "Canceled an invite", + "inviteRevokeNamed": "Canceled the invite for {{name}}", + "memberRemove": "Removed a teammate", + "memberRemoveNamed": "Removed {{name}} from the team", + "memberLeft": "Left the team", + "roleChange": "Changed a teammate’s role", + "roleChangeNamed": "Changed the role of {{name}}", + "roleSync": "Updated a teammate’s API keys to match their role", + "teamCreate": "Created the team", + "teamRename": "Renamed the team", + "teamRenameNamed": "Renamed the team to “{{name}}”", + "routineCreate": "Created a routine", + "routineUpdate": "Edited a routine", + "routineDelete": "Deleted a routine", + "routineDisable": "Turned off a routine", + "routineRun": "Ran a routine", + "routineRunStart": "A routine run started", + "routineRunFinish": "A routine run finished", + "routineWebhook": "Replaced a routine’s webhook URL" + } + }, + "sso": { + "upsellTitle": "Single sign-on is part of {{plan}}", + "upsellBody": "Sign in with your identity provider over SAML, and add or remove teammates with SCIM.", + "managedTitle": "Single sign-on is set up with us", + "managedBody": "To set up or change single sign-on, contact us." + }, + "title": "Enterprise", + "searchDescription": "Single sign-on and directory sync, and the audit log of changes across your team.", + "keywordAudit": "audit log", + "keywordActivity": "activity", + "keywordSso": "sso", + "keywordSaml": "saml", + "keywordScim": "scim", + "keywordCompliance": "compliance", + "description": "Single sign-on, sign-in policies and the audit log for your team.", + "bannerTitle": "Single sign-on and the audit log are part of {{plan}}", + "bannerBody": "Talk to us about moving your team to {{plan}}.", + "contactUs": "Contact us" } }, "menu": { @@ -2434,7 +2552,6 @@ "backgroundFailedCloud": "A new cloud workspace couldn’t start. Open it in the sidebar to continue." }, "orcaCloudSession": { - "seePlans": "See plans", "workspaceGone": "That workspace is no longer in Orca.", "thisWorkspaceLower": "this workspace", "sandboxRestartSoonDetail": "Cloud sandboxes restart after 24 hours. Files are kept and Orca resumes your agents after the restart.", @@ -9018,19 +9135,26 @@ "adminOnly": "Only team owners and admins can change this." }, "audit": { - "actionNamed": "{{action}} “{{name}}”", - "viaAgent": "Agent in a cloud workspace", "youViaKey": "You via API key", "personViaKey": " via API key", "youViaMcp": "You via MCP", "personViaMcp": " via MCP", "you": "You", - "title": "Recent activity", - "description": "Key, workspace and settings changes across your team. Visible to admins only.", - "hide": "Hide", - "show": "Show activity", - "loading": "Loading activity", - "empty": "Nothing has happened yet." + "action": { + "keyCreate": "Created API key", + "keyRevoke": "Revoked API key", + "settings": "Changed agent access", + "wsCreate": "Started a workspace", + "wsDestroy": "Deleted a workspace", + "wsSleep": "Put a workspace to sleep", + "wsWake": "Woke a workspace", + "wsArchive": "Archived a workspace", + "wsMessage": "Messaged an agent", + "projectCreate": "Created a project", + "projectUpdate": "Edited a project", + "projectDelete": "Deleted a project", + "approval": "Made an app approval code" + } }, "pageErrorDetail": "Keys, connected apps and team settings are unavailable until Orca Cloud answers.", "retry": "Try again", @@ -9104,7 +9228,8 @@ "rateLimited": "Too many requests. Try again in a minute.", "network": "Couldn’t reach Orca Cloud. Check your connection and try again.", "invalidResponse": "Orca Cloud sent an answer this version of Orca does not understand. Update Orca and try again.", - "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment." + "server": "Orca Cloud had a problem and nothing was changed. Try again in a moment.", + "planRequired": "Your team’s plan doesn’t include this." }, "scope": { "workspacesRead": "List and inspect workspaces", @@ -9126,7 +9251,6 @@ "keywordToken": "token", "keywordMcp": "mcp", "keywordCli": "cli", - "keywordAudit": "audit log", "keywordCloud": "cloud", "sectionDescription": "For the Orca Cloud API, the orca CLI and MCP clients. Claude or Codex keys go in Cloud secrets." }, @@ -21332,7 +21456,6 @@ "upgrade": { "askOwner": "Ask an owner or admin to upgrade.", "bodyNoPrice": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop.", - "cta": "See plans", "menuItem": "Upgrade to Orca Cloud", "title": "Upgrade to use Orca Cloud", "body3": "Hosted sandboxes for the whole team. Agents keep running when you close your laptop. {{price}} per seat per month." diff --git a/src/renderer/src/lib/orca-cloud-session-events.ts b/src/renderer/src/lib/orca-cloud-session-events.ts index b2512193061..f2529118184 100644 --- a/src/renderer/src/lib/orca-cloud-session-events.ts +++ b/src/renderer/src/lib/orca-cloud-session-events.ts @@ -120,11 +120,11 @@ export function installOrcaCloudSessionEvents(): () => void { } } -/** "See plans" on the create failure when the team has no plan with sandbox hours. */ +/** "Compare plans" on the create failure when the team has no plan with sandbox hours. */ export function showOrcaCloudPlanUpsell(message: string): void { toast.error(message, { action: { - label: translate('auto.lib.orcaCloudSession.seePlans', 'See plans'), + label: translate('auto.components.billing.comparePlans', 'Compare plans'), onClick: () => openPlansPage('sandbox') } }) diff --git a/src/renderer/src/lib/orca-cloud/active-team-role.test.ts b/src/renderer/src/lib/orca-cloud/active-team-role.test.ts new file mode 100644 index 00000000000..c54a13e0763 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/active-team-role.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import type { OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' +import { activeTeamRole, isActiveTeamAdmin } from './active-team-role' + +function status(patch: Partial = {}): OrcaProfileAuthStatus { + return { + activeProfileId: 'p1', + configured: true, + state: 'connected', + persistence: 'encrypted', + cloud: { + cloudProfileId: 'cp', + userId: 'u1', + email: 'a@acme.dev', + activeOrgId: 'acme', + linkedAt: 1 + }, + organizations: [ + { orgId: 'acme', name: 'Acme', role: 'Owner' }, + { orgId: 'globex', name: 'Globex', role: 'member' } + ], + ...patch + } +} + +describe('activeTeamRole', () => { + it("reads the role in the active team, whatever case auth's provider spells it in", () => { + expect(activeTeamRole(status())).toBe('owner') + expect(isActiveTeamAdmin(status())).toBe(true) + const admin = status({ organizations: [{ orgId: 'acme', name: 'Acme', role: ' ADMIN ' }] }) + expect(activeTeamRole(admin)).toBe('admin') + expect(isActiveTeamAdmin(admin)).toBe(true) + }) + + it('is the role in the ACTIVE team, not any team the person owns', () => { + const inGlobex = status({ cloud: { ...status().cloud!, activeOrgId: 'globex' } }) + expect(activeTeamRole(inGlobex)).toBe('member') + expect(isActiveTeamAdmin(inGlobex)).toBe(false) + }) + + it('treats an unknown or missing role as member, never as admin', () => { + for (const role of ['Billing manager', '', undefined]) { + const odd = status({ organizations: [{ orgId: 'acme', name: 'Acme', role }] }) + expect(activeTeamRole(odd)).toBe('member') + expect(isActiveTeamAdmin(odd)).toBe(false) + } + expect(isActiveTeamAdmin(status({ organizations: [] }))).toBe(false) + }) + + it('has no team role on a personal account, signed out, or before auth has answered', () => { + expect( + activeTeamRole(status({ cloud: { ...status().cloud!, activeOrgId: undefined } })) + ).toBeNull() + expect(activeTeamRole(status({ state: 'reconnect-required' }))).toBeNull() + expect(activeTeamRole(null)).toBeNull() + expect(isActiveTeamAdmin(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/orca-cloud/active-team-role.ts b/src/renderer/src/lib/orca-cloud/active-team-role.ts new file mode 100644 index 00000000000..aff67dc620b --- /dev/null +++ b/src/renderer/src/lib/orca-cloud/active-team-role.ts @@ -0,0 +1,29 @@ +import type { OrcaOrgRole, OrcaProfileAuthStatus } from '../../../../shared/orca-profiles' + +/** + * The signed-in user's role in the team they are acting as; null when signed out or on a personal + * account. Auth reports provider role names, so anything but owner/admin reads as member. This + * only decides what the UI offers: the services check the role on every request. + */ +export function activeTeamRole( + status: OrcaProfileAuthStatus | null | undefined +): OrcaOrgRole | null { + if (status?.state !== 'connected') { + return null + } + const orgId = status.cloud?.activeOrgId + if (!orgId) { + return null + } + const raw = status.organizations + ?.find((org) => org.orgId === orgId) + ?.role?.trim() + .toLowerCase() + return raw === 'owner' || raw === 'admin' ? raw : 'member' +} + +/** Owner or admin of the active team: who sees Settings › Enterprise. */ +export function isActiveTeamAdmin(status: OrcaProfileAuthStatus | null | undefined): boolean { + const role = activeTeamRole(status) + return role === 'owner' || role === 'admin' +} diff --git a/src/shared/cloud-api/cloud-api-types.ts b/src/shared/cloud-api/cloud-api-types.ts index dffaca1ff4b..e1cd0897034 100644 --- a/src/shared/cloud-api/cloud-api-types.ts +++ b/src/shared/cloud-api/cloud-api-types.ts @@ -84,27 +84,76 @@ export const CloudApiSettingsSchema = z.object({ }) export type CloudApiSettings = z.infer -export const CloudApiAuditEntrySchema = z.object({ - id: Id, - actor: z.object({ - type: z.string().max(64), - id: z.string().max(128), - userId: z.string().max(128).nullable(), - userName: PersonName - }), - action: z.string().max(128), - target: z.object({ type: z.string().max(64), id: z.string().max(128).nullable() }).nullable(), - // Free-form per action (e.g. a key's name); only short strings are ever shown. - detail: z.record(z.string(), z.unknown()).nullable().optional(), - createdAt: Millis -}) -export type CloudApiAuditEntry = z.infer +// The team audit log (GET /v1/audit-log): every recorded change in one feed. Owners and admins of +// a team whose plan includes it. Unknown categories, actor kinds and actions from a newer control +// plane still parse; the renderer words what it knows and shows the rest by its machine name. +export const CLOUD_AUDIT_CATEGORIES = [ + 'members', + 'security', + 'api_keys', + 'apps', + 'workspaces', + 'secrets', + 'routines', + 'settings', + 'billing', + 'other' +] as const +export type CloudAuditCategory = (typeof CLOUD_AUDIT_CATEGORIES)[number] -export const CloudApiAuditPageSchema = z.object({ - entries: z.array(CloudApiAuditEntrySchema).max(500), - nextBefore: Millis.nullable() +export const CloudAuditActorSchema = z.object({ + kind: z.enum(['person', 'former_member', 'system']).catch('person'), + userId: z.string().max(128).nullable(), + name: PersonName, + via: z.enum(['api_key', 'mcp', 'agent', 'routine']).nullable().catch(null) }) -export type CloudApiAuditPage = z.infer +export type CloudAuditActor = z.infer + +export const CloudAuditEventSchema = z.object({ + id: Id, + at: Millis, + category: z.enum(CLOUD_AUDIT_CATEGORIES).catch('other'), + action: z.string().max(128), + actor: CloudAuditActorSchema, + target: z + .object({ + type: z.string().max(64), + id: z.string().max(256).nullable(), + label: z.string().max(256).nullable().optional() + }) + .nullable(), + // Free-form per action; only short strings and numbers are ever shown. Never a secret value. + detail: z.record(z.string(), z.unknown()).nullable().optional() +}) +export type CloudAuditEvent = z.infer + +export const CloudAuditLogPageSchema = z.object({ + events: z.array(CloudAuditEventSchema).max(500), + nextCursor: z.string().max(128).nullable(), + /** Everyone who appears in the log, for the person filter; sent with the first page only. */ + actors: z.array(CloudAuditActorSchema).max(500).optional() +}) +export type CloudAuditLogPage = z.infer + +export const CloudAuditLogQuerySchema = z + .object({ + category: z.enum(CLOUD_AUDIT_CATEGORIES).optional(), + /** A user id, or `system`. */ + actor: z + .string() + .regex(/^[A-Za-z0-9_.:-]{1,128}$/) + .optional(), + cursor: z + .string() + .regex(/^\d{1,16}\.[A-Za-z0-9_-]{1,80}$/) + .optional(), + limit: z.number().int().min(1).max(200).optional() + }) + .strict() +export type CloudAuditLogQuery = z.infer + +/** A CSV export: the text, and whether more events exist past the export's cap. */ +export type CloudAuditLogExport = { csv: string; truncated: boolean } export const CloudApiProjectSchema = z.object({ id: Id, @@ -148,6 +197,8 @@ export const CLOUD_API_ERROR_CODES = [ 'signed-out', /** Signed in, but this action needs an org owner/admin. */ 'admin-required', + /** The team's plan does not include this (detail: the entitlement, e.g. `audit_log`). */ + 'plan-required', /** The key or token lacks a scope. */ 'forbidden', 'not-found',