feat(scheduled-tasks): support MCP pre-approvals (#13884)

This commit is contained in:
Wenxi authored and GitHub committed 2026-08-12 19:08:05 +00:00
1 parent 8a5902a6a4
commit d98a5d855d
8 files changed
+365 -146

No files matched your search

+21 -14
View File
@@ -6457,26 +6457,33 @@ class ScheduledTask(Base):
back_populates="task", back_populates="task",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
pre_approved_apps: Mapped[list["ScheduledTaskPreApprovedApp"]] = relationship( pre_approved_targets: Mapped[list["ScheduledTaskPreApprovedTarget"]] = relationship(
"ScheduledTaskPreApprovedApp", "ScheduledTaskPreApprovedTarget",
back_populates="task", back_populates="task",
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="ScheduledTaskPreApprovedApp.id", order_by="ScheduledTaskPreApprovedTarget.id",
) )
@property @property
def pre_approved_external_app_ids(self) -> list[int]: def pre_approved_external_app_ids(self) -> list[int]:
"""Granted external-app ids in grant order. MCP-server grants are """Granted external-app ids. MCP-server grants are excluded because
excluded — their target ids live in a different id space, and this their target ids live in a different id space."""
property backs the API's external-app-id field (the gate reads all
grants regardless of kind via ``get_running_scheduled_run_grants``).
Set via ``onyx.db.scheduled_task.set_pre_approved_apps``."""
return [ return [
grant.gated_app.external_app_id grant.gated_app.external_app_id
for grant in self.pre_approved_apps for grant in self.pre_approved_targets
if grant.gated_app.external_app_id is not None if grant.gated_app.external_app_id is not None
] ]
@property
def pre_approved_mcp_server_ids(self) -> list[int]:
"""Granted MCP-server ids. External-app grants are excluded because
their target ids live in a different id space."""
return [
grant.gated_app.mcp_server_id
for grant in self.pre_approved_targets
if grant.gated_app.mcp_server_id is not None
]
__table_args__ = ( __table_args__ = (
# Dispatcher hot path: WHERE status='active' AND deleted=false # Dispatcher hot path: WHERE status='active' AND deleted=false
# AND next_run_at <= now() ORDER BY next_run_at # AND next_run_at <= now() ORDER BY next_run_at
@@ -6567,7 +6574,7 @@ class ScheduledTaskRun(Base):
) )
class ScheduledTaskPreApprovedApp(Base): class ScheduledTaskPreApprovedTarget(Base):
"""One (task, target) pre-approval grant: the matched target's ASK-gated """One (task, target) pre-approval grant: the matched target's ASK-gated
actions skip the approval park for the task's RUNNING runs. actions skip the approval park for the task's RUNNING runs.
@@ -6577,6 +6584,7 @@ class ScheduledTaskPreApprovedApp(Base):
index serves the per-task lookup. index serves the per-task lookup.
""" """
# The table name predates MCP support. Keep it to avoid a schema-only rename.
__tablename__ = "scheduled_task_pre_approved_app" __tablename__ = "scheduled_task_pre_approved_app"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -6595,11 +6603,10 @@ class ScheduledTaskPreApprovedApp(Base):
) )
task: Mapped[ScheduledTask] = relationship( task: Mapped[ScheduledTask] = relationship(
"ScheduledTask", back_populates="pre_approved_apps" "ScheduledTask", back_populates="pre_approved_targets"
) )
# selectin: pre_approved_external_app_ids and set_pre_approved_apps read # selectin: the pre-approved target-id properties read gated_app for every
# gated_app for every grant, so batch them in one SELECT rather than one # grant, so batch them in one SELECT rather than one per grant.
# per grant.
gated_app: Mapped["GatedApp"] = relationship("GatedApp", lazy="selectin") gated_app: Mapped["GatedApp"] = relationship("GatedApp", lazy="selectin")
__table_args__ = ( __table_args__ = (
+56 -34
View File
@@ -31,7 +31,7 @@ from onyx.db.gated_app import get_or_create_gated_app_id
from onyx.db.models import ( from onyx.db.models import (
GatedApp, GatedApp,
ScheduledTask, ScheduledTask,
ScheduledTaskPreApprovedApp, ScheduledTaskPreApprovedTarget,
ScheduledTaskRun, ScheduledTaskRun,
) )
from onyx.error_handling.error_codes import OnyxErrorCode from onyx.error_handling.error_codes import OnyxErrorCode
@@ -60,6 +60,7 @@ def create_scheduled_task(
editor_mode: EditorMode, editor_mode: EditorMode,
status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE, status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE,
pre_approved_external_app_ids: list[int] | None = None, pre_approved_external_app_ids: list[int] | None = None,
pre_approved_mcp_server_ids: list[int] | None = None,
now: datetime | None = None, now: datetime | None = None,
) -> ScheduledTask: ) -> ScheduledTask:
"""Insert a new ``ScheduledTask``. """Insert a new ``ScheduledTask``.
@@ -84,42 +85,60 @@ def create_scheduled_task(
status=status, status=status,
next_run_at=next_run_at, next_run_at=next_run_at,
) )
set_pre_approved_apps( _replace_pre_approved_targets(
db_session, db_session,
task, task,
GatedAppKind.EXTERNAL_APP, external_app_ids=pre_approved_external_app_ids or [],
pre_approved_external_app_ids or [], mcp_server_ids=pre_approved_mcp_server_ids or [],
) )
db_session.add(task) db_session.add(task)
db_session.flush() db_session.flush()
return task return task
def set_pre_approved_apps( def _replace_pre_approved_targets(
db_session: Session, db_session: Session,
task: ScheduledTask, task: ScheduledTask,
kind: GatedAppKind, *,
target_ids: list[int], external_app_ids: list[int] | None = None,
mcp_server_ids: list[int] | None = None,
) -> None: ) -> None:
"""Replace a task's ``kind`` pre-approval grants with ``target_ids`` """Replace supplied target kinds and preserve omitted kinds.
(deduped); grants of other kinds are preserved. Reuses existing grant rows so
re-submitting a granted target is a no-op — recreating it would orphan+reinsert Reuse unchanged rows to avoid deleting and inserting the same unique key
the same unique key in one flush, which Postgres rejects. Removed grants drop in one flush. The orphan cascade deletes removed grants.
via the ``delete-orphan`` cascade.
""" """
wanted_gated_app_ids = [ replacements = {
get_or_create_gated_app_id(db_session, kind, target_id) kind: target_ids
for target_id in dict.fromkeys(target_ids) for kind, target_ids in (
(GatedAppKind.EXTERNAL_APP, external_app_ids),
(GatedAppKind.MCP_SERVER, mcp_server_ids),
)
if target_ids is not None
}
if not replacements:
return
existing_by_target = {
grant.gated_app.target_key: grant for grant in task.pre_approved_targets
}
replacement_grants = [
existing_by_target.get((kind, target_id))
or ScheduledTaskPreApprovedTarget(
gated_app_id=get_or_create_gated_app_id(db_session, kind, target_id)
)
for kind, target_ids in replacements.items()
for target_id in set(target_ids)
] ]
existing = {grant.gated_app_id: grant for grant in task.pre_approved_apps} retained_grants = [
other_kind_grants = [ grant
grant for grant in task.pre_approved_apps if grant.gated_app.kind is not kind for grant in task.pre_approved_targets
if grant.gated_app.kind not in replacements
]
task.pre_approved_targets = [
*replacement_grants,
*retained_grants,
] ]
task.pre_approved_apps = [
existing.get(gated_app_id)
or ScheduledTaskPreApprovedApp(gated_app_id=gated_app_id)
for gated_app_id in wanted_gated_app_ids
] + other_kind_grants
def get_scheduled_task( def get_scheduled_task(
@@ -175,6 +194,7 @@ def update_scheduled_task(
editor_mode: EditorMode | None = None, editor_mode: EditorMode | None = None,
status: ScheduledTaskStatus | None = None, status: ScheduledTaskStatus | None = None,
pre_approved_external_app_ids: list[int] | None = None, pre_approved_external_app_ids: list[int] | None = None,
pre_approved_mcp_server_ids: list[int] | None = None,
now: datetime | None = None, now: datetime | None = None,
) -> ScheduledTask: ) -> ScheduledTask:
"""Apply a partial update to a scheduled task. """Apply a partial update to a scheduled task.
@@ -184,8 +204,8 @@ def update_scheduled_task(
``next_run_at`` is recomputed from ``now``. ``next_run_at`` is recomputed from ``now``.
- If ``status`` transitions to PAUSED, ``next_run_at`` is set to NULL. - If ``status`` transitions to PAUSED, ``next_run_at`` is set to NULL.
- If ``status`` transitions to ACTIVE, ``next_run_at`` is recomputed. - If ``status`` transitions to ACTIVE, ``next_run_at`` is recomputed.
- ``pre_approved_external_app_ids`` follows normal patch semantics: supplied - Each pre-approved target field follows normal patch semantics: supplied
replaces the set, omitted leaves it unchanged. replaces that target kind, and omitted leaves it unchanged.
Raises: Raises:
OnyxError(NOT_FOUND): the task does not exist or is not owned by OnyxError(NOT_FOUND): the task does not exist or is not owned by
@@ -200,10 +220,12 @@ def update_scheduled_task(
task.name = name task.name = name
if prompt is not None: if prompt is not None:
task.prompt = prompt task.prompt = prompt
if pre_approved_external_app_ids is not None: _replace_pre_approved_targets(
set_pre_approved_apps( db_session,
db_session, task, GatedAppKind.EXTERNAL_APP, pre_approved_external_app_ids task,
) external_app_ids=pre_approved_external_app_ids,
mcp_server_ids=pre_approved_mcp_server_ids,
)
if editor_mode is not None: if editor_mode is not None:
task.editor_mode = editor_mode task.editor_mode = editor_mode
if cron_expression is not None and cron_expression != task.cron_expression: if cron_expression is not None and cron_expression != task.cron_expression:
@@ -540,15 +562,15 @@ def get_live_scheduled_run_grants(
if run is None: if run is None:
return None return None
run_id, task_id = run run_id, task_id = run
gated_apps = db_session.scalars( gated_targets = db_session.scalars(
select(GatedApp) select(GatedApp)
.join( .join(
ScheduledTaskPreApprovedApp, ScheduledTaskPreApprovedTarget,
ScheduledTaskPreApprovedApp.gated_app_id == GatedApp.id, ScheduledTaskPreApprovedTarget.gated_app_id == GatedApp.id,
) )
.where(ScheduledTaskPreApprovedApp.scheduled_task_id == task_id) .where(ScheduledTaskPreApprovedTarget.scheduled_task_id == task_id)
).all() ).all()
granted: set[GrantedTarget] = {ga.target_key for ga in gated_apps} granted: set[GrantedTarget] = {target.target_key for target in gated_targets}
return run_id, granted return run_id, granted
@@ -14,7 +14,7 @@ The router is mounted under the existing ``/build`` prefix (see
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import AbstractSet, Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response from fastapi import APIRouter, Depends, Query, Response
@@ -32,6 +32,7 @@ from onyx.db.enums import (
ScheduledTaskTriggerSource, ScheduledTaskTriggerSource,
) )
from onyx.db.external_app import get_external_apps from onyx.db.external_app import get_external_apps
from onyx.db.mcp import get_craft_enabled_mcp_servers
from onyx.db.models import ScheduledTask, ScheduledTaskRun, User from onyx.db.models import ScheduledTask, ScheduledTaskRun, User
from onyx.db.scheduled_task import ( from onyx.db.scheduled_task import (
create_scheduled_task, create_scheduled_task,
@@ -122,6 +123,7 @@ class ScheduledTaskCreate(_Forbid):
status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE
run_immediately: bool = False run_immediately: bool = False
pre_approved_app_ids: list[int] = Field(default_factory=list) pre_approved_app_ids: list[int] = Field(default_factory=list)
pre_approved_mcp_server_ids: list[int] = Field(default_factory=list)
_dispatch = model_validator(mode="before")(_dispatch_editor_payload) _dispatch = model_validator(mode="before")(_dispatch_editor_payload)
@@ -139,6 +141,7 @@ class ScheduledTaskPatch(_Forbid):
editor_payload: EditorPayload | None = None editor_payload: EditorPayload | None = None
status: ScheduledTaskStatus | None = None status: ScheduledTaskStatus | None = None
pre_approved_app_ids: list[int] | None = None pre_approved_app_ids: list[int] | None = None
pre_approved_mcp_server_ids: list[int] | None = None
_dispatch = model_validator(mode="before")(_dispatch_editor_payload) _dispatch = model_validator(mode="before")(_dispatch_editor_payload)
@@ -210,6 +213,7 @@ class ScheduledTaskDetail(BaseModel):
next_runs: list[datetime] next_runs: list[datetime]
last_run: RunSummary | None last_run: RunSummary | None
pre_approved_app_ids: list[int] pre_approved_app_ids: list[int]
pre_approved_mcp_server_ids: list[int]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -298,28 +302,53 @@ def _detail(
next_runs=next_runs, next_runs=next_runs,
last_run=RunSummary.from_model(last_run) if last_run is not None else None, last_run=RunSummary.from_model(last_run) if last_run is not None else None,
pre_approved_app_ids=task.pre_approved_external_app_ids, pre_approved_app_ids=task.pre_approved_external_app_ids,
pre_approved_mcp_server_ids=task.pre_approved_mcp_server_ids,
created_at=task.created_at, created_at=task.created_at,
updated_at=task.updated_at, updated_at=task.updated_at,
) )
def _validated_app_ids(db_session: Session, app_ids: list[int]) -> list[int]: def _validate_app_ids(db_session: Session, app_ids: list[int]) -> None:
"""Dedupe (order-preserving) and verify each id is a configured app. """Reject unknown external app ids.
Existence-only: a grant on an app that never produces an ASK match is Existence-only: a grant on an app that never produces an ASK match is
inert, so credential / has-ASK-action filtering stays editor-side. inert, so credential / has-ASK-action filtering stays editor-side.
""" """
deduped = list(dict.fromkeys(app_ids)) if not app_ids:
if not deduped: return
return []
known_ids = {app.id for app in get_external_apps(db_session)} known_ids = {app.id for app in get_external_apps(db_session)}
unknown = [app_id for app_id in deduped if app_id not in known_ids] unknown_ids = sorted(set(app_ids) - known_ids)
if unknown: if unknown_ids:
raise OnyxError( raise OnyxError(
OnyxErrorCode.INVALID_INPUT, OnyxErrorCode.INVALID_INPUT,
f"Unknown external app id(s): {unknown}", f"Unknown external app id(s): {unknown_ids}",
)
def _validate_mcp_server_ids(
db_session: Session,
user: User,
server_ids: list[int],
already_approved_server_ids: AbstractSet[int] = frozenset(),
) -> None:
"""Reject new MCP server ids unavailable to this user in Craft.
Existing grants can remain after access changes. The MCP resolver still
blocks use, and users can remove the stale grant from the task editor.
"""
if not server_ids:
return
available_ids = {
server.id for server in get_craft_enabled_mcp_servers(db_session, user)
}
unavailable_ids = sorted(
set(server_ids) - available_ids - already_approved_server_ids
)
if unavailable_ids:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Unknown or unavailable Craft MCP server id(s): {unavailable_ids}",
) )
return deduped
def _enqueue_executor(run_id: UUID) -> None: def _enqueue_executor(run_id: UUID) -> None:
@@ -392,6 +421,8 @@ def create_task(
and enqueue the executor. Does NOT touch ``next_run_at``. and enqueue the executor. Does NOT touch ``next_run_at``.
""" """
cron_expression = compile_to_cron(request.editor_payload) cron_expression = compile_to_cron(request.editor_payload)
_validate_app_ids(db_session, request.pre_approved_app_ids)
_validate_mcp_server_ids(db_session, user, request.pre_approved_mcp_server_ids)
task = create_scheduled_task( task = create_scheduled_task(
db_session=db_session, db_session=db_session,
@@ -401,9 +432,8 @@ def create_task(
cron_expression=cron_expression, cron_expression=cron_expression,
editor_mode=request.editor_mode, editor_mode=request.editor_mode,
status=request.status, status=request.status,
pre_approved_external_app_ids=_validated_app_ids( pre_approved_external_app_ids=request.pre_approved_app_ids,
db_session, request.pre_approved_app_ids pre_approved_mcp_server_ids=request.pre_approved_mcp_server_ids,
),
) )
if request.run_immediately: if request.run_immediately:
@@ -455,6 +485,22 @@ def patch_task(
# whenever editor_payload is — no runtime check needed here. # whenever editor_payload is — no runtime check needed here.
cron_expression = compile_to_cron(request.editor_payload) cron_expression = compile_to_cron(request.editor_payload)
if request.pre_approved_app_ids is not None:
_validate_app_ids(db_session, request.pre_approved_app_ids)
if request.pre_approved_mcp_server_ids is not None:
existing_task = get_scheduled_task(
db_session=db_session,
task_id=task_id,
user_id=user.id,
)
_validate_mcp_server_ids(
db_session,
user,
request.pre_approved_mcp_server_ids,
already_approved_server_ids=set(existing_task.pre_approved_mcp_server_ids),
)
task = update_scheduled_task( task = update_scheduled_task(
db_session=db_session, db_session=db_session,
task_id=task_id, task_id=task_id,
@@ -464,11 +510,8 @@ def patch_task(
cron_expression=cron_expression, cron_expression=cron_expression,
editor_mode=request.editor_mode, editor_mode=request.editor_mode,
status=request.status, status=request.status,
pre_approved_external_app_ids=( pre_approved_external_app_ids=request.pre_approved_app_ids,
_validated_app_ids(db_session, request.pre_approved_app_ids) pre_approved_mcp_server_ids=request.pre_approved_mcp_server_ids,
if request.pre_approved_app_ids is not None
else None
),
) )
db_session.commit() db_session.commit()
db_session.refresh(task) db_session.refresh(task)
@@ -23,13 +23,12 @@ from onyx.db.enums import (
ScheduledTaskStatus, ScheduledTaskStatus,
ScheduledTaskTriggerSource, ScheduledTaskTriggerSource,
) )
from onyx.db.gated_app import get_or_create_gated_app_id
from onyx.db.models import ( from onyx.db.models import (
BuildSession, BuildSession,
ExternalApp, ExternalApp,
MCPServer, MCPServer,
ScheduledTask, ScheduledTask,
ScheduledTaskPreApprovedApp, ScheduledTaskPreApprovedTarget,
User, User,
) )
from onyx.db.scheduled_task import ( from onyx.db.scheduled_task import (
@@ -58,11 +57,25 @@ def _make_app(db_session: Session) -> int:
return app.id return app.id
def _make_mcp_server(db_session: Session, user: User) -> int:
server = MCPServer(
owner=user.email,
name=f"pre_approval_mcp_{uuid4().hex[:8]}",
server_url="https://example.com/mcp",
available_in_craft=True,
is_public=False,
)
db_session.add(server)
db_session.flush()
return server.id
def _seed_task( def _seed_task(
db_session: Session, db_session: Session,
user: User, user: User,
*, *,
pre_approved_external_app_ids: list[int] | None = None, pre_approved_external_app_ids: list[int] | None = None,
pre_approved_mcp_server_ids: list[int] | None = None,
prompt: str = "Summarise yesterday's events", prompt: str = "Summarise yesterday's events",
) -> ScheduledTask: ) -> ScheduledTask:
task = create_scheduled_task( task = create_scheduled_task(
@@ -74,6 +87,7 @@ def _seed_task(
editor_mode="advanced", editor_mode="advanced",
status=ScheduledTaskStatus.ACTIVE, status=ScheduledTaskStatus.ACTIVE,
pre_approved_external_app_ids=pre_approved_external_app_ids, pre_approved_external_app_ids=pre_approved_external_app_ids,
pre_approved_mcp_server_ids=pre_approved_mcp_server_ids,
) )
db_session.commit() db_session.commit()
db_session.refresh(task) db_session.refresh(task)
@@ -93,7 +107,13 @@ def test_grants_returned_for_running_run(
user = make_user(db_session) user = make_user(db_session)
bs = build_session_with_user(user=user) bs = build_session_with_user(user=user)
app_a, app_b = _make_app(db_session), _make_app(db_session) app_a, app_b = _make_app(db_session), _make_app(db_session)
task = _seed_task(db_session, user, pre_approved_external_app_ids=[app_a, app_b]) server_id = _make_mcp_server(db_session, user)
task = _seed_task(
db_session,
user,
pre_approved_external_app_ids=[app_a, app_b],
pre_approved_mcp_server_ids=[server_id],
)
run = insert_run( run = insert_run(
db_session=db_session, db_session=db_session,
task_id=task.id, task_id=task.id,
@@ -115,6 +135,7 @@ def test_grants_returned_for_running_run(
assert granted == { assert granted == {
(GatedAppKind.EXTERNAL_APP, app_a), (GatedAppKind.EXTERNAL_APP, app_a),
(GatedAppKind.EXTERNAL_APP, app_b), (GatedAppKind.EXTERNAL_APP, app_b),
(GatedAppKind.MCP_SERVER, server_id),
} }
@@ -306,33 +327,18 @@ def test_mcp_grants_survive_external_app_replacement(
tenant_context: None, # noqa: ARG001 tenant_context: None, # noqa: ARG001
build_session_with_user: Callable[..., BuildSession], build_session_with_user: Callable[..., BuildSession],
) -> None: ) -> None:
"""``set_pre_approved_apps`` replaces only the given kind's grants: an MCP-server """Each target kind has independent replacement semantics and reaches the gate."""
grant (seeded directly — no API writes these yet) survives a wholesale
external-app replacement, stays out of ``pre_approved_external_app_ids``, and reaches
the gate through ``get_live_scheduled_run_grants`` as its (kind, id) target.
"""
user = make_user(db_session) user = make_user(db_session)
bs = build_session_with_user(user=user) bs = build_session_with_user(user=user)
app_a, app_b = _make_app(db_session), _make_app(db_session) app_a, app_b = _make_app(db_session), _make_app(db_session)
task = _seed_task(db_session, user, pre_approved_external_app_ids=[app_a]) server_a = _make_mcp_server(db_session, user)
server_b = _make_mcp_server(db_session, user)
server = MCPServer( task = _seed_task(
owner=user.email, db_session,
name=f"pre_approval_mcp_{uuid4().hex[:8]}", user,
server_url="https://example.com/mcp", pre_approved_external_app_ids=[app_a],
is_public=False, pre_approved_mcp_server_ids=[server_a],
) )
db_session.add(server)
db_session.flush()
mcp_gated_app_id = get_or_create_gated_app_id(
db_session, GatedAppKind.MCP_SERVER, server.id
)
db_session.add(
ScheduledTaskPreApprovedApp(
scheduled_task_id=task.id, gated_app_id=mcp_gated_app_id
)
)
db_session.commit()
updated = update_scheduled_task( updated = update_scheduled_task(
db_session=db_session, db_session=db_session,
@@ -343,10 +349,18 @@ def test_mcp_grants_survive_external_app_replacement(
db_session.commit() db_session.commit()
assert updated.pre_approved_external_app_ids == [app_b] # MCP grant excluded assert updated.pre_approved_external_app_ids == [app_b] # MCP grant excluded
assert {g.gated_app.target_key for g in updated.pre_approved_apps} == { assert updated.pre_approved_mcp_server_ids == [server_a]
(GatedAppKind.EXTERNAL_APP, app_b),
(GatedAppKind.MCP_SERVER, server.id), updated = update_scheduled_task(
} db_session=db_session,
task_id=task.id,
user_id=user.id,
pre_approved_mcp_server_ids=[server_b],
)
db_session.commit()
assert updated.pre_approved_external_app_ids == [app_b]
assert updated.pre_approved_mcp_server_ids == [server_b]
run = insert_run( run = insert_run(
db_session=db_session, db_session=db_session,
@@ -365,23 +379,31 @@ def test_mcp_grants_survive_external_app_replacement(
assert grants is not None assert grants is not None
assert grants[1] == { assert grants[1] == {
(GatedAppKind.EXTERNAL_APP, app_b), (GatedAppKind.EXTERNAL_APP, app_b),
(GatedAppKind.MCP_SERVER, server.id), (GatedAppKind.MCP_SERVER, server_b),
} }
def test_create_persists_grants( def test_create_stores_each_grant_once(
db_session: Session, db_session: Session,
tenant_context: None, # noqa: ARG001 tenant_context: None, # noqa: ARG001
) -> None: ) -> None:
user = make_user(db_session) user = make_user(db_session)
app_a, app_b = _make_app(db_session), _make_app(db_session) app_a, app_b = _make_app(db_session), _make_app(db_session)
assert app_a < app_b # ids autoincrement, so the higher id is created last server_id = _make_mcp_server(db_session, user)
# Insertion order is preserved (not sorted): pass the higher id first. task = _seed_task(
task = _seed_task(db_session, user, pre_approved_external_app_ids=[app_b, app_a]) db_session,
assert task.pre_approved_external_app_ids == [app_b, app_a] user,
pre_approved_external_app_ids=[app_b, app_a, app_b],
pre_approved_mcp_server_ids=[server_id, server_id],
)
assert set(task.pre_approved_external_app_ids) == {app_a, app_b}
assert len(task.pre_approved_external_app_ids) == 2
assert set(task.pre_approved_mcp_server_ids) == {server_id}
assert len(task.pre_approved_mcp_server_ids) == 1
bare = _seed_task(db_session, user) bare = _seed_task(db_session, user)
assert bare.pre_approved_external_app_ids == [] assert bare.pre_approved_external_app_ids == []
assert bare.pre_approved_mcp_server_ids == []
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -405,8 +427,8 @@ def test_deleting_app_drops_grants(
remaining = ( remaining = (
db_session.execute( db_session.execute(
select(ScheduledTaskPreApprovedApp).where( select(ScheduledTaskPreApprovedTarget).where(
ScheduledTaskPreApprovedApp.scheduled_task_id == task.id ScheduledTaskPreApprovedTarget.scheduled_task_id == task.id
) )
) )
.scalars() .scalars()
@@ -451,14 +473,12 @@ def test_deleting_app_nulls_action_approval_fk(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_validated_app_ids_rejects_unknown_and_dedupes( def test_validate_app_ids_accepts_known_duplicates_and_reports_unknown_ids_once(
db_session: Session, db_session: Session,
tenant_context: None, # noqa: ARG001 tenant_context: None, # noqa: ARG001
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Dedupe is order-preserving; any id outside the tenant's apps raises """Duplicates are valid, but unknown ids raise INVALID_INPUT."""
INVALID_INPUT. Apps are stubbed — only the validation logic is under
test, not ``get_external_apps``'s SQL."""
class _App: class _App:
def __init__(self, app_id: int) -> None: def __init__(self, app_id: int) -> None:
@@ -470,9 +490,10 @@ def test_validated_app_ids_rejects_unknown_and_dedupes(
lambda _db: [_App(7), _App(9)], lambda _db: [_App(7), _App(9)],
) )
assert scheduled_tasks_api._validated_app_ids(db_session, []) == [] scheduled_tasks_api._validate_app_ids(db_session, [])
assert scheduled_tasks_api._validated_app_ids(db_session, [9, 7, 9]) == [9, 7] scheduled_tasks_api._validate_app_ids(db_session, [9, 7, 9])
with pytest.raises(OnyxError) as exc_info: with pytest.raises(OnyxError) as exc_info:
scheduled_tasks_api._validated_app_ids(db_session, [7, 123]) scheduled_tasks_api._validate_app_ids(db_session, [456, 7, 123, 456])
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
assert exc_info.value.detail == "Unknown external app id(s): [123, 456]"
@@ -17,6 +17,7 @@ from onyx.db.enums import (
ScheduledTaskStatus, ScheduledTaskStatus,
ScheduledTaskTriggerSource, ScheduledTaskTriggerSource,
) )
from onyx.db.mcp import create_mcp_server__no_commit, update_mcp_server__no_commit
from onyx.db.models import ScheduledTask, ScheduledTaskRun from onyx.db.models import ScheduledTask, ScheduledTaskRun
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA_STANDARD_VALUE from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA_STANDARD_VALUE
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
@@ -51,6 +52,7 @@ def _create_task(
editor_payload: dict[str, Any] | None = None, editor_payload: dict[str, Any] | None = None,
status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE, status: ScheduledTaskStatus = ScheduledTaskStatus.ACTIVE,
run_immediately: bool = False, run_immediately: bool = False,
pre_approved_mcp_server_ids: list[int] | None = None,
) -> httpx.Response: ) -> httpx.Response:
body: dict[str, Any] = { body: dict[str, Any] = {
"name": name or f"task-{uuid4().hex[:8]}", "name": name or f"task-{uuid4().hex[:8]}",
@@ -60,6 +62,8 @@ def _create_task(
"status": status.value, "status": status.value,
"run_immediately": run_immediately, "run_immediately": run_immediately,
} }
if pre_approved_mcp_server_ids is not None:
body["pre_approved_mcp_server_ids"] = pre_approved_mcp_server_ids
return client.post( return client.post(
_url(), _url(),
json=body, json=body,
@@ -135,6 +139,28 @@ def _get_runs_for_task(task_id: UUID) -> list[ScheduledTaskRun]:
) )
def _create_mcp_server(owner: str, *, available_in_craft: bool) -> int:
with get_session_with_current_tenant() as db_session:
server = create_mcp_server__no_commit(
owner_email=owner,
name=f"scheduled-task-mcp-{uuid4().hex[:8]}",
description=None,
server_url="https://example.com/mcp",
auth_type=None,
transport=None,
auth_performer=None,
db_session=db_session,
is_public=True,
)
update_mcp_server__no_commit(
server_id=server.id,
db_session=db_session,
available_in_craft=available_in_craft,
)
db_session.commit()
return server.id
def test_create_task_compiles_cron(admin_user: DATestUser) -> None: def test_create_task_compiles_cron(admin_user: DATestUser) -> None:
response = _create_task( response = _create_task(
admin_user, admin_user,
@@ -152,6 +178,82 @@ def test_create_task_compiles_cron(admin_user: DATestUser) -> None:
assert row.cron_expression == cron assert row.cron_expression == cron
def test_create_and_patch_mcp_pre_approvals(admin_user: DATestUser) -> None:
server_a = _create_mcp_server(admin_user.email, available_in_craft=True)
server_b = _create_mcp_server(admin_user.email, available_in_craft=True)
unavailable_server = _create_mcp_server(admin_user.email, available_in_craft=False)
create_response = _create_task(
admin_user,
pre_approved_mcp_server_ids=[server_b, server_a, server_b],
)
create_response.raise_for_status()
created = create_response.json()
task_id = UUID(created["id"])
created_mcp_server_ids = created["pre_approved_mcp_server_ids"]
assert len(created_mcp_server_ids) == 2
assert set(created_mcp_server_ids) == {server_a, server_b}
patch_response = _patch_task(
admin_user,
task_id,
{"pre_approved_mcp_server_ids": [server_a]},
)
patch_response.raise_for_status()
assert patch_response.json()["pre_approved_mcp_server_ids"] == [server_a]
rejected_response = _patch_task(
admin_user,
task_id,
{"pre_approved_mcp_server_ids": [unavailable_server]},
)
assert rejected_response.status_code == 400
detail_response = client.get(
_url(str(task_id)),
headers=admin_user.headers,
cookies=admin_user.cookies,
)
detail_response.raise_for_status()
assert detail_response.json()["pre_approved_mcp_server_ids"] == [server_a]
def test_patch_retains_and_removes_existing_unavailable_mcp_pre_approval(
admin_user: DATestUser,
) -> None:
server_id = _create_mcp_server(admin_user.email, available_in_craft=True)
create_response = _create_task(
admin_user,
pre_approved_mcp_server_ids=[server_id],
)
create_response.raise_for_status()
task_id = UUID(create_response.json()["id"])
with get_session_with_current_tenant() as db_session:
update_mcp_server__no_commit(
server_id=server_id,
db_session=db_session,
available_in_craft=False,
)
db_session.commit()
retain_response = _patch_task(
admin_user,
task_id,
{"pre_approved_mcp_server_ids": [server_id]},
)
retain_response.raise_for_status()
assert retain_response.json()["pre_approved_mcp_server_ids"] == [server_id]
remove_response = _patch_task(
admin_user,
task_id,
{"pre_approved_mcp_server_ids": []},
)
remove_response.raise_for_status()
assert remove_response.json()["pre_approved_mcp_server_ids"] == []
def test_create_task_interval_days_requires_time_of_day(admin_user: DATestUser) -> None: def test_create_task_interval_days_requires_time_of_day(admin_user: DATestUser) -> None:
response = _create_task( response = _create_task(
admin_user, admin_user,
+35 -13
View File
@@ -154,6 +154,15 @@ def _assert_403(flow: http.HTTPFlow, expected_code: SandboxProxyError) -> None:
_MATCH = make_matched_actions(payload={"text": "hi"}) _MATCH = make_matched_actions(payload={"text": "hi"})
_MATCH_MCP = AllMatchedActions(
actions=_MATCH.actions,
target=GatedTarget(
kind=GatedAppKind.MCP_SERVER,
id=73,
app_name="Linear MCP",
),
payload=_MATCH.payload,
)
_MATCH_MULTI_ASK = AllMatchedActions( _MATCH_MULTI_ASK = AllMatchedActions(
actions=( actions=(
MatchedAction( MatchedAction(
@@ -561,16 +570,19 @@ async def test_ask_denied_blocks(
_RUN_ID = UUID("55555555-5555-5555-5555-555555555555") _RUN_ID = UUID("55555555-5555-5555-5555-555555555555")
# make_matched_actions defaults external_app_id=42. # make_matched_actions defaults external_app_id=42.
_GRANTED_APP_ID = 42 _GRANTED_APP_ID = 42
_GRANTED_MCP_SERVER_ID = 73
def _stub_grants( def _stub_grants(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
result: tuple[UUID, list[int]] | None | Exception, result: tuple[UUID, list[int]] | None | Exception,
*,
kind: GatedAppKind = GatedAppKind.EXTERNAL_APP,
) -> list[UUID]: ) -> list[UUID]:
"""Stub the gate's grant lookup; returns the recorded session_ids. """Stub the gate's grant lookup; returns the recorded session_ids.
Callers pass granted external-app ids; the stub wraps them as the The stub wraps target ids as the ``(kind, id)`` keys returned by the real
``(kind, id)`` targets the real lookup now returns.""" lookup."""
calls: list[UUID] = [] calls: list[UUID] = []
def _lookup( def _lookup(
@@ -583,8 +595,8 @@ def _stub_grants(
raise result raise result
if result is None: if result is None:
return None return None
run_id, app_ids = result run_id, target_ids = result
return run_id, {(GatedAppKind.EXTERNAL_APP, app_id) for app_id in app_ids} return run_id, {(kind, target_id) for target_id in target_ids}
monkeypatch.setattr(gate, "get_live_scheduled_run_grants", _lookup) monkeypatch.setattr(gate, "get_live_scheduled_run_grants", _lookup)
return calls return calls
@@ -643,17 +655,27 @@ class _SessionGrantCache:
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize(
("matched_actions", "target_kind", "target_id"),
[
(_MATCH, GatedAppKind.EXTERNAL_APP, _GRANTED_APP_ID),
(_MATCH_MCP, GatedAppKind.MCP_SERVER, _GRANTED_MCP_SERVER_ID),
],
ids=["external-app", "mcp-server"],
)
async def test_pre_approved_scheduled_run_skips_park( async def test_pre_approved_scheduled_run_skips_park(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
matched_actions: AllMatchedActions,
target_kind: GatedAppKind,
target_id: int,
) -> None: ) -> None:
"""Granted app on a RUNNING scheduled run: forwarded immediately with a """A granted target skips the approval park during a scheduled run."""
pre-decided APPROVED row — the park pipeline never runs."""
user_id = uuid4() user_id = uuid4()
sandbox = make_resolved_sandbox(user_id=user_id) sandbox = make_resolved_sandbox(user_id=user_id)
resolver = StubResolver(sandbox=sandbox, session_by_id=UUID(_TAG_UUID)) resolver = StubResolver(sandbox=sandbox, session_by_id=UUID(_TAG_UUID))
addon = _build(resolver=resolver, matcher=_StubMatcher(result=_MATCH)) addon = _build(resolver=resolver, matcher=_StubMatcher(result=matched_actions))
spy = _spy_pipeline(addon, monkeypatch) spy = _spy_pipeline(addon, monkeypatch)
_stub_grants(monkeypatch, (_RUN_ID, [_GRANTED_APP_ID])) _stub_grants(monkeypatch, (_RUN_ID, [target_id]), kind=target_kind)
inserted = _spy_pre_approve_insert(monkeypatch) inserted = _spy_pre_approve_insert(monkeypatch)
notified: list[dict[str, Any]] = [] notified: list[dict[str, Any]] = []
monkeypatch.setattr(gate, "create_notification", lambda **kw: notified.append(kw)) monkeypatch.setattr(gate, "create_notification", lambda **kw: notified.append(kw))
@@ -664,17 +686,17 @@ async def test_pre_approved_scheduled_run_skips_park(
assert flow.response is None # forwarded assert flow.response is None # forwarded
assert not spy.approval_ran # park pipeline skipped assert not spy.approval_ran # park pipeline skipped
assert spy.awaited == [] assert spy.awaited == []
assert spy.dispatched == [(_MATCH, user_id, sandbox.tenant_id)] assert spy.dispatched == [(matched_actions, user_id, sandbox.tenant_id)]
assert len(inserted) == 1 assert len(inserted) == 1
assert inserted[0]["decision"] == ApprovalDecision.APPROVED assert inserted[0]["decision"] == ApprovalDecision.APPROVED
assert inserted[0]["decided_via"] == ApprovalDecidedVia.PRE_APPROVAL assert inserted[0]["decided_via"] == ApprovalDecidedVia.PRE_APPROVAL
assert inserted[0]["target"] == (GatedAppKind.EXTERNAL_APP, _GRANTED_APP_ID) assert inserted[0]["target"] == (target_kind, target_id)
# Dedup contract: additional_data is exactly the stable (run, app) pair. # Dedup contract: additional_data is exactly the stable run and target.
assert len(notified) == 1 assert len(notified) == 1
assert notified[0]["additional_data"] == { assert notified[0]["additional_data"] == {
"run_id": str(_RUN_ID), "run_id": str(_RUN_ID),
"target_kind": GatedAppKind.EXTERNAL_APP.value, "target_kind": target_kind.value,
"target_id": _GRANTED_APP_ID, "target_id": target_id,
} }
@@ -42,7 +42,7 @@ Proxy runtime:
Approval persistence and API: Approval persistence and API:
- `backend/onyx/db/models.py` defines `ActionApproval`, - `backend/onyx/db/models.py` defines `ActionApproval`,
`ExternalAppPolicy`, and `ScheduledTaskPreApprovedApp`. `ExternalAppPolicy`, and `ScheduledTaskPreApprovedTarget`.
- `backend/onyx/db/enums.py` defines `EndpointPolicy`, - `backend/onyx/db/enums.py` defines `EndpointPolicy`,
`ApprovalDecision`, and `ApprovalDecidedVia`. `ApprovalDecision`, and `ApprovalDecidedVia`.
- `backend/onyx/server/features/build/db/action_approval.py` owns approval DB - `backend/onyx/server/features/build/db/action_approval.py` owns approval DB
@@ -451,8 +451,8 @@ Partial coverage still parks.
### Scheduled Task Pre-Approvals ### Scheduled Task Pre-Approvals
Scheduled task pre-approvals are another grant source inside the same proxy Scheduled task pre-approvals are another grant source inside the same proxy
approval path. A task can store a set of pre-approved external app ids in approval path. A task can store external-app and MCP-server grants in the
`scheduled_task_pre_approved_app`. legacy-named `scheduled_task_pre_approved_app` table.
For an `ASK` request, the gate checks grant sources in this order: For an `ASK` request, the gate checks grant sources in this order:
@@ -463,7 +463,7 @@ A scheduled-task grant applies only when:
- The `BuildSession` has a `ScheduledTaskRun` row. - The `BuildSession` has a `ScheduledTaskRun` row.
- That run is currently `RUNNING`. - That run is currently `RUNNING`.
- The task has a grant for the matched `external_app_id`. - The task has a grant for the matched `(target kind, target id)`.
When it applies, the proxy inserts an `action_approval` row already When it applies, the proxy inserts an `action_approval` row already
`APPROVED` with `decided_via=PRE_APPROVAL`, emits a deduped scheduled-task `APPROVED` with `decided_via=PRE_APPROVAL`, emits a deduped scheduled-task
@@ -125,20 +125,22 @@ unguarded after an `APPROVED` row is already committed.
## Data Model ## Data Model
New table `scheduled_task_pre_approved_app` — one row per `(task, app)` The legacy-named `scheduled_task_pre_approved_app` table stores one row per
grant: `(task, target)` grant:
- `scheduled_task_id` → `scheduled_task.id` (`ON DELETE CASCADE`) and - `scheduled_task_id` references `scheduled_task.id` with `ON DELETE CASCADE`.
`external_app_id` → `external_app.id` (`ON DELETE CASCADE`), with a - `gated_app_id` references the polymorphic `gated_app.id` with
`UNIQUE(scheduled_task_id, external_app_id)` constraint that keeps `ON DELETE CASCADE`. Each `gated_app` row identifies exactly one external app
grants idempotent and serves the per-task lookup. The FKs give real or MCP server.
referential integrity — a grant can't point at a removed app, and - `UNIQUE(scheduled_task_id, gated_app_id)` keeps grants idempotent and serves
removing either side drops the grant. `ScheduledTask.pre_approved_apps` the per-task lookup.
is the ORM collection; `pre_approved_app_ids` is a read-only accessor - `ScheduledTask.pre_approved_targets` is the ORM collection of
over it, so the API contract (`list[int]`) is unchanged. The write `ScheduledTaskPreApprovedTarget` rows. The
path replaces the whole set (`set_pre_approved_apps`), validated `pre_approved_external_app_ids` and `pre_approved_mcp_server_ids` properties
against the configured apps (via the tenant-scoped session) and project each target kind into its API field.
deduped order-preserving. - `_replace_pre_approved_targets` replaces each supplied target kind and
preserves omitted kinds. It deduplicates submitted IDs and reuses unchanged
rows.
- `action_approval.decided_via` — nullable (`user | pre_approval`, - `action_approval.decided_via` — nullable (`user | pre_approval`,
NULL for legacy/expired rows): the audit marker distinguishing a NULL for legacy/expired rows): the audit marker distinguishing a
human click from a pre-approval. Kept separate from `decision` so human click from a pre-approval. Kept separate from `decision` so
@@ -160,10 +162,10 @@ pre-decided inserts go through `insert_action_approval` in
- `ScheduledTaskCreate` / `ScheduledTaskPatch` gain - `ScheduledTaskCreate` / `ScheduledTaskPatch` gain
`pre_approved_app_ids: list[int]`; `ScheduledTaskDetail` returns it. `pre_approved_app_ids: list[int]`; `ScheduledTaskDetail` returns it.
The write path validates ids via `_validated_app_ids` and dedupes The write path validates ids via `_validate_app_ids` and stores each grant
(order-preserving) — existence only; a credential / ≥1-`ASK` filter is once. Grant order has no meaning. Validation checks existence only; a
editor-side advisory, since a grant on a no-`ASK` app is inert and credential / ≥1-`ASK` filter is editor-side advisory because a grant on a
never consulted. no-`ASK` app is inert and never consulted.
- New `NotificationType.SCHEDULED_TASK_PRE_APPROVED_ACTION`, emitted - New `NotificationType.SCHEDULED_TASK_PRE_APPROVED_ACTION`, emitted
per `(run, app)` on the first unattended forward so chatty tasks per `(run, app)` on the first unattended forward so chatty tasks
don't flood the bell. Dedup rides `create_notification`'s existing don't flood the bell. Dedup rides `create_notification`'s existing
@@ -254,7 +256,7 @@ The grant-source seam means future modes drop in as new
- Grant patch semantics: a prompt edit preserves grants, supplied - Grant patch semantics: a prompt edit preserves grants, supplied
`pre_approved_app_ids` replaces the set, and re-submitting an existing `pre_approved_app_ids` replaces the set, and re-submitting an existing
grant is idempotent (no unique-key collision). grant is idempotent (no unique-key collision).
- Create persistence + `_validated_app_ids` dedupe and unknown-id - Create persistence, duplicate grant normalization, and unknown-id
rejection. rejection.
- **Unit** (gate, stubbed DB): - **Unit** (gate, stubbed DB):
`backend/tests/unit/sandbox_proxy/test_gate.py` `backend/tests/unit/sandbox_proxy/test_gate.py`