Files
1b-agent 93a5a0a221 Initial commit: one_os_compositor Odoo 20 addon — core OWL library + backend models + JWT auth
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)
2026-10-08 08:48:46 +00:00

309 lines
13 KiB
Python

# -*- 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.