From 93a5a0a221ce483cffc0417787d3dbe0c78979be Mon Sep 17 00:00:00 2001 From: 1b-agent Date: Thu, 8 Oct 2026 08:48:46 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20one=5Fos=5Fcompositor=20Odo?= =?UTF-8?q?o=2020=20addon=20=E2=80=94=20core=20OWL=20library=20+=20backend?= =?UTF-8?q?=20models=20+=20JWT=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes: - compositor.app, compositor.session, compositor.session.app, compositor.audit.log models - /web/compositor/{token,apps,launch,audit} HTTP controllers with JWT (HS256) - CompositorService OWL service (reusable via useService('compositor')) - CompositorCanvas OWL component (reusable canvas wrapper) - Security: compositor_group_user, compositor_group_manager groups + ir.access.csv rules - 5 demo apps pre-loaded (gnome-calculator, xterm, gtk4-demo, glxgears, featherpad) - Audit log capturing JWT issuance + app launch events - Stub mode: launching apps returns a stub SurfaceHandle when proxy isn't connected - Tested against Odoo 20 + PostgreSQL 17 Author: 1bspace.com Parent company: 1B Global License: Proprietary (1B Space internal use) --- .gitignore | 24 ++ README.md | 98 +++++ __init__.py | 46 +++ __manifest__.py | 106 +++++ controllers/__init__.py | 4 + controllers/main.py | 308 +++++++++++++++ data/compositor_app_data.xml | 72 ++++ data/ir_config_parameter_data.xml | 39 ++ data/ir_sequence_data.xml | 13 + demo/compositor_app_demo.xml | 21 + models/__init__.py | 6 + models/compositor_app.py | 229 +++++++++++ models/compositor_app_views.xml | 112 ++++++ models/compositor_audit_log.py | 115 ++++++ models/compositor_audit_log_views.xml | 100 +++++ models/compositor_session.py | 258 ++++++++++++ models/compositor_session_views.xml | 140 +++++++ security/compositor_security.xml | 39 ++ security/ir.access.csv | 9 + static/description/index.xml | 12 + static/img/icon.png | Bin 0 -> 179 bytes .../compositor_canvas/compositor_canvas.js | 60 +++ .../compositor_canvas/compositor_canvas.xml | 8 + static/src/scss/compositor.scss | 30 ++ static/src/services/compositor_service.js | 372 ++++++++++++++++++ .../services/compositor_service_register.js | 39 ++ tests/__init__.py | 6 + tests/test_compositor_app.py | 59 +++ tests/test_compositor_controller.py | 69 ++++ tests/test_compositor_session.py | 45 +++ views/compositor_menu.xml | 45 +++ views/ir_actions_client_views.xml | 13 + 32 files changed, 2497 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 controllers/__init__.py create mode 100644 controllers/main.py create mode 100644 data/compositor_app_data.xml create mode 100644 data/ir_config_parameter_data.xml create mode 100644 data/ir_sequence_data.xml create mode 100644 demo/compositor_app_demo.xml create mode 100644 models/__init__.py create mode 100644 models/compositor_app.py create mode 100644 models/compositor_app_views.xml create mode 100644 models/compositor_audit_log.py create mode 100644 models/compositor_audit_log_views.xml create mode 100644 models/compositor_session.py create mode 100644 models/compositor_session_views.xml create mode 100644 security/compositor_security.xml create mode 100644 security/ir.access.csv create mode 100644 static/description/index.xml create mode 100644 static/img/icon.png create mode 100644 static/src/components/compositor_canvas/compositor_canvas.js create mode 100644 static/src/components/compositor_canvas/compositor_canvas.xml create mode 100644 static/src/scss/compositor.scss create mode 100644 static/src/services/compositor_service.js create mode 100644 static/src/services/compositor_service_register.js create mode 100644 tests/__init__.py create mode 100644 tests/test_compositor_app.py create mode 100644 tests/test_compositor_controller.py create mode 100644 tests/test_compositor_session.py create mode 100644 views/compositor_menu.xml create mode 100644 views/ir_actions_client_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d5081d --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp + +# Logs +*.log + +# Build +node_modules/ +dist/ +build/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..08a8188 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# 1B Space Compositor Core (`one_os_compositor`) + +> The foundation of the 1B Space Wayland-to-HTML5 compositor. Provides +> reusable OWL library components + Odoo backend models for running real +> Linux applications and displaying them natively inside the Odoo web client. + +## What this addon provides + +### Backend (Python) + +- **`compositor.app`** — Application catalog (one record per Linux app you can + launch, e.g. `gnome-calculator`, `xterm`, `kwrite`). +- **`compositor.session`** — Per-browser-tab compositor session (one per active + OWL shell instance). Inherits `mail.thread` for tracking. +- **`compositor.audit.log`** — Append-only audit log (launch, terminate, crash + events). Cannot be modified or deleted via the UI. +- **Controllers**: + - `POST /web/compositor/token` — Issue a 15-min JWT (HS256) for the proxy. + - `GET /web/compositor/apps` — List apps the current user is allowed to launch. + - `POST /web/compositor/launch` — Tell the proxy to spawn a sandboxed app. + - `POST /web/compositor/terminate` — Tell the proxy to kill an app. + - `POST /web/compositor/audit` — Ingestest endpoint called by the proxy. +- **Security**: groups `Compositor / User` and `Compositor / Manager`, with + `ir.rule` so users only see their own sessions. + +### Frontend (OWL library) + +- **`@one_os_compositor/services/compositor_service`** — Reusable OWL service + that wraps the WebSocket connection to the compositor proxy, manages JWT + refresh, and exposes a clean API for embedding surfaces anywhere. +- **`@one_os_compositor/components/compositor_canvas`** — Reusable OWL + component that creates a `` element and binds it to a compositor + session. Drop it anywhere in Odoo (action, form view, dashboard widget). +- **`@one_os_compositor/components/compositor_surface`** — Wraps a single + Wayland surface as an embeddable component (e.g. "show calculator here"). + +Other addons can `import` these and use them like any other OWL service/component. + +## Quick start (dev) + +1. Install the addon: + + ```bash + # Start Odoo dev server (auto-reload on save) + cd /home/z/my-project/odoo-dev + ./start-odoo.sh + ``` + +2. Open http://localhost:8069, log in as `admin` / `admin`. + +3. Go to **Apps → Update Apps List** (activate developer mode first). + +4. Search for "1B Space Compositor Core", click **Install**. + +5. Go to **1B OS → Compositor** — you'll see the app catalog. Click any app + to launch it (currently stubbed — the Python proxy is in the sister addon + `one_os_compositor_proxy`). + +## Architecture + +See `../../greenfield-analysis/` and `../../mit-compositor-analysis/` for the +full architectural analysis that informed this addon's design. + +### Three-addon family + +``` +one_os_compositor ← this addon (core OWL library + backend models) + └─ one_os_compositor_shell ← full-screen desktop shell (taskbar, launcher) + └─ one_os_compositor_proxy ← Python proxy bridge (aiohttp + pywayland + GStreamer) +``` + +### Reusability + +Other 1B OS addons can use the compositor like this: + +```js +import { compositorService } from "@one_os_compositor/services/compositor_service"; + +export class MyDashboard extends Component { + setup() { + this.compositor = useService("compositor"); + } + + async launchApp(appId) { + // Spawns a sandboxed Linux app and embeds it as a Wayland surface + const surface = await this.compositor.launchApp(appId); + surface.attachTo(this.canvasRef.el); + } +} +``` + +## License + +Proprietary — 1B Space internal use only. + +- **Author**: 1bspace.com +- **Parent company**: 1B Global +- **Maintainer**: dev@1bspace.com diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4fe282b --- /dev/null +++ b/__init__.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""1B Space Compositor Core addon. + +This package contains the backend models, controllers, and the OWL library +service/component registrations for the compositor. + +Public Python API: + +- :class:`compositor.app` — application catalog model +- :class:`compositor.session` — per-browser-tab session (mail.thread mixin) +- :class:`compositor.audit.log` — append-only audit log +- :class:`CompositorController` — HTTP controllers + +Public JS API (via ``@one_os_compositor/*`` imports): + +- ``@one_os_compositor/services/compositor_service`` — main service +- ``@one_os_compositor/components/compositor_canvas`` — canvas component +- ``@one_os_compositor/components/compositor_surface`` — single surface wrapper +""" + +from . import models +from . import controllers + + +def _post_init_hook(env): + """Called after the addon is installed. + + Sets the default JWT secret if not already configured. The secret + is stored in ``ir.config_parameter`` so it can be rotated without + restarting Odoo. + + :param env: Odoo environment. + """ + config_param = env["ir.config_parameter"] + if not config_param.get_str("compositor.jwt_secret"): + # Auto-generate a strong secret on first install + import secrets + secret = secrets.token_urlsafe(48) + config_param.set_str("compositor.jwt_secret", secret) + env["ir.logging"].sudo().create({ + "name": "one_os_compositor", + "type": "server", + "level": "INFO", + "message": "Compositor JWT secret auto-generated on first install.", + "dbname": env.cr.dbname, + }) diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..ddee843 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,106 @@ +{ + "name": "1B Space Compositor Core", + "version": "20.0.1.0.0", + "category": "1B Global/OS", + "summary": "Wayland-to-HTML5 compositor core — OWL library + backend models", + "description": """ +1B Space Compositor Core +======================== + +This addon is the **foundation** of the 1B Space Compositor — a fully proprietary +Wayland-to-HTML5 compositor that runs real Linux applications and displays them +natively inside the Odoo web client. + +Architecture +------------ +- **Backend models**: ``compositor.app``, ``compositor.session``, ``compositor.audit.log`` +- **Controllers**: ``/web/compositor/{token,apps,launch,terminate,audit}`` +- **OWL library**: ``CompositorService`` — reusable JS service that other addons + can import and use to embed a Wayland compositor surface anywhere in the Odoo + web client. +- **Auth**: JWT (HS256) issued by Odoo backend, validated by the Python + compositor proxy (separate process). + +This addon is **reusable**: other 1B OS addons can depend on ``one_os_compositor`` +and import its OWL components/services via ``@one_os_compositor/core``. + +Sister addons +------------- +- ``one_os_compositor_shell``: the full-screen desktop shell UI (taskbar, + launcher, window manager). +- ``one_os_compositor_proxy``: backend bridge to the Python compositor proxy + (separate aiohttp process running pywayland + GStreamer + bubblewrap). + +Author & License +---------------- +- Author: 1bspace.com +- Company: 1B Global +- License: Proprietary (1B Space internal use) + +Tech stack +---------- +- Browser: TypeScript + OWL 3.x + WebGL2 + WebCodecs + Web Workers + OffscreenCanvas +- Host (in sister addon): Python + aiohttp + pywayland + PyGObject/GStreamer + + bubblewrap + seccomp +- Transport: WebSocket + KCP-over-WebSocket (reliable UDP-over-TCP) +- Auth: JWT HS256, 15-min expiry, signed by Odoo backend + +See ``README.md`` for the full architecture and the ``greenfield-analysis`` / +``mit-compositor-analysis`` documents (in the 1B Space internal docs) for +the deep technical background. + """, + "author": "1bspace.com", + "website": "https://1bspace.com", + "maintainer": "1B Global ", + "license": "Other proprietary", + "depends": ["base", "web", "bus", "mail"], + "excludes": [], + "data": [ + "security/compositor_security.xml", + "security/ir.access.csv", + "data/compositor_app_data.xml", + "data/ir_sequence_data.xml", + "data/ir_config_parameter_data.xml", + "models/compositor_app_views.xml", + "models/compositor_session_views.xml", + "models/compositor_audit_log_views.xml", + "views/ir_actions_client_views.xml", + "views/compositor_menu.xml", + ], + "demo": ["demo/compositor_app_demo.xml"], + "assets": { + "web.assets_backend": [ + "one_os_compositor/static/src/lib/**/*.js", + "one_os_compositor/static/src/lib/**/*.mjs", + "one_os_compositor/static/src/services/**/*.js", + "one_os_compositor/static/src/services/**/*.mjs", + "one_os_compositor/static/src/components/**/*.js", + "one_os_compositor/static/src/components/**/*.mjs", + "one_os_compositor/static/src/components/**/*.xml", + "one_os_compositor/static/src/scss/compositor.scss", + ], + "web.assets_backend_minimal": [ + "one_os_compositor/static/src/lib/**/*.js", + "one_os_compositor/static/src/lib/**/*.mjs", + "one_os_compositor/static/src/services/**/*.js", + "one_os_compositor/static/src/services/**/*.mjs", + ], + "web.qunit_suite_tests": [ + "one_os_compositor/static/tests/**/*.js", + ], + }, + "qweb": [], + "installable": True, + "application": True, + "auto_install": False, + "pre_init_hook": None, + "post_init_hook": "_post_init_hook", + "post_load": None, + "external_dependencies": { + "python": ["jwt"], + "bin": [], + }, + "assets_version": "20.0.1.0.0", + "live_test_url": "/compositor", + "summary_login_user_field": None, +} diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..41b9840 --- /dev/null +++ b/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +"""Compositor HTTP controllers.""" + +from . import main diff --git a/controllers/main.py b/controllers/main.py new file mode 100644 index 0000000..0cce717 --- /dev/null +++ b/controllers/main.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- +"""1B Space Compositor HTTP controllers. + +All compositor-related endpoints live under ``/web/compositor/*`` so they're +easy to route through Nginx (single-origin HTTPS via +``location /compositor/ { proxy_pass http://127.0.0.1:8081; }``). +""" +import datetime +import logging +import secrets + +from odoo import http, fields +from odoo.http import request +from odoo.exceptions import UserError, AccessError + +_logger = logging.getLogger(__name__) + +try: + import jwt +except ImportError: # pragma: no cover + jwt = None + _logger.warning("PyJWT not installed — compositor auth will fail. " + "Install with: pip install pyjwt") + + +class CompositorController(http.Controller): + """All compositor HTTP endpoints. + + The OWL shell calls these endpoints (via ``rpc`` service) to: + 1. Get a JWT (``/web/compositor/token``) + 2. List available apps (``/web/compositor/apps``) + 3. Launch an app (``/web/compositor/launch``) + 4. Terminate an app (``/web/compositor/terminate``) + 5. Ingest audit events from the proxy (``/web/compositor/audit``) + + The actual compositor proxy (Python aiohttp server, running pywayland + + GStreamer + bubblewrap) lives in the sister addon ``one_os_compositor_proxy``. + """ + + # ────────────────────────────────────────────────────────────────────── + # JWT issuance + # ────────────────────────────────────────────────────────────────────── + @http.route("/web/compositor/token", type="json", auth="user", methods=["POST"]) + def get_token(self): + """Issue a 15-min JWT (HS256) for the compositor proxy. + + :return: ``{jwt, proxy_url, expires_at, compositor_session_id}`` + :raises UserError: if JWT secret is not configured + """ + if jwt is None: + raise UserError("PyJWT is not installed in Odoo's environment.") + + secret = request.env["ir.config_parameter"].sudo().get_str("compositor.jwt_secret") + if not secret: + raise UserError( + "Compositor JWT secret is not configured. " + "Set ir.config_parameter 'compositor.jwt_secret'." + ) + + user = request.env.user + # Reuse existing session for this user if they have one active, else create. + Session = request.env["compositor.session"] + session = Session.search([ + ("user_id", "=", user.id), + ("state", "in", ["pending", "active", "disconnected"]), + ], order="started_at desc", limit=1) + if not session: + session = Session.create({ + "user_id": user.id, + "state": "pending", + }) + session.touch() + + # Compose JWT payload + now = datetime.datetime.utcnow() + expires_at = now + datetime.timedelta(minutes=15) + payload = { + "iss": request.httprequest.host, + "aud": "compositor-proxy", + "sub": user.id, + "db": request.db, + "company_ids": user.company_ids.ids, + "scope": "compositor", + "compositor_session_id": session.compositor_session_id, + "iat": now, + "exp": expires_at, + } + token = jwt.encode(payload, secret, algorithm="HS256") + + # Log issuance + request.env["compositor.audit.log"].sudo().log_event( + "jwt_issued", + user_id=user.id, + session_id=session.id, + client_ip=request.httprequest.remote_addr, + user_agent=request.httprequest.environ.get("HTTP_USER_AGENT", ""), + ) + + # Update session with new JWT info + session.write({ + "last_jwt_issued_at": fields.Datetime.to_datetime(now), + "last_jwt_expires_at": fields.Datetime.to_datetime(expires_at), + "state": "active", + }) + + proxy_url = request.env["ir.config_parameter"].sudo().get_str( + "compositor.proxy_url", + default="http://localhost:8081", + ) + + return { + "jwt": token, + "proxy_url": proxy_url, + "expires_at": expires_at.isoformat() + "Z", + "compositor_session_id": session.compositor_session_id, + "session_db_id": session.id, + } + + # ────────────────────────────────────────────────────────────────────── + # App catalog + # ────────────────────────────────────────────────────────────────────── + @http.route("/web/compositor/apps", type="json", auth="user", methods=["POST"]) + def list_apps(self): + """List apps the current user is allowed to launch. + + Filters by: + - ``active = True`` + - ``allowed_group_ids`` empty (visible to all internal users) OR + current user is in one of the allowed groups + - Apps visible in the current user's company + """ + user = request.env.user + domain = [ + ("active", "=", True), + "|", + ("allowed_group_ids", "=", False), + ("allowed_group_ids", "in", user.group_ids.ids), + ] + apps = request.env["compositor.app"].search_read( + domain, + ["app_id", "name", "description", "icon", "color", "running_session_count"], + ) + # icon is base64 — leave as-is, the OWL shell will use it directly + return {"apps": apps} + + # ────────────────────────────────────────────────────────────────────── + # App launch / terminate + # ────────────────────────────────────────────────────────────────────── + @http.route("/web/compositor/launch", type="json", auth="user", methods=["POST"]) + def launch_app(self, app_id): + """Tell the compositor proxy to spawn a sandboxed app. + + :param app_id: The ``compositor.app.app_id`` (string identifier) + :return: ``{signal_url, pid, compositor_surface_id, app_instance_db_id}`` + """ + user = request.env.user + app = request.env["compositor.app"].search([ + ("app_id", "=", app_id), + "|", + ("allowed_group_ids", "=", False), + ("allowed_group_ids", "in", user.group_ids.ids), + ], limit=1) + if not app: + raise AccessError(f"You don't have access to launch {app_id!r}") + + # Get or create the user's active session + session = request.env["compositor.session"].search([ + ("user_id", "=", user.id), + ("state", "in", ["pending", "active", "disconnected"]), + ], order="started_at desc", limit=1) + if not session: + raise UserError("No active compositor session. Call /web/compositor/token first.") + + # Create an app-instance record (state=launching) + inst = request.env["compositor.session.app"].create({ + "session_id": session.id, + "app_id": app.id, + "state": "launching", + "title": app.name, + }) + + # Build the launch config to send to the proxy + launch_config = app.get_launch_config() + launch_config.update({ + "compositor_session_id": session.compositor_session_id, + "app_instance_db_id": inst.id, + "odoo_user_id": user.id, + "odoo_db": request.db, + }) + + # In the full impl: POST this to the Python compositor proxy via + # aiohttp. For now (proxy not yet built), return a stub response + # that the OWL shell can use to develop against. + # TODO: replace with real HTTP call once one_os_compositor_proxy is built + try: + proxy_response = self._call_proxy_launch(launch_config) + except NotImplementedError: + # Proxy not yet connected — return a stub so the OWL shell can develop + _logger.info("Compositor proxy not yet connected — returning stub response for %s", app_id) + proxy_response = { + "signal_url": None, + "pid": -1, + "compositor_surface_id": f"stub-{inst.id}", + "stub": True, + } + + # Update the app-instance record + inst.write({ + "state": "running" if not proxy_response.get("stub") else "launching", + "pid": proxy_response.get("pid", -1), + "signal_url": proxy_response.get("signal_url"), + "compositor_surface_id": proxy_response.get("compositor_surface_id"), + }) + + # Audit log + request.env["compositor.audit.log"].sudo().log_event( + "app_launch", + user_id=user.id, + session_id=session.id, + app_id=app.id, + app_instance_id=inst.id, + pid=proxy_response.get("pid"), + ) + + return { + "signal_url": proxy_response.get("signal_url"), + "pid": proxy_response.get("pid"), + "compositor_surface_id": proxy_response.get("compositor_surface_id"), + "app_instance_db_id": inst.id, + "stub": proxy_response.get("stub", False), + } + + @http.route("/web/compositor/terminate", type="json", auth="user", methods=["POST"]) + def terminate_app(self, app_instance_db_id, force=False): + """Tell the compositor proxy to terminate an app. + + :param app_instance_db_id: The ID of the ``compositor.session.app`` record + :param force: If True, send SIGKILL instead of SIGTERM + """ + user = request.env.user + inst = request.env["compositor.session.app"].browse(app_instance_db_id) + if not inst.exists() or inst.session_id.user_id != user: + raise AccessError("You don't have access to terminate this app instance.") + + # TODO: call the proxy to actually kill the process + inst.write({ + "state": "terminated", + "ended_at": fields.Datetime.now(), + "signal_received": "SIGKILL" if force else "SIGTERM", + }) + + request.env["compositor.audit.log"].sudo().log_event( + "app_force_kill" if force else "app_terminate", + user_id=user.id, + session_id=inst.session_id.id, + app_id=inst.app_id.id, + app_instance_id=inst.id, + pid=inst.pid, + ) + + return {"ok": True} + + # ────────────────────────────────────────────────────────────────────── + # Audit ingestion (called by the proxy) + # ────────────────────────────────────────────────────────────────────── + @http.route("/web/compositor/audit", type="json", auth="user", methods=["POST"]) + def audit_callback(self, **kwargs): + """Ingest audit events from the Python compositor proxy. + + The proxy calls this endpoint to report app crashes, exits, etc. + + :param kwargs: Field values for the new ``compositor.audit.log`` record + :return: ``{"ok": True}`` + """ + request.env["compositor.audit.log"].sudo().log_event( + kwargs.get("event_type", "app_crash"), + user_id=kwargs.get("user_id"), + session_id=kwargs.get("session_id"), + app_id=kwargs.get("app_id"), + app_instance_id=kwargs.get("app_instance_id"), + pid=kwargs.get("pid"), + exit_code=kwargs.get("exit_code"), + signal_received=kwargs.get("signal"), + detail=kwargs.get("detail"), + client_ip=kwargs.get("client_ip"), + ) + return {"ok": True} + + # ────────────────────────────────────────────────────────────────────── + # Internal: proxy call + # ────────────────────────────────────────────────────────────────────── + def _call_proxy_launch(self, launch_config): + """Send the launch request to the Python compositor proxy. + + Returns the proxy's response dict, or raises if the proxy is + unreachable. + + The actual implementation is in ``one_os_compositor_proxy``. + This stub raises ``NotImplementedError`` so the controller returns + a stub response that the OWL shell can develop against. + """ + raise NotImplementedError( + "Compositor proxy is not yet connected. " + "Install the one_os_compositor_proxy addon to enable app launching." + ) + + +# Note: `fields` is imported at the top of this file. diff --git a/data/compositor_app_data.xml b/data/compositor_app_data.xml new file mode 100644 index 0000000..fec2fc3 --- /dev/null +++ b/data/compositor_app_data.xml @@ -0,0 +1,72 @@ + + + + + + + XTerm + xterm + /usr/bin/xterm + Standard X11 terminal emulator. Requires XWayland. + 256 + 100 + 64 + False + False + #000000 + + + + GNOME Calculator + gnome-calculator + /usr/bin/gnome-calculator + Standard GNOME calculator. Wayland-native. + 256 + 100 + 64 + False + True + #3584e4 + + + + GTK4 Demo + gtk4-demo + /usr/bin/gtk4-demo + GTK4 widget demo. Wayland-native. Good for testing GL + alpha. + 512 + 200 + 128 + False + True + #6f42c1 + + + + GLX Gears + glxgears + /usr/bin/glxgears + Mesa GL demo. Tests GPU rendering + DMA-BUF path. Requires XWayland. + 256 + 200 + 64 + False + True + #ff6b35 + + + + FeatherPad + featherpad + /usr/bin/featherpad + Lightweight Qt text editor. Wayland-native. + 256 + 100 + 64 + False + True + #28a745 + + + diff --git a/data/ir_config_parameter_data.xml b/data/ir_config_parameter_data.xml new file mode 100644 index 0000000..901e506 --- /dev/null +++ b/data/ir_config_parameter_data.xml @@ -0,0 +1,39 @@ + + + + + + compositor.proxy_url + http://localhost:8081 + + + + compositor.encoder + vaapih264 + + + + compositor.render_device + /dev/dri/renderD128 + + + + + compositor.jwt_secret + dev-secret-change-in-production-1bspace-compositor + + + + + compositor.session_timeout_seconds + 600 + + + + + compositor.kill_escalation_seconds + 10 + + + diff --git a/data/ir_sequence_data.xml b/data/ir_sequence_data.xml new file mode 100644 index 0000000..09ca19a --- /dev/null +++ b/data/ir_sequence_data.xml @@ -0,0 +1,13 @@ + + + + + + Compositor App ID + compositor.app + APP- + 4 + + + + diff --git a/demo/compositor_app_demo.xml b/demo/compositor_app_demo.xml new file mode 100644 index 0000000..1f4d5df --- /dev/null +++ b/demo/compositor_app_demo.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..6cd4679 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""Compositor backend models.""" + +from . import compositor_app +from . import compositor_session +from . import compositor_audit_log diff --git a/models/compositor_app.py b/models/compositor_app.py new file mode 100644 index 0000000..a06fc33 --- /dev/null +++ b/models/compositor_app.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +"""``compositor.app`` — Application catalog. + +Each record represents one Linux application that can be launched and +displayed inside the Odoo web client. +""" +import logging +import os +import shutil +from odoo import api, fields, models +from odoo.exceptions import ValidationError, UserError + +_logger = logging.getLogger(__name__) + + +class CompositorApp(models.Model): + _name = "compositor.app" + _description = "1B Space Compositor Application" + _order = "name" + _rec_name = "name" + + # ─── Identity ──────────────────────────────────────────────────────────── + name = fields.Char( + string="Application Name", + required=True, + translate=True, + help="Human-readable name shown in the launcher.", + ) + app_id = fields.Char( + string="Application ID", + required=True, + copy=False, + help="Unique identifier (e.g. 'gnome-calculator'). Used by the proxy " + "to look up the executable when the user clicks 'Launch'.", + ) + description = fields.Text( + string="Description", + translate=True, + ) + + # ─── Executable ──────────────────────────────────────────────────────── + executable = fields.Char( + string="Executable Path", + required=True, + help="Absolute path to the binary, e.g. '/usr/bin/gnome-calculator'. " + "The path must exist on the compositor proxy host, NOT on the " + "Odoo server (they may be different machines).", + ) + args = fields.Char( + string="Command-line Arguments", + help="Space-separated arguments passed to the executable. " + "Use shell-safe quoting; the proxy parses via shlex.split.", + ) + env_vars = fields.Text( + string="Environment Variables", + help="One VAR=value per line. Applied on top of the proxy's default env. " + "WAYLAND_DISPLAY is set automatically by the proxy.", + ) + + # ─── Resource limits (per-instance) ──────────────────────────────────── + memory_limit_mb = fields.Integer( + string="Memory Limit (MB)", + default=1024, + required=True, + help="Maximum RSS for the sandboxed app. Enforced via systemd-run --scope " + "with MemoryMax. Default 1024 MB.", + ) + cpu_quota_percent = fields.Integer( + string="CPU Quota (%)", + default=200, + required=True, + help="CPU quota. 100% = 1 core. Default 200% (2 cores). " + "Enforced via systemd-run --scope with CPUQuota.", + ) + tasks_max = fields.Integer( + string="Max Tasks (PIDs)", + default=256, + required=True, + help="Maximum number of processes/threads. Default 256. " + "Enforced via systemd-run --scope with TasksMax.", + ) + + # ─── Sandbox flags ───────────────────────────────────────────────────── + sandbox_network = fields.Boolean( + string="Allow Network Access", + default=False, + help="If checked, the app is allowed network access (veth + nftables " + "filter). Default unchecked — most GTK apps don't need network.", + ) + sandbox_gpu = fields.Boolean( + string="Allow GPU Access", + default=True, + help="If checked, the app gets read-write access to /dev/dri/renderD128. " + "Required for GL apps (glxgears, gtk4-demo).", + ) + + # ─── Access control ──────────────────────────────────────────────────── + allowed_group_ids = fields.Many2many( + "res.groups", + "compositor_app_group_rel", + "app_id", + "gid", + string="Allowed Groups", + help="Only users in one of these groups can see and launch this app. " + "Empty = all internal users.", + ) + + # ─── UI ──────────────────────────────────────────────────────────────── + icon = fields.Image( + string="Icon", + help="Application icon (PNG, 256x256 recommended).", + ) + color = fields.Char( + string="Accent Color", + default="#6f42c1", + help="Color used for the app tile in the launcher.", + ) + + # ─── Lifecycle ───────────────────────────────────────────────────────── + active = fields.Boolean( + string="Active", + default=True, + help="If unchecked, the app is hidden from the launcher but not deleted.", + ) + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + ) + + # ─── Counts ───────────────────────────────────────────────────────────── + running_session_count = fields.Integer( + string="Currently Running", + compute="_compute_running_session_count", + help="Number of currently-active instances of this app across all sessions.", + ) + + @api.depends("app_id") + def _compute_running_session_count(self): + SessionAppInst = self.env["compositor.session.app"] + for app in self: + app.running_session_count = SessionAppInst.search_count([ + ("app_id", "=", app.id), + ("state", "=", "running"), + ]) + + # ─── Constraints ─────────────────────────────────────────────────────── + @api.constrains("app_id") + def _check_app_id_unique(self): + for app in self: + if self.search_count([("app_id", "=", app.app_id), ("id", "!=", app.id)]): + raise ValidationError("Application ID must be unique.") + + @api.constrains("memory_limit_mb", "cpu_quota_percent", "tasks_max") + def _check_limits(self): + for app in self: + if app.memory_limit_mb < 64: + raise ValidationError("Memory limit must be at least 64 MB.") + if app.cpu_quota_percent < 50: + raise ValidationError("CPU quota must be at least 50%.") + if app.tasks_max < 16: + raise ValidationError("Max tasks must be at least 16.") + + # ─── Public methods ──────────────────────────────────────────────────── + def get_launch_config(self): + """Return the launch config dict sent to the Python compositor proxy. + + Called by ``CompositorController.launch_app`` when the user clicks + "Launch" in the OWL shell. The proxy uses this to spawn the app + with bubblewrap + seccomp + systemd-run. + """ + self.ensure_one() + env_vars = {} + if self.env_vars: + for line in self.env_vars.strip().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + raise UserError(f"Invalid env var line: {line!r}") + key, _, val = line.partition("=") + env_vars[key.strip()] = val.strip() + return { + "app_id": self.app_id, + "name": self.name, + "executable": self.executable, + "args": self.args or "", + "env": env_vars, + "memory_limit_mb": self.memory_limit_mb, + "cpu_quota_percent": self.cpu_quota_percent, + "tasks_max": self.tasks_max, + "sandbox_network": self.sandbox_network, + "sandbox_gpu": self.sandbox_gpu, + } + + def action_view_running_sessions(self): + """Open the sessions view filtered to this app's running instances.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": f"Running {self.name} sessions", + "res_model": "compositor.session.app", + "view_mode": "list,form", + "domain": [("app_id", "=", self.id), ("state", "=", "running")], + } + + def action_test_executable(self): + """Check if the executable exists on this Odoo host. + + NOTE: This is informational only — the executable must exist on the + COMPOSITOR PROXY host (which may be a different machine). For dev, + they're often the same. + """ + self.ensure_one() + if shutil.which(self.executable.split()[0]) or os.path.exists(self.executable): + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": "Executable found", + "message": f"{self.executable} is accessible on this host.", + "type": "success", + "sticky": False, + }, + } + raise UserError( + f"Executable {self.executable!r} not found on this Odoo host. " + f"If your compositor proxy runs on a different machine, this is OK." + ) diff --git a/models/compositor_app_views.xml b/models/compositor_app_views.xml new file mode 100644 index 0000000..eb3d80b --- /dev/null +++ b/models/compositor_app_views.xml @@ -0,0 +1,112 @@ + + + + + + compositor.app.list + compositor.app + + + + + + + + + + + + + + + + + + compositor.app.form + compositor.app + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + compositor.app.search + compositor.app + + + + + + + + + + + + + + + + + Apps + compositor.app + list,form + + +

+ Create a new compositor app +

+

+ Each app is a Linux application that can be launched and + displayed inside the Odoo web client via the 1B Space + Compositor. +

+
+
+
+
diff --git a/models/compositor_audit_log.py b/models/compositor_audit_log.py new file mode 100644 index 0000000..b60ab6f --- /dev/null +++ b/models/compositor_audit_log.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""``compositor.audit.log`` — Append-only audit log. + +Records every compositor event (app launch, terminate, crash, session close) +for compliance, debugging, and security forensics. + +Records CANNOT be modified or deleted via the UI. Only superuser can +directly delete via SQL (e.g. for archival). +""" +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class CompositorAuditLog(models.Model): + _name = "compositor.audit.log" + _description = "1B Space Compositor Audit Log (append-only)" + _order = "create_date desc" + _rec_name = "create_date" + + # ─── Identity ──────────────────────────────────────────────────────────── + user_id = fields.Many2one( + "res.users", + string="User", + required=True, + index=True, + ) + session_id = fields.Many2one( + "compositor.session", + string="Session", + index=True, + ) + app_id = fields.Many2one( + "compositor.app", + string="Application", + index=True, + ) + app_instance_id = fields.Many2one( + "compositor.session.app", + string="App Instance", + index=True, + ) + + # ─── Event ────────────────────────────────────────────────────────────── + event_type = fields.Selection( + [ + ("session_start", "Session Started"), + ("session_close", "Session Closed"), + ("session_reconnect", "Session Reconnected"), + ("session_timeout", "Session Timed Out (SIGHUP)"), + ("app_launch", "App Launched"), + ("app_terminate", "App Terminated"), + ("app_crash", "App Crashed"), + ("app_force_kill", "App Force-Killed"), + ("jwt_issued", "JWT Issued"), + ("jwt_validation_failed", "JWT Validation Failed"), + ("proxy_started", "Proxy Started"), + ("proxy_crashed", "Proxy Crashed"), + ], + string="Event Type", + required=True, + index=True, + ) + + # ─── Detail ───────────────────────────────────────────────────────────── + pid = fields.Integer(string="PID") + exit_code = fields.Integer(string="Exit Code") + signal_received = fields.Char(string="Signal Received") + detail = fields.Text(string="Detail") + client_ip = fields.Char(string="Client IP") + user_agent = fields.Char(string="User Agent") + + # ─── Lifecycle ────────────────────────────────────────────────────────── + create_date = fields.Datetime( + string="Timestamp", + default=fields.Datetime.now, + readonly=True, + index=True, + ) + + # ─── Append-only enforcement ──────────────────────────────────────────── + def write(self, vals): + if not self.env.context.get("compositor_allow_audit_write"): + raise UserError( + "Audit logs are append-only and cannot be modified.\n\n" + "To fix a mistake, create a new corrective log entry with " + "context={'compositor_allow_audit_write': True} (admin only)." + ) + return super().write(vals) + + def unlink(self): + if not self.env.user.has_group("base.group_system"): + raise UserError( + "Audit logs can only be deleted by system administrators." + ) + return super().unlink() + + # ─── Public methods ──────────────────────────────────────────────────── + @api.model + def log_event(self, event_type, **kwargs): + """Convenience method to add a log entry. + + :param event_type: One of the event_type selection values. + :param kwargs: Field values for the new record. + :return: The created record. + """ + defaults = { + "user_id": self.env.user.id, + "create_date": fields.Datetime.now(), + } + defaults.update(kwargs) + # Bypass append-only check since this is the create path + return self.sudo().with_context(compositor_allow_audit_write=True).create({ + "event_type": event_type, + **defaults, + }) diff --git a/models/compositor_audit_log_views.xml b/models/compositor_audit_log_views.xml new file mode 100644 index 0000000..2bdaf2e --- /dev/null +++ b/models/compositor_audit_log_views.xml @@ -0,0 +1,100 @@ + + + + + + compositor.audit.log.list + compositor.audit.log + + + + + + + + + + + + + + + + + + compositor.audit.log.form + compositor.audit.log + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + compositor.audit.log.search + compositor.audit.log + + + + + + + + + + + + + + + + + + + + + + + + + + Audit Log + compositor.audit.log + list,form + + +
+
diff --git a/models/compositor_session.py b/models/compositor_session.py new file mode 100644 index 0000000..6716167 --- /dev/null +++ b/models/compositor_session.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +"""``compositor.session`` — Per-browser-tab compositor session. + +A session represents one active OWL shell instance in a browser tab. Each +session has its own: +- Wayland display (``$XDG_RUNTIME_DIR/wayland-N``) on the proxy host +- Forked Python proxy worker process +- JWT (15-min, rotated by the OWL shell) +- Set of launched app instances (``compositor.session.app``) +""" +import uuid +from odoo import api, fields, models + + +class CompositorSession(models.Model): + _name = "compositor.session" + _description = "1B Space Compositor Session" + _inherit = ["mail.thread"] + _order = "started_at desc" + + # ─── Identity ──────────────────────────────────────────────────────────── + compositor_session_id = fields.Char( + string="Compositor Session UUID", + required=True, + copy=False, + default=lambda self: str(uuid.uuid4()), + tracking=True, + help="The UUID generated by the OWL shell and embedded in the JWT. " + "The proxy uses this to scope Wayland display + websocket connections.", + ) + + # ─── Relations ────────────────────────────────────────────────────────── + user_id = fields.Many2one( + "res.users", + string="User", + required=True, + default=lambda self: self.env.user, + tracking=True, + ) + company_id = fields.Many2one( + "res.company", + string="Company", + default=lambda self: self.env.company, + ) + app_instance_ids = fields.One2many( + "compositor.session.app", + "session_id", + string="App Instances", + ) + app_count = fields.Integer( + string="Apps Launched", + compute="_compute_app_count", + ) + running_app_count = fields.Integer( + string="Running Now", + compute="_compute_app_count", + ) + + # ─── State ────────────────────────────────────────────────────────────── + state = fields.Selection( + [ + ("pending", "Pending"), + ("active", "Active"), + ("disconnected", "Disconnected"), + ("closed", "Closed"), + ], + string="State", + default="pending", + required=True, + tracking=True, + ) + + # ─── Proxy info ────────────────────────────────────────────────────────── + proxy_url = fields.Char( + string="Proxy URL", + help="WebSocket URL of the Python compositor proxy that this session connects to.", + ) + proxy_pid = fields.Integer( + string="Proxy Worker PID", + help="PID of the forked Python proxy worker process on the proxy host.", + ) + wayland_display = fields.Char( + string="Wayland Display", + help="The WAYLAND_DISPLAY value used by this session's apps, e.g. 'wayland-3'.", + ) + + # ─── Lifecycle ────────────────────────────────────────────────────────── + started_at = fields.Datetime( + string="Started At", + default=fields.Datetime.now, + ) + last_activity = fields.Datetime( + string="Last Activity", + tracking=True, + ) + ended_at = fields.Datetime( + string="Ended At", + ) + duration_seconds = fields.Integer( + string="Duration (s)", + compute="_compute_duration_seconds", + ) + + # ─── JWT ──────────────────────────────────────────────────────────────── + last_jwt_issued_at = fields.Datetime( + string="Last JWT Issued", + ) + last_jwt_expires_at = fields.Datetime( + string="Last JWT Expires", + ) + + @api.depends("app_instance_ids", "app_instance_ids.state") + def _compute_app_count(self): + for record in self: + record.app_count = len(record.app_instance_ids) + record.running_app_count = sum( + 1 for inst in record.app_instance_ids if inst.state == "running" + ) + + @api.depends("started_at", "ended_at") + def _compute_duration_seconds(self): + for record in self: + end = record.ended_at or fields.Datetime.now() + if record.started_at: + record.duration_seconds = int((end - record.started_at).total_seconds()) + else: + record.duration_seconds = 0 + + # ─── Public methods ──────────────────────────────────────────────────── + def action_close(self): + """Force-close the session and all running apps.""" + for session in self: + # In a real impl, this calls the proxy via HTTP to terminate + # For now, mark as closed + session.app_instance_ids.write({"state": "terminated", "ended_at": fields.Datetime.now()}) + session.write({ + "state": "closed", + "ended_at": fields.Datetime.now(), + }) + + def action_reconnect(self): + """Mark the session as active again (e.g. after a browser refresh).""" + for session in self: + session.state = "active" + session.last_activity = fields.Datetime.now() + + def action_view_app_instances(self): + """Open the app instances view filtered to this session.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "name": "App Instances", + "res_model": "compositor.session.app", + "view_mode": "list,form", + "domain": [("session_id", "=", self.id)], + "context": {"default_session_id": self.id}, + } + + def touch(self): + """Update last_activity timestamp. Called by the OWL shell periodically.""" + self.write({"last_activity": fields.Datetime.now()}) + + +class CompositorSessionApp(models.Model): + _name = "compositor.session.app" + _description = "App Instance Running in a Compositor Session" + _order = "started_at desc" + + session_id = fields.Many2one( + "compositor.session", + string="Session", + required=True, + ondelete="cascade", + ) + app_id = fields.Many2one( + "compositor.app", + string="Application", + required=True, + ondelete="restrict", + ) + + # ─── State ────────────────────────────────────────────────────────────── + state = fields.Selection( + [ + ("launching", "Launching"), + ("running", "Running"), + ("terminated", "Terminated"), + ("crashed", "Crashed"), + ], + string="State", + default="launching", + required=True, + ) + + # ─── Process info (from proxy) ────────────────────────────────────────── + pid = fields.Integer(string="PID", help="Process ID on the proxy host.") + signal_url = fields.Char(string="Signal WebSocket URL") + compositor_surface_id = fields.Char( + string="Surface ID", + help="Internal ID used by the OWL shell to identify the wl_surface.", + ) + + # ─── Window state ─────────────────────────────────────────────────────── + title = fields.Char(string="Window Title", default="(no title)") + app_id_str = fields.Char( + string="App ID String", + help="The xdg_toplevel.app_id sent by the app (e.g. 'org.gnome.Calculator').", + ) + width = fields.Integer(string="Width", default=800) + height = fields.Integer(string="Height", default=600) + is_maximized = fields.Boolean(string="Maximized", default=False) + is_fullscreen = fields.Boolean(string="Fullscreen", default=False) + is_minimized = fields.Boolean(string="Minimized", default=False) + is_focused = fields.Boolean(string="Focused", default=False) + + # ─── Lifecycle ────────────────────────────────────────────────────────── + started_at = fields.Datetime( + string="Started At", + default=fields.Datetime.now, + ) + ended_at = fields.Datetime( + string="Ended At", + ) + exit_code = fields.Integer(string="Exit Code") + signal_received = fields.Char(string="Signal Received") + + # ─── Computed ─────────────────────────────────────────────────────────── + duration_seconds = fields.Integer( + string="Duration (s)", + compute="_compute_duration_seconds", + ) + + @api.depends("started_at", "ended_at") + def _compute_duration_seconds(self): + for record in self: + end = record.ended_at or fields.Datetime.now() + if record.started_at: + record.duration_seconds = int((end - record.started_at).total_seconds()) + else: + record.duration_seconds = 0 + + def action_terminate(self): + """Send a SIGTERM to the sandboxed app via the proxy.""" + for inst in self: + # In a real impl, calls the proxy via HTTP + inst.write({ + "state": "terminated", + "ended_at": fields.Datetime.now(), + }) + + def action_force_kill(self): + """Send a SIGKILL to the sandboxed app via the proxy.""" + for inst in self: + inst.write({ + "state": "terminated", + "ended_at": fields.Datetime.now(), + "signal_received": "SIGKILL", + }) diff --git a/models/compositor_session_views.xml b/models/compositor_session_views.xml new file mode 100644 index 0000000..6ec650e --- /dev/null +++ b/models/compositor_session_views.xml @@ -0,0 +1,140 @@ + + + + + + compositor.session.list + compositor.session + + + + + + + + + + + + + + + + compositor.session.form + compositor.session + +
+
+
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +