Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b090c6530 | |||
| 5deef611ba | |||
| 498ca19d6a | |||
| 5fb7d0f7de | |||
| 9c89bc0c52 | |||
| 2f6cb30fde | |||
| 427c2a93ec | |||
| 9a0bfc461e | |||
| e659e518d9 |
+112
-2
@@ -7,7 +7,7 @@ import os
|
||||
import re
|
||||
import tarfile
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
@@ -45,7 +45,7 @@ from omnigent.runtime import (
|
||||
)
|
||||
from omnigent.runtime.agent_cache import AgentCache
|
||||
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
|
||||
from omnigent.server.auth import AuthProvider
|
||||
from omnigent.server.auth import AuthProvider, SharingMode
|
||||
from omnigent.server.managed_hosts import ManagedSandboxConfig
|
||||
from omnigent.server.mcp_pool import ServerMcpPool
|
||||
from omnigent.server.performance_metrics import (
|
||||
@@ -70,6 +70,7 @@ from omnigent.server.routes.sessions import (
|
||||
create_sessions_router,
|
||||
set_server_runner_router,
|
||||
)
|
||||
from omnigent.server.routes.sharing import create_sharing_router
|
||||
from omnigent.server.routes.terminal_attach import create_terminal_attach_router
|
||||
from omnigent.server.ws_origin import WebSocketOriginMiddleware
|
||||
from omnigent.stores import (
|
||||
@@ -1077,6 +1078,8 @@ def create_app(
|
||||
admins: list[str] | None = None,
|
||||
allowed_domains: list[str] | None = None,
|
||||
sandbox_config: ManagedSandboxConfig | None = None,
|
||||
sharing_mode: SharingMode | Callable[[], SharingMode] | None = None,
|
||||
public_sharing: bool | Callable[[], bool] | None = None,
|
||||
) -> FastAPI:
|
||||
"""
|
||||
Build and return the FastAPI application with all routes mounted.
|
||||
@@ -1134,6 +1137,34 @@ def create_app(
|
||||
``host_type="managed"`` create fails with a clear error).
|
||||
Managed-host credentials live on the ``hosts`` table, so no
|
||||
extra store is wired.
|
||||
:param sharing_mode: Server policy for creating new session
|
||||
permission grants (see :class:`SharingMode`): ``ON`` allows
|
||||
grants at any level plus public/workspace read, ``READ_ONLY``
|
||||
caps grants at read (edit/manage rejected with 403),
|
||||
``RESTRICTED_READ_ONLY`` additionally blocks sharing a session
|
||||
whose working directory is a home or root directory, and ``OFF``
|
||||
rejects all new grants (403). Only *new* grants are gated —
|
||||
revoke/list, self-ownership grants, and existing grants are
|
||||
unaffected in every mode. Accepts a static :class:`SharingMode`,
|
||||
a zero-arg callable resolved per request (for deployments that
|
||||
flip the policy at runtime), or ``None`` — which defaults from
|
||||
the ``OMNIGENT_SHARING_MODE`` env var
|
||||
(``on``/``read_only``/``restricted_read_only``/``off``), failing
|
||||
open to ``ON`` when unset or unrecognized. Reported by
|
||||
``GET /v1/info`` as ``sharing_mode`` so the web app can gate its
|
||||
Share controls to match.
|
||||
:param public_sharing: Whether public (anyone-with-the-link) read
|
||||
access may be granted — i.e. whether the ``__public__`` grant is
|
||||
allowed. Orthogonal to ``sharing_mode``: a server can keep normal
|
||||
user-to-user sharing on while disabling public links. When
|
||||
disabled, granting ``__public__`` is rejected (403) and the Share
|
||||
modal hides the "Public access" toggle; existing public grants
|
||||
are unaffected. Accepts a static bool, a zero-arg callable
|
||||
resolved per request, or ``None`` — which defaults from the
|
||||
``OMNIGENT_PUBLIC_SHARING`` env var (enabled unless explicitly
|
||||
falsy — ``0``/``false``/``no``/``off``), failing open to enabled
|
||||
when unset. Reported by ``GET /v1/info`` as
|
||||
``public_sharing_enabled``.
|
||||
:returns: A fully configured :class:`FastAPI` application.
|
||||
:raises ValueError: If ``permission_store`` is provided
|
||||
without an ``auth_provider``.
|
||||
@@ -1365,6 +1396,63 @@ def create_app(
|
||||
from omnigent.server.admin_list import load_admin_list
|
||||
|
||||
admin_list = load_admin_list(extra=frozenset(admins or ()))
|
||||
# Session-sharing policy, normalized to a per-request callable, plus a
|
||||
# ``sharing_mode_writable`` flag gating the admin ``PUT /v1/sharing``
|
||||
# endpoint.
|
||||
#
|
||||
# ``None`` (the OSS default): ``OMNIGENT_SHARING_MODE`` sets the boot
|
||||
# default, but an admin-set override file (``<data_dir>/sharing_mode``,
|
||||
# written from Settings → Sharing) takes precedence when present — read per
|
||||
# request so a change applies without a restart. Editable here.
|
||||
#
|
||||
# A static value or a callable (managed/embedded deploys, e.g. a Databricks
|
||||
# SAFE flag) is authoritative and NOT editable via the admin endpoint.
|
||||
if sharing_mode is None:
|
||||
from omnigent.server.sharing_settings import read_sharing_mode_override
|
||||
|
||||
_sharing_env_default = SharingMode.coerce(os.environ.get("OMNIGENT_SHARING_MODE"))
|
||||
|
||||
def _resolve_sharing_mode() -> SharingMode:
|
||||
override = read_sharing_mode_override()
|
||||
return override if override is not None else _sharing_env_default
|
||||
|
||||
app.state.sharing_mode = _resolve_sharing_mode
|
||||
app.state.sharing_mode_writable = True
|
||||
elif callable(sharing_mode):
|
||||
_sharing_callable = sharing_mode
|
||||
app.state.sharing_mode = lambda: SharingMode.coerce(_sharing_callable())
|
||||
app.state.sharing_mode_writable = False
|
||||
else:
|
||||
_sharing_static = SharingMode.coerce(sharing_mode)
|
||||
app.state.sharing_mode = lambda: _sharing_static
|
||||
app.state.sharing_mode_writable = False
|
||||
# Public (anyone-with-the-link) access policy, same shape as sharing_mode
|
||||
# above and independent of it. ``None`` reads ``OMNIGENT_PUBLIC_SHARING``
|
||||
# (default enabled) with a ``<data_dir>/public_sharing`` file override,
|
||||
# editable from the admin panel; a static bool or callable is authoritative
|
||||
# and not editable there.
|
||||
if public_sharing is None:
|
||||
from omnigent.server.sharing_settings import (
|
||||
public_sharing_env_default,
|
||||
read_public_sharing_override,
|
||||
)
|
||||
|
||||
_public_env_default = public_sharing_env_default()
|
||||
|
||||
def _resolve_public_sharing() -> bool:
|
||||
override = read_public_sharing_override()
|
||||
return override if override is not None else _public_env_default
|
||||
|
||||
app.state.public_sharing = _resolve_public_sharing
|
||||
app.state.public_sharing_writable = True
|
||||
elif callable(public_sharing):
|
||||
_public_callable = public_sharing
|
||||
app.state.public_sharing = lambda: bool(_public_callable())
|
||||
app.state.public_sharing_writable = False
|
||||
else:
|
||||
_public_static = bool(public_sharing)
|
||||
app.state.public_sharing = lambda: _public_static
|
||||
app.state.public_sharing_writable = False
|
||||
# Tracks in-flight background managed-host launches (POST
|
||||
# /v1/sessions returns before the sandbox exists) so a message
|
||||
# racing the provision can rendezvous instead of failing with
|
||||
@@ -1821,6 +1909,15 @@ def create_app(
|
||||
# actually offered; None when no provider is named (embedding
|
||||
# configs may leave it unset) so the UI keeps the generic label.
|
||||
sandbox_provider = sandbox_config.provider if managed_sandboxes_enabled else None
|
||||
# sharing_mode is the server's session-sharing policy
|
||||
# (on/read_only/off), surfaced so the web app can hide the Share
|
||||
# control (off) or restrict it to read-only (read_only) in lockstep
|
||||
# with the server-side grant gate.
|
||||
sharing_mode = app.state.sharing_mode()
|
||||
# public_sharing_enabled: whether the __public__ (anyone-with-the-link)
|
||||
# grant is allowed. Independent of sharing_mode — drives whether the
|
||||
# Share modal shows the "Public access" toggle.
|
||||
public_sharing_enabled = app.state.public_sharing()
|
||||
# server_version is the installed omnigent package version (same
|
||||
# source as /api/version), surfaced so the web UI can show it in the
|
||||
# session info popover alongside the per-session host version.
|
||||
@@ -1844,6 +1941,8 @@ def create_app(
|
||||
"databricks_features": databricks_features,
|
||||
"managed_sandboxes_enabled": managed_sandboxes_enabled,
|
||||
"sandbox_provider": sandbox_provider,
|
||||
"sharing_mode": sharing_mode.value,
|
||||
"public_sharing_enabled": public_sharing_enabled,
|
||||
"server_version": _server_version(),
|
||||
"smart_routing_enabled": smart_routing_enabled,
|
||||
}
|
||||
@@ -1996,6 +2095,17 @@ def create_app(
|
||||
prefix="/v1",
|
||||
tags=["policy_registry"],
|
||||
)
|
||||
# Admin control for the server-wide sharing settings. Always mounted (the
|
||||
# handlers self-gate on admin); PUT is a no-op-reject unless this server
|
||||
# resolves the setting from the editable file-backed default.
|
||||
app.include_router(
|
||||
create_sharing_router(
|
||||
auth_provider=auth_provider,
|
||||
permission_store=permission_store,
|
||||
),
|
||||
prefix="/v1",
|
||||
tags=["sharing"],
|
||||
)
|
||||
|
||||
# ── Tunnel lifecycle callbacks (Step 8.5 crash recovery) ───
|
||||
async def _on_runner_disconnect(runner_id: str) -> None:
|
||||
|
||||
@@ -31,6 +31,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
@@ -79,6 +80,78 @@ LEVEL_MANAGE = 3
|
||||
LEVEL_OWNER = 4
|
||||
|
||||
|
||||
class SharingMode(str, Enum):
|
||||
"""Server policy for creating new session permission grants.
|
||||
|
||||
- ``ON``: grants at any level (read/edit/manage) plus workspace/public read.
|
||||
- ``READ_ONLY``: grants are capped at read (view) — edit/manage grants are
|
||||
rejected; workspace/public read still allowed.
|
||||
- ``RESTRICTED_READ_ONLY``: like ``READ_ONLY`` (grants capped at read), but
|
||||
sessions whose working directory is a user home directory or the
|
||||
filesystem root (see :func:`workspace_sharing_blocked`) cannot be shared
|
||||
at all — not even read — because that cwd exposes an entire home/filesystem.
|
||||
- ``OFF``: no new grants at all.
|
||||
|
||||
Value is the lowercase name so ``GET /v1/info`` and the
|
||||
``OMNIGENT_SHARING_MODE`` env var round-trip it directly. Defaults to ``ON``.
|
||||
"""
|
||||
|
||||
OFF = "off"
|
||||
READ_ONLY = "read_only"
|
||||
RESTRICTED_READ_ONLY = "restricted_read_only"
|
||||
ON = "on"
|
||||
|
||||
@classmethod
|
||||
def coerce(cls, value: object) -> SharingMode:
|
||||
"""Map a ``SharingMode``/str/``None`` to a mode, failing open to ``ON``
|
||||
for anything unset or unrecognized (env-var parse + callable boundary)."""
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return cls(value.strip().lower())
|
||||
except ValueError:
|
||||
return cls.ON
|
||||
return cls.ON
|
||||
|
||||
|
||||
# Directories whose *direct children* are user home directories, across the
|
||||
# Unix / macOS / container layouts a runner might use: ``/home`` (Linux),
|
||||
# ``/Users`` (macOS), and ``/var/home`` (ostree — Silverblue/CoreOS/Flatcar,
|
||||
# where ``/home`` symlinks here). Matched by path *shape*, never by resolving
|
||||
# ``~``: the runner and its home may live on a different host than this server
|
||||
# process, so the local process's home is not a reliable signal. Deliberately
|
||||
# excludes project-workspace roots (``/workspace``, ``/workspaces/<repo>``) —
|
||||
# those hold a single checkout, not a whole home, and stay shareable.
|
||||
_HOME_PARENT_DIRS = ("/home", "/Users", "/var/home")
|
||||
# Absolute paths that are themselves a home or the filesystem root.
|
||||
_BLOCKED_WORKSPACE_ROOTS = ("/", "/root")
|
||||
|
||||
|
||||
def workspace_sharing_blocked(workspace: str | None) -> bool:
|
||||
"""True when a session's working directory is too broad to share under
|
||||
:attr:`SharingMode.RESTRICTED_READ_ONLY` — the filesystem root or a user
|
||||
home directory, whose whole contents a grant would expose.
|
||||
|
||||
Recognizes the filesystem root (``/``), root's home (``/root``), and any
|
||||
direct child of a common home parent (see :data:`_HOME_PARENT_DIRS` — e.g.
|
||||
``/home/alice``, ``/Users/bob``, ``/var/home/carol``). A subdirectory of a
|
||||
home (``/home/alice/proj``) is shareable, as is a ``None``/empty workspace
|
||||
(no recorded cwd).
|
||||
|
||||
Pattern-based on purpose: the runner (and thus the home the session lives
|
||||
in) may be on a different host than this server process, so only the path
|
||||
shape is reliable — resolving the local ``~`` would test the wrong host.
|
||||
"""
|
||||
if not workspace:
|
||||
return False
|
||||
path = os.path.normpath(workspace)
|
||||
if path in _BLOCKED_WORKSPACE_ROOTS:
|
||||
return True
|
||||
parent, _, leaf = path.rpartition("/")
|
||||
return bool(leaf) and parent in _HOME_PARENT_DIRS
|
||||
|
||||
|
||||
def env_var_is_truthy(name: str, *, default: bool = False) -> bool:
|
||||
"""Parse a boolean-style environment variable.
|
||||
|
||||
|
||||
@@ -154,7 +154,9 @@ from omnigent.server.auth import (
|
||||
LEVEL_READ,
|
||||
RESERVED_USER_PUBLIC,
|
||||
AuthProvider,
|
||||
SharingMode,
|
||||
local_single_user_enabled,
|
||||
workspace_sharing_blocked,
|
||||
)
|
||||
from omnigent.server.bundles import bundle_location, validate_agent_bundle
|
||||
from omnigent.server.host_registry import HostConnection, HostRegistry, RunnerExitReports
|
||||
@@ -20149,6 +20151,36 @@ def create_sessions_router(
|
||||
await _require_access(
|
||||
user_id, session_id, LEVEL_MANAGE, permission_store, conversation_store
|
||||
)
|
||||
# Server-wide sharing policy gate (see SharingMode). Applied only
|
||||
# to *new* grants — revoke/list and owner grants are unaffected.
|
||||
# ``getattr`` default keeps a hand-built app (a router mounted without
|
||||
# create_app, e.g. in a focused test) from AttributeError-ing; every
|
||||
# production path sets these via create_app.
|
||||
_sharing_mode = getattr(request.app.state, "sharing_mode", lambda: SharingMode.ON)()
|
||||
if _sharing_mode == SharingMode.OFF:
|
||||
raise OmnigentError(
|
||||
"Sharing has been disabled for this Omnigent server.",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
# RESTRICTED_READ_ONLY blocks sharing entirely (even read) for a session
|
||||
# whose cwd is a home dir or the filesystem root — that workspace is too
|
||||
# broad to expose. Other sessions fall through to the read-only cap.
|
||||
if _sharing_mode == SharingMode.RESTRICTED_READ_ONLY:
|
||||
_conv = await asyncio.to_thread(conversation_store.get_conversation, session_id)
|
||||
if _conv is not None and workspace_sharing_blocked(_conv.workspace):
|
||||
raise OmnigentError(
|
||||
"This session's working directory (a home or root directory) "
|
||||
"cannot be shared on this Omnigent server.",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
if (
|
||||
_sharing_mode in (SharingMode.READ_ONLY, SharingMode.RESTRICTED_READ_ONLY)
|
||||
and body.level > LEVEL_READ
|
||||
):
|
||||
raise OmnigentError(
|
||||
"Sharing is limited to read-only access on this Omnigent server.",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
if permission_store is None:
|
||||
raise OmnigentError(
|
||||
"Permissions not enabled",
|
||||
@@ -20159,11 +20191,21 @@ def create_sessions_router(
|
||||
"Cannot modify your own permissions",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
if body.user_id == RESERVED_USER_PUBLIC and body.level > LEVEL_READ:
|
||||
raise OmnigentError(
|
||||
"Public access is limited to read-only (level 1)",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
if body.user_id == RESERVED_USER_PUBLIC:
|
||||
# Public-access kill switch, independent of the sharing_mode gate
|
||||
# above (see app.state.public_sharing). Blocks the anyone-with-the
|
||||
# -link grant while leaving user-to-user sharing intact. ``getattr``
|
||||
# default mirrors the sharing_mode read above (hand-built apps).
|
||||
if not getattr(request.app.state, "public_sharing", lambda: True)():
|
||||
raise OmnigentError(
|
||||
"Public access has been disabled for this Omnigent server.",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
if body.level > LEVEL_READ:
|
||||
raise OmnigentError(
|
||||
"Public access is limited to read-only (level 1)",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
existing = await asyncio.to_thread(permission_store.get, body.user_id, session_id)
|
||||
if existing is not None and existing.level == LEVEL_OWNER:
|
||||
raise OmnigentError(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Admin route for the server-wide session-sharing policy.
|
||||
|
||||
``GET /v1/sharing`` reports two independent settings and whether each is
|
||||
editable here: the sharing *mode* (the tri-state tier + tier list) and whether
|
||||
*public* (anyone-with-the-link) access may be granted. ``PUT /v1/sharing``
|
||||
sets either or both (admin only), persisting an override file
|
||||
(``<data_dir>/sharing_mode`` / ``<data_dir>/public_sharing``) that the grant
|
||||
gate and ``GET /v1/info`` read per request.
|
||||
|
||||
Editing a setting is only possible when the server resolves it from its file
|
||||
(the OSS default — ``create_app(sharing_mode=None, public_sharing=None)``). A
|
||||
deployment that injects its own resolver — a static value or a callable such as
|
||||
a Databricks SAFE flag — reports that setting as not editable and rejects
|
||||
writes to it, since its policy is authoritative elsewhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from omnigent.errors import ErrorCode, OmnigentError
|
||||
from omnigent.server.auth import AuthProvider, SharingMode
|
||||
from omnigent.server.routes._auth_helpers import get_user_id
|
||||
from omnigent.server.sharing_settings import (
|
||||
write_public_sharing_override,
|
||||
write_sharing_mode_override,
|
||||
)
|
||||
from omnigent.stores.permission_store import PermissionStore
|
||||
|
||||
# The tiers offered to admins, most-permissive first (matches the UI order).
|
||||
_TIERS: tuple[SharingMode, ...] = (
|
||||
SharingMode.ON,
|
||||
SharingMode.READ_ONLY,
|
||||
SharingMode.RESTRICTED_READ_ONLY,
|
||||
SharingMode.OFF,
|
||||
)
|
||||
|
||||
|
||||
class SetSharingRequest(BaseModel):
|
||||
"""Body for ``PUT /v1/sharing``.
|
||||
|
||||
Both fields are optional so an admin can update either setting
|
||||
independently; at least one must be present.
|
||||
"""
|
||||
|
||||
sharing_mode: str | None = None
|
||||
public_sharing: bool | None = None
|
||||
|
||||
|
||||
def _state_response(request: Request) -> dict[str, Any]:
|
||||
"""Shape the sharing-settings payload from live ``app.state`` — shared by
|
||||
GET and PUT so both reflect any override just written."""
|
||||
state = request.app.state
|
||||
mode: SharingMode = state.sharing_mode()
|
||||
return {
|
||||
"object": "sharing",
|
||||
"sharing_mode": mode.value,
|
||||
"editable": bool(getattr(state, "sharing_mode_writable", False)),
|
||||
"options": [tier.value for tier in _TIERS],
|
||||
"public_sharing_enabled": bool(state.public_sharing()),
|
||||
"public_sharing_editable": bool(getattr(state, "public_sharing_writable", False)),
|
||||
}
|
||||
|
||||
|
||||
async def _require_admin(
|
||||
request: Request,
|
||||
auth_provider: AuthProvider | None,
|
||||
permission_store: PermissionStore | None,
|
||||
) -> None:
|
||||
"""Verify the caller is an admin, mirroring the default-policies gate.
|
||||
|
||||
Single-user mode (no permission store) skips the check. Multi-user mode
|
||||
raises 401 if unauthenticated or 403 if the user is not an admin.
|
||||
"""
|
||||
if permission_store is None:
|
||||
return
|
||||
user_id = get_user_id(request, auth_provider)
|
||||
if user_id is None:
|
||||
raise OmnigentError("Authentication required", code=ErrorCode.UNAUTHORIZED)
|
||||
is_admin = await asyncio.to_thread(permission_store.is_admin, user_id)
|
||||
if not is_admin:
|
||||
raise OmnigentError(
|
||||
"Admin privileges required to manage sharing settings",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
|
||||
|
||||
def create_sharing_router(
|
||||
auth_provider: AuthProvider | None = None,
|
||||
permission_store: PermissionStore | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build the admin sharing router (mounted under ``/v1``)."""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/sharing")
|
||||
async def get_sharing(request: Request) -> dict[str, Any]:
|
||||
"""Report both settings, whether each is editable here, and the tiers."""
|
||||
await _require_admin(request, auth_provider, permission_store)
|
||||
return _state_response(request)
|
||||
|
||||
@router.put("/sharing")
|
||||
async def set_sharing(request: Request, body: SetSharingRequest) -> dict[str, Any]:
|
||||
"""Set the sharing mode and/or public-access setting (admin only).
|
||||
|
||||
Updates only the fields present in the body; requires at least one.
|
||||
Rejects an unknown mode value with 400 (no fail-open coercion — an admin
|
||||
setting a value should learn about a typo). Rejects a write to a setting
|
||||
the deployment manages itself (not file-backed) with 403.
|
||||
"""
|
||||
await _require_admin(request, auth_provider, permission_store)
|
||||
state = request.app.state
|
||||
if body.sharing_mode is None and body.public_sharing is None:
|
||||
raise OmnigentError(
|
||||
"No sharing settings to update.",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
# Validate AND authorize both fields before writing either, so a request
|
||||
# updating both never persists one and then rejects the other (a partial
|
||||
# apply — reachable only when a deployment makes exactly one setting
|
||||
# file-backed and the other a managed callable).
|
||||
mode: SharingMode | None = None
|
||||
if body.sharing_mode is not None:
|
||||
if not getattr(state, "sharing_mode_writable", False):
|
||||
raise OmnigentError(
|
||||
"Sharing mode is managed by this deployment and cannot be changed here.",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
try:
|
||||
mode = SharingMode(body.sharing_mode.strip().lower())
|
||||
except ValueError as exc:
|
||||
raise OmnigentError(
|
||||
f"Unknown sharing mode {body.sharing_mode!r}. Expected one of: "
|
||||
+ ", ".join(tier.value for tier in _TIERS)
|
||||
+ ".",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
) from exc
|
||||
if body.public_sharing is not None and not getattr(
|
||||
state, "public_sharing_writable", False
|
||||
):
|
||||
raise OmnigentError(
|
||||
"Public access is managed by this deployment and cannot be changed here.",
|
||||
code=ErrorCode.FORBIDDEN,
|
||||
)
|
||||
# All checks passed — apply the writes.
|
||||
if mode is not None:
|
||||
await asyncio.to_thread(write_sharing_mode_override, mode)
|
||||
if body.public_sharing is not None:
|
||||
await asyncio.to_thread(write_public_sharing_override, body.public_sharing)
|
||||
return _state_response(request)
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,145 @@
|
||||
"""File-backed session-sharing settings for the OSS server.
|
||||
|
||||
Two server-wide sharing policies default from env vars at boot but can be
|
||||
overridden at runtime from the Settings → Sharing admin panel, each persisted to
|
||||
a plaintext file in :func:`resolve_data_dir` (next to the ``admins`` roster) so
|
||||
it survives restarts without a database migration and takes effect without a
|
||||
redeploy:
|
||||
|
||||
- the sharing *mode* — ``OMNIGENT_SHARING_MODE`` → ``<data_dir>/sharing_mode``
|
||||
(``on`` / ``read_only`` / ``restricted_read_only`` / ``off``);
|
||||
- whether *public* (anyone-with-the-link) read access may be granted —
|
||||
``OMNIGENT_PUBLIC_SHARING`` → ``<data_dir>/public_sharing`` (``on`` / ``off``).
|
||||
|
||||
A missing, empty, or unreadable file means "no override recorded", so the caller
|
||||
falls back to the env-var default; an unrecognized value is likewise ignored
|
||||
(falling back rather than silently changing behavior). Reads are mtime-cached
|
||||
per file so the per-request hot path is cheap, mirroring the ``admins`` roster
|
||||
loader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.server.admin_list import resolve_data_dir
|
||||
from omnigent.server.auth import SharingMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SHARING_MODE_FILE = "sharing_mode"
|
||||
_PUBLIC_SHARING_FILE = "public_sharing"
|
||||
# Public sharing is enabled unless a value explicitly says otherwise, so a typo
|
||||
# or a stray value fails OPEN (never silently disables a working feature).
|
||||
_PUBLIC_FALSY = ("0", "false", "no", "off")
|
||||
|
||||
# mtime cache keyed by absolute path → (mtime, stripped text). Keyed by path so a
|
||||
# data-dir change (e.g. across tests) never reads through a stale entry.
|
||||
_cache: dict[str, tuple[float, str]] = {}
|
||||
|
||||
|
||||
def resolve_sharing_mode_path() -> Path:
|
||||
"""Path of the file holding the admin sharing-mode override."""
|
||||
return resolve_data_dir() / _SHARING_MODE_FILE
|
||||
|
||||
|
||||
def resolve_public_sharing_path() -> Path:
|
||||
"""Path of the file holding the admin public-sharing override."""
|
||||
return resolve_data_dir() / _PUBLIC_SHARING_FILE
|
||||
|
||||
|
||||
def _read_override_text(path: Path) -> str | None:
|
||||
"""mtime-cached read of an override file's stripped contents.
|
||||
|
||||
Returns ``None`` for a missing or unreadable file (never raises), so callers
|
||||
fall back to their env-var default.
|
||||
"""
|
||||
key = str(path)
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
cached = _cache.get(key)
|
||||
if cached is not None and cached[0] == mtime:
|
||||
return cached[1]
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return None
|
||||
_cache[key] = (mtime, raw)
|
||||
return raw
|
||||
|
||||
|
||||
def _write_override_text(path: Path, value: str) -> None:
|
||||
"""Persist an override atomically.
|
||||
|
||||
Writes to a temp file in the data dir and ``os.replace``s it into place so a
|
||||
concurrent read never sees a half-written file. Invalidates the cache entry
|
||||
so the next read reflects the change.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(value + "\n")
|
||||
os.replace(tmp, path)
|
||||
except OSError:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
_cache.pop(str(path), None)
|
||||
|
||||
|
||||
def read_sharing_mode_override() -> SharingMode | None:
|
||||
"""Return the admin-set sharing-mode override, or ``None`` when unset.
|
||||
|
||||
A missing/empty/unreadable file or an unrecognized value yields ``None`` —
|
||||
the caller then falls back to the env-var default rather than silently
|
||||
changing behavior.
|
||||
"""
|
||||
raw = _read_override_text(resolve_sharing_mode_path())
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return SharingMode(raw.lower())
|
||||
except ValueError:
|
||||
logger.warning("Ignoring unrecognized sharing_mode override %r", raw)
|
||||
return None
|
||||
|
||||
|
||||
def write_sharing_mode_override(mode: SharingMode) -> None:
|
||||
"""Persist the admin sharing-mode override atomically."""
|
||||
_write_override_text(resolve_sharing_mode_path(), mode.value)
|
||||
|
||||
|
||||
def public_sharing_env_default() -> bool:
|
||||
"""Boot default for public sharing from ``OMNIGENT_PUBLIC_SHARING``.
|
||||
|
||||
Enabled unless the value is explicitly falsy (``0``/``false``/``no``/``off``,
|
||||
case-insensitive); unset or unrecognized fails open to enabled.
|
||||
"""
|
||||
raw = os.environ.get("OMNIGENT_PUBLIC_SHARING")
|
||||
if not raw or not raw.strip():
|
||||
return True
|
||||
return raw.strip().lower() not in _PUBLIC_FALSY
|
||||
|
||||
|
||||
def read_public_sharing_override() -> bool | None:
|
||||
"""Return the admin-set public-sharing override, or ``None`` when unset.
|
||||
|
||||
``True``/``False`` reflect a recorded ``on``/``off``; a missing/empty file
|
||||
yields ``None`` so the caller falls back to the env-var default.
|
||||
"""
|
||||
raw = _read_override_text(resolve_public_sharing_path())
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
return raw.lower() not in _PUBLIC_FALSY
|
||||
|
||||
|
||||
def write_public_sharing_override(enabled: bool) -> None:
|
||||
"""Persist the admin public-sharing override atomically."""
|
||||
_write_override_text(resolve_public_sharing_path(), "on" if enabled else "off")
|
||||
@@ -5338,6 +5338,35 @@
|
||||
"title": "SetCodexGoalRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"SetSharingRequest": {
|
||||
"description": "Body for `PUT /v1/sharing`.\n\nBoth fields are optional so an admin can update either setting\nindependently; at least one must be present.",
|
||||
"properties": {
|
||||
"public_sharing": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Public Sharing"
|
||||
},
|
||||
"sharing_mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Sharing Mode"
|
||||
}
|
||||
},
|
||||
"title": "SetSharingRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillSummary": {
|
||||
"description": "Safe subset of a discovered skill for API exposure.\n\nSurfaces the skill name and one-line description so clients\n(e.g. the web composer's slash-command menu) can list which\nskills the session has access to. The full skill `content`\nis intentionally omitted \u2014 it's only loaded server-side when\nthe harness invokes the skill, and it can be large.",
|
||||
"properties": {
|
||||
@@ -10814,6 +10843,72 @@
|
||||
"sessions"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/sharing": {
|
||||
"get": {
|
||||
"description": "Report both settings, whether each is editable here, and the tiers.",
|
||||
"operationId": "get_sharing_v1_sharing_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"title": "Response Get Sharing V1 Sharing Get",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
}
|
||||
},
|
||||
"summary": "Get Sharing",
|
||||
"tags": [
|
||||
"sharing"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Set the sharing mode and/or public-access setting (admin only).\n\nUpdates only the fields present in the body; requires at least one.\nRejects an unknown mode value with 400 (no fail-open coercion \u2014 an admin\nsetting a value should learn about a typo). Rejects a write to a setting\nthe deployment manages itself (not file-backed) with 403.",
|
||||
"operationId": "set_sharing_v1_sharing_put",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetSharingRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"title": "Response Set Sharing V1 Sharing Put",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Set Sharing",
|
||||
"tags": [
|
||||
"sharing"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""UI: the Share control is grayed out with an explanatory tooltip when the
|
||||
server's ``OMNIGENT_SHARING_MODE`` is ``off``.
|
||||
|
||||
Companion to ``test_permissions_modal.py::test_local_server_disables_share_
|
||||
button_with_tooltip`` (which covers the *local-server* disable). The shared
|
||||
session-scoped ``live_server`` runs the default policy (sharing ``on``) and
|
||||
can't be reconfigured per test, and the admin ``PUT /v1/sharing`` route is
|
||||
admin-gated (the headerless ``local`` browser identity isn't an admin here), so
|
||||
this spins up a dedicated server with ``OMNIGENT_SHARING_MODE=off`` and drives
|
||||
the real SPA to confirm the server-side kill switch surfaces as a disabled
|
||||
Share button — not merely a 403 on the grant endpoint.
|
||||
|
||||
Served through the public-looking loopback alias (``_PUBLIC_LOOPBACK_HOST``, the
|
||||
same one ``test_permissions_modal`` uses) so ``isCurrentServerLocal()`` is
|
||||
false and the *only* reason Share is disabled is the sharing-off policy — the
|
||||
local-server reason would otherwise mask it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from tests.e2e_ui.conftest import (
|
||||
_HEALTH_POLL_INTERVAL_S,
|
||||
_HEALTH_TIMEOUT_S,
|
||||
_PUBLIC_LOOPBACK_HOST,
|
||||
_REPO_ROOT,
|
||||
_TEST_AGENT_YAML,
|
||||
_build_hello_world_bundle,
|
||||
_find_free_port,
|
||||
)
|
||||
|
||||
# Mirrors AppShell.tsx's shareDisabledReason for the sharing-off case.
|
||||
_OFF_REASON = "Sharing has been disabled for this Omnigent server."
|
||||
|
||||
|
||||
def _public_loopback_url(base_url: str) -> str:
|
||||
"""Return *base_url* through the browser's public-looking loopback alias."""
|
||||
parsed = urlsplit(base_url)
|
||||
if parsed.port is None:
|
||||
raise AssertionError(f"e2e base URL missing port: {base_url!r}")
|
||||
return urlunsplit((parsed.scheme, f"{_PUBLIC_LOOPBACK_HOST}:{parsed.port}", "", "", ""))
|
||||
|
||||
|
||||
def _terminate(proc: subprocess.Popen[bytes]) -> None:
|
||||
"""SIGTERM with a short grace period, escalating to SIGKILL."""
|
||||
if proc.poll() is None:
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sharing_off_session(
|
||||
built_spa: None,
|
||||
mock_llm_server_url: str,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[tuple[str, str]]:
|
||||
"""A dedicated ``OMNIGENT_SHARING_MODE=off`` server + runner + one session.
|
||||
|
||||
Mirrors the shared ``live_server`` spawn and ``seeded_session`` create/bind
|
||||
(a separate instance is required because the shared server runs sharing
|
||||
``on`` and is session-scoped). Yields ``(base_url, session_id)``; no agent
|
||||
turn runs — the Share button only needs a session to exist.
|
||||
"""
|
||||
from omnigent.runner.identity import token_bound_runner_id
|
||||
|
||||
port = _find_free_port()
|
||||
server_tmp = tmp_path_factory.mktemp("e2e_ui_sharing_off")
|
||||
log_path = server_tmp / "server.log"
|
||||
db_path = server_tmp / "test.db"
|
||||
artifact_dir = server_tmp / "artifacts"
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
agent_yaml_path = server_tmp / "hello_world.yaml"
|
||||
agent_yaml_path.write_text(_TEST_AGENT_YAML)
|
||||
|
||||
binding_token = secrets.token_urlsafe(32)
|
||||
runner_id = token_bound_runner_id(binding_token)
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
pythonpath = f"{_REPO_ROOT}{os.pathsep}{os.environ.get('PYTHONPATH', '')}"
|
||||
|
||||
server_env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": pythonpath,
|
||||
"OMNIGENT_RUNNER_TUNNEL_TOKEN": binding_token,
|
||||
# The setting under test — the whole point of a dedicated server.
|
||||
"OMNIGENT_SHARING_MODE": "off",
|
||||
"OPENAI_BASE_URL": f"{mock_llm_server_url}/v1",
|
||||
"OPENAI_API_KEY": "mock-key",
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
}
|
||||
log_handle = open(log_path, "w") # noqa: SIM115 — lives for the Popen; closed in finally
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from omnigent.cli import main; main()",
|
||||
"server",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--database-uri",
|
||||
f"sqlite:///{db_path}",
|
||||
"--artifact-location",
|
||||
str(artifact_dir),
|
||||
"--agent",
|
||||
str(agent_yaml_path),
|
||||
],
|
||||
env=server_env,
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
runner_log_path = server_tmp / "runner.log"
|
||||
runner_log_handle = open(runner_log_path, "w") # noqa: SIM115
|
||||
runner_env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": pythonpath,
|
||||
"OMNIGENT_RUNNER_ID": runner_id,
|
||||
"OMNIGENT_RUNNER_TUNNEL_BINDING_TOKEN": binding_token,
|
||||
"OMNIGENT_RUNNER_PARENT_PID": str(os.getpid()),
|
||||
"RUNNER_SERVER_URL": base_url,
|
||||
"OPENAI_BASE_URL": f"{mock_llm_server_url}/v1",
|
||||
"OPENAI_API_KEY": "mock-key",
|
||||
}
|
||||
runner_proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "omnigent.runner._entry"],
|
||||
env=runner_env,
|
||||
stdout=runner_log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
try:
|
||||
# Poll /health + runner status until the server can route (same shape
|
||||
# as the shared live_server fixture).
|
||||
deadline = time.monotonic() + _HEALTH_TIMEOUT_S
|
||||
ready = False
|
||||
last_error = "not polled yet"
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
last_error = f"server exited early with code {proc.returncode}"
|
||||
break
|
||||
try:
|
||||
if httpx.get(f"{base_url}/health", timeout=2).status_code == 200:
|
||||
status = httpx.get(f"{base_url}/v1/runners/{runner_id}/status", timeout=2)
|
||||
if status.status_code == 200 and status.json()["online"] is True:
|
||||
ready = True
|
||||
break
|
||||
last_error = f"runner status HTTP {status.status_code}"
|
||||
except (httpx.ConnectError, httpx.ReadError, httpx.TimeoutException) as exc:
|
||||
last_error = f"{type(exc).__name__}: {exc}"
|
||||
time.sleep(_HEALTH_POLL_INTERVAL_S)
|
||||
if not ready:
|
||||
log_handle.flush()
|
||||
log_text = log_path.read_text() if log_path.exists() else ""
|
||||
raise RuntimeError(
|
||||
f"sharing-off server not healthy within {_HEALTH_TIMEOUT_S:.0f}s on "
|
||||
f"{base_url} (last_error={last_error}).\n{log_text[-3000:]}"
|
||||
)
|
||||
|
||||
# Create a hello_world session and bind it to the runner (mirrors
|
||||
# seeded_session). No turn is dispatched.
|
||||
bundle = _build_hello_world_bundle()
|
||||
create = httpx.post(
|
||||
f"{base_url}/v1/sessions",
|
||||
data={"metadata": _json.dumps({})},
|
||||
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
|
||||
timeout=30.0,
|
||||
)
|
||||
create.raise_for_status()
|
||||
session_id = create.json()["session_id"]
|
||||
httpx.patch(
|
||||
f"{base_url}/v1/sessions/{session_id}",
|
||||
json={"runner_id": runner_id},
|
||||
timeout=10.0,
|
||||
).raise_for_status()
|
||||
|
||||
yield (base_url, session_id)
|
||||
finally:
|
||||
_terminate(runner_proc)
|
||||
runner_log_handle.close()
|
||||
_terminate(proc)
|
||||
log_handle.close()
|
||||
|
||||
|
||||
def test_sharing_off_disables_share_button_with_tooltip(
|
||||
page: Page,
|
||||
sharing_off_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""``OMNIGENT_SHARING_MODE=off`` grays out the header Share button and
|
||||
explains why — the server-side kill switch surfaced in the SPA."""
|
||||
base_url, session_id = sharing_off_session
|
||||
# Public-looking host so the local-server disable doesn't fire; the only
|
||||
# reason Share is disabled here is the sharing-off policy.
|
||||
public_url = _public_loopback_url(base_url)
|
||||
page.goto(f"{public_url}/c/{session_id}")
|
||||
|
||||
share = page.get_by_role("button", name="Share session")
|
||||
expect(share).to_be_visible(timeout=60_000)
|
||||
expect(share).to_be_disabled()
|
||||
|
||||
page.get_by_label(f"Share session disabled: {_OFF_REASON}").hover()
|
||||
tooltip = page.locator("[data-slot=tooltip-content]", has_text=_OFF_REASON)
|
||||
expect(tooltip).to_be_visible(timeout=5_000)
|
||||
# A disabled Share control opens no dialog.
|
||||
expect(page.get_by_role("dialog")).to_have_count(0)
|
||||
@@ -0,0 +1,707 @@
|
||||
"""Tests for the per-server session-sharing mode gate.
|
||||
|
||||
Covers the whole feature surface:
|
||||
|
||||
- :meth:`SharingMode.coerce` — the fail-open-to-ON contract for the
|
||||
env-var and callable boundaries.
|
||||
- ``create_app(sharing_mode=…)`` wiring — static value, per-request
|
||||
callable, and the ``OMNIGENT_SHARING_MODE`` env-var default.
|
||||
- ``GET /v1/info`` reporting ``sharing_mode`` so the web app stays in
|
||||
lockstep with the server gate.
|
||||
- The ``PUT /v1/sessions/{id}/permissions`` gate: ``OFF`` rejects all
|
||||
new grants (403), ``READ_ONLY`` caps grants at read (edit → 403,
|
||||
read → ok), and ``ON`` is behavior-preserving. Revoke stays allowed
|
||||
in every mode.
|
||||
|
||||
The app is built via the real :func:`create_app` so the tests exercise
|
||||
the actual ``app.state.sharing_mode`` normalization and the route gate,
|
||||
not a hand-rolled stub. Requests go through ``httpx.ASGITransport`` (no
|
||||
lifespan) since none of these paths need the runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.runtime.agent_cache import AgentCache
|
||||
from omnigent.server import sharing_settings
|
||||
from omnigent.server.app import create_app
|
||||
from omnigent.server.auth import (
|
||||
LEVEL_EDIT,
|
||||
LEVEL_MANAGE,
|
||||
LEVEL_OWNER,
|
||||
LEVEL_READ,
|
||||
RESERVED_USER_PUBLIC,
|
||||
AuthProvider,
|
||||
SharingMode,
|
||||
UnifiedAuthProvider,
|
||||
workspace_sharing_blocked,
|
||||
)
|
||||
from omnigent.server.sharing_settings import (
|
||||
read_public_sharing_override,
|
||||
read_sharing_mode_override,
|
||||
resolve_sharing_mode_path,
|
||||
write_public_sharing_override,
|
||||
write_sharing_mode_override,
|
||||
)
|
||||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||||
from omnigent.stores.conversation_store.sqlalchemy_store import SqlAlchemyConversationStore
|
||||
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
||||
from omnigent.stores.permission_store.sqlalchemy_store import SqlAlchemyPermissionStore
|
||||
|
||||
# Reserved test identities. The owner is granted MANAGE so it can reach
|
||||
# the grant endpoint; the grantee is the target of each new grant; the admin
|
||||
# manages the server-wide sharing mode.
|
||||
_OWNER = "owner@sharing.test"
|
||||
_GRANTEE = "bob@sharing.test"
|
||||
_ADMIN = "admin@sharing.test"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Point ``resolve_data_dir()`` at the per-test tmp dir so the file-backed
|
||||
sharing overrides are isolated, and reset the module cache so no value
|
||||
leaks across tests."""
|
||||
monkeypatch.setenv("OMNIGENT_ADMIN_CREDENTIALS_PATH", str(tmp_path / "admin-credentials"))
|
||||
sharing_settings._cache = {}
|
||||
|
||||
|
||||
def _build_app(
|
||||
db_uri: str,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
sharing_mode: SharingMode | object | None = None,
|
||||
public_sharing: bool | object | None = None,
|
||||
permission_store: SqlAlchemyPermissionStore | None = None,
|
||||
auth_provider: AuthProvider | None = None,
|
||||
) -> FastAPI:
|
||||
"""Build a real ``create_app`` wired to per-test SQLite stores."""
|
||||
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
||||
return create_app(
|
||||
agent_store=SqlAlchemyAgentStore(db_uri),
|
||||
file_store=SqlAlchemyFileStore(db_uri),
|
||||
conversation_store=SqlAlchemyConversationStore(db_uri),
|
||||
artifact_store=artifact_store,
|
||||
agent_cache=AgentCache(artifact_store=artifact_store, cache_dir=tmp_path / "cache"),
|
||||
permission_store=permission_store,
|
||||
auth_provider=auth_provider,
|
||||
sharing_mode=sharing_mode,
|
||||
public_sharing=public_sharing,
|
||||
)
|
||||
|
||||
|
||||
def _client(app: FastAPI, email: str | None = None) -> httpx.AsyncClient:
|
||||
"""An in-process async client, optionally carrying a header identity."""
|
||||
headers = {"X-Forwarded-Email": email} if email else {}
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _seed_owned_session(
|
||||
db_uri: str,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
sharing_mode: SharingMode = SharingMode.ON,
|
||||
public_sharing: bool | object | None = None,
|
||||
workspace: str | None = None,
|
||||
) -> tuple[FastAPI, str]:
|
||||
"""Build an app whose ``_OWNER`` identity manages a real session.
|
||||
|
||||
Seeds a conversation and an OWNER grant directly into the shared DB
|
||||
so ``PUT …/permissions`` gets past the manage-access check and hits
|
||||
the sharing gate (and, when allowed, actually persists the grant).
|
||||
``workspace`` sets the session's recorded cwd, exercising the
|
||||
``RESTRICTED_READ_ONLY`` home/root block; ``public_sharing`` exercises
|
||||
the public-access gate.
|
||||
"""
|
||||
permission_store = SqlAlchemyPermissionStore(db_uri)
|
||||
conversation_store = SqlAlchemyConversationStore(db_uri)
|
||||
conv = conversation_store.create_conversation(workspace=workspace)
|
||||
permission_store.ensure_user(_OWNER)
|
||||
permission_store.grant(_OWNER, conv.id, LEVEL_OWNER)
|
||||
app = _build_app(
|
||||
db_uri,
|
||||
tmp_path,
|
||||
sharing_mode=sharing_mode,
|
||||
public_sharing=public_sharing,
|
||||
permission_store=permission_store,
|
||||
auth_provider=UnifiedAuthProvider(source="header"),
|
||||
)
|
||||
return app, conv.id
|
||||
|
||||
|
||||
def _admin_app(
|
||||
db_uri: str,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
sharing_mode: SharingMode | object | None = None,
|
||||
public_sharing: bool | object | None = None,
|
||||
) -> FastAPI:
|
||||
"""Build an app with a seeded admin identity for the sharing routes.
|
||||
|
||||
A ``None`` setting yields the editable file-backed default; a static value
|
||||
yields the non-editable (managed) case.
|
||||
"""
|
||||
permission_store = SqlAlchemyPermissionStore(db_uri)
|
||||
permission_store.ensure_user(_ADMIN, is_admin=True)
|
||||
return _build_app(
|
||||
db_uri,
|
||||
tmp_path,
|
||||
sharing_mode=sharing_mode,
|
||||
public_sharing=public_sharing,
|
||||
permission_store=permission_store,
|
||||
auth_provider=UnifiedAuthProvider(source="header"),
|
||||
)
|
||||
|
||||
|
||||
# ── SharingMode.coerce — fail-open-to-ON contract ────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(SharingMode.OFF, SharingMode.OFF),
|
||||
(SharingMode.READ_ONLY, SharingMode.READ_ONLY),
|
||||
(SharingMode.RESTRICTED_READ_ONLY, SharingMode.RESTRICTED_READ_ONLY),
|
||||
(SharingMode.ON, SharingMode.ON),
|
||||
("off", SharingMode.OFF),
|
||||
("read_only", SharingMode.READ_ONLY),
|
||||
("restricted_read_only", SharingMode.RESTRICTED_READ_ONLY),
|
||||
("on", SharingMode.ON),
|
||||
("READ_ONLY", SharingMode.READ_ONLY), # case-insensitive
|
||||
(" Restricted_Read_Only ", SharingMode.RESTRICTED_READ_ONLY),
|
||||
(" On ", SharingMode.ON), # whitespace-tolerant
|
||||
(None, SharingMode.ON), # unset → fail open
|
||||
("", SharingMode.ON), # empty → fail open
|
||||
("garbage", SharingMode.ON), # unrecognized → fail open
|
||||
(123, SharingMode.ON), # wrong type → fail open
|
||||
],
|
||||
)
|
||||
def test_coerce_fails_open_to_on(value: object, expected: SharingMode) -> None:
|
||||
"""Anything unset/unrecognized coerces to ON; valid values round-trip."""
|
||||
assert SharingMode.coerce(value) is expected
|
||||
|
||||
|
||||
# ── create_app wiring: env default / static / callable ───────────────
|
||||
|
||||
|
||||
def test_wiring_defaults_to_on_when_env_unset(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No arg + unset env → the top-level default is ON."""
|
||||
monkeypatch.delenv("OMNIGENT_SHARING_MODE", raising=False)
|
||||
app = _build_app(db_uri, tmp_path)
|
||||
assert app.state.sharing_mode() is SharingMode.ON
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("off", SharingMode.OFF),
|
||||
("read_only", SharingMode.READ_ONLY),
|
||||
("on", SharingMode.ON),
|
||||
("nonsense", SharingMode.ON), # fail open
|
||||
],
|
||||
)
|
||||
def test_wiring_reads_env_var(
|
||||
db_uri: str,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
raw: str,
|
||||
expected: SharingMode,
|
||||
) -> None:
|
||||
"""``OMNIGENT_SHARING_MODE`` is the top-level control when no arg is given."""
|
||||
monkeypatch.setenv("OMNIGENT_SHARING_MODE", raw)
|
||||
app = _build_app(db_uri, tmp_path)
|
||||
assert app.state.sharing_mode() is expected
|
||||
|
||||
|
||||
def test_wiring_static_value_overrides_env(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An explicit ``sharing_mode=`` beats the env var."""
|
||||
monkeypatch.setenv("OMNIGENT_SHARING_MODE", "off")
|
||||
app = _build_app(db_uri, tmp_path, sharing_mode=SharingMode.READ_ONLY)
|
||||
assert app.state.sharing_mode() is SharingMode.READ_ONLY
|
||||
|
||||
|
||||
def test_wiring_callable_is_resolved_per_request(db_uri: str, tmp_path: Path) -> None:
|
||||
"""A callable is invoked (and coerced) on each resolution, not cached."""
|
||||
modes = iter(["on", "off", "garbage"])
|
||||
app = _build_app(db_uri, tmp_path, sharing_mode=lambda: next(modes))
|
||||
assert app.state.sharing_mode() is SharingMode.ON
|
||||
assert app.state.sharing_mode() is SharingMode.OFF
|
||||
# The callable boundary also fails open for a bad value.
|
||||
assert app.state.sharing_mode() is SharingMode.ON
|
||||
|
||||
|
||||
# ── GET /v1/info reports the mode ────────────────────────────────────
|
||||
|
||||
|
||||
async def test_info_reports_default_on(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv("OMNIGENT_SHARING_MODE", raising=False)
|
||||
app = _build_app(db_uri, tmp_path)
|
||||
async with _client(app) as c:
|
||||
resp = await c.get("/v1/info")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sharing_mode"] == "on"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode,expected",
|
||||
[
|
||||
(SharingMode.OFF, "off"),
|
||||
(SharingMode.READ_ONLY, "read_only"),
|
||||
(SharingMode.RESTRICTED_READ_ONLY, "restricted_read_only"),
|
||||
(SharingMode.ON, "on"),
|
||||
],
|
||||
)
|
||||
async def test_info_reports_configured_mode(
|
||||
db_uri: str, tmp_path: Path, mode: SharingMode, expected: str
|
||||
) -> None:
|
||||
app = _build_app(db_uri, tmp_path, sharing_mode=mode)
|
||||
async with _client(app) as c:
|
||||
resp = await c.get("/v1/info")
|
||||
assert resp.json()["sharing_mode"] == expected
|
||||
|
||||
|
||||
# ── The grant gate — no permission store needed (gate precedes it) ───
|
||||
|
||||
|
||||
async def test_off_rejects_new_grant_at_any_level(db_uri: str, tmp_path: Path) -> None:
|
||||
"""OFF blocks a new grant regardless of level, before the store check."""
|
||||
app = _build_app(db_uri, tmp_path, sharing_mode=SharingMode.OFF)
|
||||
async with _client(app) as c:
|
||||
for level in (LEVEL_READ, LEVEL_EDIT, LEVEL_MANAGE):
|
||||
resp = await c.put(
|
||||
"/v1/sessions/conv_absent/permissions",
|
||||
json={"user_id": _GRANTEE, "level": level},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "disabled" in resp.text.lower()
|
||||
|
||||
|
||||
async def test_read_only_rejects_edit_grant(db_uri: str, tmp_path: Path) -> None:
|
||||
"""READ_ONLY rejects an edit (level > read) grant with 403."""
|
||||
app = _build_app(db_uri, tmp_path, sharing_mode=SharingMode.READ_ONLY)
|
||||
async with _client(app) as c:
|
||||
resp = await c.put(
|
||||
"/v1/sessions/conv_absent/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_EDIT},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "read-only" in resp.text.lower()
|
||||
|
||||
|
||||
# ── The grant gate — allowed paths persist against a real store ──────
|
||||
|
||||
|
||||
async def test_on_allows_edit_grant(db_uri: str, tmp_path: Path) -> None:
|
||||
"""ON is behavior-preserving: an edit grant succeeds (200)."""
|
||||
app, sid = _seed_owned_session(db_uri, tmp_path, sharing_mode=SharingMode.ON)
|
||||
async with _client(app, _OWNER) as c:
|
||||
resp = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_EDIT},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["level"] == LEVEL_EDIT
|
||||
|
||||
|
||||
async def test_read_only_allows_read_but_not_edit(db_uri: str, tmp_path: Path) -> None:
|
||||
"""READ_ONLY lets a read grant through but still rejects edit."""
|
||||
app, sid = _seed_owned_session(db_uri, tmp_path, sharing_mode=SharingMode.READ_ONLY)
|
||||
async with _client(app, _OWNER) as c:
|
||||
ok = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_READ},
|
||||
)
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["level"] == LEVEL_READ
|
||||
|
||||
denied = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_EDIT},
|
||||
)
|
||||
assert denied.status_code == 403, denied.text
|
||||
|
||||
|
||||
async def test_off_rejects_grant_even_with_manage_access(db_uri: str, tmp_path: Path) -> None:
|
||||
"""Even a legitimate manager cannot create a grant when sharing is OFF."""
|
||||
app, sid = _seed_owned_session(db_uri, tmp_path, sharing_mode=SharingMode.OFF)
|
||||
async with _client(app, _OWNER) as c:
|
||||
resp = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_READ},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
|
||||
|
||||
async def test_revoke_is_unaffected_by_read_only(db_uri: str, tmp_path: Path) -> None:
|
||||
"""Revoke stays allowed in READ_ONLY — only *new* grants are gated."""
|
||||
app, sid = _seed_owned_session(db_uri, tmp_path, sharing_mode=SharingMode.READ_ONLY)
|
||||
async with _client(app, _OWNER) as c:
|
||||
await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_READ},
|
||||
)
|
||||
revoke = await c.delete(f"/v1/sessions/{sid}/permissions/{_GRANTEE}")
|
||||
assert revoke.status_code == 204, revoke.text
|
||||
|
||||
|
||||
# ── workspace_sharing_blocked — the home/root cwd predicate ──────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workspace",
|
||||
[
|
||||
"/",
|
||||
"/root",
|
||||
"/root/", # trailing slash normalized
|
||||
"/home/alice",
|
||||
"/Users/bob",
|
||||
"/var/home/carol", # ostree home layout
|
||||
],
|
||||
)
|
||||
def test_workspace_sharing_blocked_true(workspace: str) -> None:
|
||||
"""The filesystem root and user home dirs are blocked, host-agnostically."""
|
||||
assert workspace_sharing_blocked(workspace) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"workspace",
|
||||
[
|
||||
None, # no recorded cwd
|
||||
"",
|
||||
"/home/alice/project", # a subdirectory of home is fine
|
||||
"/Users/bob/code",
|
||||
"/var/home/carol/repo",
|
||||
"/home", # the parent container itself is not a home dir
|
||||
"/var/home",
|
||||
"/workspaces/omnigent", # a project checkout, not a home
|
||||
"/srv/work",
|
||||
"/tmp/session",
|
||||
],
|
||||
)
|
||||
def test_workspace_sharing_blocked_false(workspace: str | None) -> None:
|
||||
"""A subdirectory / project / arbitrary path (or no cwd) is shareable."""
|
||||
assert workspace_sharing_blocked(workspace) is False
|
||||
|
||||
|
||||
# ── RESTRICTED_READ_ONLY gate — home/root cwd blocked entirely ───────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blocked_workspace", ["/", "/home/alice", "/root"])
|
||||
async def test_restricted_blocks_home_or_root_session_even_read(
|
||||
db_uri: str, tmp_path: Path, blocked_workspace: str
|
||||
) -> None:
|
||||
"""RESTRICTED_READ_ONLY rejects *all* grants (even read) on a session
|
||||
whose cwd is a home dir or the filesystem root."""
|
||||
app, sid = _seed_owned_session(
|
||||
db_uri,
|
||||
tmp_path,
|
||||
sharing_mode=SharingMode.RESTRICTED_READ_ONLY,
|
||||
workspace=blocked_workspace,
|
||||
)
|
||||
async with _client(app, _OWNER) as c:
|
||||
resp = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_READ},
|
||||
)
|
||||
assert resp.status_code == 403, resp.text
|
||||
assert "cannot be shared" in resp.text.lower()
|
||||
|
||||
|
||||
async def test_restricted_allows_read_on_normal_session(db_uri: str, tmp_path: Path) -> None:
|
||||
"""RESTRICTED_READ_ONLY behaves like READ_ONLY for a non-home/root cwd:
|
||||
a read grant is allowed, an edit grant is rejected."""
|
||||
app, sid = _seed_owned_session(
|
||||
db_uri,
|
||||
tmp_path,
|
||||
sharing_mode=SharingMode.RESTRICTED_READ_ONLY,
|
||||
workspace="/home/alice/project",
|
||||
)
|
||||
async with _client(app, _OWNER) as c:
|
||||
ok = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_READ},
|
||||
)
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["level"] == LEVEL_READ
|
||||
|
||||
denied = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_EDIT},
|
||||
)
|
||||
assert denied.status_code == 403, denied.text
|
||||
assert "read-only" in denied.text.lower()
|
||||
|
||||
|
||||
async def test_restricted_allows_read_when_no_workspace(db_uri: str, tmp_path: Path) -> None:
|
||||
"""A session with no recorded cwd is not treated as home/root — a read
|
||||
grant is allowed under RESTRICTED_READ_ONLY."""
|
||||
app, sid = _seed_owned_session(
|
||||
db_uri, tmp_path, sharing_mode=SharingMode.RESTRICTED_READ_ONLY, workspace=None
|
||||
)
|
||||
async with _client(app, _OWNER) as c:
|
||||
resp = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_READ},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
# ── File-backed override: persistence + create_app precedence ────────
|
||||
|
||||
|
||||
def test_override_file_roundtrip(tmp_path: Path) -> None:
|
||||
"""write/read round-trips the override; an unset file reads as None."""
|
||||
assert read_sharing_mode_override() is None
|
||||
write_sharing_mode_override(SharingMode.RESTRICTED_READ_ONLY)
|
||||
assert resolve_sharing_mode_path().exists()
|
||||
assert read_sharing_mode_override() is SharingMode.RESTRICTED_READ_ONLY
|
||||
|
||||
|
||||
def test_override_beats_env_default(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""When an override file exists, create_app's default resolver returns it,
|
||||
ignoring the env default; the path is marked editable."""
|
||||
monkeypatch.setenv("OMNIGENT_SHARING_MODE", "on")
|
||||
write_sharing_mode_override(SharingMode.OFF)
|
||||
app = _build_app(db_uri, tmp_path) # None → file-backed default
|
||||
assert app.state.sharing_mode() is SharingMode.OFF
|
||||
assert app.state.sharing_mode_writable is True
|
||||
|
||||
|
||||
def test_env_default_used_when_no_override(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""With no override file, the env default applies."""
|
||||
monkeypatch.setenv("OMNIGENT_SHARING_MODE", "read_only")
|
||||
app = _build_app(db_uri, tmp_path)
|
||||
assert app.state.sharing_mode() is SharingMode.READ_ONLY
|
||||
|
||||
|
||||
# ── Admin route: GET / PUT /v1/sharing ───────────────────────────────
|
||||
|
||||
|
||||
async def test_get_reports_state(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("OMNIGENT_SHARING_MODE", "on")
|
||||
app = _admin_app(db_uri, tmp_path)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
resp = await c.get("/v1/sharing")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["sharing_mode"] == "on"
|
||||
assert body["editable"] is True
|
||||
assert body["options"] == ["on", "read_only", "restricted_read_only", "off"]
|
||||
|
||||
|
||||
async def test_put_sets_mode_and_persists(db_uri: str, tmp_path: Path) -> None:
|
||||
"""An admin PUT persists the override; GET, /v1/info, and the live
|
||||
resolver all reflect it."""
|
||||
app = _admin_app(db_uri, tmp_path)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
put = await c.put("/v1/sharing", json={"sharing_mode": "restricted_read_only"})
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["sharing_mode"] == "restricted_read_only"
|
||||
|
||||
assert (await c.get("/v1/sharing")).json()["sharing_mode"] == "restricted_read_only"
|
||||
assert (await c.get("/v1/info")).json()["sharing_mode"] == "restricted_read_only"
|
||||
assert app.state.sharing_mode() is SharingMode.RESTRICTED_READ_ONLY
|
||||
assert read_sharing_mode_override() is SharingMode.RESTRICTED_READ_ONLY
|
||||
|
||||
|
||||
async def test_put_rejects_unknown_value(db_uri: str, tmp_path: Path) -> None:
|
||||
"""A typo'd tier is a 400 — no silent fail-open coercion on an admin PUT."""
|
||||
app = _admin_app(db_uri, tmp_path)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
resp = await c.put("/v1/sharing", json={"sharing_mode": "bogus"})
|
||||
assert resp.status_code == 400, resp.text
|
||||
# unchanged: nothing was persisted
|
||||
assert read_sharing_mode_override() is None
|
||||
|
||||
|
||||
async def test_admin_endpoint_requires_admin(db_uri: str, tmp_path: Path) -> None:
|
||||
"""A non-admin identity is forbidden from reading or writing the mode."""
|
||||
app = _admin_app(db_uri, tmp_path) # only _ADMIN is an admin
|
||||
async with _client(app, "intruder@sharing.test") as c:
|
||||
assert (await c.get("/v1/sharing")).status_code == 403
|
||||
put = await c.put("/v1/sharing", json={"sharing_mode": "off"})
|
||||
assert put.status_code == 403, put.text
|
||||
|
||||
|
||||
async def test_put_rejected_when_not_writable(db_uri: str, tmp_path: Path) -> None:
|
||||
"""A deployment-managed mode (static/callable) reports editable=false and
|
||||
rejects writes."""
|
||||
app = _admin_app(db_uri, tmp_path, sharing_mode=SharingMode.ON)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
assert (await c.get("/v1/sharing")).json()["editable"] is False
|
||||
put = await c.put("/v1/sharing", json={"sharing_mode": "off"})
|
||||
assert put.status_code == 403, put.text
|
||||
|
||||
|
||||
async def test_put_requires_a_field(db_uri: str, tmp_path: Path) -> None:
|
||||
"""An empty body updates nothing and is a 400."""
|
||||
app = _admin_app(db_uri, tmp_path)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
resp = await c.put("/v1/sharing", json={})
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
# ── Public-access switch (OMNIGENT_PUBLIC_SHARING) ───────────────────
|
||||
|
||||
|
||||
def test_public_sharing_defaults_enabled(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No arg + unset env → public sharing is enabled, file-editable."""
|
||||
monkeypatch.delenv("OMNIGENT_PUBLIC_SHARING", raising=False)
|
||||
app = _build_app(db_uri, tmp_path)
|
||||
assert app.state.public_sharing() is True
|
||||
assert app.state.public_sharing_writable is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected", [("0", False), ("false", False), ("no", False), ("1", True), ("on", True)]
|
||||
)
|
||||
def test_public_sharing_reads_env_var(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool
|
||||
) -> None:
|
||||
"""``OMNIGENT_PUBLIC_SHARING`` is the top-level default when no arg is given."""
|
||||
monkeypatch.setenv("OMNIGENT_PUBLIC_SHARING", raw)
|
||||
app = _build_app(db_uri, tmp_path)
|
||||
assert app.state.public_sharing() is expected
|
||||
|
||||
|
||||
def test_public_static_value_is_not_writable(db_uri: str, tmp_path: Path) -> None:
|
||||
"""An explicit bool is authoritative and not admin-editable."""
|
||||
app = _build_app(db_uri, tmp_path, public_sharing=False)
|
||||
assert app.state.public_sharing() is False
|
||||
assert app.state.public_sharing_writable is False
|
||||
|
||||
|
||||
def test_public_override_roundtrip_and_precedence(
|
||||
db_uri: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The override file round-trips and beats the env default."""
|
||||
assert read_public_sharing_override() is None
|
||||
monkeypatch.setenv("OMNIGENT_PUBLIC_SHARING", "1")
|
||||
write_public_sharing_override(False)
|
||||
assert read_public_sharing_override() is False
|
||||
app = _build_app(db_uri, tmp_path) # None → file-backed default
|
||||
assert app.state.public_sharing() is False
|
||||
|
||||
|
||||
async def test_info_reports_public_sharing(db_uri: str, tmp_path: Path) -> None:
|
||||
app_on = _build_app(db_uri, tmp_path, public_sharing=True)
|
||||
async with _client(app_on) as c:
|
||||
assert (await c.get("/v1/info")).json()["public_sharing_enabled"] is True
|
||||
app_off = _build_app(db_uri, tmp_path, public_sharing=False)
|
||||
async with _client(app_off) as c:
|
||||
assert (await c.get("/v1/info")).json()["public_sharing_enabled"] is False
|
||||
|
||||
|
||||
async def test_public_grant_blocked_when_disabled(db_uri: str, tmp_path: Path) -> None:
|
||||
"""When public sharing is off, the ``__public__`` grant is 403 — but a
|
||||
normal user grant still succeeds (the two switches are independent)."""
|
||||
app, sid = _seed_owned_session(db_uri, tmp_path, public_sharing=False)
|
||||
async with _client(app, _OWNER) as c:
|
||||
public = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": RESERVED_USER_PUBLIC, "level": LEVEL_READ},
|
||||
)
|
||||
assert public.status_code == 403, public.text
|
||||
assert "public access has been disabled" in public.text.lower()
|
||||
|
||||
user = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": _GRANTEE, "level": LEVEL_READ},
|
||||
)
|
||||
assert user.status_code == 200, user.text
|
||||
|
||||
|
||||
async def test_public_grant_allowed_when_enabled(db_uri: str, tmp_path: Path) -> None:
|
||||
"""The default (public enabled) still lets a ``__public__`` read grant through."""
|
||||
app, sid = _seed_owned_session(db_uri, tmp_path, public_sharing=True)
|
||||
async with _client(app, _OWNER) as c:
|
||||
resp = await c.put(
|
||||
f"/v1/sessions/{sid}/permissions",
|
||||
json={"user_id": RESERVED_USER_PUBLIC, "level": LEVEL_READ},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["level"] == LEVEL_READ
|
||||
|
||||
|
||||
async def test_admin_get_reports_public_state(db_uri: str, tmp_path: Path) -> None:
|
||||
app = _admin_app(db_uri, tmp_path)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
body = (await c.get("/v1/sharing")).json()
|
||||
assert body["public_sharing_enabled"] is True
|
||||
assert body["public_sharing_editable"] is True
|
||||
|
||||
|
||||
async def test_admin_put_disables_public_and_gate_follows(db_uri: str, tmp_path: Path) -> None:
|
||||
"""An admin PUT of public_sharing=false persists, is reflected in /v1/info,
|
||||
and makes the grant gate reject the ``__public__`` grant."""
|
||||
permission_store = SqlAlchemyPermissionStore(db_uri)
|
||||
permission_store.ensure_user(_ADMIN, is_admin=True)
|
||||
conv = SqlAlchemyConversationStore(db_uri).create_conversation()
|
||||
permission_store.grant(_ADMIN, conv.id, LEVEL_OWNER)
|
||||
app = _build_app(
|
||||
db_uri,
|
||||
tmp_path,
|
||||
permission_store=permission_store,
|
||||
auth_provider=UnifiedAuthProvider(source="header"),
|
||||
)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
put = await c.put("/v1/sharing", json={"public_sharing": False})
|
||||
assert put.status_code == 200, put.text
|
||||
assert put.json()["public_sharing_enabled"] is False
|
||||
|
||||
assert (await c.get("/v1/info")).json()["public_sharing_enabled"] is False
|
||||
assert read_public_sharing_override() is False
|
||||
|
||||
blocked = await c.put(
|
||||
f"/v1/sessions/{conv.id}/permissions",
|
||||
json={"user_id": RESERVED_USER_PUBLIC, "level": LEVEL_READ},
|
||||
)
|
||||
assert blocked.status_code == 403, blocked.text
|
||||
|
||||
|
||||
async def test_admin_put_public_rejected_when_not_writable(db_uri: str, tmp_path: Path) -> None:
|
||||
"""A deployment-managed public setting reports not-editable and rejects writes."""
|
||||
app = _admin_app(db_uri, tmp_path, public_sharing=True)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
assert (await c.get("/v1/sharing")).json()["public_sharing_editable"] is False
|
||||
put = await c.put("/v1/sharing", json={"public_sharing": False})
|
||||
assert put.status_code == 403, put.text
|
||||
|
||||
|
||||
async def test_admin_put_is_atomic_across_mixed_writability(db_uri: str, tmp_path: Path) -> None:
|
||||
"""A both-fields PUT where only one setting is file-backed rejects the whole
|
||||
request without persisting the writable half (no partial apply).
|
||||
|
||||
Mode is file-backed (editable); public access is deployment-managed (a
|
||||
callable, not editable). Setting both must 403 on public *before* the mode
|
||||
override is written.
|
||||
"""
|
||||
app = _admin_app(db_uri, tmp_path, public_sharing=lambda: True)
|
||||
async with _client(app, _ADMIN) as c:
|
||||
resp = await c.put("/v1/sharing", json={"sharing_mode": "off", "public_sharing": False})
|
||||
assert resp.status_code == 403, resp.text
|
||||
# The writable half must NOT have been persisted (no partial apply).
|
||||
assert read_sharing_mode_override() is None
|
||||
@@ -2,6 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { ServerInfo, SharingMode } from "@/lib/capabilities";
|
||||
import { CapabilitiesProvider } from "@/lib/CapabilitiesContext";
|
||||
import { PermissionsModal } from "./PermissionsModal";
|
||||
|
||||
vi.mock("@/lib/permissionsApi", () => ({
|
||||
@@ -42,6 +44,44 @@ function createWrapper() {
|
||||
};
|
||||
}
|
||||
|
||||
/** Full OSS ServerInfo with permissive defaults; override per test. */
|
||||
function serverInfo(overrides: Partial<ServerInfo> = {}): ServerInfo {
|
||||
return {
|
||||
accounts_enabled: false,
|
||||
login_url: null,
|
||||
needs_setup: false,
|
||||
databricks_features: false,
|
||||
managed_sandboxes_enabled: false,
|
||||
sandbox_provider: null,
|
||||
sharing_mode: "on",
|
||||
public_sharing_enabled: true,
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrapper that pins arbitrary ServerInfo overrides via CapabilitiesProvider. */
|
||||
function createInfoWrapper(overrides: Partial<ServerInfo>) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<TooltipProvider>
|
||||
<CapabilitiesProvider info={serverInfo(overrides)}>{children}</CapabilitiesProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrapper that pins the server's sharing policy via CapabilitiesProvider. */
|
||||
function createSharingWrapper(mode: SharingMode) {
|
||||
return createInfoWrapper({ sharing_mode: mode });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
grantMock.mockReset();
|
||||
@@ -433,4 +473,114 @@ describe("PermissionsModal", () => {
|
||||
await waitFor(() => expect(screen.getByText("No matches")).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharing mode", () => {
|
||||
it("off: shows the disabled notice and never fetches grants", async () => {
|
||||
render(<PermissionsModal sessionId="conv_abc" open={true} onOpenChange={() => {}} />, {
|
||||
wrapper: createSharingWrapper("off"),
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByText("Sharing has been disabled for this Omnigent server."),
|
||||
).toBeInTheDocument();
|
||||
// Off short-circuits before the grant-list query and hides all controls.
|
||||
expect(listMock).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: /grant/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on: renders the full controls with no disabled/read-only notice", async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
render(<PermissionsModal sessionId="conv_abc" open={true} onOpenChange={() => {}} />, {
|
||||
wrapper: createSharingWrapper("on"),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalledWith("conv_abc"));
|
||||
expect(screen.getByRole("button", { name: /grant/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Invite others to view or collaborate on this session."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Sharing has been disabled for this Omnigent server."),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("read_only: shows the read-only notice, keeps Grant, offers only Read", async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
render(<PermissionsModal sessionId="conv_abc" open={true} onOpenChange={() => {}} />, {
|
||||
wrapper: createSharingWrapper("read_only"),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalledWith("conv_abc"));
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This server allows read-only sharing — invite others to view this session.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
// Read grants are still allowed, so the Grant control stays.
|
||||
expect(screen.getByRole("button", { name: /grant/i })).toBeInTheDocument();
|
||||
// The add-form level select must offer only Read (Edit is hidden). With no
|
||||
// grants there is exactly one combobox (the add-form select).
|
||||
const trigger = screen.getByRole("combobox");
|
||||
trigger.focus();
|
||||
fireEvent.keyDown(trigger, { key: "Enter" });
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const options = within(listbox).getAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["Read"]);
|
||||
});
|
||||
|
||||
it("restricted_read_only: presents the same read-only UI as read_only", async () => {
|
||||
// The per-session home/root block is enforced server-side; the modal
|
||||
// itself shows the read-only affordance for every session.
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
render(<PermissionsModal sessionId="conv_abc" open={true} onOpenChange={() => {}} />, {
|
||||
wrapper: createSharingWrapper("restricted_read_only"),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalledWith("conv_abc"));
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This server allows read-only sharing — invite others to view this session.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /grant/i })).toBeInTheDocument();
|
||||
const trigger = screen.getByRole("combobox");
|
||||
trigger.focus();
|
||||
fireEvent.keyDown(trigger, { key: "Enter" });
|
||||
const listbox = await screen.findByRole("listbox");
|
||||
const options = within(listbox).getAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual(["Read"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public access", () => {
|
||||
it("hides the Public access toggle when the server disables public sharing", async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
render(<PermissionsModal sessionId="conv_abc" open={true} onOpenChange={() => {}} />, {
|
||||
wrapper: createInfoWrapper({ public_sharing_enabled: false }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalledWith("conv_abc"));
|
||||
// The user-grant UI stays; only the public toggle is gone.
|
||||
expect(screen.getByRole("button", { name: /grant/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Public access")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Public access toggle when public sharing is enabled", async () => {
|
||||
listMock.mockResolvedValue([]);
|
||||
|
||||
render(<PermissionsModal sessionId="conv_abc" open={true} onOpenChange={() => {}} />, {
|
||||
wrapper: createInfoWrapper({ public_sharing_enabled: true }),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(listMock).toHaveBeenCalledWith("conv_abc"));
|
||||
expect(screen.getByText("Public access")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,12 +41,21 @@ import {
|
||||
useRevokePermission,
|
||||
} from "@/hooks/usePermissions";
|
||||
import { useUserSearch } from "@/hooks/useUserSearch";
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import { getOmnigentTransformShareLink, getOmnigentUserSearch } from "@/lib/host";
|
||||
import { useRebasePath } from "@/lib/routing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PUBLIC_USER = "__public__";
|
||||
|
||||
/** Numeric permission level → display label for fixed (non-editable) rows. */
|
||||
const LEVEL_LABELS: Record<number, string> = {
|
||||
1: "Read",
|
||||
2: "Edit",
|
||||
3: "Manage",
|
||||
4: "Owner",
|
||||
};
|
||||
|
||||
interface PermissionsModalProps {
|
||||
sessionId: string;
|
||||
open: boolean;
|
||||
@@ -54,7 +63,24 @@ interface PermissionsModalProps {
|
||||
}
|
||||
|
||||
export function PermissionsModal({ sessionId, open, onOpenChange }: PermissionsModalProps) {
|
||||
const { data: permissions, isLoading } = usePermissions(open ? sessionId : null);
|
||||
// Server sharing policy. While the boot probe is in flight we treat the
|
||||
// server as "on" (fail open) so the modal renders its full controls; the
|
||||
// server-side gate is the real enforcement point regardless.
|
||||
const info = useServerInfo();
|
||||
const sharingMode = info === "loading" ? "on" : info.sharing_mode;
|
||||
const sharingOff = sharingMode === "off";
|
||||
// Both read-capped tiers present the read-only UI. Under
|
||||
// "restricted_read_only" the server additionally blocks home/root-cwd
|
||||
// sessions entirely; that per-session rule is enforced server-side and
|
||||
// surfaces here as an error on the grant attempt.
|
||||
const sharingReadOnly = sharingMode === "read_only" || sharingMode === "restricted_read_only";
|
||||
// Public (anyone-with-the-link) access is a separate server switch from the
|
||||
// sharing tiers; when off, hide the toggle (the server rejects the grant too).
|
||||
const publicSharingEnabled = info === "loading" ? true : info.public_sharing_enabled;
|
||||
// In "off" mode never fetch the grant list — the modal short-circuits to a
|
||||
// notice below, so the request would be wasted (and the server rejects any
|
||||
// grant anyway).
|
||||
const { data: permissions, isLoading } = usePermissions(open && !sharingOff ? sessionId : null);
|
||||
const grant = useGrantPermission(sessionId);
|
||||
const revoke = useRevokePermission(sessionId);
|
||||
|
||||
@@ -106,28 +132,54 @@ export function PermissionsModal({ sessionId, open, onOpenChange }: PermissionsM
|
||||
}
|
||||
}
|
||||
|
||||
// "off" short-circuit: sharing is disabled server-wide, so skip the whole
|
||||
// grant UI and show a plain notice instead. Mirrors the server's 403.
|
||||
if (sharingOff) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">Sharing unavailable</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sharing has been disabled for this Omnigent server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">Share this session</DialogTitle>
|
||||
<DialogDescription>
|
||||
Invite others to view or collaborate on this session.
|
||||
{sharingReadOnly
|
||||
? "This server allows read-only sharing — invite others to view this session."
|
||||
: "Invite others to view or collaborate on this session."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Public toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Public access</p>
|
||||
<p className="text-xs text-muted-foreground">Anyone can view this session</p>
|
||||
{/* Public toggle — hidden when the server disables public access. */}
|
||||
{publicSharingEnabled && (
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Public access</p>
|
||||
<p className="text-xs text-muted-foreground">Anyone can view this session</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
onCheckedChange={handlePublicToggle}
|
||||
disabled={grant.isPending || revoke.isPending}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
onCheckedChange={handlePublicToggle}
|
||||
disabled={grant.isPending || revoke.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current grants. DialogContent is a grid, and grid items default to
|
||||
min-width:auto — without min-w-0 a long nowrap email sets the whole
|
||||
@@ -157,6 +209,7 @@ export function PermissionsModal({ sessionId, open, onOpenChange }: PermissionsM
|
||||
onRevoke={handleRevoke}
|
||||
onChangeLevel={handleChangeLevel}
|
||||
busy={grant.isPending || revoke.isPending}
|
||||
readOnly={sharingReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -182,7 +235,8 @@ export function PermissionsModal({ sessionId, open, onOpenChange }: PermissionsM
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">Read</SelectItem>
|
||||
<SelectItem value="2">Edit</SelectItem>
|
||||
{/* Read-only sharing caps new grants at view; hide Edit. */}
|
||||
{!sharingReadOnly && <SelectItem value="2">Edit</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -422,17 +476,22 @@ function GrantRow({
|
||||
onRevoke,
|
||||
onChangeLevel,
|
||||
busy,
|
||||
readOnly,
|
||||
}: {
|
||||
permission: Permission;
|
||||
onRevoke: (userId: string) => void;
|
||||
onChangeLevel: (userId: string, level: number) => void;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const isOwner = permission.level === 4;
|
||||
// Manage is not grantable from the UI, so a pre-existing manage grant
|
||||
// renders as a fixed label rather than a dropdown choice. Unlike the
|
||||
// owner row it can still be revoked.
|
||||
const isManage = permission.level === 3;
|
||||
// Read-only sharing mode: existing grants can't be re-leveled, so the level
|
||||
// shows as a fixed label (like owner/manage) — but the row stays revocable.
|
||||
const fixedLevel = isOwner || isManage || readOnly;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md px-2 py-0.5 hover:bg-muted/50">
|
||||
@@ -442,9 +501,9 @@ function GrantRow({
|
||||
<span className="flex-1 truncate text-sm" title={permission.user_id}>
|
||||
{permission.user_id}
|
||||
</span>
|
||||
{isOwner || isManage ? (
|
||||
{fixedLevel ? (
|
||||
<span className="flex h-8 w-28 items-center px-3 text-sm text-muted-foreground">
|
||||
{isOwner ? "Owner" : "Manage"}
|
||||
{LEVEL_LABELS[permission.level] ?? "Read"}
|
||||
</span>
|
||||
) : (
|
||||
<Select
|
||||
|
||||
@@ -107,6 +107,8 @@ const SERVER_INFO_OFFLINE_FALLBACK: ServerInfo = {
|
||||
databricks_features: false,
|
||||
managed_sandboxes_enabled: false,
|
||||
sandbox_provider: null,
|
||||
sharing_mode: "on",
|
||||
public_sharing_enabled: true,
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { SharingMode } from "@/lib/capabilities";
|
||||
import { authenticatedFetch } from "@/lib/identity";
|
||||
|
||||
/** Server-wide sharing settings from ``GET /v1/sharing`` (admin). */
|
||||
export interface SharingState {
|
||||
object: "sharing";
|
||||
sharing_mode: SharingMode;
|
||||
/** False when the deployment injects its own mode resolver (not file-backed). */
|
||||
editable: boolean;
|
||||
/** Available tiers, most-permissive first. */
|
||||
options: SharingMode[];
|
||||
/** Whether public (anyone-with-the-link) access may be granted. */
|
||||
public_sharing_enabled: boolean;
|
||||
/** False when the deployment manages public access itself (not file-backed). */
|
||||
public_sharing_editable: boolean;
|
||||
}
|
||||
|
||||
/** Partial update for ``PUT /v1/sharing`` — set either or both. */
|
||||
export interface SharingUpdate {
|
||||
sharing_mode?: SharingMode;
|
||||
public_sharing?: boolean;
|
||||
}
|
||||
|
||||
const QUERY_KEY = ["sharing"];
|
||||
|
||||
async function fetchSharing(): Promise<SharingState> {
|
||||
const res = await authenticatedFetch("/v1/sharing");
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error?.message ?? `${res.status} ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as SharingState;
|
||||
}
|
||||
|
||||
/** Fetch the current server-wide sharing settings (admin only). */
|
||||
export function useSharing() {
|
||||
return useQuery({ queryKey: QUERY_KEY, queryFn: fetchSharing, staleTime: 5_000 });
|
||||
}
|
||||
|
||||
/** PUT /v1/sharing — update the mode and/or public-access setting (admin). */
|
||||
export function useSetSharing() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (update: SharingUpdate) => {
|
||||
const res = await authenticatedFetch("/v1/sharing", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error?.message ?? `${res.status} ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as SharingState;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Reflect the new value immediately, then revalidate.
|
||||
queryClient.setQueryData(QUERY_KEY, data);
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,18 @@
|
||||
|
||||
import { hostFetch } from "./host";
|
||||
|
||||
/**
|
||||
* Server session-sharing policy (mirrors the backend ``SharingMode``):
|
||||
* ``"on"`` allows grants at any level, ``"read_only"`` caps grants at
|
||||
* view, ``"restricted_read_only"`` also caps at view but the server
|
||||
* additionally blocks sharing sessions whose cwd is a home/root
|
||||
* directory (enforced server-side), and ``"off"`` disables all new
|
||||
* grants (the SPA hides the Share control). Fails open to ``"on"`` for
|
||||
* an unknown/missing value.
|
||||
*/
|
||||
export type SharingMode = "on" | "read_only" | "restricted_read_only" | "off";
|
||||
const _SHARING_MODES: readonly SharingMode[] = ["on", "read_only", "restricted_read_only", "off"];
|
||||
|
||||
/** Shape of the response from ``GET /v1/info``. */
|
||||
export interface ServerInfo {
|
||||
accounts_enabled: boolean;
|
||||
@@ -56,6 +68,19 @@ export interface ServerInfo {
|
||||
* ``managed_sandboxes_enabled`` is true.
|
||||
*/
|
||||
sandbox_provider: string | null;
|
||||
/**
|
||||
* Server session-sharing policy. Drives whether the SPA shows the
|
||||
* Share control (``"on"``), restricts it to read-only invites
|
||||
* (``"read_only"``), or hides it entirely (``"off"``), in lockstep
|
||||
* with the server-side grant gate. Fails open to ``"on"``.
|
||||
*/
|
||||
sharing_mode: SharingMode;
|
||||
/**
|
||||
* Whether public (anyone-with-the-link) read access may be granted.
|
||||
* Independent of ``sharing_mode`` — drives whether the Share modal shows
|
||||
* the "Public access" toggle. Fails open to ``true``.
|
||||
*/
|
||||
public_sharing_enabled: boolean;
|
||||
/**
|
||||
* Installed omnigent server version (same value as ``/api/version``),
|
||||
* e.g. ``"0.3.0.dev0"``. Shown in the session info popover's version
|
||||
@@ -78,6 +103,10 @@ const _OFF: ServerInfo = {
|
||||
databricks_features: false,
|
||||
managed_sandboxes_enabled: false,
|
||||
sandbox_provider: null,
|
||||
// Sharing fails OPEN (opposite of the other caps): a failed probe must
|
||||
// not silently disable sharing, so the sentinel is the permissive "on".
|
||||
sharing_mode: "on",
|
||||
public_sharing_enabled: true,
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
};
|
||||
@@ -112,6 +141,11 @@ export async function resolveServerInfo(): Promise<ServerInfo> {
|
||||
managed_sandboxes_enabled: data.managed_sandboxes_enabled === true,
|
||||
sandbox_provider:
|
||||
typeof data.sandbox_provider === "string" ? data.sandbox_provider : null,
|
||||
sharing_mode: _SHARING_MODES.includes(data.sharing_mode as SharingMode)
|
||||
? (data.sharing_mode as SharingMode)
|
||||
: "on",
|
||||
// Fail open: only an explicit false disables the public toggle.
|
||||
public_sharing_enabled: data.public_sharing_enabled !== false,
|
||||
server_version: typeof data.server_version === "string" ? data.server_version : null,
|
||||
smart_routing_enabled: data.smart_routing_enabled === true,
|
||||
};
|
||||
|
||||
@@ -82,6 +82,8 @@ const _bootProbe: Promise<ServerInfo> = Promise.race([
|
||||
databricks_features: false,
|
||||
managed_sandboxes_enabled: false,
|
||||
sandbox_provider: null,
|
||||
sharing_mode: "on",
|
||||
public_sharing_enabled: true,
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
}),
|
||||
|
||||
@@ -142,6 +142,9 @@ const MembersPage = lazy(() =>
|
||||
const PoliciesPage = lazy(() =>
|
||||
import("@/pages/PoliciesPage").then((m) => ({ default: m.PoliciesPage })),
|
||||
);
|
||||
const SharingPage = lazy(() =>
|
||||
import("@/pages/SharingPage").then((m) => ({ default: m.SharingPage })),
|
||||
);
|
||||
|
||||
/**
|
||||
* Settings content panel. The section nav lives in the sidebar card
|
||||
@@ -164,10 +167,16 @@ export function SettingsPage() {
|
||||
// Rendered in ANY multi-user mode (accounts AND OIDC), not gated on
|
||||
// `accountsEnabled` — the nav + pages handle admin gating, and Members runs
|
||||
// read-only under OIDC (no password actions).
|
||||
if (section === "members" || section === "policies") {
|
||||
if (section === "members" || section === "policies" || section === "sharing") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
{section === "members" ? <MembersPage /> : <PoliciesPage />}
|
||||
{section === "members" ? (
|
||||
<MembersPage />
|
||||
) : section === "policies" ? (
|
||||
<PoliciesPage />
|
||||
) : (
|
||||
<SharingPage />
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Tests for the admin SharingPage (server-wide sharing-settings picker).
|
||||
//
|
||||
// Browser e2e is impractical (admin-gated), so the surface is pinned here by
|
||||
// mocking the mode-agnostic identity probe (resolveIdentity / getCurrentIsAdmin
|
||||
// gate admin) and the react-query sharing hooks, so no QueryClient or
|
||||
// network is needed.
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SharingPage } from "./SharingPage";
|
||||
import * as identity from "@/lib/identity";
|
||||
import * as sharingHook from "@/hooks/useSharing";
|
||||
import type { SharingState } from "@/hooks/useSharing";
|
||||
|
||||
const serverInfoMocks = vi.hoisted(() => ({
|
||||
accountsEnabled: true,
|
||||
loginUrl: null as string | null,
|
||||
serverVersion: "0.3.0.dev0" as string | null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/CapabilitiesContext", () => ({
|
||||
useServerInfo: () => ({
|
||||
accounts_enabled: serverInfoMocks.accountsEnabled,
|
||||
login_url: serverInfoMocks.loginUrl,
|
||||
server_version: serverInfoMocks.serverVersion,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/identity", () => ({
|
||||
resolveIdentity: vi.fn(),
|
||||
getCurrentIsAdmin: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/hooks/useSharing", () => ({
|
||||
useSharing: vi.fn(),
|
||||
useSetSharing: vi.fn(),
|
||||
}));
|
||||
|
||||
const setModeMutate = vi.fn();
|
||||
|
||||
function state(overrides: Partial<SharingState> = {}): SharingState {
|
||||
return {
|
||||
object: "sharing",
|
||||
sharing_mode: "on",
|
||||
editable: true,
|
||||
options: ["on", "read_only", "restricted_read_only", "off"],
|
||||
public_sharing_enabled: true,
|
||||
public_sharing_editable: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setSharingState(s: SharingState | undefined, isLoading = false) {
|
||||
vi.mocked(sharingHook.useSharing).mockReturnValue({
|
||||
data: s,
|
||||
isLoading,
|
||||
} as unknown as ReturnType<typeof sharingHook.useSharing>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(identity.resolveIdentity).mockResolvedValue("admin@example.com");
|
||||
vi.mocked(identity.getCurrentIsAdmin).mockReturnValue(true);
|
||||
setModeMutate.mockReset();
|
||||
vi.mocked(sharingHook.useSetSharing).mockReturnValue({
|
||||
mutate: setModeMutate,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof sharingHook.useSetSharing>);
|
||||
serverInfoMocks.accountsEnabled = true;
|
||||
serverInfoMocks.loginUrl = null;
|
||||
serverInfoMocks.serverVersion = "0.3.0.dev0";
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SharingPage", () => {
|
||||
it("shows all four tiers with the current one selected (admin)", async () => {
|
||||
setSharingState(state({ sharing_mode: "read_only" }));
|
||||
|
||||
render(<SharingPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("On")).toBeInTheDocument());
|
||||
expect(screen.getByText("Read only")).toBeInTheDocument();
|
||||
expect(screen.getByText("Read only (restricted)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||
|
||||
// The current tier's radio is checked; a different one is not.
|
||||
const radios = screen.getAllByRole("radio") as HTMLInputElement[];
|
||||
expect(radios).toHaveLength(4);
|
||||
const readOnly = radios.find((r) => r.value === "read_only")!;
|
||||
const off = radios.find((r) => r.value === "off")!;
|
||||
expect(readOnly.checked).toBe(true);
|
||||
expect(off.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("calls the mutation with the chosen tier", async () => {
|
||||
setSharingState(state({ sharing_mode: "on" }));
|
||||
|
||||
render(<SharingPage />);
|
||||
await waitFor(() => expect(screen.getByText("On")).toBeInTheDocument());
|
||||
|
||||
const restricted = (screen.getAllByRole("radio") as HTMLInputElement[]).find(
|
||||
(r) => r.value === "restricted_read_only",
|
||||
)!;
|
||||
fireEvent.click(restricted);
|
||||
|
||||
expect(setModeMutate).toHaveBeenCalledWith(
|
||||
{ sharing_mode: "restricted_read_only" },
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("is read-only with a notice when the deployment manages the mode", async () => {
|
||||
setSharingState(state({ editable: false }));
|
||||
|
||||
render(<SharingPage />);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/managed by this deployment and can't be changed here/i),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// Radios are disabled; clicking does nothing.
|
||||
const radios = screen.getAllByRole("radio") as HTMLInputElement[];
|
||||
expect(radios.every((r) => r.disabled)).toBe(true);
|
||||
fireEvent.click(radios.find((r) => r.value === "off")!);
|
||||
expect(setModeMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a no-permission message to a non-admin", async () => {
|
||||
vi.mocked(identity.getCurrentIsAdmin).mockReturnValue(false);
|
||||
setSharingState(state());
|
||||
|
||||
render(<SharingPage />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("You don't have permission to manage session sharing."),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("public access toggle", () => {
|
||||
it("renders an enabled, checked switch when public sharing is on and editable", async () => {
|
||||
setSharingState(state({ public_sharing_enabled: true, public_sharing_editable: true }));
|
||||
|
||||
render(<SharingPage />);
|
||||
await waitFor(() => expect(screen.getByText("On")).toBeInTheDocument());
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: /public access/i });
|
||||
expect(toggle).toBeEnabled();
|
||||
expect(toggle).toBeChecked();
|
||||
});
|
||||
|
||||
it("toggling the switch calls the mutation with public_sharing", async () => {
|
||||
setSharingState(state({ public_sharing_enabled: true, public_sharing_editable: true }));
|
||||
|
||||
render(<SharingPage />);
|
||||
await waitFor(() => expect(screen.getByText("On")).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: /public access/i }));
|
||||
|
||||
expect(setModeMutate).toHaveBeenCalledWith({ public_sharing: false }, expect.anything());
|
||||
});
|
||||
|
||||
it("disables the switch (no mutation) when public access is deployment-managed", async () => {
|
||||
setSharingState(state({ public_sharing_enabled: true, public_sharing_editable: false }));
|
||||
|
||||
render(<SharingPage />);
|
||||
await waitFor(() => expect(screen.getByText("On")).toBeInTheDocument());
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: /public access/i });
|
||||
expect(toggle).toBeDisabled();
|
||||
fireEvent.click(toggle);
|
||||
expect(setModeMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Admin session-sharing settings page (``/settings/sharing``). Rendered as a
|
||||
* Settings sub-category, alongside Members and Policies.
|
||||
*
|
||||
* Lets an admin pick the server-wide sharing tier (on / read only / read only
|
||||
* restricted / off). Gated on the client by an admin check (non-admins see a
|
||||
* "no permission" message) AND on the server by the route handler — client-
|
||||
* side gating is just UX. When the deployment injects its own sharing policy
|
||||
* (``editable: false``), the control is read-only.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PageScroll } from "@/components/PageScroll";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SharingMode } from "@/lib/capabilities";
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import { getCurrentIsAdmin, resolveIdentity } from "@/lib/identity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSetSharing, useSharing } from "@/hooks/useSharing";
|
||||
|
||||
/** The four tiers, most-permissive first, with human-readable copy. */
|
||||
const TIERS: { id: SharingMode; label: string; description: string }[] = [
|
||||
{
|
||||
id: "on",
|
||||
label: "On",
|
||||
description:
|
||||
"Anyone with manage access can share a session at any level (read, edit, or manage) and toggle public / workspace read.",
|
||||
},
|
||||
{
|
||||
id: "read_only",
|
||||
label: "Read only",
|
||||
description:
|
||||
"New shares are capped at read (view) access. Edit and manage grants are rejected.",
|
||||
},
|
||||
{
|
||||
id: "restricted_read_only",
|
||||
label: "Read only (restricted)",
|
||||
description:
|
||||
"Read-only, and sessions whose working directory is a home directory or the filesystem root cannot be shared at all — not even read.",
|
||||
},
|
||||
{
|
||||
id: "off",
|
||||
label: "Off",
|
||||
description:
|
||||
"Sharing is disabled. No new grants can be created and the Share control is hidden.",
|
||||
},
|
||||
];
|
||||
|
||||
export function SharingPage() {
|
||||
const info = useServerInfo();
|
||||
// Plain header/single-user mode: no auth endpoints exist. server_version
|
||||
// distinguishes a live single-user server from a failed /v1/info probe.
|
||||
const isSingleUser =
|
||||
info !== "loading" &&
|
||||
!info.accounts_enabled &&
|
||||
info.login_url === null &&
|
||||
info.server_version !== null;
|
||||
const [meIsAdmin, setMeIsAdmin] = useState<boolean | null>(null);
|
||||
|
||||
const { data: state, isLoading } = useSharing();
|
||||
const setMode = useSetSharing();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Admin probe via the mode-agnostic `/v1/me` identity (works under OIDC
|
||||
// too). Skipped in single-user mode where no auth endpoints exist.
|
||||
useEffect(() => {
|
||||
if (isSingleUser) return;
|
||||
void (async () => {
|
||||
const userId = await resolveIdentity();
|
||||
if (userId === null) return;
|
||||
setMeIsAdmin(getCurrentIsAdmin());
|
||||
})();
|
||||
}, [isSingleUser]);
|
||||
|
||||
if (!isSingleUser && meIsAdmin === null) {
|
||||
return (
|
||||
<div className="flex min-h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSingleUser && meIsAdmin === false) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-12">
|
||||
<h1 className="mb-2 text-2xl font-semibold">Session sharing</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You don't have permission to manage session sharing.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = state?.sharing_mode;
|
||||
const editable = state?.editable ?? false;
|
||||
const publicEnabled = state?.public_sharing_enabled ?? true;
|
||||
const publicEditable = state?.public_sharing_editable ?? false;
|
||||
|
||||
function choose(mode: SharingMode) {
|
||||
if (!editable || mode === current || setMode.isPending) return;
|
||||
setError(null);
|
||||
setMode.mutate({ sharing_mode: mode }, { onError: (err) => setError(err.message) });
|
||||
}
|
||||
|
||||
function togglePublic(next: boolean) {
|
||||
if (!publicEditable || setMode.isPending) return;
|
||||
setError(null);
|
||||
setMode.mutate({ public_sharing: next }, { onError: (err) => setError(err.message) });
|
||||
}
|
||||
|
||||
return (
|
||||
<PageScroll contentClassName="px-6">
|
||||
<div className="mx-auto w-full max-w-2xl py-2">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold">Session sharing</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Control whether users on this server can share sessions with others. Applies server-wide
|
||||
and takes effect immediately. Changes affect only new shares — existing grants
|
||||
(including already-public sessions) keep working until revoked.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading || current === undefined ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{!editable && (
|
||||
<p className="mb-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
The sharing mode is managed by this deployment and can't be changed here.
|
||||
</p>
|
||||
)}
|
||||
<fieldset
|
||||
className="space-y-2"
|
||||
disabled={!editable || setMode.isPending}
|
||||
aria-label="Session sharing mode"
|
||||
>
|
||||
{TIERS.map((tier) => {
|
||||
const selected = tier.id === current;
|
||||
return (
|
||||
<label
|
||||
key={tier.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition-colors",
|
||||
selected ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50",
|
||||
(!editable || setMode.isPending) && "cursor-not-allowed opacity-70",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="sharing-mode"
|
||||
value={tier.id}
|
||||
checked={selected}
|
||||
onChange={() => choose(tier.id)}
|
||||
disabled={!editable || setMode.isPending}
|
||||
className="mt-1 size-4 accent-primary"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="block text-sm font-medium">{tier.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{tier.description}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
{/* Public access — a separate switch from the tiers above. */}
|
||||
<div className="mt-6 flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div className="pr-4">
|
||||
<p className="text-sm font-medium">Public access</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Allow sharing a session with anyone who has the link (public read access). When
|
||||
off, the Share dialog's "Public access" toggle is hidden and new public grants are
|
||||
rejected; sessions already shared publicly stay public until revoked.
|
||||
</p>
|
||||
{!publicEditable && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Managed by this deployment and can't be changed here.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={publicEnabled}
|
||||
onCheckedChange={togglePublic}
|
||||
disabled={!publicEditable || setMode.isPending}
|
||||
aria-label="Public access"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageScroll>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { ServerInfo } from "@/lib/capabilities";
|
||||
import { CapabilitiesProvider } from "@/lib/CapabilitiesContext";
|
||||
import { writeSessionWorkspaceState } from "@/lib/sessionWorkspaceState";
|
||||
|
||||
vi.mock("@/hooks/useConversations", () => ({
|
||||
@@ -303,11 +305,28 @@ function SessionNavButton({ to }: { to: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderShell(path: string) {
|
||||
/** Full ServerInfo with permissive defaults; override per test. */
|
||||
function serverInfo(overrides: Partial<ServerInfo> = {}): ServerInfo {
|
||||
return {
|
||||
accounts_enabled: false,
|
||||
login_url: null,
|
||||
needs_setup: false,
|
||||
databricks_features: false,
|
||||
managed_sandboxes_enabled: false,
|
||||
sandbox_provider: null,
|
||||
sharing_mode: "on",
|
||||
public_sharing_enabled: true,
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderShell(path: string, info?: ServerInfo) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
const tree = (
|
||||
<QueryClientProvider client={qc}>
|
||||
<TooltipProvider>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
@@ -336,8 +355,11 @@ function renderShell(path: string) {
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
</QueryClientProvider>
|
||||
);
|
||||
// Without an explicit info the CapabilitiesContext default ("loading")
|
||||
// applies, matching production first paint and every pre-existing test.
|
||||
return render(info ? <CapabilitiesProvider info={info}>{tree}</CapabilitiesProvider> : tree);
|
||||
}
|
||||
|
||||
function mockConversations(
|
||||
@@ -2724,6 +2746,36 @@ describe("AppShell share action", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the Share button when the server reports sharing_mode off", () => {
|
||||
// Non-local origin isolates the reason to the server policy (not the
|
||||
// local-server path), so the tooltip must be the sharing-off message.
|
||||
withWindowOrigin("https://app.example.com", () => {
|
||||
mockConversations([{ id: "conv_top", permission_level: null }]);
|
||||
|
||||
renderShell("/c/conv_top", serverInfo({ sharing_mode: "off" }));
|
||||
|
||||
const shareButton = screen.getByRole("button", { name: /share session/i });
|
||||
expect(shareButton).toBeDisabled();
|
||||
expect(shareButton).toHaveAttribute(
|
||||
"title",
|
||||
"Sharing has been disabled for this Omnigent server.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Share button enabled when sharing_mode is read_only", () => {
|
||||
// read_only still permits (read) grants, so the affordance stays live —
|
||||
// the modal caps the level, the button is not disabled.
|
||||
withWindowOrigin("https://app.example.com", () => {
|
||||
mockConversations([{ id: "conv_top", permission_level: null }]);
|
||||
|
||||
renderShell("/c/conv_top", serverInfo({ sharing_mode: "read_only" }));
|
||||
|
||||
const shareButton = screen.getByRole("button", { name: /share session/i });
|
||||
expect(shareButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the Share button on a sub-agent (child) session", () => {
|
||||
// The server rejects sharing a sub-agent session (children inherit the
|
||||
// parent's grants), so the affordance is suppressed for children even
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "@/hooks/useWorkspaceChangedFiles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isNativeWrapper as isNativeWrapperLabel } from "@/lib/nativeCodingAgents";
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import { isCurrentServerLocal } from "@/lib/serverOrigin";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
import { livenessRowFromSession, useSessionLiveness } from "@/hooks/useSessionLiveness";
|
||||
@@ -349,10 +350,18 @@ export function AppShell() {
|
||||
// the server's parent-delegation path — so we hide the affordance.
|
||||
const canShare =
|
||||
!!conversationId && isKnownTopLevel && (permissionLevel === null || permissionLevel >= 3);
|
||||
const shareDisabled = canShare && isCurrentServerLocal();
|
||||
const shareDisabledReason = shareDisabled
|
||||
? "Sharing is unavailable from a local server."
|
||||
: undefined;
|
||||
// Two independent reasons the Share affordance is present-but-disabled: a
|
||||
// local single-user server can't share at all, and a deployed server whose
|
||||
// admin set OMNIGENT_SHARING_MODE=off reports sharing_mode "off" via
|
||||
// /v1/info. Fail open (share enabled) while the capability probe loads.
|
||||
const serverInfo = useServerInfo();
|
||||
const sharingOff = serverInfo !== "loading" && serverInfo.sharing_mode === "off";
|
||||
const shareDisabled = canShare && (isCurrentServerLocal() || sharingOff);
|
||||
const shareDisabledReason = !shareDisabled
|
||||
? undefined
|
||||
: isCurrentServerLocal()
|
||||
? "Sharing is unavailable from a local server."
|
||||
: "Sharing has been disabled for this Omnigent server.";
|
||||
// Any viewer can fork a shared session; top-level only (the server
|
||||
// rejects forking a sub-agent). Surfaced as ForkDialogContext.canFork —
|
||||
// the per-message "Fork from here" action is the only fork entry point.
|
||||
|
||||
@@ -631,6 +631,8 @@ function renderLanding(infoOverrides: Partial<ServerInfo> = {}, route = "/") {
|
||||
databricks_features: false,
|
||||
managed_sandboxes_enabled: false,
|
||||
sandbox_provider: null,
|
||||
sharing_mode: "on",
|
||||
public_sharing_enabled: true,
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
...infoOverrides,
|
||||
|
||||
@@ -11,6 +11,8 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/rea
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { ServerInfo } from "@/lib/capabilities";
|
||||
import { CapabilitiesProvider } from "@/lib/CapabilitiesContext";
|
||||
|
||||
// Controllable rename mutation so the double-click test can assert the
|
||||
// committed title was forwarded to the PATCH. Declared via vi.hoisted so the
|
||||
@@ -93,13 +95,31 @@ function mockConversations(conversations: Conversation[]) {
|
||||
useConvMock.mockImplementation(() => dataResult);
|
||||
}
|
||||
|
||||
/** Full ServerInfo with permissive defaults; override per test. */
|
||||
function serverInfo(overrides: Partial<ServerInfo> = {}): ServerInfo {
|
||||
return {
|
||||
accounts_enabled: false,
|
||||
login_url: null,
|
||||
needs_setup: false,
|
||||
databricks_features: false,
|
||||
managed_sandboxes_enabled: false,
|
||||
sandbox_provider: null,
|
||||
sharing_mode: "on",
|
||||
public_sharing_enabled: true,
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// `activeId` mounts the sidebar at `/c/:conversationId` (via a matching
|
||||
// Route so `useParams` populates), making that row the active one — the
|
||||
// rest of the suite renders at `/` where no row is active.
|
||||
function renderSidebar(activeId?: string) {
|
||||
// rest of the suite renders at `/` where no row is active. `info` pins the
|
||||
// server sharing policy via CapabilitiesProvider (default "loading" → on).
|
||||
function renderSidebar(activeId?: string, info?: ServerInfo) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const sidebar = <Sidebar open={true} onClose={vi.fn()} />;
|
||||
return render(
|
||||
const tree = (
|
||||
<QueryClientProvider client={qc}>
|
||||
<TooltipProvider>
|
||||
<MemoryRouter initialEntries={[activeId ? `/c/${activeId}` : "/"]}>
|
||||
@@ -112,8 +132,11 @@ function renderSidebar(activeId?: string) {
|
||||
)}
|
||||
</MemoryRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
</QueryClientProvider>
|
||||
);
|
||||
// No explicit info → CapabilitiesContext default ("loading"), matching every
|
||||
// pre-existing test (sharing treated as on).
|
||||
return render(info ? <CapabilitiesProvider info={info}>{tree}</CapabilitiesProvider> : tree);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -339,3 +362,27 @@ describe("right-click context menu", () => {
|
||||
expect(screen.getByTestId("rename-conversation-input")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sharing kill switch", () => {
|
||||
it("disables the row's Share item for a manager when sharing_mode is off", () => {
|
||||
// CONV is owner-level (permission_level null → canManage), yet a server
|
||||
// reporting sharing_mode off must gray out Share for everyone.
|
||||
mockConversations([CONV]);
|
||||
renderSidebar(undefined, serverInfo({ sharing_mode: "off" }));
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole("link", { name: /My Session/ }));
|
||||
|
||||
// Radix marks a disabled menu item with data-disabled; the enabled
|
||||
// (on / read_only) branch renders a plain selectable item without it.
|
||||
expect(screen.getByTestId("share-conversation")).toHaveAttribute("data-disabled");
|
||||
});
|
||||
|
||||
it("keeps the row's Share item enabled for a manager when sharing is on", () => {
|
||||
mockConversations([CONV]);
|
||||
renderSidebar(undefined, serverInfo({ sharing_mode: "on" }));
|
||||
|
||||
fireEvent.contextMenu(screen.getByRole("link", { name: /My Session/ }));
|
||||
|
||||
expect(screen.getByTestId("share-conversation")).not.toHaveAttribute("data-disabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
} from "@/hooks/useConversations";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import { showToast } from "@/components/ui/toast";
|
||||
import { PermissionsModal } from "@/components/PermissionsModal";
|
||||
import { SessionStateBadge } from "@/components/SessionStateBadge";
|
||||
@@ -1966,6 +1967,7 @@ function ConversationMenuItems({
|
||||
isOwner,
|
||||
canEdit,
|
||||
canManage,
|
||||
sharingOff,
|
||||
canStop,
|
||||
canMarkUnread,
|
||||
currentProject,
|
||||
@@ -1989,6 +1991,9 @@ function ConversationMenuItems({
|
||||
isOwner: boolean;
|
||||
canEdit: boolean;
|
||||
canManage: boolean;
|
||||
// Server-wide sharing kill switch (OMNIGENT_SHARING_MODE=off): disables the
|
||||
// Share item for everyone, independent of the per-user manage check.
|
||||
sharingOff: boolean;
|
||||
canStop: boolean;
|
||||
// Whether "Mark as unread" applies: any row not already showing the
|
||||
// unread dot (the active thread and running sessions included).
|
||||
@@ -2024,7 +2029,7 @@ function ConversationMenuItems({
|
||||
{isPinned ? "Unpin" : "Pin"}
|
||||
</C.Item>
|
||||
)}
|
||||
{canManage ? (
|
||||
{canManage && !sharingOff ? (
|
||||
<C.Item data-testid="share-conversation" onSelect={() => setShareOpen(true)}>
|
||||
<ShareIcon className="size-3.5" />
|
||||
Share
|
||||
@@ -2039,8 +2044,12 @@ function ConversationMenuItems({
|
||||
</C.Item>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{/* Sharing-off is server-wide, so it outranks the per-user manage
|
||||
reason when both apply. */}
|
||||
<TooltipContent side="left">
|
||||
You need manage permissions to share this session
|
||||
{sharingOff
|
||||
? "Sharing has been disabled for this Omnigent server."
|
||||
: "You need manage permissions to share this session"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -2310,6 +2319,11 @@ function ConversationRow({
|
||||
const isOwner = isOwnedByViewer(conversation);
|
||||
const canEdit = conversation.permission_level === null || conversation.permission_level >= 2;
|
||||
const canManage = conversation.permission_level === null || conversation.permission_level >= 3;
|
||||
// Server-wide sharing kill switch (OMNIGENT_SHARING_MODE=off) reported by
|
||||
// /v1/info — disables the row's Share item even for managers. Fail open
|
||||
// (share enabled) while the capability probe is still loading.
|
||||
const serverInfo = useServerInfo();
|
||||
const sharingOff = serverInfo !== "loading" && serverInfo.sharing_mode === "off";
|
||||
// Gates the kebab's "Stop session" item. `false` = runner known-offline
|
||||
// (already stopped — hide the destructive control); `undefined` = not yet
|
||||
// observed, don't block. Non-sticky Stop: no "Resume" affordance — the
|
||||
@@ -2510,6 +2524,7 @@ function ConversationRow({
|
||||
isOwner,
|
||||
canEdit,
|
||||
canManage,
|
||||
sharingOff,
|
||||
canStop,
|
||||
canMarkUnread,
|
||||
currentProject,
|
||||
|
||||
@@ -94,21 +94,21 @@ describe("settingsNavGroups", () => {
|
||||
expect(ids(true)).toContain("cli");
|
||||
});
|
||||
|
||||
it("includes the Admin group (Members / Policies) for any admin, in accounts OR OIDC mode", () => {
|
||||
it("includes the Admin group (Members / Policies / Sharing) for any admin, in accounts OR OIDC mode", () => {
|
||||
const ids = (accountsEnabled: boolean, isAdmin: boolean) =>
|
||||
settingsNavGroups(accountsEnabled, false, isAdmin)
|
||||
.flatMap((g) => g.items)
|
||||
.map((i) => i.id);
|
||||
// Non-admin → no Members / Policies, regardless of auth mode.
|
||||
// Non-admin → no admin items, regardless of auth mode.
|
||||
expect(ids(true, false)).not.toContain("members");
|
||||
expect(ids(false, false)).not.toContain("members");
|
||||
// Admin on an accounts deploy → both appear, grouped under "Admin".
|
||||
// Admin on an accounts deploy → all appear, grouped under "Admin".
|
||||
const accountsAdmin = settingsNavGroups(true, false, true).find((g) => g.title === "Admin");
|
||||
expect(accountsAdmin?.items.map((i) => i.id)).toEqual(["members", "policies"]);
|
||||
expect(accountsAdmin?.items.map((i) => i.id)).toEqual(["members", "policies", "sharing"]);
|
||||
// Admin under OIDC (accountsEnabled false) → still appears. This is the
|
||||
// #1489 fix: OIDC previously had no admin chrome at all.
|
||||
const oidcAdmin = settingsNavGroups(false, false, true).find((g) => g.title === "Admin");
|
||||
expect(oidcAdmin?.items.map((i) => i.id)).toEqual(["members", "policies"]);
|
||||
expect(oidcAdmin?.items.map((i) => i.id)).toEqual(["members", "policies", "sharing"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
KeyboardIcon,
|
||||
PaletteIcon,
|
||||
PanelRightOpenIcon,
|
||||
Share2Icon,
|
||||
ShieldCheckIcon,
|
||||
TerminalIcon,
|
||||
UserCogIcon,
|
||||
@@ -34,6 +35,7 @@ export type SettingsSectionId =
|
||||
| "account"
|
||||
| "members"
|
||||
| "policies"
|
||||
| "sharing"
|
||||
| "archived"
|
||||
| "cli";
|
||||
|
||||
@@ -44,6 +46,7 @@ const SECTION_IDS: readonly SettingsSectionId[] = [
|
||||
"account",
|
||||
"members",
|
||||
"policies",
|
||||
"sharing",
|
||||
"archived",
|
||||
"cli",
|
||||
];
|
||||
@@ -108,6 +111,7 @@ export function settingsNavGroups(
|
||||
items: [
|
||||
{ id: "members", label: "Members", icon: UsersIcon },
|
||||
{ id: "policies", label: "Policies", icon: ShieldCheckIcon },
|
||||
{ id: "sharing", label: "Sharing", icon: Share2Icon },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user