# 07 — Native App Bridge (pywayland + bubblewrap + seccomp + systemd-run) This chapter covers the native app bridge in the MIT approach: how real Linux applications get launched, sandboxed, connected to the Python proxy, and routed to the browser. **This is the most important chapter for the user** — they want to run real Linux apps in their Odoo 20 web app. ## Process model — three layers (same as Greenfield, but in Python) ### Layer 1: Main process (HTTP + WS hub) Single Python process (aiohttp) listens on `:8081`. Per Odoo session, it forks one `SessionProcess` child — one child per `compositor_session_id`, **not** per app. Multiple apps in the same browser tab share one child. WebSocket upgrades are handed off to the matching child via `multiprocessing.reduction.send_handle` (uses SCM_RIGHTS on Linux). ### Layer 2: Session process The forked child. Receives IPC messages: - `start` — creates `Session` (Display, initShm, initDrm) + WS dispatcher - `launch_app` — spawns the actual app binary via `subprocess.Popen` + `bubblewrap` + `seccomp` + `systemd-run --scope` - `ws_upgrade` — hands WS to the dispatcher On `session.close()` the process exits. ### Layer 3: Per-client session For each new `wl_client` (one per Wayland connection), creates a `PROTOCOL` ARQ channel + `FRAME` ARQ channel. Marshalls FDs as ProxyFD JSON. Routes requests via per-resource dispatchers. ## App launch flow (definitive answer) 1. Browser sends `GET http://proxy:8081/` with `Authorization: Bearer ` header. 2. Main process validates JWT (HS256, via `pyjwt`). 3. Main process forks a `SessionProcess` if none exists for that `compositor_session_id`, sends `start` IPC. 4. Main sends `launch_app` IPC with `{name, executable, args, env, serial}`. 5. `SessionProcess` calls `launch_application()`: - `appEnv = {...os.environ, ...env, WAYLAND_DISPLAY: session.wayland_display}` - `subprocess.Popen(['systemd-run', '--user', '--scope', '-p', 'MemoryMax=1G', '-p', 'CPUQuota=200%', '-p', 'TasksMax=256', '--', 'bwrap', '--unshare-all', '--clearenv', '--seccomp', '', '--ro-bind', '/usr', '/usr', '--dev-bind', '/dev/dri/renderD128', '/dev/dri/renderD128', '--ro-bind', f'{host_xdg}/wayland-N', f'/run/user/{uid}/wayland-N', '--uid', str(uid), '--gid', str(gid), '--new-session', '--die-with-parent', executable, *args], env=appEnv)` 6. On `spawn` event, captures `pid`, creates `NativeAppContext`, registers `exit` listener. 7. Replies to main with `{pid, key}` → main returns HTTP 201 with `{baseURL, signalURL, key, pid, name}`. 8. Browser opens `signalURL` as WebSocket → control channel established. 9. App connects to `$XDG_RUNTIME_DIR/wayland-N` → libwayland fires `wl_client` creation → pywayland's per-resource dispatchers route requests to `{native, browser}`. 10. For each new `wl_client`, creates a `PROTOCOL` ARQ channel and sends `CONNECT_CHANNEL` over the signaling WS; browser opens a new WS at `/channel?id=…`. 11. Wayland wire messages now flow: app → libwayland → pywayland dispatcher → routed to `{native, browser}`. ## Sandboxing (NEW — Greenfield has none) This is the **critical gap** in Greenfield (pitfall M). The MIT approach ships with proper sandboxing from day one. ### Recommended stack - **bubblewrap (bwrap)** — LGPL-2.0+, unprivileged, setuid-free, Flatpak-grade - **seccomp BPF** — libseccomp LGPL-2.1, Docker's default profile + allow `CLONE_NEWUSER` - **systemd-run --scope** — native cgroup v2 integration (MemoryMax, CPUQuota, TasksMax) - **Linux user namespaces** — UID remap to 100000-165535 via `/etc/subuid`/`/etc/subgid` - **cgroups v2** — memory, CPU, PID limits per app ### Reference bwrap command line (GTK4 app, GPU, no network) ```bash systemd-run --user --scope --unit=app-odoo-calculator-$(uuid) \ -p MemoryMax=256M -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=32 -p Delegate=yes -- \ bwrap \ --clearenv --unshare-all --hostname sandbox --new-session --die-with-parent \ --symlink usr/lib /lib --symlink usr/lib64 /lib64 \ --symlink usr/bin /bin --symlink usr/bin /sbin \ --ro-bind /usr /usr --ro-bind /opt /opt \ --ro-bind /etc/passwd /etc/passwd --ro-bind /etc/group /etc/group \ --ro-bind /etc/hosts /etc/hosts --ro-bind /etc/localtime /etc/localtime \ --ro-bind /etc/locale.conf /etc/locale.conf --ro-bind /etc/machine-id /etc/machine-id \ --ro-bind /etc/fonts /etc/fonts --ro-bind /etc/ssl /etc/ssl \ --ro-bind /etc/ca-certificates /etc/ca-certificates \ --proc /proc --dev /dev \ --dev-bind-try /dev/dri/renderD128 /dev/dri/renderD128 \ --tmpfs /tmp --tmpfs /var/tmp --tmpfs /run \ --dir /run/user/100000 \ --ro-bind "$HOST_XDG/wayland-0" /run/user/100000/wayland-0 \ --overlay /usr/share/compositor-proxy/skel/gnome-calculator:/var/lib/compositor-proxy/tenants/$TENANT/appdata/gnome-calculator-upper /home/appuser \ --setenv HOME /home/appuser \ --setenv XDG_RUNTIME_DIR /run/user/100000 \ --setenv WAYLAND_DISPLAY wayland-0 \ --setenv XDG_DATA_DIRS /usr/share --setenv PATH /usr/bin --setenv LANG en_US.UTF-8 \ --uid 100000 --gid 100000 \ --seccomp /proc/self/fd/3 \ /usr/bin/gnome-calculator ``` ### Key design decisions | Concern | Decision | Rationale | |---|---|---| | Sandbox runner | **bubblewrap** | MIT license, unprivileged, setuid-free, Flatpak-grade | | cgroup limits | **`systemd-run --user --scope` wrapping bwrap** | Native cgroup v2 integration; no manual cgroupfs writes | | Wayland socket | **Single-file `--ro-bind` of `wayland-N`** into fresh `/run/user/` tmpfs | Critical: prevents access to PulseAudio, pipewire, D-Bus, keyring, and sibling tenants' sockets | | GPU | **`--dev-bind /dev/dri/renderD128`** (single render node, NOT `card0`) | Kernel render-node access control already restricts to non-display ioctls | | Network | **`--unshare-net` by default** (loopback-down only); per-app opt-in to veth+nftables filter | Most GTK apps need no network. Firefox/Odoo clients opt in. | | Seccomp | **Docker's default profile** (allowlist of ~250 syscalls, blocking ~44) + allow `CLONE_NEWUSER` for nested sandboxes | Compile to BPF at proxy startup; pass via `bwrap --seccomp ` | | Filesystem | **`/usr` ro-bind, minimal `/etc` whitelist, `/tmp` tmpfs, `$HOME` overlay (changes discarded)** | No host write access; per-app `$HOME` reset on each launch kills malware persistence | | User ns | **UID remap to 100000–165535 via `/etc/subuid`/`/etc/subgid`** | Even on kernel escape the app is unprivileged on host; per-app UID enables nftables filtering and audit | | XWayland | **v1: unsandboxed (documented limitation); v2: per-Xwayland bwrap sandbox** | Hardest piece (3–4 days). v1 ships like Flatpak's old `socket=x11`. | | `$XDG_RUNTIME_DIR` | **`--clearenv` then explicit `--setenv` whitelist** | No host env leak (DB password, Odoo session cookies, AWS keys, etc.) | ## Native addon architecture (NO native Node addons — pure Python) **No N-API addons, no `.node` files, no CMake/Ninja build chain.** The entire proxy is pure Python. ### Replaces Greenfield's 3 native C addons | Greenfield native | Python replacement | Notes | |---|---|---| | `wayland-server-addon.node` (1231 lines C, patched libwayland) | **pywayland** (Apache-2.0) + 5-line cffi shim | Vanilla upstream libwayland 1.26.0 bundled in pywayland's wheel. Apply Greenfield's `changes.diff` (MIT patches) optionally for fast-sync optimization. | | `proxy-poll-addon.node` (127 lines C, libuv `uv_poll_t`) | `asyncio.get_running_loop().add_reader(wl_fd, dispatch_fn)` | Built-in asyncio. NO addon needed. | | `proxy-encoding-addon.node` (290 lines C) + `libproxy-encoding.so` (~1400 lines C, GStreamer) | **PyGObject + GStreamer Python bindings** (LGPL) | Same GStreamer, Python wrapper. ~700 LOC Python replaces ~1700 LOC C. | | `westfield-*.c` (EGL, dmabuf, xwayland helpers, ~2000 lines C) | **Vendored Greenfield C files via cffi** (MIT) | Reuse verbatim. ~3000 LOC of MIT C cherry-picked from Greenfield. | ### Vendored Greenfield C files (MIT, reused as-is) These files from `greenfield/packages/compositor-proxy/native/wayland/src/` are MIT-licensed (Greenfield's own work or vendored from wlroots which is also MIT) and can be **reused verbatim** in our Python proxy: | File | Lines | Purpose | |---|---|---| | `westfield-buffer.c` | ~150 | Buffer helpers | | `westfield-dmabuf.c` | ~200 | DMA-BUF import via EGL/GBM | | `westfield-egl.c` | ~900 | EGL zero-copy setup | | `westfield-xwayland.c` | ~465 | XWayland spawn + bridge | | `wlr_drm.c` | ~244 | `wl_drm` global impl (vendored from wlroots) | | `wlr_linux_dmabuf_v1.c` | ~1,110 | `zwp_linux_dmabuf_v1` global impl (vendored from wlroots) | | `drm_format_set.c` | ~239 | DRM format helpers (vendored from wlroots) | | `addon.c` | ~73 | wlroots addon system helper | | **TOTAL** | **~3,374 lines C** | All MIT, reused verbatim | These compile into a single `libwestfield.so` shared library, loaded by the Python proxy via cffi at runtime. ## XWayland bridge ### Native side (Python equivalent of westfield-xwayland.c) In Python: ```python import subprocess, os, socket def spawn_xwayland(display_num, wl_client_fd, wm_fd, display_fd): # Create X11 socket x11_socket = f"/tmp/.X11-unix/X{display_num}" listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) listener.bind(x11_socket) listener.listen(1) # Set DISPLAY env os.environ["DISPLAY"] = f":{display_num}" # Fork + exec Xwayland subprocess.Popen([ "Xwayland", f":{display_num}", "-ac", "-rootless", "-listen", str(listener.fileno()), "-displayfd", str(display_fd), "-wm", str(wm_fd), ], pass_fds=[listener.fileno(), display_fd, wm_fd]) # Xwayland itself becomes a Wayland client of the proxy # (via wl_client_create(wl_display, wl_client_fd)) ``` ### xtsb (REUSED MIT, browser side) `@gfld/xtsb` (MIT) — pure-TS X11 client, no libxcb. Dual-environment (Node `net.Socket` + browser WebSocket). Auto-generated from XCB XML protocol files via `xcbgen/ts_client.py`. The browser-side xtsb implements the actual XWM logic (translates X `CreateNotify` events → `xdg_toplevel` Wayland surfaces). **Optional**: skip XWayland in MVP (8 weeks of effort). Add only if you need to run X11-only apps (most modern GTK/Qt apps are Wayland-native). ## SDK misconception corrected (same as Greenfield) The `sdk/` directory is **NOT for native apps**. It's exclusively for **WASM apps** that run inside the browser via Emscripten. The `sysrootlibs/` cross-compiles wayland/gtk/cairo/etc. to wasm32. Native Linux apps on the proxy use the host's own system libraries. ## ASCII diagram — native app launch & connection ``` Browser (Odoo OWL shell) Python proxy host (Linux) App (sandboxed in bwrap) ========================== ==================== ============================== [1] User clicks app in shell $ python -m compositor_proxy.cli \ │ └─── main.py listens on :8081 │ sessionProcesses: Dict[id, Process] │ │ HTTP GET /gtk4-demo │ Authorization: Bearer ├──────────────────────────────► │ │ │ validate JWT (HS256, pyjwt) │ │ if no child for : │ │ multiprocessing.Process(target=session_main) │ │ IPC: {type:'start', payload:{config, session_id}} │ │ └── create_session() → Display │ │ ├── Display() │ │ ├── display.add_socket() → "wayland-N" │ │ │ (becomes WAYLAND_DISPLAY) │ │ ├── display.init_shm() + YUV420/NV12/... │ │ ├── init_drm("/dev/dri/renderD128") │ │ │ (uses vendored westfield-egl.c via cffi) │ │ │ (registers zwp_linux_dmabuf_v1 + wl_drm globals) │ │ └── asyncio.add_reader(wl_fd, dispatch_wl) │ │ (5-line cffi shim for wl_event_loop_get_fd) │ │ │ │ IPC: {type:'launch_app', payload:{name, executable, args, env, serial}} │ │ └── launch_application() (NativeAppContext.py) │ │ ├── appEnv = {...os.environ, ...env, │ │ │ WAYLAND_DISPLAY: "wayland-N"} │ │ ├── subprocess.Popen([ │ │ │ 'systemd-run', '--user', '--scope', │ │ │ '-p', 'MemoryMax=1G', │ │ │ '-p', 'CPUQuota=200%', │ │ │ '-p', 'TasksMax=256', '--', │ │ │ 'bwrap', │ │ │ '--unshare-all', '--clearenv', │ │ │ '--seccomp', '', │ │ │ '--ro-bind', '/usr', '/usr', │ │ │ '--dev-bind', '/dev/dri/renderD128', '/dev/dri/renderD128', │ │ │ '--ro-bind', '$XDG/wayland-N', '/run/user/100000/wayland-N', │ │ │ '--uid', '100000', '--gid', '100000', │ │ │ '--new-session', '--die-with-parent', │ │ │ '/usr/bin/gtk4-demo' │ │ │ ], env=appEnv) │ │ │ ◄── systemd-run + bwrap, NO chroot, NO container │ │ │ ──────────────────────────────► [2] gtk4-demo (sandboxed) │ │ │ │ │ │ │ │ libwayland-client.so │ │ │ │ connects to │ │ │ │ $XDG_RUNTIME_DIR/wayland-N │ │ │ │ │ │ │ ▼ │ │ │ ┌──────────────────────────┐ │ │ │ │ wl_display event loop │ │ │ │ │ (in proxy, libwayland- │ │ │ │ │ server.so.0 — vanilla, │ │ │ │ │ NO patches needed) │ │ │ │ │ │ │ │ │ ◄── asyncio fires, │ wl_client created → │ │ │ │ dispatch_wl() called │ Client.get_credentials │ │ │ │ wl_event_loop_dispatch() │ → (pid, uid, gid) │ │ │ │ │ find_matching_app_ctx │ │ │ │ │ (pid) by PPID walk │ │ │ │ │ │ │ │ │ │ Per-resource dispatchers│ │ │ │ │ (auto-generated by │ │ │ │ │ pywayland scanner) │ │ │ │ │ route to {native, │ │ │ │ │ browser} │ │ │ │ └──────────────────────────┘ │ │ │ │ │ ├── child.on('spawn') → capture pid │ │ │ create_native_app_context(pid, name, sandbox=True) │ │ │ native_app_context.on_disconnect() # start 10-min SIGHUP timer │ │ │ │ │ └── child.on('exit') → native_app_context.on_exit({exit_code|signal}) │ │ │ │ IPC reply: {type:'launch_app_success', payload:{pid, key}} │ │ HTTP 201 Created │ Body: { baseURL, signalURL:"ws://…/signal?key=…", key, pid, name, sandboxed:true } ◄────────────────────────────── │ │ │ │ Open WS: ws://…/signal?key=<16-hex> │ Authorization: Bearer ├──────────────────────────────► │ │ │ aiohttp demux /signal → native_app_context.on_connect(ws) │ │ clears 10-min SIGHUP timer │ │ │ │ For each new wl_client (one per wayland connection): │ │ create_protocol_channel() → ARQChannel (kcp over WS) │ │ sends CONNECT_CHANNEL {url:"ws://…/channel?id=…&key=…", desc} │ ◄──── CONNECT_CHANNEL JSON ───┤ │ │ │ Open WS: ws://…/channel?id=…&key=… │ Authorization: Bearer ├──────────────────────────────► │ binds WS to the ARQChannel │ │ │ Wayland wire protocol flows over the ARQ channel (KCP reliable, 100ms interval): │ │ App → wl_surface.commit ──── proxy intercepts via dispatcher ────► browser compositor │ App ← wl_surface.frame ──── proxy intercepts E1 (frame callback) ◄── FrameFeedback paces callback │ App → wl_display.sync ────── proxy fast-path sync (no roundtrip if no reply expected) │ App ← wl_keyboard.enter ─── browser → proxy → libwayland-server sends via SCM_RIGHTS │ │ Buffer path (per wl_surface): │ - App submits wl_buffer (dmabuf or shm) │ - setBufferCreatedCallback → ProxyBuffer with creationSerial │ - On commit: dispatcher → Encoder.encodeFrame() │ └── gst_parse_launch("appsrc ! glupload ! glcolorconvert ! glshader ! " │ "glcolorconvert ! gldownload ! queue ! vaapih264enc ! appsink") │ (vaapih264enc — LGPL, NOT x264enc which is GPL) │ └── GLib MainLoop in separate thread │ └── encoded H.264 sample → asyncio.run_coroutine_threadsafe → main thread │ └── sent over the surface's FRAME data channel (separate WS) │ │ X11 path (xterm etc., IF XWayland enabled): │ - App connects to /tmp/.X11-unix/X1 (DISPLAY=:1, set by spawn_xwayland()) │ - First X connect → spawn_xwayland() forks Xwayland: execlp("Xwayland", …, "-rootless", "-wm", wm_fd) │ - Xwayland itself becomes a Wayland client of the proxy (wl_client_create) │ - When Xwayland ready (displayFd readable): │ └── xtsb (REUSED MIT, browser side) speaks X11 → translates X windows → xdg_toplevel surfaces │ │ Lifecycle: │ - User closes browser tab → /signal WS closes with code 1001 → kill('SIGHUP') │ - User clicks "force quit" in shell → /signal WS sends KILL_APP {signal:'SIGTERM'} → kill('SIGTERM') │ └── if not exited in 10s → SIGKILL │ - App crashes → child 'exit' event → native_app_context.on_exit() → APP_TERMINATED msg → browser UI updates │ - All clients gone for >1s → session.close() → process.exit() in SessionProcess.py → main deletes entry │ │ Sandbox (NEW vs Greenfield): │ - bwrap --unshare-all --clearenv --seccomp │ - --ro-bind /usr /usr, minimal /etc whitelist │ - --dev-bind /dev/dri/renderD128 (single GPU render node, no card0) │ - --ro-bind $HOST_XDG/wayland-N /run/user/100000/wayland-N (single socket only) │ - --uid 100000-165535 per-app (--gid same) │ - --unshare-net by default │ - cgroup: MemoryMax=1G, CPUQuota=200%, TasksMax=256 │ - $HOME overlay (changes discarded on app exit) ``` ## Effort estimates | Phase | Days | Deliverable | |---|---|---| | **v1 shippable** | **7-9 days** | bubblewrap integration + seccomp + cgroup limits + Wayland socket isolation + GPU render-node passthrough + default-deny net. XWayland unsandboxed (documented). | | v2 full | +4-9 days | + XWayland sandbox + veth+nftables networking + per-tenant hierarchical cgroups + XDG Desktop Portal | ### v1 breakdown | Component | Days | |---|---| | bwrap integration | 2-3 | | seccomp profile + compile | 1-2 | | systemd-run cgroup wrapper | 1 | | Wayland socket ownership fix | 0.5 | | GPU passthrough testing | 1 | | Schema + docs | 1 | | **TOTAL** | **7-9** | ## File reference ### Python proxy (custom, ~2880 LOC) | File | Role | |---|---| | `proxy/main.py` | HTTP + WS hub, forks per session | | `proxy/session.py` | Top-level proxy session | | `proxy/session_controller.py` | aiohttp WS server with 9 endpoint paths | | `proxy/native_app_context.py` | Per-app state, signaling WS, kill timers, bwrap spawn | | `proxy/native_wayland_client_session.py` | Per-client message dispatcher | | `proxy/native_wayland_compositor_session.py` | Display creation, initShm, initDrm | | `proxy/channel.py` | ARQChannel + SimpleChannel (clean-room) | | `proxy/kcp_adapter.py` | kcp PyPI package integration | | `proxy/frame_feedback.py` | Receives 4-byte decode feedback, paces wl_frame.done | | `proxy/proxy_buffer.py` | Proxy-side buffer representation | | `proxy/surface_buffer_encoding.py` | Encoding pipeline orchestration | | `proxy/xwayland_session.py` | XWayland spawn + bridge (Python equivalent of westfield-xwayland.c) | | `proxy/wayland_server.py` | pywayland wrapper + cffi shim | | `proxy/io/` | WebSocket server glue | | `proxy/encoding/` | GStreamer encoder orchestration via PyGObject | | `proxy/protocol/` | pywayland-generated dispatchers (auto) | | `proxy/jwt_auth.py` | JWT validation (pyjwt) | | `proxy/sandbox.py` | bubblewrap + seccomp + systemd-run wrappers | ### Vendored Greenfield C files (MIT, reused as-is) | Path | Role | |---|---| | `native/westfield-buffer.c` | Buffer helpers | | `native/westfield-dmabuf.c` | DMA-BUF import via EGL/GBM | | `native/westfield-egl.c` | EGL zero-copy setup | | `native/westfield-xwayland.c` | XWayland spawn + bridge (used as reference, ported to Python) | | `native/wlr_drm.c` | `wl_drm` global impl (vendored from wlroots) | | `native/wlr_linux_dmabuf_v1.c` | `zwp_linux_dmabuf_v1` global impl (vendored from wlroots) | | `native/drm_format_set.c` | DRM format helpers (vendored from wlroots) | | `native/addon.c` | wlroots addon system helper | | `native/changes.diff` | Patches to libwayland-server (optional, for fast-sync) | ### Reused (MIT/Apache) | Component | Role | |---|---| | pywayland | Python bindings to libwayland-server | | `xkbcommon` PyPI package | Python bindings to libxkbcommon (optional, for XWayland) | | PyGObject (`gi.repository.Gst`) | GStreamer Python bindings | | `kcp` PyPI package | Cython wrapper over upstream `ikcp.c` | | aiohttp | Async HTTP + WebSocket server | | PyJWT | JWT validation | | bubblewrap | Unprivileged sandboxing | | libseccomp | Seccomp BPF filter compilation |