Initial commit: MIT-licensed compositor architecture & build plan (14 files, ~80 pages, 10 parallel research agents)

This commit is contained in:
1b-agent committed 2026-10-08 08:48:28 +00:00
commit 582e61e942
15 files changed
+4970

No files matched your search

+423
View File
@@ -0,0 +1,423 @@
# 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/<path>` with
`Authorization: Bearer <jwt>` 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', '<memfd>',
'--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/<UID>` 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 <memfd>` |
| 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 <jwt>
├──────────────────────────────► │
│ │ validate JWT (HS256, pyjwt)
│ │ if no child for <session_id>:
│ │ 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', '<memfd>',
│ │ │ '--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 <jwt>
├──────────────────────────────► │
│ │ 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 <jwt>
├──────────────────────────────► │ 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 <memfd>
│ - --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 |