From 8e0ee32e802c0fd0914233f3bde8fab0c3f88d85 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Fri, 19 Jun 2026 10:12:18 +0100 Subject: [PATCH 1/3] =?UTF-8?q?desktop(macos):=20fix=20VM=20networking=20+?= =?UTF-8?q?=20add=20debug-exec=20channel=20and=20guest=E2=86=92host=20cont?= =?UTF-8?q?rol-plane=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native Virtualization.framework desktop backend boots systemd → /etc/rc.local (MININIT is inert), which never brought up networking and had no guest→host path. Fixes, all verified end-to-end: - Networking: NIC forced to eth0 (net.ifnames=0); rc.local leases via dhclient (no bogus -timeout flag); added isc-dhcp-client + iproute2 to the image. Guest now gets IP/route/DNS automatically at boot and reaches the internet. - Guest→host control plane: reverse-vsock forwarder in vz-helper (--reverse-port-forward 8787:8787) + guest socat + CONTROLPLANE_URL/INTERNAL_API_TOKEN in rc.local. Unlocks OAuth integration tools, approvals, and callbacks on desktop. Verified: guest→host /health 200, gateway 403 NOT_ATTACHED w/ valid PTY token, discovery 200 — over loopback only (nothing exposed on a NIC). - /debug/exec endpoint (orcabot CLI primitive): runs guest shell from the host. Gated by ORCABOT_DEBUG_EXEC=1 (desktop-only, off in prod) + a random per-boot token delivered via the root-only VM console; agents can neither read nor guess it. - orcabot-server PATH floor so child binaries (browser stack, agent CLIs) resolve when launched from a bare PID-1 environment — also fixes the "Reconnecting to browser". - Image staging stamps the source signature instead of trusting dest mtime (the rw disk image's mtime is bumped by the guest, which silently skipped re-staging). - build-images.sh: SKIP_KERNEL=1 reuses the prebuilt kernel for fast rootfs-only rebuilds. Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/app/src-tauri/src/main.rs | 13 +- desktop/app/src-tauri/src/vm/image.rs | 58 +++++--- desktop/app/src-tauri/src/vm/macos.rs | 28 ++-- .../src-tauri/vz-helper/Sources/main.swift | 82 ++++++++++ desktop/vm/scripts/build-images.sh | 120 ++++++++++++++- sandbox/cmd/server/main.go | 140 +++++++++++++++++- sandbox/docker/Dockerfile | 5 + 7 files changed, 408 insertions(+), 38 deletions(-) diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 591a673f..66d1fcc8 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -1,7 +1,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -// REVISION: main-v4-vm-host-loopback-url -const MODULE_REVISION: &str = "main-v4-vm-host-loopback-url"; +// REVISION: main-v5-vm-eth0-dhcp-bringup +const MODULE_REVISION: &str = "main-v5-vm-eth0-dhcp-bringup"; mod commands; mod vm; @@ -527,10 +527,15 @@ impl DesktopServices { } // Default kernel command line; VZ virtio console shows up as hvc0 on macOS. + // net.ifnames=0 biosdevname=0: force legacy interface naming so the virtio NIC + // is `eth0` instead of `enp0s1`. This is paired with the DHCP bring-up in the + // VM's minimal init (vm/scripts/build-images.sh MININIT): the macOS direct-boot + // path runs that init, NOT OpenRC, so it leases an address on eth0 itself. + // Without both halves the guest has no IP/DNS/route → no internet (npm hangs). let cmdline = if cfg!(target_os = "macos") { - "console=hvc0 earlycon=virtio_console keep_bootcon root=/dev/vda rw loglevel=7 ignore_loglevel rdinit=/init" + "console=hvc0 earlycon=virtio_console keep_bootcon root=/dev/vda rw net.ifnames=0 biosdevname=0 loglevel=7 ignore_loglevel rdinit=/init" } else { - "console=ttyS0 root=/dev/vda rw quiet" + "console=ttyS0 root=/dev/vda rw net.ifnames=0 biosdevname=0 quiet" }; config = config.with_cmdline(cmdline); diff --git a/desktop/app/src-tauri/src/vm/image.rs b/desktop/app/src-tauri/src/vm/image.rs index e69a56a7..30615f4f 100644 --- a/desktop/app/src-tauri/src/vm/image.rs +++ b/desktop/app/src-tauri/src/vm/image.rs @@ -36,33 +36,51 @@ pub fn stage_image(src: &Path, dest: &Path) -> Result { } else { copy_file(src, &dest_path)?; } + // Record the source signature so a later runtime mutation of dest (the VM + // image boots read-write, so the guest bumps its mtime) never makes a + // genuinely-updated source look stale and skip re-staging. + if let Ok(sig) = source_signature(src) { + let _ = fs::write(stamp_path(&dest_path), sig); + } } Ok(dest_path) } -/// Check if staging is needed based on modification time and size. -fn needs_staging(src: &Path, dest: &Path) -> Result { - let src_meta = fs::metadata(src)?; - - match fs::metadata(dest) { - Ok(dest_meta) => { - let src_modified = src_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); - let dest_modified = dest_meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); - - // Re-stage if source is newer - if src_modified > dest_modified { - return Ok(true); - } +/// Stable signature of the SOURCE file (modification time + size). +fn source_signature(src: &Path) -> Result { + let meta = fs::metadata(src)?; + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + Ok(format!("{}:{}", mtime, meta.len())) +} - // For non-gzipped files, also check size - if !src.extension().map_or(false, |e| e == "gz") && src_meta.len() != dest_meta.len() { - return Ok(true); - } +/// Path of the sidecar stamp file recording the source signature at last stage. +fn stamp_path(dest: &Path) -> PathBuf { + let mut s = dest.as_os_str().to_owned(); + s.push(".stamp"); + PathBuf::from(s) +} - Ok(false) - } - Err(_) => Ok(true), // Destination doesn't exist +/// Check if staging is needed by comparing the source's signature against the +/// stamp recorded at the last successful stage. +/// +/// We deliberately do NOT compare the destination's own mtime: the VM disk image +/// is mounted read-write, so the running guest mutates the staged copy and bumps +/// its mtime past a freshly-rebuilt source — which made the old "source newer" +/// check skip re-staging and silently boot a stale image. +fn needs_staging(src: &Path, dest: &Path) -> Result { + if !dest.exists() { + return Ok(true); + } + let sig = source_signature(src)?; + match fs::read_to_string(stamp_path(dest)) { + Ok(recorded) => Ok(recorded.trim() != sig), + Err(_) => Ok(true), // no stamp (e.g. older install) → re-stage and write one } } diff --git a/desktop/app/src-tauri/src/vm/macos.rs b/desktop/app/src-tauri/src/vm/macos.rs index 00efd0bb..7084ec91 100644 --- a/desktop/app/src-tauri/src/vm/macos.rs +++ b/desktop/app/src-tauri/src/vm/macos.rs @@ -141,6 +141,13 @@ impl MacOSVM { // The guest runs socat to bridge vsock:port -> localhost:port "--port-forward", &format!("{}:{}", config.sandbox_port, config.sandbox_port), + // Reverse forward: guest vsock:8787 -> host 127.0.0.1:8787 (control plane). + // Gives the sandbox a guest->host route so it can call the control plane + // (integration gateway, egress/secret approvals, event callbacks). The + // guest runs socat TCP-LISTEN:8787 -> VSOCK-CONNECT:2:8787 and sets + // CONTROLPLANE_URL=http://127.0.0.1:8787. (8787 = default CONTROLPLANE_PORT.) + "--reverse-port-forward", + "8787:8787", ]); cmd.stdout(Stdio::inherit()); @@ -156,18 +163,17 @@ impl MacOSVM { self.using_native_vz = true; self.sandbox_url = format!("http://127.0.0.1:{}", config.sandbox_port); - // The native backend uses Apple NAT for guest egress plus a host→guest - // vsock forwarder for inbound; it has no guest→host route to services on - // the host loopback. Sandbox→controlplane callbacks (integration - // gateway, domain approvals, execution callbacks) won't reach the - // controlplane unless it's bound to a guest-reachable interface and - // CONTROLPLANE_URL is set to match. See vm::host_loopback_url. + // The native backend uses Apple NAT for guest egress, a host→guest vsock + // forwarder for inbound (sandbox :8080), and a reverse vsock forwarder for + // guest→host (--reverse-port-forward 8787:8787 above) so the sandbox can + // call back to the control plane. The guest's rc.local runs the matching + // `socat TCP-LISTEN:8787 -> VSOCK-CONNECT:2:8787` and sets + // CONTROLPLANE_URL=http://127.0.0.1:8787. This keeps the control plane on + // host loopback (nothing exposed on a network interface). eprintln!( - "[vm] WARNING: native Virtualization.framework backend has no guest→host \ - route to the controlplane on 127.0.0.1; sandbox→controlplane callbacks \ - (integrations, approvals, execution callbacks) will not reach the host. \ - Use the QEMU fallback, or bind the controlplane to a guest-reachable \ - interface and set CONTROLPLANE_URL accordingly." + "[vm] native VZ backend: inbound vsock (sandbox :{}), reverse vsock \ + (guest→host control plane :8787) active.", + config.sandbox_port ); Ok(()) diff --git a/desktop/app/src-tauri/vz-helper/Sources/main.swift b/desktop/app/src-tauri/vz-helper/Sources/main.swift index db068112..189cab47 100644 --- a/desktop/app/src-tauri/vz-helper/Sources/main.swift +++ b/desktop/app/src-tauri/vz-helper/Sources/main.swift @@ -6,6 +6,7 @@ import Network // MARK: - Global references to prevent deallocation var globalVM: VZVirtualMachine? var globalForwarders: [TCPToVsockForwarder] = [] +var globalVsockListeners: [VsockListener] = [] var globalConsolePipe: Pipe? var globalConsoleLogFile: FileHandle? @@ -40,6 +41,9 @@ struct VZHelper: ParsableCommand { @Option(name: .long, help: "Port forward via vsock (hostPort:guestPort)") var portForward: [String] = [] + @Option(name: .long, help: "Reverse forward: guest vsock port to host TCP port (vsockPort:hostPort). Lets the guest reach host loopback services, e.g. the control plane.") + var reversePortForward: [String] = [] + @Flag(name: .long, help: "Disable directory sharing (for debugging)") var noShare: Bool = false @@ -71,6 +75,20 @@ struct VZHelper: ParsableCommand { } } + // Parse reverse port forwards (guest vsock port -> host TCP port) + var reversePortForwards: [(vsockPort: UInt32, hostPort: UInt16)] = [] + for rpf in reversePortForward { + let parts = rpf.split(separator: ":") + if parts.count == 2, + let vsockPort = UInt32(parts[0]), + let hostPort = UInt16(parts[1]) { + reversePortForwards.append((vsockPort, hostPort)) + print(" Reverse forward: guest vsock:\(vsockPort) -> host localhost:\(hostPort)") + } else { + print("Warning: Invalid reverse port forward specification: \(rpf)") + } + } + // Validate files exist guard FileManager.default.fileExists(atPath: kernel) else { throw VMError.fileNotFound("Kernel not found: \(kernel)") @@ -323,6 +341,27 @@ struct VZHelper: ParsableCommand { print("[VZ] WARNING: No vsock device found!") } + // Set up reverse forwarders: accept guest-initiated vsock connections and + // bridge them to host loopback TCP (e.g. the control plane on :8787). This + // is what gives the sandbox a guest->host route to call the control plane. + if !reversePortForwards.isEmpty { + globalVsockListeners = [] + if let vsockDevice = vm.socketDevices.first as? VZVirtioSocketDevice { + for rpf in reversePortForwards { + let l = VsockListener(vsockPort: rpf.vsockPort, hostPort: rpf.hostPort) + let vzl = VZVirtioSocketListener() + vzl.delegate = l + l.vzListener = vzl + vsockDevice.setSocketListener(vzl, forPort: rpf.vsockPort) + globalVsockListeners.append(l) + print("[VZ] Reverse forwarder ACTIVE: guest vsock:\(rpf.vsockPort) -> host 127.0.0.1:\(rpf.hostPort)") + } + } else { + print("[VZ] WARNING: reverse port forwards requested but no vsock device found") + } + fflush(stdout) + } + // Handle signals for graceful shutdown signal(SIGINT) { _ in print("\nReceived SIGINT, shutting down...") @@ -490,6 +529,49 @@ class TCPToVsockForwarder { } } +// MARK: - Reverse Vsock Listener (guest -> host) + +/// Accepts guest-initiated vsock connections on `vsockPort` and bridges each to a +/// host loopback TCP service at 127.0.0.1:`hostPort`. The inverse of +/// TCPToVsockForwarder: it gives the guest a route to host-only services (the +/// control plane on :8787) without exposing anything on a network interface. +/// The guest side runs `socat TCP-LISTEN:hostPort ... VSOCK-CONNECT:2:vsockPort`. +class VsockListener: NSObject, VZVirtioSocketListenerDelegate { + let vsockPort: UInt32 + let hostPort: UInt16 + var vzListener: VZVirtioSocketListener? + private var connections: [UUID: ConnectionBridge] = [:] + private let queue = DispatchQueue(label: "vsock-listener") + + init(vsockPort: UInt32, hostPort: UInt16) { + self.vsockPort = vsockPort + self.hostPort = hostPort + } + + func listener(_ listener: VZVirtioSocketListener, + shouldAcceptNewConnection connection: VZVirtioSocketConnection, + from socketDevice: VZVirtioSocketDevice) -> Bool { + guard let port = NWEndpoint.Port(rawValue: hostPort) else { return false } + let id = UUID() + // Outbound TCP to the host loopback service; ConnectionBridge starts it. + let tcp = NWConnection(host: "127.0.0.1", port: port, using: .tcp) + print("[RCONN] \(id.uuidString.prefix(8)): guest vsock:\(vsockPort) -> host 127.0.0.1:\(hostPort)") + fflush(stdout) + let bridge = ConnectionBridge(id: id, tcpConnection: tcp, vsockConnection: connection) { [weak self] cid in + self?.queue.async { + print("[RCONN] \(cid.uuidString.prefix(8)): closed, removing from pool") + fflush(stdout) + self?.connections.removeValue(forKey: cid) + } + } + queue.async { + self.connections[id] = bridge + bridge.start() + } + return true + } +} + // MARK: - Bidirectional Connection Bridge /// Bridges data between a TCP NWConnection and a VZVirtioSocketConnection diff --git a/desktop/vm/scripts/build-images.sh b/desktop/vm/scripts/build-images.sh index e79066ea..251f3101 100755 --- a/desktop/vm/scripts/build-images.sh +++ b/desktop/vm/scripts/build-images.sh @@ -163,6 +163,13 @@ fi mkdir -p /mnt/rootfs/sbin cat > /mnt/rootfs/sbin/init << "MININIT" #!/bin/sh +# The kernel execs PID 1 with an empty/minimal PATH. Set a full one so tools in +# /usr/sbin (ip, dhclient, modprobe) resolve — without this the network bring-up +# below is silently skipped and the guest has no outbound connectivity. +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +# Enable the sandbox debug-exec endpoint (POST /debug/exec) for the desktop VM +# only. Fly boots via OpenRC, never this init, so it stays off in production. +export ORCABOT_DEBUG_EXEC=1 mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t devtmpfs devtmpfs /dev 2>/dev/null || mount -t devtmpfs dev /dev @@ -175,6 +182,7 @@ mount -t 9p workspace /workspace -o trans=virtio,version=9p2000.L 2>/dev/null || # Log to the VM console for early debugging exec >/dev/console 2>&1 +echo "[init] REVISION: mininit-v6-dhcp-no-timeout" echo "[init] starting orcabot sandbox" # Load vsock modules if available @@ -204,6 +212,44 @@ else grep -E "vsock|virtio" /proc/modules > /dev/console 2>/dev/null || true fi +# Bring up outbound networking. This minimal init does NOT run OpenRC/ifupdown, +# so /etc/network/interfaces is never consulted — we must lease an address here +# ourselves. The Virtualization.framework NAT attachment runs a DHCP server, so a +# DHCP lease on the (net.ifnames=0 → eth0) virtio NIC gives the guest an IP, a +# default route, and DNS (dhclient-script writes /etc/resolv.conf). Without this +# the guest has no internet and `npm install` / agent bootstrap hangs forever. +NETIF=eth0 +[ -e /sys/class/net/$NETIF ] || NETIF=$(ls /sys/class/net 2>/dev/null | grep -v "^lo$" | head -1) +IP_BIN=/usr/sbin/ip; [ -x "$IP_BIN" ] || IP_BIN=/sbin/ip; [ -x "$IP_BIN" ] || IP_BIN=ip +DHC_BIN=/usr/sbin/dhclient; [ -x "$DHC_BIN" ] || DHC_BIN=/sbin/dhclient; [ -x "$DHC_BIN" ] || DHC_BIN=dhclient +if [ -n "$NETIF" ]; then + echo "[init] bringing up $NETIF via DHCP (ip=$IP_BIN dhclient=$DHC_BIN)" > /dev/console + # Lease in the background with retries so a slow link/NAT bring-up at early boot + # does not block orcabot-server startup. At boot the virtio link often has no + # carrier yet and the host VZ NAT DHCP server is not answering, so a single + # one-shot attempt fails; we re-up the link and retry. -1 = one attempt per try, + # -timeout caps each attempt so the loop stays responsive. + ( + n=0 + while [ "$n" -lt 20 ]; do + "$IP_BIN" link set dev "$NETIF" up 2>/dev/null + if "$DHC_BIN" -1 "$NETIF" > /dev/console 2>&1; then + echo "[init] DHCP lease acquired on $NETIF (attempt $n)" > /dev/console + "$IP_BIN" -4 addr show "$NETIF" > /dev/console 2>&1 + "$IP_BIN" route show > /dev/console 2>&1 + cat /etc/resolv.conf > /dev/console 2>&1 + exit 0 + fi + echo "[init] DHCP attempt $n failed on $NETIF (carrier=$(cat /sys/class/net/$NETIF/carrier 2>/dev/null)); retrying" > /dev/console + n=$((n + 1)) + sleep 2 + done + echo "[init] DHCP gave up on $NETIF after $n attempts" > /dev/console + ) & +else + echo "[init] no usable network interface found (have: $(ls /sys/class/net 2>/dev/null | tr "\n" " "))" > /dev/console +fi + # Start vsock-to-TCP bridge (host connects to vsock, guest listens). # The bridge must stay alive as long as the server runs, so we start both # as children of PID 1 and wait. Using exec would orphan the background @@ -214,6 +260,15 @@ if command -v socat >/dev/null 2>&1; then SOCAT_PID=$! fi +# Load host-provided env (CONTROLPLANE_URL, INTERNAL_API_TOKEN, etc.). The host +# writes this file before boot; the OpenRC path sources it too (see init.sh). +if [ -f /etc/orcabot.env ]; then + echo "[init] sourcing /etc/orcabot.env" > /dev/console + set -a + . /etc/orcabot.env + set +a +fi + echo "[init] starting orcabot-server" > /dev/console /usr/local/bin/orcabot-server & SERVER_PID=$! @@ -309,7 +364,7 @@ chmod +x /mnt/rootfs/etc/init.d/orcabot mkdir -p /mnt/rootfs/etc/rc.d cat > /mnt/rootfs/etc/rc.local << "RCLOCAL" #!/bin/sh -echo "Starting Orcabot sandbox (rc.local v3)..." > /dev/console +echo "Starting Orcabot sandbox (rc.local v6-reverse-vsock-cp)..." > /dev/console # Ensure log/run dirs exist mkdir -p /var/log /run @@ -335,11 +390,66 @@ else echo "/dev/vsock missing" > /dev/console fi +# Bring up outbound networking. systemd boots here but ifupdown/networking.service +# is not installed, so nothing actions /etc/network/interfaces. The Virtualization. +# framework NAT attachment runs a DHCP server; lease an address on eth0 (forced by +# net.ifnames=0) so the guest gets IP/route/DNS (dhclient-script writes resolv.conf). +# Backgrounded with retries so a slow carrier/NAT bring-up does not delay the server, +# and so npm/git/agent traffic works once it settles. Without this the guest has no +# internet and `npm install` / agent bootstrap hangs forever. +echo "Bringing up network via DHCP..." > /dev/console +NETIF=eth0 +[ -e /sys/class/net/$NETIF ] || NETIF=$(ls /sys/class/net 2>/dev/null | grep -v "^lo$" | head -1) +IP_BIN=/usr/sbin/ip; [ -x "$IP_BIN" ] || IP_BIN=/sbin/ip; [ -x "$IP_BIN" ] || IP_BIN=ip +DHC_BIN=/usr/sbin/dhclient; [ -x "$DHC_BIN" ] || DHC_BIN=/sbin/dhclient; [ -x "$DHC_BIN" ] || DHC_BIN=dhclient +if [ -n "$NETIF" ]; then + ( + n=0 + while [ "$n" -lt 10 ]; do + "$IP_BIN" link set dev "$NETIF" up 2>/dev/null + # -1: one attempt then exit (the dhclient built-in ~60s timeout + retransmit + # rides out a not-yet-ready NAT DHCP server). NOTE: dhclient has NO -timeout + # CLI flag — passing one makes it exit immediately with "Unknown command". + if "$DHC_BIN" -1 "$NETIF" > /dev/console 2>&1; then + echo "DHCP lease acquired on $NETIF (attempt $n)" > /dev/console + "$IP_BIN" -4 addr show "$NETIF" > /dev/console 2>&1 + "$IP_BIN" route show > /dev/console 2>&1 + exit 0 + fi + echo "DHCP attempt $n failed on $NETIF (carrier=$(cat /sys/class/net/$NETIF/carrier 2>/dev/null)); retrying" > /dev/console + n=$((n + 1)) + sleep 2 + done + echo "DHCP gave up on $NETIF after $n attempts" > /dev/console + ) & +else + echo "no usable network interface found" > /dev/console +fi + +# Reverse vsock bridge: guest 127.0.0.1:8787 -> vsock CID 2 (host) :8787 -> host +# control plane. The host vz-helper registers the matching listener +# (--reverse-port-forward 8787:8787). This is the guest->host route that lets the +# sandbox call the control plane (integration gateway, approvals, callbacks). +if command -v socat >/dev/null 2>&1; then + echo "Starting reverse vsock bridge (control plane)..." > /dev/console + socat TCP-LISTEN:8787,reuseaddr,fork VSOCK-CONNECT:2:8787 > /var/log/vsock-cp.log 2>&1 & + echo $! > /run/vsock-cp.pid +fi + # Start the sandbox server export PORT=${PORT:-8080} export WORKSPACE_BASE=/workspace export ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*} export SANDBOX_INTERNAL_TOKEN=${SANDBOX_INTERNAL_TOKEN:-dev-sandbox-token} +# Control-plane callback config. CONTROLPLANE_URL points at the reverse vsock bridge +# above; INTERNAL_API_TOKEN must match what the host workerd expects (main.rs default +# is dev-internal-token). These reach only the server process — PTY env filters them, +# so agents never see INTERNAL_API_TOKEN. +export CONTROLPLANE_URL=${CONTROLPLANE_URL:-http://127.0.0.1:8787} +export INTERNAL_API_TOKEN=${INTERNAL_API_TOKEN:-dev-internal-token} +# Enable the debug-exec endpoint (POST /debug/exec) for the desktop VM only. Fly +# starts orcabot-server via its own path, never this rc.local, so it stays off there. +export ORCABOT_DEBUG_EXEC=1 touch /var/log/orcabot.log /var/log/vsock-bridge.log /usr/local/bin/orcabot-server >> /var/log/orcabot.log 2>&1 & echo $! > /run/orcabot.pid @@ -403,6 +513,13 @@ log "Created: $OUTPUT_DIR/sandbox.img" # ============================================================================= # Step 4: Extract kernel and initrd for direct boot # ============================================================================= +# The custom-kernel build below compiles Linux from source (~10 min) and only needs +# to run when the kernel/initrd are missing or the kernel config changes. SKIP_KERNEL=1 +# reuses the existing vmlinuz/initrd/custom-modules from a prior build — use it for fast +# rootfs-only iterations (rc.local, sandbox server binary), which don't touch the kernel. +if [ "${SKIP_KERNEL:-0}" = "1" ] && [ -f "$OUTPUT_DIR/vmlinuz" ] && [ -f "$OUTPUT_DIR/initrd.img" ]; then +log "SKIP_KERNEL=1: reusing existing kernel + initrd (skipping ~10min kernel build)" +else log "Extracting kernel and initrd..." # macOS Virtualization.framework on ARM64 requires a raw "Image" format kernel, @@ -516,6 +633,7 @@ else fi ' +fi # end SKIP_KERNEL guard # If we built a custom kernel, inject its modules into the disk image. if [ -f "$OUTPUT_DIR/custom-modules.tar.gz" ] && [ -f "$OUTPUT_DIR/sandbox.img" ]; then diff --git a/sandbox/cmd/server/main.go b/sandbox/cmd/server/main.go index 87179b5c..7b81aed5 100644 --- a/sandbox/cmd/server/main.go +++ b/sandbox/cmd/server/main.go @@ -1,14 +1,16 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: main-v32-egress-deny-always +// REVISION: main-v34-debug-exec-token package main import ( "bytes" "context" + "crypto/rand" "crypto/subtle" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -17,6 +19,7 @@ import ( "net/http" "net/http/pprof" "os" + "os/exec" "os/signal" "strconv" "strings" @@ -35,7 +38,13 @@ import ( "github.com/Hyper-Int/OrcaBot/sandbox/internal/ws" ) -const mainRevision = "main-v32-egress-deny-always" +const mainRevision = "main-v34-debug-exec-token" + +// debugExecToken is a random per-boot secret guarding POST /debug/exec. Generated +// once at startup when ORCABOT_DEBUG_EXEC=1 and delivered to the host ONLY via the +// VM console (root-only device), so a non-root agent in a PTY can neither read nor +// guess it. Empty = endpoint rejects everything. +var debugExecToken string const ( maxFileSizeBytes = 50 * 1024 * 1024 @@ -48,6 +57,115 @@ func init() { log.Printf("[main] REVISION: %s loaded at %s", mainRevision, time.Now().Format(time.RFC3339)) } +// handleDebugExec runs a shell command inside the sandbox and returns its +// stdout/stderr/exit code as JSON. It is the primitive behind the desktop debug +// API and the future `orcabot` CLI (`orcabot exec`). +// +// SECURITY: two gates. (1) ORCABOT_DEBUG_EXEC=1 — set only by the desktop VM init, +// never on Fly — so the endpoint is entirely absent in production. (2) a random +// per-boot token (X-Debug-Exec-Token) delivered to the host only via the root-only +// VM console, so a non-root agent in a PTY can neither read nor guess it. This +// matters because /debug/exec runs as root: where agents are non-root (the +// egress-enabled config), it would otherwise be a privilege-escalation path. The +// token is checked first thing in the handler, constant-time. +func (s *Server) handleDebugExec(w http.ResponseWriter, r *http.Request) { + if os.Getenv("ORCABOT_DEBUG_EXEC") != "1" { + http.Error(w, "debug exec disabled (set ORCABOT_DEBUG_EXEC=1)", http.StatusForbidden) + return + } + // Auth via the random per-boot token (X-Debug-Exec-Token), NOT the internal + // token — on desktop the internal token is a guessable dev constant. The + // per-boot token is delivered only over the root-only VM console, so a non-root + // agent can neither read nor guess it. Constant-time compare; empty = reject. + if debugExecToken == "" || + subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Debug-Exec-Token")), []byte(debugExecToken)) != 1 { + http.Error(w, "forbidden: missing or invalid X-Debug-Exec-Token", http.StatusForbidden) + return + } + var req struct { + Cmd string `json:"cmd"` + TimeoutMs int `json:"timeout_ms"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Cmd) == "" { + http.Error(w, "bad request: need JSON {\"cmd\": \"...\"}", http.StatusBadRequest) + return + } + timeout := 15 * time.Second + if req.TimeoutMs > 0 && req.TimeoutMs <= 120000 { + timeout = time.Duration(req.TimeoutMs) * time.Millisecond + } + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "/bin/sh", "-c", req.Cmd) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + runErr := cmd.Run() + + exitCode := 0 + if runErr != nil { + var ee *exec.ExitError + if errors.As(runErr, &ee) { + exitCode = ee.ExitCode() + } else { + exitCode = -1 + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "revision": mainRevision, + "cmd": req.Cmd, + "exit_code": exitCode, + "stdout": stdout.String(), + "stderr": stderr.String(), + "timed_out": ctx.Err() == context.DeadlineExceeded, + }) +} + +// initDebugExecToken mints the random per-boot secret for POST /debug/exec and +// delivers it to the host out of band, where no in-guest agent can read it. +func initDebugExecToken() { + if os.Getenv("ORCABOT_DEBUG_EXEC") != "1" { + return + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + log.Printf("[debug-exec] token generation failed: %v — endpoint will reject all requests", err) + return + } + debugExecToken = hex.EncodeToString(b) + + // Deliver to the host via the VM console ONLY. /dev/console is root-only + // (crw------- root), so a non-root agent in a PTY cannot read it, and the token + // is deliberately NOT written to the agent-readable server log. The desktop host + // captures the console (VZ_CONSOLE_DIRECT → /tmp/vz-console.log); the CLI reads + // the token from there. Best-effort: if the console is unavailable the endpoint + // still works, the operator just has no way to learn the token. + if f, err := os.OpenFile("/dev/console", os.O_WRONLY, 0); err == nil { + fmt.Fprintf(f, "[debug-exec] auth token: %s\n", debugExecToken) + _ = f.Close() + } else { + log.Printf("[debug-exec] token minted but could not write to /dev/console: %v", err) + } +} + +// ensurePathFloor guarantees a usable PATH for exec.LookPath even when the +// process is started from a bare environment (e.g. the desktop VM's PID-1 init). +func ensurePathFloor() { + const defaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + cur := os.Getenv("PATH") + switch { + case cur == "": + os.Setenv("PATH", defaultPath) + log.Printf("[main] PATH was empty; set floor %q", defaultPath) + case !strings.Contains(cur, "/usr/bin"): + os.Setenv("PATH", cur+":"+defaultPath) + log.Printf("[main] PATH missing core dirs; appended floor") + } +} + func main() { // Set umask 0002 so every child process — interactive shell, bash -c, or direct // exec — creates group-writable files by default. Shell startup files (profile.d, @@ -58,6 +176,17 @@ func main() { // REVISION: main-v20-umask-0002 syscall.Umask(0002) + // Defensive PATH floor. When launched by the desktop VM's minimal init (PID 1 + // with an empty environment), os.Getenv("PATH") can be empty, which makes + // exec.LookPath fail for every child binary — the browser stack + // (Xvfb/x11vnc/websockify/chromium), agent CLIs, git, etc. — silently. Ensure a + // sane PATH so child exec never fails on a bare environment. No-op on Fly and + // anywhere PATH is already set normally. + ensurePathFloor() + + // Mint the per-boot /debug/exec token (desktop debug API / orcabot CLI). + initDebugExecToken() + port := os.Getenv("PORT") if port == "" { port = "8080" @@ -439,6 +568,13 @@ func (s *Server) Handler() http.Handler { // Health check - unauthenticated (for load balancer probes) mux.HandleFunc("GET /health", s.handleHеalth) + // Debug exec — desktop debug API / `orcabot` CLI primitive. Runs a shell command + // in the sandbox and returns stdout/stderr/exit. Gated by ORCABOT_DEBUG_EXEC=1 + // (off by default; only the desktop VM's init sets it) AND a random per-boot + // token checked inside the handler, so it is absent in production and + // unreachable by in-guest agents. No requireMachine: desktop has no Fly machine id. + mux.HandleFunc("POST /debug/exec", s.handleDebugExec) + // Debug profiling - machine routing first, then auth mux.HandleFunc("GET /debug/pprof/", s.requireMachine(s.auth.RequireAuthFunc(pprof.Index))) mux.HandleFunc("GET /debug/pprof/cmdline", s.requireMachine(s.auth.RequireAuthFunc(pprof.Cmdline))) diff --git a/sandbox/docker/Dockerfile b/sandbox/docker/Dockerfile index e9c5351e..12a6db45 100644 --- a/sandbox/docker/Dockerfile +++ b/sandbox/docker/Dockerfile @@ -66,6 +66,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ openssh-client \ socat \ iptables \ + # DHCP client + iproute2: only used by the desktop VM (Virtualization.framework + # NAT provides a DHCP server; the guest must lease its own address). On Fly the + # platform configures networking externally, so these sit inert there. + isc-dhcp-client \ + iproute2 \ # Browser stack chromium \ xvfb \ From 15519cf1e4e3fe4f1096a0acb3703da0f65f4cb4 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Fri, 19 Jun 2026 10:12:31 +0100 Subject: [PATCH 2/3] egress: honor deny-always + trackers at every interception point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckAndHold (browser navigation) and the transparent/kernel path only consulted the allowlist, so permanently-denied and tracker domains slipped through there even though the CONNECT/HTTP proxy paths blocked them. Unify all paths behind a single decide() ladder (localhost → deny → allow → tracker → hold) with a permitted() helper, so every interception point makes the same decision. Adds a regression test covering deny/tracker (incl. user "Always Allow" overriding the tracker denylist). Co-Authored-By: Claude Opus 4.8 (1M context) --- sandbox/internal/egress/proxy.go | 112 ++++++++----------- sandbox/internal/egress/proxy_test.go | 40 +++++++ sandbox/internal/egress/transparent_linux.go | 15 +-- 3 files changed, 92 insertions(+), 75 deletions(-) diff --git a/sandbox/internal/egress/proxy.go b/sandbox/internal/egress/proxy.go index 2cedd15a..6d31cb96 100644 --- a/sandbox/internal/egress/proxy.go +++ b/sandbox/internal/egress/proxy.go @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: egress-proxy-v9-tracker-denylist +// REVISION: egress-proxy-v10-unified-decide package egress @@ -20,7 +20,7 @@ import ( "github.com/Hyper-Int/OrcaBot/sandbox/internal/id" ) -const proxyRevision = "egress-proxy-v9-tracker-denylist" +const proxyRevision = "egress-proxy-v10-unified-decide" func init() { log.Printf("[egress-proxy] REVISION: %s loaded at %s", proxyRevision, time.Now().Format(time.RFC3339)) @@ -267,47 +267,53 @@ func isLocalhost(host string) bool { return false } -// handleConnect handles HTTPS CONNECT tunneling. -func (p *EgressProxy) handleConnect(w http.ResponseWriter, r *http.Request) { - host, port := splitHostPort(r.Host, 443) - - // Localhost always bypasses — never hold or prompt for loopback traffic. +// decide runs the single source-of-truth egress decision ladder for host:port and +// returns the resulting decision. EVERY interception point (CONNECT, plain HTTP, +// browser-navigation CheckAndHold, transparent/kernel proxy) must go through this so +// they all honor the same order: +// +// localhost → allow (bypass, no audit) +// IsDenied → deny (permanent "deny always", precedence over allowlist) +// IsAllowed → allow (default/user allowlist; audited) +// IsTracker → deny (known analytics/ad domain; silent, no audit/flood) +// otherwise → hold for user approval (allow_once/always | deny | timeout) +// +// Returns DecisionDefault/DecisionAllowOnce/DecisionAllowAlways when permitted, or +// DecisionDeny/DecisionTimeout when blocked. Callers permit on the allow set. +func (p *EgressProxy) decide(host string, port int) string { if isLocalhost(host) { - p.tunnelConnect(w, host, port) - return + return DecisionDefault } - - // Permanently denied domains are rejected immediately — no prompt, no hold. - // This takes precedence over the allowlist so trackers the user blocked stay blocked. + // Permanent deny takes precedence over the allowlist, so trackers/domains the + // user blocked stay blocked. if p.allowlist.IsDenied(host) { - http.Error(w, "Egress denied: domain permanently blocked", http.StatusForbidden) - return + return DecisionDeny } - if p.allowlist.IsAllowed(host) { p.emitAudit(host, port, "", DecisionDefault) - p.tunnelConnect(w, host, port) - return + return DecisionDefault } - - // Known trackers (analytics/ads) are silently denied — no prompt, no connection. - // Checked AFTER IsAllowed so a user "Always Allow" still wins. No audit emit: - // trackers retry aggressively and would flood the log. + // Known trackers are silently denied — checked AFTER IsAllowed so a user + // "Always Allow" still wins; no audit (they retry aggressively and would flood). if p.allowlist.IsTracker(host) { - http.Error(w, "Egress denied: tracker blocked", http.StatusForbidden) - return + return DecisionDeny } + return p.holdForApproval(host, port) +} - // Hold the connection and wait for approval - decision := p.holdForApproval(host, port) +// permitted reports whether a decision from decide() allows the connection. +func permitted(decision string) bool { + return decision == DecisionDefault || decision == DecisionAllowOnce || decision == DecisionAllowAlways +} - switch decision { - case DecisionAllowOnce, DecisionAllowAlways: +// handleConnect handles HTTPS CONNECT tunneling. +func (p *EgressProxy) handleConnect(w http.ResponseWriter, r *http.Request) { + host, port := splitHostPort(r.Host, 443) + if permitted(p.decide(host, port)) { p.tunnelConnect(w, host, port) - default: - // Deny: send 403 and close - http.Error(w, "Egress denied: domain not approved", http.StatusForbidden) + return } + http.Error(w, "Egress denied: domain not approved", http.StatusForbidden) } // handleHTTP handles plain HTTP proxy requests. @@ -318,27 +324,9 @@ func (p *EgressProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { return } - // Localhost always bypasses — never hold or prompt for loopback traffic. - if isLocalhost(host) { - // fall through to forward - } else if p.allowlist.IsDenied(host) { - // Permanently denied — reject immediately, no prompt (takes precedence over allowlist). - http.Error(w, "Egress denied: domain permanently blocked", http.StatusForbidden) - return - } else if p.allowlist.IsAllowed(host) { - p.emitAudit(host, port, "", DecisionDefault) - // fall through to forward - } else if p.allowlist.IsTracker(host) { - // Known tracker — silently denied (no prompt). Checked after IsAllowed so a - // user "Always Allow" still wins. - http.Error(w, "Egress denied: tracker blocked", http.StatusForbidden) + if !permitted(p.decide(host, port)) { + http.Error(w, "Egress denied: domain not approved", http.StatusForbidden) return - } else { - decision := p.holdForApproval(host, port) - if decision != DecisionAllowOnce && decision != DecisionAllowAlways { - http.Error(w, "Egress denied: domain not approved", http.StatusForbidden) - return - } } // Forward the request @@ -369,23 +357,17 @@ func (p *EgressProxy) handleHTTP(w http.ResponseWriter, r *http.Request) { io.Copy(w, resp.Body) } -// CheckAndHold evaluates host:port against the egress allowlist, mirroring the -// CONNECT handler logic. Used to gate browser navigation (Chromium runs as root -// and is outside the iptables UID-range rule, so this is the only interception -// point for xdg-open traffic). +// CheckAndHold evaluates host:port against the full egress decision ladder. Used to +// gate browser navigation (Chromium runs as root and is outside the iptables +// UID-range rule, so this is the only interception point for xdg-open traffic). +// Delegates to decide() so browser navigation honors permanent denies and the +// tracker denylist, not just the allowlist. // -// Returns DecisionDefault or DecisionAllow* if the host is permitted, or -// DecisionDeny if the user denied or the timeout elapsed. -// REVISION: egress-proxy-v7-loopback-range +// Returns DecisionDefault/DecisionAllow* if permitted, or DecisionDeny/DecisionTimeout +// if blocked (denied, tracker, user-denied, or timed out). +// REVISION: egress-proxy-v10-unified-decide func (p *EgressProxy) CheckAndHold(host string, port int) string { - if isLocalhost(host) { - return DecisionDefault - } - if p.allowlist.IsAllowed(host) { - p.emitAudit(host, port, "", DecisionDefault) - return DecisionDefault - } - return p.holdForApproval(host, port) + return p.decide(host, port) } // holdForApproval blocks the current goroutine until the user approves/denies diff --git a/sandbox/internal/egress/proxy_test.go b/sandbox/internal/egress/proxy_test.go index 28cc5215..7d9f98df 100644 --- a/sandbox/internal/egress/proxy_test.go +++ b/sandbox/internal/egress/proxy_test.go @@ -63,6 +63,46 @@ func TestExtractHTTPDestination_HostSpoofRejected(t *testing.T) { } } +// TestProxy_DecideHonorsDenyAndTracker guards the unified decision ladder: every +// interception point (incl. CheckAndHold used for browser navigation and the +// transparent/kernel path) must block permanent-denies and trackers, not just +// fall back to allowlist-or-hold. These cases return immediately (no hold). +func TestProxy_DecideHonorsDenyAndTracker(t *testing.T) { + al := NewAllowlist() + al.AddDeniedDomain("blocked.example.com", "deny-1") + proxy := NewEgressProxy(0, al) + + cases := []struct { + name string + host string + want bool // permitted? + }{ + {"localhost bypass", "127.0.0.1", true}, + {"default allowlisted", "github.com", true}, + {"permanent deny", "blocked.example.com", false}, + {"known tracker (GTM)", "www.googletagmanager.com", false}, + {"known tracker (GA wildcard)", "region1.google-analytics.com", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := permitted(proxy.decide(tc.host, 443)) + if got != tc.want { + t.Errorf("decide(%q) permitted=%v, want %v", tc.host, got, tc.want) + } + // CheckAndHold (browser-navigation gate) must agree with decide. + if permitted(proxy.CheckAndHold(tc.host, 443)) != tc.want { + t.Errorf("CheckAndHold(%q) disagreed with decide", tc.host) + } + }) + } + + // A user "Always Allow" must override the tracker denylist. + al.AddUserDomain("www.googletagmanager.com", "user-1") + if !permitted(proxy.decide("www.googletagmanager.com", 443)) { + t.Error("expected user-approved tracker to be permitted (user allow wins)") + } +} + func TestProxy_Resolve(t *testing.T) { al := NewAllowlist() proxy := NewEgressProxy(0, al) diff --git a/sandbox/internal/egress/transparent_linux.go b/sandbox/internal/egress/transparent_linux.go index fb636da7..26c71bb9 100644 --- a/sandbox/internal/egress/transparent_linux.go +++ b/sandbox/internal/egress/transparent_linux.go @@ -182,16 +182,11 @@ func (p *EgressProxy) handleTransparent(conn net.Conn) { } domain = strings.ToLower(domain) - // Allowlist check + hold for user approval (same flow as CONNECT path) - if !isLocalhost(domain) { - if p.allowlist.IsAllowed(domain) { - p.emitAudit(domain, origPort, "", DecisionDefault) - } else { - decision := p.holdForApproval(domain, origPort) - if decision != DecisionAllowOnce && decision != DecisionAllowAlways { - return // denied or timed out - } - } + // Full egress decision ladder (same source of truth as the CONNECT/HTTP paths): + // localhost → deny → allowlist → tracker → hold. decide() handles localhost and + // emits the allow audit. + if !permitted(p.decide(domain, origPort)) { + return // denied (permanent/tracker/user) or timed out } // Dial the declared domain (not origIP). Using origIP here would allow a PTY From 2e1f1caa35032bf8b768964e00a5b16d4d285be3 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Fri, 19 Jun 2026 10:21:52 +0100 Subject: [PATCH 3/3] chore: gitignore local root scratch (recordings, plans, logs, root node files) Root-anchored so they don't shadow tracked assets in subdirs (frontend/public/*.png, *.mp4, desktop icons). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ebd4a561..9bc24d84 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,25 @@ e2e/playwright-report/ e2e/node_modules/ # Claude Code settings -.claude/ \ No newline at end of file +.claude/ + +# Local dev scratch / artifacts at repo root (anchored to root so they don't +# shadow tracked assets in subdirs, e.g. frontend/public/*.png, *.mp4). +# Screen recordings / scratch media +/*.mkv +/*.mov +/*.mp4 +/*.webm +/*.png +# Scratch plans / docs +/PLAN-*.md +/EGRESS_HARDENING.md +/GMAIL.md +# Local logs / scratch output +/logs/ +/sandboxlogs.txt +/reference/ +# Local-only scripts and root node scratch +/deploy_dev.sh +/package.json +/package-lock.json \ No newline at end of file