diff --git a/scripts/build-chromium-xr.sh b/scripts/build-chromium-xr.sh index e81cb81..9c477f4 100755 --- a/scripts/build-chromium-xr.sh +++ b/scripts/build-chromium-xr.sh @@ -1,6 +1,7 @@ #!/bin/bash # Linux-side (x64 host): cross-compile arm64 Chromium with the Linux OpenXR CLs -# (8441736 + 8132979, bug 506004811) so WebXR immersive-vr works on the Frame. +# (8441736 + 8132979, bug 506004811), plus a one-option seccomp fix, so WebXR +# immersive-vr works on the Frame. # Needs ~90 GB free, no sudo. Takes hours; run it detached on the build host: # scp scripts/build-chromium-xr.sh buildhost:chromium-xr/build.sh # ssh buildhost 'cd ~/chromium-xr && tmux new -d -s chromium-xr "./build.sh > build.log 2>&1"' @@ -43,6 +44,55 @@ gclient runhooks src/build/linux/sysroot_scripts/install-sysroot.py --arch=arm64 guard cd src +# CL 8441736's XR seccomp policy refuses getsockopt, and SteamVR's IPC client +# calls getsockopt(SO_PEERCRED) inside xrCreateInstance, which crashes the XR +# process (verified on the Frame 2026-09-26). Allow only that option. +IFS= read -r -d '' PEERCRED_PATCH <<'P' || true +diff --git a/sandbox/policy/linux/bpf_xr_policy_linux.cc b/sandbox/policy/linux/bpf_xr_policy_linux.cc +index 435e13d396..297453f582 100644 +--- a/sandbox/policy/linux/bpf_xr_policy_linux.cc ++++ b/sandbox/policy/linux/bpf_xr_policy_linux.cc +@@ -11,6 +11,7 @@ + #include "sandbox/linux/system_headers/linux_syscalls.h" + #include "sandbox/policy/linux/sandbox_linux.h" + ++using sandbox::bpf_dsl::AllOf; + using sandbox::bpf_dsl::Allow; + using sandbox::bpf_dsl::Arg; + using sandbox::bpf_dsl::Error; +@@ -27,8 +28,8 @@ XrProcessPolicy::~XrProcessPolicy() = default; + ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const { + switch (system_call_number) { + // The runtime reaches its compositor over an AF_UNIX socket and passes fds +- // with SCM_RIGHTS, neither of which the GPU policy allows. get/setsockopt +- // stay disallowed; add a narrow level/optname restriction if ever needed. ++ // with SCM_RIGHTS, neither of which the GPU policy allows. setsockopt ++ // stays disallowed; getsockopt is limited to SO_PEERCRED below. + #if defined(__NR_getpeername) + case __NR_getpeername: + #endif +@@ -49,6 +50,16 @@ ResultExpr XrProcessPolicy::EvaluateSyscall(int system_call_number) const { + case __NR_get_robust_list: + #endif + return Allow(); ++#if defined(__NR_getsockopt) ++ case __NR_getsockopt: { ++ // SteamVR's IPC client checks who is on the other end of its socket ++ // with SO_PEERCRED. Nothing else is readable. ++ const Arg level(1); ++ const Arg optname(2); ++ return If(AllOf(level == SOL_SOCKET, optname == SO_PEERCRED), Allow()) ++ .Else(Error(EPERM)); ++ } ++#endif + #if defined(__NR_kill) + case __NR_kill: { + // SteamVR probes its sibling processes for liveness with kill(pid, 0). +P +if ! printf '%s\n' "$PEERCRED_PATCH" | git apply --reverse --check 2>/dev/null; then + printf '%s\n' "$PEERCRED_PATCH" | git apply + stage "applied SO_PEERCRED patch" +fi mkdir -p out/XR cat > out/XR/args.gn <<'A' target_os = "linux" diff --git a/scripts/chromium-xr.sh b/scripts/chromium-xr.sh index 8e320ff..ccfab0a 100755 --- a/scripts/chromium-xr.sh +++ b/scripts/chromium-xr.sh @@ -9,7 +9,7 @@ # # Usage: # scripts/chromium-xr.sh install [TARBALL] # default: scp from $BUILD_HOST -# scripts/chromium-xr.sh launch [URL] # opens in the headset desktop +# scripts/chromium-xr.sh launch [URL] # opens as its own panel in the headset # scripts/chromium-xr.sh check # isSessionSupported via DevTools set -euo pipefail @@ -35,10 +35,15 @@ case "${1:-}" in ssh "$FRAME_ALIAS" '~/chromium-xr.new/chrome --version && rm -rf ~/chromium-xr && mv ~/chromium-xr.new ~/chromium-xr' ;; launch) - # run-on-frame starts in $HOME on the Frame, so the profile path is relative. - exec "$here/run-on-frame.sh" -- '~/chromium-xr/chrome' \ + # Its own VR panel on gamescope's X display, so the Plasma desktop doesn't + # need to be open. The app starts in $HOME, so the profile path is relative. + # Without --no-first-run and --password-store=basic, startup can stop at a + # first-run or keyring prompt before DevTools comes up. + exec "$here/panel-on-frame.sh" --name chromium-xr -- '~/chromium-xr/chrome' \ --user-data-dir=.config/chromium-xr \ --enable-features=OpenXR \ + --ozone-platform=x11 \ + --no-first-run --no-default-browser-check --password-store=basic \ --remote-debugging-port="$DEVTOOLS_PORT" \ "${2:-https://immersive-web.github.io/webxr-samples/}" ;; diff --git a/ui/frame_android.py b/ui/frame_android.py index c015eb5..2536129 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -29,7 +29,9 @@ class FrameError(RuntimeError): def ssh(cmd, input=None, timeout=120): try: - p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], input=input, capture_output=True, + # No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it. + feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL} + p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed, timeout=timeout, text=isinstance(input, str) or input is None) except subprocess.TimeoutExpired: raise FrameError(f'timed out talking to {FRAME}') @@ -65,7 +67,7 @@ def apk_info(path): tool = aapt2() if not tool: raise FrameError(f"aapt2 not found: {frame_host.install_hint('aapt2')}") - out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, text=True).stdout + out = subprocess.run([tool, 'dump', 'badging', path], capture_output=True, stdin=subprocess.DEVNULL, text=True).stdout m = re.search(r"package: name='([^']+)'.*?versionName='([^']*)'", out) if not m: raise FrameError(f'not a readable APK: {os.path.basename(path)}') @@ -115,7 +117,7 @@ def _copy(src, dest, executable=False, timeout=600): else: cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}'] try: - subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=timeout) + subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout) except subprocess.TimeoutExpired: raise FrameError(f'copying {name} to the Frame timed out') except subprocess.CalledProcessError as e: diff --git a/ui/frame_connect.py b/ui/frame_connect.py index aa912ab..bdf93ec 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -119,7 +119,9 @@ def write_config(host, port=22): def key_login_works(): - return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", FRAME_ALIAS, "true"], + # accept-new: trust a first-seen host key (as the copy step does); a changed one still fails. + return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", + "-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"], capture_output=True).returncode == 0 diff --git a/ui/frame_host.py b/ui/frame_host.py index 994a056..3833e06 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -185,7 +185,7 @@ def clipboard_text(): for cmd in cmds: if not shutil.which(cmd[0]): continue - r = subprocess.run(cmd, capture_output=True, timeout=10) + r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, timeout=10) if r.returncode == 0: text = r.stdout.decode("utf-8", errors="replace") return text[:-2] if WINDOWS and text.endswith("\r\n") else text @@ -197,7 +197,7 @@ def clipboard_text(): def ssh_hostname(alias): """The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP.""" try: - out = subprocess.run(["ssh", "-G", alias], capture_output=True, text=True, timeout=10).stdout + out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout except (OSError, subprocess.TimeoutExpired): return alias for line in out.splitlines(): diff --git a/ui/server.py b/ui/server.py index 6a48e14..a70f234 100755 --- a/ui/server.py +++ b/ui/server.py @@ -95,7 +95,7 @@ def ensure_master(): def up(): try: - return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, + return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL, timeout=5).returncode == 0 except subprocess.TimeoutExpired: return False @@ -118,7 +118,10 @@ def ensure_master(): def ssh(remote, *, stdin=None, timeout=30, text=True): try: ensure_master() - r = subprocess.run([*SSH, FRAME, remote], input=stdin, capture_output=True, + # Never let ssh inherit our stdin: under the app it's the pipe held open for + # --exit-on-eof, and Windows' ssh.exe waits on it forever. + feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL} + r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed, text=text, errors="replace" if text else None, timeout=timeout) except subprocess.TimeoutExpired: raise Failure(f"Timed out talking to {FRAME}") @@ -494,7 +497,7 @@ def adb_path(): def adb(adb_bin, *args, timeout=20): try: - r = subprocess.run([adb_bin, *args], capture_output=True, text=True, + r = subprocess.run([adb_bin, *args], capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=timeout) except subprocess.TimeoutExpired: raise Failure(f"adb {' '.join(args[-2:])} timed out") @@ -609,7 +612,7 @@ class AdbTunnel: self._stop_ssh() for p in self.local: try: - subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, timeout=10) + subprocess.run([self.adb, "disconnect", self.serial(p)], capture_output=True, stdin=subprocess.DEVNULL, timeout=10) except (subprocess.TimeoutExpired, OSError): pass finally: @@ -799,7 +802,7 @@ def push_file(path, dest="Downloads/"): else: # Modern scp uses SFTP, so the remote path isn't parsed by a shell. cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"] - r = subprocess.run(cmd, capture_output=True, text=True, errors="replace", timeout=3600) + r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600) except subprocess.TimeoutExpired: raise Failure(f"Copying {name} timed out") if r.returncode != 0: @@ -1046,7 +1049,7 @@ def main(): finally: # The master was started with -N, so it stays up until told to exit. if CONTROL: - subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True) + subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL) if _master and _master.poll() is None: _master.terminate() for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way