Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,25 @@ e2e/playwright-report/
e2e/node_modules/

# Claude Code settings
.claude/
.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
13 changes: 9 additions & 4 deletions desktop/app/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);

Expand Down
58 changes: 38 additions & 20 deletions desktop/app/src-tauri/src/vm/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,33 +36,51 @@ pub fn stage_image(src: &Path, dest: &Path) -> Result<PathBuf, VMError> {
} 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<bool, VMError> {
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<String, VMError> {
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<bool, VMError> {
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
}
}

Expand Down
28 changes: 17 additions & 11 deletions desktop/app/src-tauri/src/vm/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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(())
Expand Down
82 changes: 82 additions & 0 deletions desktop/app/src-tauri/vz-helper/Sources/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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...")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading