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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The Cloud VM runs inside a Firecracker microVM. Docker requires:

9. **Go version**: The project requires Go 1.25.0 (per `server/go.mod`). The system Go may be older — ensure `/usr/local/go/bin` is on PATH.

10. **Build headful image**: `cd /workspace && DOCKER_BUILDKIT=1 docker build -f images/chromium-headful/Dockerfile -t kernel-headful-test .` — this takes significantly longer than headless (~10 min) due to Xorg dependencies, Mutter, and the WebRTC client build. The headful `run-docker.sh` runs interactively (`-it`); for background use, run with `-d` instead.
10. **Build headful image**: `cd /workspace && DOCKER_BUILDKIT=1 docker build -f images/chromium-headful/Dockerfile -t kernel-headful-test .` — this takes significantly longer than headless (~10 min) due to Xorg dependencies, the window manager, and the WebRTC client build. The headful `run-docker.sh` runs interactively (`-it`); for background use, run with `-d` instead.

11. **`/process/exec` API schema**: The `command` field is a single string (the binary name), not an array. Arguments go in the separate `args` array field. Response `stdout_b64` / `stderr_b64` are base64-encoded.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Deployed successfully!
- The image requires at least 8gb of memory.
- To deploy the implementation with WebRTC desktop streaming enabled instead of noVNC: `ENABLE_WEBRTC=true NEKO_ICESERVERS=xxx ./run-unikernel.sh`
- Deploying to Unikraft Cloud requires the usage of a [TURN server](https://webrtc.org/getting-started/turn-server) when `ENABLE_WEBRTC=true`, as direct exposure of UDP ports is not currently supported. `NEKO_ICESERVERS`: Describes multiple STUN and TURN server that can be used by the ICEAgent to establish a connection with a peer. e.g. `[{"urls": ["turn:turn.example.com:19302", "stun:stun.example.com:19302"], "username": "name", "credential": "password"}, {"urls": ["stun:stun.example2.com:19302"]}]`.
- Various services (mutter, tint) take a few seconds to start-up. Once they do, the standby and restart time is extremely fast.
- Various services (openbox, tint) take a few seconds to start-up. Once they do, the standby and restart time is extremely fast.
- The Unikraft deployment generates a url. This url is public, meaning _anyone_ can access the remote GUI if they have the url. Only use this for non-sensitive browser interactions, and delete the unikernel instance when you're done.
- You can call `browser.close()` to disconnect to the browser, and the unikernel will go into standby after network activity ends. You can then reconnect to the instance using CDP. `browser.close()` ends the websocket connection but doesn't actually close the browser.
- VCPUS value can be adjusted using the variable: `VCPUS=8`
Expand Down
10 changes: 9 additions & 1 deletion images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,15 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=$CACHEIDPREFIX-ap
scrot \
imagemagick \
sudo \
mutter \
openbox \
curl \
# Network tools
net-tools \
netcat \
# Userland apps
x11-apps \
# xprop, used to detect when the window manager has taken the display
x11-utils \
tint2 \
wget \
xdg-utils \
Expand Down Expand Up @@ -407,6 +409,12 @@ COPY shared/pulseaudio-daemon.conf /etc/pulse/daemon.conf.d/10-kernel-sample-rat
RUN chmod +x /usr/local/bin/start-pulseaudio.sh
COPY images/chromium-headful/supervisord.conf /etc/supervisor/supervisord.conf
COPY images/chromium-headful/supervisor/services/ /etc/supervisor/conf.d/services/
# Openbox reads /etc/xdg/openbox/rc.xml when the running user has no
# ~/.config/openbox, which keeps this working whether the WM runs as root or
# as the kernel user.
COPY images/chromium-headful/openbox/rc.xml /etc/xdg/openbox/rc.xml
COPY images/chromium-headful/openbox/themes/kernel-light /usr/share/themes/kernel-light
COPY shared/set-desktop-background.sh /usr/local/bin/set-desktop-background.sh
COPY shared/envoy/supervisor-envoy.conf /etc/supervisor/conf.d/services/envoy.conf

# Install Envoy proxy
Expand Down
89 changes: 89 additions & 0 deletions images/chromium-headful/openbox/rc.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Openbox configuration for the headful browser image.

Decoration is left entirely to chromium, which asks per window through
_MOTIF_WM_HINTS: the main browser window requests no frame (it draws its own
client-side decoration) and openbox honours that, while popups — OIDC and
other login windows — request one and get it. Do not add an <applications>
rule forcing <decor>no</decor> on role "browser": it is redundant for the
main window, and devtools windows also carry that role, so the rule strips
their titlebar and leaves them with no way to close.

This file sets the titlebar layout and the input bindings needed by the
remote-control API. The titlebar has no minimize button: the image has no
taskbar, and maximize is meaningless on a fixed-size display. Minimize is not
reachable from the UI, and Alt-Tab restores a window minimized through X.

No <keyboard> section is set on purpose. Alt-Tab and Alt-F4 work with this
file as shipped. Whether a <keyboard> section replaces openbox's built-in
bindings has not been tested, so if you add one, re-check Alt-Tab and Alt-F4.
-->
<openbox_config xmlns="http://openbox.org/3.4/rc">
<theme>
<name>kernel-light</name>
<titleLayout>LC</titleLayout>
<animateIconify>no</animateIconify>
</theme>

<!-- This file replaces the distro rc.xml, so keep the input bindings needed
for focus, titlebar movement, resizing, and popup close actions here. -->
<mouse>
<context name="Frame">
<mousebind button="A-Left" action="Press">
<action name="Focus"/>
<action name="Raise"/>
</mousebind>
<mousebind button="A-Left" action="Drag">
<action name="Move"/>
</mousebind>
<mousebind button="A-Right" action="Drag">
<action name="Resize"/>
</mousebind>
</context>

<context name="Titlebar Close">
<mousebind button="Left" action="Press">
<action name="Focus"/>
<action name="Raise"/>
</mousebind>
</context>

<context name="Titlebar">
<mousebind button="Left" action="Drag">
<action name="Move"/>
</mousebind>
</context>

<context name="Top Left Right Bottom TLCorner TRCorner BRCorner BLCorner">
<mousebind button="Left" action="Drag">
<action name="Resize"/>
</mousebind>
</context>

<context name="Client">
<mousebind button="Left" action="Press">
<action name="Focus"/>
<action name="Raise"/>
</mousebind>
<mousebind button="Middle" action="Press">
<action name="Focus"/>
<action name="Raise"/>
</mousebind>
<mousebind button="Right" action="Press">
<action name="Focus"/>
<action name="Raise"/>
</mousebind>
</context>

<context name="Close">
<mousebind button="Left" action="Click">
<action name="Close"/>
</mousebind>
</context>

<!-- The root window is deliberately unbound: clicks on the desktop are a
no-op, matching mutter, and there is no desktop-management window in
this image for the Desktop context to apply to. -->
</mouse>
</openbox_config>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# kernel-light — light openbox theme for the headful browser image.
# Only chromium popup windows are decorated (the main window draws its own CSD
# and requests no frame), so this only has to look right on a small dialog.
# Palette per the kernel design system: beige #f2f0e7, charcoal #212225,
# grey-light-11 #60646c, grey-light-07 #d0d2d9 (borders), kernel green #81b300.
# Closest match to mutter's default beige titlebar, so the WM swap is nearly
# invisible on decorated popups.

border.width : 1
padding.width : 6
padding.height : 10
window.client.padding.width : 0
window.client.padding.height : 0
window.handle.width : 0

*.justify : center
*.font : shadow=n:font=DejaVu Sans:size=11

# frame border
window.active.border.color : #d0d2d9
window.inactive.border.color : #e0e1e6
window.*.client.color : #f2f0e7

# titlebar: flat kernel beige, charcoal text
window.*.title.bg : flat solid
window.active.title.bg.color : #f2f0e7
window.inactive.title.bg.color : #e1dccf
window.active.label.bg : parentrelative
window.inactive.label.bg : parentrelative
window.active.label.text.color : #212225
window.inactive.label.text.color : #60646c

# buttons: charcoal controls on beige
window.*.button.*.bg : parentrelative
window.active.button.unpressed.image.color : #212225
window.active.button.hover.image.color : #60646c
window.active.button.pressed.image.color : #81b300
window.active.button.disabled.image.color : #d0d2d9
window.inactive.button.unpressed.image.color : #60646c
window.inactive.button.hover.image.color : #212225
window.active.button.toggled.unpressed.image.color : #81b300


osd.border.color : #d0d2d9
osd.bg.color : #f2f0e7
osd.label.text.color : #212225
osd.hilight.bg.color : #81b300
osd.unhilight.bg.color : #d0d2d9
7 changes: 0 additions & 7 deletions images/chromium-headful/supervisor/services/mutter.conf

This file was deleted.

7 changes: 7 additions & 0 deletions images/chromium-headful/supervisor/services/openbox.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[program:openbox]
command=openbox --replace --sm-disable
autostart=false
autorestart=true
startsecs=0
stdout_logfile=/var/log/supervisord/openbox
redirect_stderr=true
21 changes: 10 additions & 11 deletions server/cmd/api/api/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,9 @@ func (s *ApiService) patchDisplayLocked(ctx context.Context, req oapi.PatchDispl
}
width, height = realizedW, realizedH
}
// Re-assert the maximized window state via CDP so mutter reflows the
// window onto the new root; applyResolutionAndConverge already verified
// the root size, which is the authoritative post-condition. Fatal on
// Re-assert the maximized window state via CDP so the window manager
// reflows the window onto the new root; applyResolutionAndConverge already
// verified the root size, which is the authoritative post-condition. Fatal on
// failure — returning 200 with the window stuck in its old state would
// leave the caller with no signal of the mismatch. The previous approach
// of restarting chromium so it could re-apply --start-maximized had the
Expand Down Expand Up @@ -421,15 +421,15 @@ func (s *ApiService) withCDPClientTimeout(ctx context.Context, timeout time.Dura

// setWindowMaximizedViaCDP re-asserts that the chromium OS window is in
// the "maximized" state via Browser.setWindowBounds. After a successful
// xrandr/Neko resize, mutter reflows a maximized (or fullscreen) window
// to fill the new root automatically — this call ensures the window is
// xrandr/Neko resize, the window manager reflows a maximized (or fullscreen)
// window to fill the new root automatically — this call ensures the window is
// in the state that triggers the reflow. The CDP call is idempotent: it
// no-ops on a window already in maximized or fullscreen state.
//
// The PATCH /display handler reads the X root separately (via
// getCurrentResolutionFromXrandr) to capture the realized size for the
// response, rather than reading the chromium window's own bounds. That
// keeps the contract panel-robust: if mutter ever gained a taskbar/dock,
// keeps the contract panel-robust: if the WM ever gained a taskbar/dock,
// a maximized window would be smaller than the root by the panel's
// reserved space, and any window-bounds-based check would diverge from
// the X root.
Expand Down Expand Up @@ -536,11 +536,10 @@ func abs(x int) int {
// otherwise xrandr — and waits for the X root to actually reach the requested
// size, returning the realized dimensions.
//
// The poll absorbs transient X root states: chromium running in --kiosk briefly
// pushes the root to the dummy DDX's max mode (3840x2160) while mutter settles
// on the new screen, and a single immediate read would catch that transient. It
// also captures libxcvt's rounded size on requests the driver can't honour
// exactly (CVT 8-pixel grid + FWXGA bump for 1360x768 -> 1366x768).
// The poll absorbs transient X root states while Xorg/Neko applies the new
// mode and the WM settles; a single immediate read can catch an intermediate
// size. It also captures libxcvt's rounded size on requests the driver can't
// honour exactly (CVT 8-pixel grid + FWXGA bump for 1360x768 -> 1366x768).
//
// On the Neko path it re-issues the reconfig when the root never converges.
// Neko applies screen configuration asynchronously and can drop/clobber a
Expand Down
14 changes: 7 additions & 7 deletions server/cmd/chromium-launcher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,14 @@ func main() {
fmt.Fprintf(os.Stderr, "warning: X display :1 not responsive after %s\n", d)
}

// Headful: wait for mutter to register before exec'ing chromium. If
// chromium maps its window with no WM present, the CSD hint it sends has
// no listener; mutter starts later, reparents the existing window, and
// applies default SSD — i.e., the titlebar with the close X. Headless
// has no WM, so skip.
// Headful: wait for the window manager to register before exec'ing
// chromium. If chromium maps its window with no WM present, the CSD hint
// it sends has no listener; the WM starts later, reparents the existing
// window, and applies default SSD — i.e., the titlebar with the close X.
// Headless has no WM, so skip.
if !*headless {
if d := x11.WaitForMutter(20 * time.Second); d >= 20*time.Second {
fmt.Fprintf(os.Stderr, "warning: mutter not registered after %s\n", d)
if d := x11.WaitForWindowManager(20 * time.Second); d >= 20*time.Second {
fmt.Fprintf(os.Stderr, "warning: no window manager registered after %s\n", d)
}
}

Expand Down
45 changes: 45 additions & 0 deletions server/cmd/wrapper/desktop_background_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)

// TestSetDesktopBackgroundStalledProbe guards the boot path: the background
// script runs synchronously between the X server coming up and openbox
// starting, so a stalled geometry probe must fall back within its advertised
// budget instead of blocking wrapper startup indefinitely.
func TestSetDesktopBackgroundStalledProbe(t *testing.T) {
script := filepath.Join("..", "..", "..", "shared", "set-desktop-background.sh")
if _, err := os.Stat(script); err != nil {
t.Fatalf("script not found: %v", err)
}

fake := t.TempDir()
bin := filepath.Join(fake, "xwininfo")
// A geometry probe that hangs forever: without the per-stage bound the
// script would block for the full sleep instead of reaching the fallback.
if err := os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755); err != nil {
t.Fatal(err)
}

cmd := exec.Command("bash", script)
cmd.Env = append(os.Environ(),
"PATH="+fake+string(os.PathListSeparator)+os.Getenv("PATH"),
"BG_TIMEOUT=2",
"DISPLAY=",
)

start := time.Now()
err := cmd.Run()
elapsed := time.Since(start)

if elapsed > 8*time.Second {
t.Fatalf("script ran for %v with a stalled probe; the per-stage timeout did not bound it", elapsed)
}
t.Logf("script fell back in %v (stalled probe bounded)", elapsed)
_ = err // exit code is not asserted: the fallback chain fails without a display
}
26 changes: 19 additions & 7 deletions server/cmd/wrapper/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,14 @@ func main() {
// (baked into the image at build time, see shared/envoy/bake-certs.sh)
// so it trusts the forward proxy on first start with no runtime cert
// work to wait on. chromium-launcher internally waits for the X server
// and (headful) for mutter before exec'ing chromium, so we start it in
// parallel with the X server to overlap chromium-launcher's preamble
// with display startup. chromedriver listens on 9225 immediately and
// only attaches to chromium on session creation, so it can come up
// alongside everything. mutter has no internal X-wait, so it's started
// and (headful) for the window manager before exec'ing chromium, so we
// start it in parallel with the X server to overlap chromium-launcher's
// preamble with display startup. chromedriver listens on 9225 immediately
// and only attaches to chromium on session creation, so it can come up
// alongside everything. openbox has no internal X-wait, so it's started
// as soon as the X server is confirmed up — chromium-launcher gates on
// it so chrome can negotiate CSD with the WM before mapping its window
// (without it, mutter reparents the existing window with default SSD
// (without it, the WM reparents the existing window with default SSD
// and a titlebar appears). neko reads the active display mode at start,
// so it's deferred until after the dbus wait on the WebRTC path.
xServer := "xorg"
Expand All @@ -187,7 +187,8 @@ func main() {
startAll(xServer, "dbus", "chromedriver", "pulseaudio")
waitForX(defaultDisplay, 20*time.Second)
if prof == profileHeadful {
startAll("mutter")
setDesktopBackground()
startAll("openbox")
}
waitForSocket(pulseSocket, 10*time.Second)
startAll("chromium")
Expand Down Expand Up @@ -374,3 +375,14 @@ func fatalf(format string, args ...any) {
logf(format, args...)
os.Exit(1)
}

// setDesktopBackground paints the X root window before the WM starts.
// Best-effort: a failed paint only leaves the root at its fallback color.
func setDesktopBackground() {
if !isExecutable("/usr/local/bin/set-desktop-background.sh") {
return
}
if err := runStream("set-desktop-background", "/usr/local/bin/set-desktop-background.sh"); err != nil {
logf("WARNING: desktop background: %v", err)
}
}
Loading
Loading