Compare commits

...

9 Commits

Author SHA1 Message Date
Bryan Qiu 9b090c6530 test(sharing): e2e_ui share-button gray-out + harden grant-gate state reads
- sessions.py (#2 from review): the grant gate now reads app.state via
  getattr(..., default) — getattr(request.app.state, "sharing_mode",
  lambda: SharingMode.ON)() and the public equivalent — so a router mounted
  without create_app (a focused test) can't AttributeError. Behavior-preserving
  for every production path (create_app always sets both).
- tests/e2e_ui/collaboration/test_sharing_mode_off.py: a Playwright test for
  the server-side kill switch surfacing in the SPA. Spins up a dedicated server
  with OMNIGENT_SHARING_MODE=off (the shared live_server is session-scoped/on,
  and the admin route is admin-gated for the headerless local identity),
  creates a session, and asserts the header Share button is disabled with the
  "Sharing has been disabled…" tooltip — served via the public-loopback alias
  so the local-server disable doesn't mask it. Mirrors the assertion shape of
  test_permissions_modal.py::test_local_server_disables_share_button_with_tooltip.

Co-authored-by: Isaac
2026-07-10 08:13:40 +00:00
Bryan Qiu 5deef611ba refactor(sharing): atomic admin PUT + docstring/copy accuracy
Follow-up on PR review:

- routes/sharing.py: validate AND authorize both fields before writing either,
  so a both-fields PUT where only one setting is file-backed (mode editable,
  public deployment-managed, or vice-versa) can no longer persist one override
  and then 403 on the other. Adds test_admin_put_is_atomic_across_mixed_
  writability (403 + the writable half is not persisted).
- app.py: create_app docstrings — sharing_mode now lists restricted_read_only;
  public_sharing describes the env var as "enabled unless explicitly falsy
  (0/false/no/off)" (matching public_sharing_env_default, not env_var_is_truthy)
  and notes existing public grants are unaffected.
- SharingPage.tsx: surface the non-retroactive behavior — changes affect only
  new shares; existing grants (including already-public sessions) keep working
  until revoked.

Co-authored-by: Isaac
2026-07-10 07:41:16 +00:00
Bryan Qiu 498ca19d6a refactor(sharing): host-agnostic workspace block + rename endpoint to /v1/sharing
Addresses PR review:

#4 — workspace_sharing_blocked no longer resolves the server process's ``~``
(meaningless on a remote runner whose home lives on another host). It now
matches purely on path shape and covers the common home layouts: the
filesystem root (/), root's home (/root), and any direct child of /home,
/Users, or /var/home (ostree). Project-workspace roots (/workspace,
/workspaces/<repo>) are deliberately NOT blocked — they hold a single
checkout, not a whole home. Tests updated accordingly (drops the ~ case, adds
/var/home + a /workspaces project-dir shareable case).

#5 — the admin endpoint/resource now governs two settings (mode + public
access), so ``/v1/sharing-mode`` → ``/v1/sharing``, object ``"sharing_mode"``
→ ``"sharing"``, create_sharing_mode_router → create_sharing_router,
SetSharingModeRequest → SetSharingRequest, and the web hook useSharingMode.ts
→ useSharing.ts (useSharing / useSetSharing, SharingState / SharingUpdate).
The response's ``sharing_mode`` field (the tier value) and the SharingMode
enum are unchanged. openapi.json regenerated.

Co-authored-by: Isaac
2026-07-10 07:12:00 +00:00
Bryan Qiu 5fb7d0f7de test(sharing): regenerate openapi.json + update Admin-nav test
CI drift from the sharing work:
- openapi.json was stale — regenerated via scripts/dump_openapi.py to include
  the /v1/sharing-mode GET/PUT routes and the SetSharingModeRequest body
  (sharing_mode + public_sharing). Fixes test_openapi_json_matches_generator_output.
- settingsNav.test.tsx asserted the Admin group was exactly [members, policies];
  the Sharing section added a third item. Updated the expectation to
  [members, policies, sharing].

Co-authored-by: Isaac
2026-07-10 06:59:48 +00:00
Bryan Qiu 9c89bc0c52 feat(sharing): add OMNIGENT_PUBLIC_SHARING switch for public (link) access
Adds a server-wide switch for public (anyone-with-the-link) read access,
independent of the sharing tiers: an org can keep normal user-to-user sharing
on while disabling public links. Controlled at the top level by the
OMNIGENT_PUBLIC_SHARING env var (default enabled, fails open) and, like the
sharing mode, overridable at runtime from Settings → Sharing.

When disabled, granting the __public__ sentinel is rejected (403), /v1/info
reports public_sharing_enabled: false, and the Share modal hides the "Public
access" toggle. User-to-user grants are unaffected.

- sharing_settings.py: file-backed public_sharing override (<data_dir>/
  public_sharing) + env default parse, sharing the mtime-cached reader with the
  sharing_mode override (cache refactored to a per-path dict).
- app.py: create_app gains a `public_sharing` param (bool / callable / None),
  normalized to app.state.public_sharing + a public_sharing_writable flag;
  /v1/info reports public_sharing_enabled.
- routes/sessions.py: the grant gate rejects a __public__ grant when public
  sharing is off, independent of the sharing_mode gate.
- routes/sharing_mode.py: GET now also reports public_sharing_enabled +
  public_sharing_editable; PUT accepts an optional public_sharing boolean
  (each field independently writable, 400 when the body updates nothing).
- web: capabilities.ts carries public_sharing_enabled (fail-open true); the
  Share modal hides the public toggle when off; the Sharing admin page gains a
  "Public access" switch (read-only when deployment-managed).

Tests: server coverage for the env default / static / file-override wiring,
the public grant gate (blocked when off, user grants still allowed), /v1/info
reporting, and the admin GET/PUT (persist, reflected in /v1/info and the gate,
403 when not writable); web tests for the modal hiding the toggle and the
admin page's public switch.

Co-authored-by: Isaac
2026-07-10 06:40:53 +00:00
Bryan Qiu 2f6cb30fde feat(sharing): admin panel control for the server-wide sharing mode
Makes OMNIGENT_SHARING_MODE runtime-configurable from Settings → Sharing, so an
admin can pick among the four tiers (on / read only / read only restricted /
off) without a redeploy. The env var remains the boot default; the admin choice
is a per-server override that wins when set.

Persistence follows the OSS operator-editable-state convention (no DB
migration): the override lives in <data_dir>/sharing_mode next to the admins
roster, read mtime-cached per request so a change takes effect immediately and
survives restarts.

- server/sharing_settings.py: file-backed override read/write (atomic,
  mtime-cached), falling back to the env default when unset/unrecognized.
- server/app.py: the create_app default resolver now reads override-else-env
  and marks app.state.sharing_mode_writable; an explicit static/callable mode
  (managed/embedded, e.g. a SAFE flag) stays authoritative and non-editable.
- routes/sharing_mode.py: admin-gated GET/PUT /v1/sharing-mode reporting the
  current mode + an `editable` flag + the tiers; PUT strictly validates (400 on
  an unknown value, no fail-open) and 403s when not file-backed.
- web: a new admin-only Settings → Sharing section (SharingPage + useSharingMode
  hooks + settingsNav entry) with a 4-tier picker, read-only when the server
  reports editable:false.

Tests: file-override roundtrip + create_app precedence over the env default, the
admin route (GET state, PUT persist reflected in /v1/info and the gate, 400 on
unknown, 403 for non-admin and for a deployment-managed mode), and a SharingPage
suite (tiers render, choosing calls the mutation, read-only notice, non-admin
gate).

Co-authored-by: Isaac
2026-07-10 05:14:30 +00:00
Bryan Qiu 427c2a93ec feat(sharing): add restricted_read_only tier (blocks home/root-cwd sessions)
Adds a fourth OMNIGENT_SHARING_MODE tier, restricted_read_only: it caps new
grants at read like read_only, but additionally rejects ALL grants (even read)
on a session whose working directory is a user home directory or the filesystem
root — that cwd exposes an entire home/filesystem, so it must not be shared.

- auth.py: SharingMode.RESTRICTED_READ_ONLY + workspace_sharing_blocked() helper
  (recognizes /, /root, direct children of /home and /Users, and the server's
  own ~; subdirectories of a home and an unset cwd stay shareable).
- routes/sessions.py: the grant gate looks up the session workspace and 403s a
  home/root-cwd session entirely; other sessions fall through to the read cap.
- web: capabilities.ts recognizes the value; the Share modal presents the same
  read-only UI as read_only. The per-session home/root block is enforced
  server-side and surfaces as an error on the grant attempt.

Tests: coerce + /v1/info round-trip the new value, a workspace_sharing_blocked
truth table, and the gate (home/root cwd -> 403 even read; normal cwd -> read
ok / edit 403; no cwd -> read ok), plus a modal test for the read-only UI.

Co-authored-by: Isaac
2026-07-10 04:58:34 +00:00
Bryan Qiu 9a0bfc461e feat(sharing/web): gray out Share affordances when sharing_mode is off
Extends the existing shareDisabled pattern so both the ChatHeader Share
button and the sidebar row's Share menu item render disabled (with a
tooltip) when /v1/info reports sharing_mode "off". read_only keeps them
enabled — the modal caps the grant level. Fails open (enabled) while the
capability probe is still loading.

Existing collaboration surfaces ("Shared with me", presence, fork) are
intentionally untouched: turning sharing off blocks *new* grants but does
not revoke existing access, so those must keep working.

Adds AppShell + Sidebar.rowActions tests for the off (disabled) and
on / read_only (enabled) states.

Co-authored-by: Isaac
2026-07-10 04:47:43 +00:00
Bryan Qiu e659e518d9 feat(sharing): add OMNIGENT_SHARING_MODE server gate (on / read_only / off)
Adds a tri-state session-sharing policy to create_app, defaulting from
the top-level OMNIGENT_SHARING_MODE env var (on / read_only / off) and
failing open to ON. When off, grant_permission is rejected (403) and the
SPA shows a "sharing disabled" dialog; when read_only, new grants are
capped at read (edit/manage rejected) and the Share modal offers only
read. GET /v1/info reports sharing_mode so the web app gates its Share
controls to match. Revoke/list and self-ownership grants are unaffected
in every mode.

Also accepts a static SharingMode or a per-request callable, so a
deployment can flip the policy at runtime (e.g. a Databricks SAFE flag)
without a restart.

Tests: 29 new server tests (coerce fail-open, create_app wiring incl.
the env var, /v1/info, and the 403/200 grant gate against a seeded
store) plus 3 web tests for the modal's off / read_only / on states.

Co-authored-by: Isaac
2026-07-10 04:46:03 +00:00
24 changed files with 2413 additions and 43 deletions
+112 -2
View File
@@ -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:
+73
View File
@@ -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.
+47 -5
View File
@@ -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(
+155
View File
@@ -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
+145
View File
@@ -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")
+95
View File
@@ -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();
});
});
});
+75 -16
View File
@@ -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
+2
View File
@@ -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,
};
+63
View File
@@ -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 });
},
});
}
+34
View File
@@ -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,
};
+2
View File
@@ -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,
}),
+11 -2
View File
@@ -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>
);
}
+177
View File
@@ -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();
});
});
});
+196
View File
@@ -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>
);
}
+55 -3
View File
@@ -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
+13 -4
View File
@@ -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.
+2
View File
@@ -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,
+51 -4
View File
@@ -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");
});
});
+17 -2
View File
@@ -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,
+5 -5
View File
@@ -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"]);
});
});
+4
View File
@@ -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 },
],
});
}