Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ Follow the installation guide in our documentation to choose the right path and
> not work; users must approve the app manually and install later versions
> themselves.

## Agent Plugins

Install portable `.codex-plugin/plugin.json` bundles from **Workspace →
Plugins**. Omlorix imports bundled Agent Skills and remote MCP servers, provides
review-first lifecycle controls, preserves unsupported manifest metadata, and
keeps hooks and personal local-process servers disabled by design. See [Agent
Plugins](agent-plugins.md) for the supported contract and security model.

## License

Omlorix is source-available under the [PolyForm Free Trial License 1.0.0](LICENSE).
Expand Down
49 changes: 49 additions & 0 deletions agent-plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Agent Plugins

Omlorix supports portable OpenAI Agent Plugin ZIP bundles through **Workspace →
Plugins**. A bundle must contain exactly one `.codex-plugin/plugin.json`
manifest. Omlorix currently installs the capabilities it can execute through its
provider-neutral runtime:

- Agent Skills from declared skill folders, including their `SKILL.md`,
`scripts/`, `references/`, and `assets/` files.
- Remote streamable-HTTP and SSE MCP servers from a referenced `.mcp.json` or
an inline `mcpServers` map.
- MCP-hosted app UIs exposed by those servers through Omlorix's existing MCP Apps
sandbox.

The workflow validates the complete archive, shows a component and warning
preview, then installs the plugin disabled. Enabling or disabling the plugin
controls every installed skill and MCP server as one aggregate. Export returns
the original validated bundle, and uninstall removes all components that still
belong to the plugin.

## Compatibility boundaries

Omlorix preserves the full manifest and source bundle so unknown and future
fields survive export. Two OpenAI-specific capabilities are intentionally not
executed:

- Plugin hooks are untrusted executable automation. They remain portable
metadata and never run in Omlorix.
- Registered ChatGPT app IDs depend on OpenAI's hosted app registry. They remain
portable metadata; a plugin must expose its app UI through an installed MCP
server for Omlorix to render it.

Personal plugins cannot install stdio MCP servers because that would allow an
uploaded archive to start a local backend process. Administrators can continue
to configure reviewed stdio servers through the existing admin MCP controls.

## Security model

Plugin parsing rejects absolute paths, traversal, backslashes, NUL bytes,
duplicate archive paths, symbolic links, excessive file/entry/expanded sizes,
and suspicious compression ratios. Manifest references are independently
confined to the bundle root. MCP headers are persisted through Omlorix's existing
encrypted secret columns and are omitted from plugin API responses. Lifecycle
changes are authenticated, owner-scoped, CSRF-protected by the shared request
dependency, and audit logged.

The implemented format follows OpenAI's current [plugin bundle
documentation](https://developers.openai.com/plugins/build/plugins) and
[security guidance](https://developers.openai.com/plugins/guides/security-privacy).
81 changes: 81 additions & 0 deletions backend/alembic_main/versions/add_agent_plugins_20260808.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Add portable agent plugin lifecycle tables.

Revision ID: agent_plugins_20260808
Revises: slide_storage_meta_20260806
Create Date: 2026-08-08
"""

from __future__ import annotations

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

from app.database import DATABASE_SCHEMA


revision: str = "agent_plugins_20260808"
down_revision: Union[str, Sequence[str], None] = "slide_storage_meta_20260806"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _app_schema() -> str | None:
"""Return the application schema, or no schema for SQLite."""
if op.get_bind().dialect.name == "sqlite":
return None
return str(op.get_context().version_table_schema or DATABASE_SCHEMA)


def upgrade() -> None:
"""Create plugin aggregates and portable component links."""
schema = _app_schema()
json_default = sa.text("'{}'::json") if op.get_bind().dialect.name == "postgresql" else sa.text("'{}'")
op.create_table(
"agent_plugins",
sa.Column("id", sa.String(), nullable=False),
sa.Column("owner_type", sa.String(), nullable=False, server_default="user"),
sa.Column("owner_user_id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("version", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("content_sha256", sa.String(), nullable=False),
sa.Column("manifest", sa.JSON(), nullable=False, server_default=json_default),
sa.Column("compatibility", sa.JSON(), nullable=False, server_default=json_default),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("owner_type = 'user'", name="ck_agent_plugins_owner_type"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("owner_user_id", "name", name="uq_agent_plugins_owner_name"),
schema=schema,
)
op.create_index("ix_agent_plugins_owner_user_id", "agent_plugins", ["owner_user_id"], schema=schema)
op.create_index("ix_agent_plugins_enabled", "agent_plugins", ["enabled"], schema=schema)
op.create_table(
"agent_plugin_components",
sa.Column("id", sa.String(), nullable=False),
sa.Column("plugin_id", sa.String(), nullable=False),
sa.Column("component_type", sa.String(), nullable=False),
sa.Column("component_id", sa.String(), nullable=False),
sa.Column("component_key", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("component_type IN ('skill', 'mcp_server')", name="ck_agent_plugin_components_type"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("component_type", "component_id", name="uq_agent_plugin_components_lookup"),
schema=schema,
)
op.create_index("ix_agent_plugin_components_plugin_id", "agent_plugin_components", ["plugin_id"], schema=schema)
op.create_index("ix_agent_plugin_components_lookup", "agent_plugin_components", ["component_type", "component_id"], schema=schema)


def downgrade() -> None:
"""Remove portable plugin metadata while leaving standalone components."""
schema = _app_schema()
op.drop_index("ix_agent_plugin_components_lookup", table_name="agent_plugin_components", schema=schema)
op.drop_index("ix_agent_plugin_components_plugin_id", table_name="agent_plugin_components", schema=schema)
op.drop_table("agent_plugin_components", schema=schema)
op.drop_index("ix_agent_plugins_enabled", table_name="agent_plugins", schema=schema)
op.drop_index("ix_agent_plugins_owner_user_id", table_name="agent_plugins", schema=schema)
op.drop_table("agent_plugins", schema=schema)
3 changes: 3 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"chats",
"logo",
"profilepicture",
"plugins",
"skills",
"userFiles",
)
Expand Down Expand Up @@ -95,6 +96,7 @@
)
from app.notes.router import notes_router
from app.prompts.router import prompts_router
from app.plugins.router import plugins_router
from app.llm.ollama.router import ollama_router
from app.llm.lmstudio.router import lmstudio_router
from app.llm.worker import start_llm_provider_worker, stop_llm_provider_worker
Expand Down Expand Up @@ -623,6 +625,7 @@ def _load_prometheus_metrics_token() -> str:
app.include_router(memories_router)
app.include_router(notes_router)
app.include_router(prompts_router)
app.include_router(plugins_router)
app.include_router(llm_router)
app.include_router(ollama_router)
app.include_router(lmstudio_router)
Expand Down
13 changes: 13 additions & 0 deletions backend/app/mcp/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,11 @@ def update_mcp_server(db, server_id: str, **updates) -> MCPServer:
def delete_mcp_server(db, server_id: str) -> None:
server = get_mcp_server(db, server_id)
deleted_server_id = server.id
# Personal MCP settings can remove a plugin component independently. Keep
# the plugin aggregate consistent instead of retaining a dangling link.
from app.plugins.models import COMPONENT_MCP_SERVER, detach_plugin_component

detach_plugin_component(db, COMPONENT_MCP_SERVER, server.id)
# Redirect states deliberately have no database foreign key so migrations
# work across the supported schemas. Remove them explicitly with the server.
db.query(MCPOAuthState).filter(MCPOAuthState.server_id == server.id).delete(
Expand Down Expand Up @@ -371,6 +376,14 @@ def list_mcp_servers(db, *, owner_type: str | None = None, owner_user_id: str |
query = query.filter(MCPServer.enabled.is_(True))
if not include_managed:
query = query.filter(MCPServer.managed_connection_id.is_(None))
# A disabled plugin must remain disabled even if an older process or a
# direct MCP edit left its component row enabled. This fail-closed filter is
# applied to every discovery and execution path through this shared helper.
from app.plugins.models import COMPONENT_MCP_SERVER, disabled_plugin_component_ids

disabled_ids = disabled_plugin_component_ids(db, COMPONENT_MCP_SERVER)
if disabled_ids:
query = query.filter(~MCPServer.id.in_(disabled_ids))
return query.order_by(MCPServer.name.asc(), MCPServer.created_at.asc()).all()


Expand Down
1 change: 1 addition & 0 deletions backend/app/model_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"app.mcp.models",
"app.memories.models",
"app.notes.models",
"app.plugins.models",
"app.projects.models",
"app.prompts.models",
"app.realtime.models",
Expand Down
1 change: 1 addition & 0 deletions backend/app/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Portable OpenAI Agent Plugin support for Omlorix."""
146 changes: 146 additions & 0 deletions backend/app/plugins/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Database models and persistence helpers for portable agent plugins."""

from __future__ import annotations

from datetime import datetime, timezone
import uuid

from fastapi import HTTPException
from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.exc import OperationalError

from app.database import Base


OWNER_USER = "user"
COMPONENT_SKILL = "skill"
COMPONENT_MCP_SERVER = "mcp_server"


class AgentPlugin(Base):
"""One user-owned plugin bundle and its compatibility metadata."""

__tablename__ = "agent_plugins"
__table_args__ = (
CheckConstraint("owner_type = 'user'", name="ck_agent_plugins_owner_type"),
UniqueConstraint("owner_user_id", "name", name="uq_agent_plugins_owner_name"),
Index("ix_agent_plugins_owner_user_id", "owner_user_id"),
Index("ix_agent_plugins_enabled", "enabled"),
)

id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
owner_type = Column(String, nullable=False, default=OWNER_USER)
owner_user_id = Column(String, nullable=False)
name = Column(String, nullable=False)
version = Column(String, nullable=False)
description = Column(Text, nullable=True)
enabled = Column(Boolean, nullable=False, default=False)
content_sha256 = Column(String, nullable=False)
manifest = Column(JSON, nullable=False, default=dict)
compatibility = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))


class AgentPluginComponent(Base):
"""Links plugin lifecycle operations to an installed Omlorix capability."""

__tablename__ = "agent_plugin_components"
__table_args__ = (
CheckConstraint(
"component_type IN ('skill', 'mcp_server')",
name="ck_agent_plugin_components_type",
),
Index("ix_agent_plugin_components_plugin_id", "plugin_id"),
Index("ix_agent_plugin_components_lookup", "component_type", "component_id"),
UniqueConstraint("component_type", "component_id", name="uq_agent_plugin_components_lookup"),
)

id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
plugin_id = Column(String, nullable=False)
component_type = Column(String, nullable=False)
component_id = Column(String, nullable=False)
component_key = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))


def get_user_plugin(db, user_id: str, plugin_id: str) -> AgentPlugin:
"""Return a plugin only when the authenticated user owns it."""
plugin = (
db.query(AgentPlugin)
.filter(
AgentPlugin.id == str(plugin_id or "").strip(),
AgentPlugin.owner_user_id == str(user_id or "").strip(),
)
.first()
)
if not plugin:
raise HTTPException(status_code=404, detail="Plugin not found.")
return plugin


def list_user_plugins(db, user_id: str) -> list[AgentPlugin]:
"""List the user's installed plugins, newest first."""
return (
db.query(AgentPlugin)
.filter(AgentPlugin.owner_user_id == str(user_id or "").strip())
.order_by(AgentPlugin.created_at.desc(), AgentPlugin.id.desc())
.all()
)


def plugin_component_is_enabled(db, component_type: str, component_id: str) -> bool:
"""Return false only when a component belongs to a disabled plugin."""
try:
row = (
db.query(AgentPlugin.enabled)
.join(AgentPluginComponent, AgentPluginComponent.plugin_id == AgentPlugin.id)
.filter(
AgentPluginComponent.component_type == component_type,
AgentPluginComponent.component_id == str(component_id or "").strip(),
)
.first()
)
except OperationalError as exc:
# Some focused SQLite unit tests deliberately create only the legacy
# feature table. Preserve those partial-schema fixtures while never
# failing open for PostgreSQL or for any other database error.
db.rollback()
if db.get_bind().dialect.name == "sqlite" and "no such table" in str(exc).lower():
return True
raise
return True if row is None else bool(row[0])


def disabled_plugin_component_ids(db, component_type: str) -> set[str]:
"""Return component IDs hidden by disabled plugin parents."""
try:
rows = (
db.query(AgentPluginComponent.component_id)
.join(AgentPlugin, AgentPlugin.id == AgentPluginComponent.plugin_id)
.filter(
AgentPluginComponent.component_type == component_type,
AgentPlugin.enabled.is_(False),
)
.all()
)
except OperationalError as exc:
db.rollback()
if db.get_bind().dialect.name == "sqlite" and "no such table" in str(exc).lower():
return set()
raise
return {str(row[0]) for row in rows}


def detach_plugin_component(db, component_type: str, component_id: str) -> None:
"""Remove a lifecycle link when its standalone component is deleted."""
try:
db.query(AgentPluginComponent).filter(
AgentPluginComponent.component_type == component_type,
AgentPluginComponent.component_id == str(component_id or "").strip(),
).delete(synchronize_session=False)
except OperationalError as exc:
db.rollback()
if db.get_bind().dialect.name == "sqlite" and "no such table" in str(exc).lower():
return
raise
Loading
Loading