feat(server): add deployment-wide release feature flags (#4775)
## Related issue Follow-up to #4673. ## Summary - Add a typed, default-off release-feature registry driven by one comma-separated `OMNIGENT_FEATURES` environment variable, with strict validation and lifecycle metadata. - Gate the web Usage route/navigation and page-only report enrichment while preserving the existing `GET /v1/usage` CLI API. - Migrate web-driven harness installation to the same immutable startup snapshot and wire rollout configuration across Docker, Kubernetes, Render, Railway, and Databricks. ELI5: the server reads one list of enabled features when it starts, enforces that same list on backend routes, and tells the web app which controls and pages to show. ```text OMNIGENT_FEATURES | v FeatureFlags snapshot / \ backend gates GET /v1/info | v frontend gates ``` ## Test Plan - `uv run pytest tests/server/test_feature_flags.py tests/host/test_local_server.py tests/server/integration/test_utility_endpoints.py tests/server/integration/test_hosts_install_harness.py tests/server/integration/test_hosts_store_credential.py tests/server/routes/test_usage_report.py tests/server/test_openapi_drift.py -q` - `cd web && pnpm vitest run src/lib/capabilities.test.ts src/lib/harnessSetup.test.ts src/App.test.tsx src/shell/Sidebar.test.tsx` - `uv run pytest tests/e2e_ui/sessions/test_usage_page_feature.py -q` - `uv run python scripts/dump_openapi.py --check` - `pre-commit run --files <changed files>` - Verified default-off and enabled Usage route/sidebar behavior, strict unknown-feature rejection, legacy CLI usage compatibility, and harness route enforcement. ## Demo - Default off: the updated visual baselines show the original sidebar without the Usage row. - Enabled Usage page: https://github.com/user-attachments/assets/8385d4f0-47ad-430f-bf2c-06c35af6c499 ## Type of change - [ ] Bug fix - [x] Feature - [x] UI / frontend change - [ ] Refactor / chore - [x] Docs - [ ] Test / CI - [ ] Breaking change ## Test coverage - [x] Unit tests added / updated - [x] Integration tests added / updated - [x] E2E tests added / updated - [x] Manual verification completed - [x] Existing tests cover this change - [ ] Not applicable ## Coverage notes Manually reviewed the default-off visual output and verified that the Usage route is absent while the capability is disabled. Targeted backend and frontend tests cover both flag states, capability parsing, startup snapshots, and harness enforcement. ## Changelog Usage and web-driven harness setup can now be enabled per deployment with `OMNIGENT_FEATURES`. Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
@@ -147,6 +147,17 @@ UC Volume wheel paths because `uv lock` validates path sources locally.
|
||||
|
||||
Re-running is safe — every step is idempotent.
|
||||
|
||||
Release features are off by default. Enable one or more for the whole app by
|
||||
adding the comma-separated deploy argument, then reload the web app after the
|
||||
redeploy:
|
||||
|
||||
```bash
|
||||
--features usage_page,harness_install
|
||||
```
|
||||
|
||||
See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for the current
|
||||
inventory and rollback procedure.
|
||||
|
||||
> [!TIP]
|
||||
> To lock against a private PyPI mirror or proxy instead of public
|
||||
> PyPI, set `UV_INDEX_URL` before running `deploy.py`.
|
||||
|
||||
@@ -21,6 +21,9 @@ variables:
|
||||
UC schema (catalog.schema) holding the OTel destination tables.
|
||||
The platform writes to <schema>.otel_logs, otel_metrics, otel_spans.
|
||||
default: main.omnigent_logs
|
||||
features:
|
||||
description: "Comma-separated deployment-wide release features."
|
||||
default: ""
|
||||
|
||||
resources:
|
||||
apps:
|
||||
@@ -44,6 +47,8 @@ resources:
|
||||
value_from: artifact_volume
|
||||
- name: OTEL_TRACES_SAMPLER
|
||||
value: 'always_on'
|
||||
- name: OMNIGENT_FEATURES
|
||||
value: "${var.features}"
|
||||
resources:
|
||||
- name: postgres
|
||||
postgres:
|
||||
|
||||
@@ -567,6 +567,14 @@ def _parse_args() -> argparse.Namespace:
|
||||
"<schema>.otel_{logs,metrics,spans}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--features",
|
||||
default="",
|
||||
help=(
|
||||
"Comma-separated deployment-wide release features, e.g. "
|
||||
"'usage_page'. Empty keeps every release feature off."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
default="prod",
|
||||
@@ -746,6 +754,8 @@ def _bundle_vars(args: argparse.Namespace) -> list[str]:
|
||||
f"volume_name={args.volume_name}",
|
||||
"--var",
|
||||
f"otel_table_schema={args.otel_table_schema}",
|
||||
"--var",
|
||||
f"features={args.features}",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ POSTGRES_PASSWORD=change-me-please
|
||||
# Host port the omnigent container is published on. Default 8000.
|
||||
# OMNIGENT_PORT=8000
|
||||
|
||||
# ── Release features ─────────────────────────────────────
|
||||
# Comma-separated deployment-wide release features. Empty/unset keeps every
|
||||
# release feature off. Unknown names fail startup so typos cannot silently
|
||||
# change rollout behavior. Current keys: usage_page, harness_install.
|
||||
# OMNIGENT_FEATURES=usage_page
|
||||
|
||||
# ── Image ────────────────────────────────────────────────
|
||||
# The compose stack pulls a pre-built image from GHCR (built by CI on
|
||||
# every main-branch merge). Default: ghcr.io/omnigent-ai/omnigent-server.
|
||||
|
||||
@@ -39,6 +39,27 @@ Reset everything (drops the DB and the artifact store):
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Release features
|
||||
|
||||
Release features are deployment-wide and off by default. Enable one or more
|
||||
with the comma-separated `OMNIGENT_FEATURES` variable in `.env`, then recreate
|
||||
the server container:
|
||||
|
||||
```dotenv
|
||||
OMNIGENT_FEATURES=usage_page
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
curl -s http://localhost:8000/v1/info | jq '.features'
|
||||
```
|
||||
|
||||
Known keys and their lifecycle are documented in
|
||||
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md). Unknown keys fail
|
||||
server startup so a typo cannot silently produce the wrong rollout. To roll
|
||||
back, remove the key (or empty the variable), run `docker compose up -d` again,
|
||||
and reload the web app.
|
||||
|
||||
## Multi-user mode (accounts — default)
|
||||
|
||||
Built-in accounts auth: no IdP to register, no proxy to host.
|
||||
|
||||
@@ -62,6 +62,9 @@ services:
|
||||
ARTIFACT_DIR: /data/artifacts
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
# Comma-separated deployment-wide release features. Empty means all
|
||||
# release features are off; see .env.example for the known keys.
|
||||
OMNIGENT_FEATURES: "${OMNIGENT_FEATURES:-}"
|
||||
# Anchor the server's data dir on the persistent volume so
|
||||
# file-backed operator config survives container restarts:
|
||||
# the admin roster (/data/admins) and allowed-domains file
|
||||
|
||||
@@ -91,6 +91,22 @@ Apply your chosen issuer with `kubectl apply -f <file>`. Without it, cert-manage
|
||||
logs `IssuerNotFound` and no certificate is issued (the server still runs — only
|
||||
TLS is affected).
|
||||
|
||||
## Release features
|
||||
|
||||
Release features are deployment-wide and off by default. Set the
|
||||
comma-separated `OMNIGENT_FEATURES` value in `base/configmap.yaml`, apply your
|
||||
Kustomize target, and restart the Deployment so every pod receives one fresh
|
||||
startup snapshot:
|
||||
|
||||
```bash
|
||||
kubectl kustomize deploy/kubernetes/base/ | kubectl apply -f -
|
||||
kubectl rollout restart deployment/omnigent
|
||||
kubectl rollout status deployment/omnigent
|
||||
```
|
||||
|
||||
Use the same restart after removing a feature for rollback. See
|
||||
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known keys.
|
||||
|
||||
## Deploy with an external database
|
||||
|
||||
Use this path when you have a managed Postgres (RDS, Cloud SQL, Neon, etc.).
|
||||
|
||||
@@ -8,6 +8,8 @@ data:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8000"
|
||||
ARTIFACT_DIR: "/data/artifacts"
|
||||
# Comma-separated release features; empty keeps every feature off.
|
||||
OMNIGENT_FEATURES: ""
|
||||
OMNIGENT_ADMIN_CREDENTIALS_PATH: "/data/admin-credentials"
|
||||
OMNIGENT_AUTH_ENABLED: "1"
|
||||
OMNIGENT_AUTH_PROVIDER: "accounts"
|
||||
|
||||
@@ -73,6 +73,14 @@ steps below are validated end-to-end:
|
||||
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
|
||||
> promptly after the deploy goes live.
|
||||
|
||||
## Release features
|
||||
|
||||
In the Omnigent service's **Variables** tab, set `OMNIGENT_FEATURES` to a
|
||||
comma-separated enabled set such as `usage_page`. Railway redeploys the service
|
||||
automatically. Remove the key from the value to roll back, then reload the web
|
||||
app. See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known
|
||||
keys.
|
||||
|
||||
## Use your own IdP instead (OIDC)
|
||||
|
||||
Prefer GitHub / Google / Okta login over built-in accounts? Switch the provider
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Release feature flags
|
||||
|
||||
Omnigent release features are deployment-wide, temporary rollout switches. They
|
||||
are not authorization controls or user preferences.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the comma-separated `OMNIGENT_FEATURES` environment variable and restart or
|
||||
redeploy the server:
|
||||
|
||||
```bash
|
||||
OMNIGENT_FEATURES=usage_page,harness_install
|
||||
```
|
||||
|
||||
Unset or empty means every release feature is off. Unknown names fail startup.
|
||||
The former `OMNIGENT_HARNESS_INSTALL_ENABLED` switch is rejected with a
|
||||
migration hint; use `OMNIGENT_FEATURES=harness_install` instead. The server resolves the set once at startup and publishes frontend-visible
|
||||
values in `GET /v1/info` under `features`. Users must reload the web app after a
|
||||
flag change because server capabilities are cached at page boot.
|
||||
|
||||
`omnigent/server/feature_flags.py` is the source of truth for known keys and
|
||||
lifecycle metadata.
|
||||
|
||||
## Inventory
|
||||
|
||||
| Key | Default | Owner | Review by | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `usage_page` | Off | Web | 0.11.0 | Exposes the web Usage route, sidebar navigation, timeline, and cost breakdown details. The existing `GET /v1/usage` CLI API remains available while off. |
|
||||
| `harness_install` | Off | Onboarding | 0.11.0 | Allows the web UI to install or configure supported harnesses on a connected host. |
|
||||
|
||||
At the review release, each flag must be removed by making the feature
|
||||
unconditional, removing the feature, or moving a genuinely permanent operator
|
||||
policy into normal server configuration.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
1. Deploy an immutable image with the feature absent from `OMNIGENT_FEATURES`.
|
||||
2. Enable it on one deployment, consistently across all replicas.
|
||||
3. Verify `GET /v1/info`, then reload and exercise the gated UI.
|
||||
4. Expand by deployment cohort.
|
||||
5. Roll back by removing the key and redeploying the same image.
|
||||
@@ -2888,7 +2888,7 @@ def _foreground_daemon_record(
|
||||
started_at=int(time.time()),
|
||||
host_id=host_id,
|
||||
resolved_server_url=server_url.rstrip("/") if mode == "local" else None,
|
||||
config_sig=server_config_signature(),
|
||||
config_sig=server_config_signature(include_features=mode == "local"),
|
||||
)
|
||||
|
||||
|
||||
@@ -3015,7 +3015,7 @@ def _ensure_host_daemon(server_url: str | None) -> bool:
|
||||
_persist_spawned_daemon(
|
||||
target=target,
|
||||
spawned=spawned,
|
||||
config_sig=server_config_signature(),
|
||||
config_sig=server_config_signature(include_features=not server_url),
|
||||
)
|
||||
return decision.config_changed
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ _LOCAL_SERVER_SIG_PATH = _local_data_dir() / "local_server.sig"
|
||||
_LOCAL_SERVER_LOG_REF_PATH = _local_data_dir() / "local_server.logpath"
|
||||
|
||||
|
||||
def server_config_signature() -> str:
|
||||
def server_config_signature(*, include_features: bool = True) -> str:
|
||||
"""
|
||||
Compute a signature of the server-affecting config for one invocation.
|
||||
|
||||
@@ -111,7 +111,8 @@ def server_config_signature() -> str:
|
||||
Covers the inputs that change server behavior at spawn time:
|
||||
|
||||
* the resolved auth source — auth mode is baked at boot and cannot be
|
||||
reconfigured in place; and
|
||||
reconfigured in place;
|
||||
* the enabled release-feature set — features are snapshotted at boot; and
|
||||
* the installed package version — a running server holds its code in
|
||||
memory, so after ``omni upgrade`` (or a manual ``uv tool upgrade``)
|
||||
the old process keeps serving pre-upgrade code until it is cycled.
|
||||
@@ -123,6 +124,9 @@ def server_config_signature() -> str:
|
||||
Deliberately narrow otherwise, so unrelated env churn does not force
|
||||
needless restarts.
|
||||
|
||||
:param include_features: Include server release features. Remote host
|
||||
daemons connect to a separately managed server, so their signatures
|
||||
exclude local server features.
|
||||
:returns: A short hex digest, e.g. ``"3f9a1c2b4d5e6f70"``.
|
||||
"""
|
||||
import hashlib
|
||||
@@ -130,6 +134,7 @@ def server_config_signature() -> str:
|
||||
import json
|
||||
|
||||
from omnigent.server.auth import resolve_auth_source
|
||||
from omnigent.server.feature_flags import resolve_feature_flags
|
||||
|
||||
try:
|
||||
version = importlib.metadata.version("omnigent")
|
||||
@@ -138,7 +143,14 @@ def server_config_signature() -> str:
|
||||
# nothing to key version-drift on, so leave it out of the payload.
|
||||
version = ""
|
||||
|
||||
payload = json.dumps({"auth": resolve_auth_source(), "version": version}, sort_keys=True)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"auth": resolve_auth_source(),
|
||||
"features": (resolve_feature_flags().enabled_names() if include_features else ()),
|
||||
"version": version,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ from omnigent.server.background_session_titles import (
|
||||
BackgroundSessionTitleCoordinator,
|
||||
RunnerBackgroundTitleGenerator,
|
||||
)
|
||||
from omnigent.server.feature_flags import Feature, FeatureFlags, resolve_feature_flags
|
||||
from omnigent.server.managed_hosts import ManagedSandboxDeployment
|
||||
from omnigent.server.mcp_pool import ServerMcpPool
|
||||
from omnigent.server.performance_metrics import (
|
||||
@@ -763,6 +764,7 @@ def create_app(
|
||||
sharing_mode: SharingMode | Callable[[], SharingMode] | None = None,
|
||||
public_sharing: bool | Callable[[], bool] | None = None,
|
||||
server_config: dict[str, Any] | None = None,
|
||||
feature_flags: FeatureFlags | None = None,
|
||||
) -> FastAPI:
|
||||
"""
|
||||
Build and return the FastAPI application with all routes mounted.
|
||||
@@ -853,6 +855,9 @@ def create_app(
|
||||
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 feature_flags: Optional immutable release-feature snapshot.
|
||||
When omitted, resolves the comma-separated ``OMNIGENT_FEATURES``
|
||||
enabled set once at application construction.
|
||||
: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
|
||||
@@ -872,6 +877,8 @@ def create_app(
|
||||
if permission_store is not None and auth_provider is None:
|
||||
raise ValueError("auth_provider is required when permission_store is provided")
|
||||
|
||||
resolved_feature_flags = feature_flags or resolve_feature_flags()
|
||||
|
||||
# First-boot admin bootstrap for the accounts auth provider.
|
||||
# Runs before any route is mounted so the login page is never
|
||||
# served against an empty user table (avoids the Immich-style
|
||||
@@ -1185,6 +1192,7 @@ def create_app(
|
||||
app.state.host_store = host_store
|
||||
app.state.agent_store = agent_store
|
||||
app.state.sandbox_config = sandbox_config
|
||||
app.state.feature_flags = resolved_feature_flags
|
||||
# Admin roster: the config ``admins:`` list (canonical) union'd with the
|
||||
# runtime-editable ``<data_dir>/admins`` file. Built once here so BOTH the
|
||||
# admin-gated auth routes AND ``/v1/me``'s is_admin computation consult the
|
||||
@@ -1888,17 +1896,10 @@ def create_app(
|
||||
except ImportError:
|
||||
smart_routing_enabled = False
|
||||
smart_routing_sources = {"external": False, "oss": False}
|
||||
# harness_install_enabled gates the web UI's "Install" action for a
|
||||
# missing, npm-installable harness on a connected host. Off by default
|
||||
# (OMNIGENT_HARNESS_INSTALL_ENABLED=1 opts in) while the feature rolls
|
||||
# out; when false the SPA keeps the prior "run omnigent setup" hint.
|
||||
# Read live so flipping the env var takes effect without a rebuild.
|
||||
# The env-var name is shared with the install route so the flag the UI
|
||||
# sees and the flag the route enforces can never drift apart.
|
||||
from omnigent.process_logging import env_truthy
|
||||
from omnigent.server.routes.hosts import HARNESS_INSTALL_ENABLED_ENV
|
||||
|
||||
harness_install_enabled = env_truthy(os.environ.get(HARNESS_INSTALL_ENABLED_ENV))
|
||||
# The immutable feature snapshot is shared by capability
|
||||
# advertisement and route enforcement, so frontend and backend cannot
|
||||
# disagree during a process lifetime.
|
||||
harness_install_enabled = app.state.feature_flags.enabled(Feature.HARNESS_INSTALL)
|
||||
# installable_harnesses: the exact harness ids the install route accepts
|
||||
# (bare ids + native spellings resolving to an npm-installable family),
|
||||
# so the SPA offers setup only where it will succeed and never has to
|
||||
@@ -1930,6 +1931,8 @@ def create_app(
|
||||
"server_version": _server_version(),
|
||||
"smart_routing_enabled": smart_routing_enabled,
|
||||
"smart_routing_sources": smart_routing_sources,
|
||||
"features": app.state.feature_flags.frontend_dict(),
|
||||
# Compatibility fields for frontends predating the feature map.
|
||||
"harness_install_enabled": harness_install_enabled,
|
||||
"installable_harnesses": installable_harnesses,
|
||||
"dictation_available": dictation_available,
|
||||
@@ -2032,6 +2035,7 @@ def create_app(
|
||||
create_usage_router(
|
||||
conversation_store,
|
||||
auth_provider=auth_provider,
|
||||
feature_flags=resolved_feature_flags,
|
||||
),
|
||||
prefix="/v1",
|
||||
tags=["usage"],
|
||||
@@ -2498,6 +2502,7 @@ def create_app(
|
||||
permission_store=permission_store,
|
||||
agent_store=agent_store,
|
||||
agent_cache=agent_cache,
|
||||
feature_flags=resolved_feature_flags,
|
||||
),
|
||||
prefix="/v1",
|
||||
tags=["hosts"],
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Deployment-wide release feature management.
|
||||
|
||||
Release features are enabled as a comma-separated set in
|
||||
``OMNIGENT_FEATURES``. The set is resolved once when an application or route
|
||||
factory is built, so every request handled by that process sees one immutable
|
||||
snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
FEATURES_ENV_VAR = "OMNIGENT_FEATURES"
|
||||
_REMOVED_HARNESS_INSTALL_ENV_VAR = "OMNIGENT_HARNESS_INSTALL_ENABLED"
|
||||
|
||||
|
||||
class Feature(StrEnum):
|
||||
"""Canonical release-feature keys accepted by ``OMNIGENT_FEATURES``."""
|
||||
|
||||
USAGE_PAGE = "usage_page"
|
||||
HARNESS_INSTALL = "harness_install"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureDefinition:
|
||||
"""Lifecycle metadata for one temporary release feature."""
|
||||
|
||||
feature: Feature
|
||||
description: str
|
||||
owner: str
|
||||
review_by_release: str
|
||||
frontend_visible: bool = True
|
||||
|
||||
|
||||
FEATURE_DEFINITIONS: tuple[FeatureDefinition, ...] = (
|
||||
FeatureDefinition(
|
||||
feature=Feature.USAGE_PAGE,
|
||||
description="Web Usage page with cost timeline and breakdowns",
|
||||
owner="web",
|
||||
review_by_release="0.11.0",
|
||||
),
|
||||
FeatureDefinition(
|
||||
feature=Feature.HARNESS_INSTALL,
|
||||
description="Install and configure missing harnesses from the web UI",
|
||||
owner="onboarding",
|
||||
review_by_release="0.11.0",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureFlags:
|
||||
"""Immutable feature values for one server process."""
|
||||
|
||||
enabled_features: frozenset[Feature] = frozenset()
|
||||
|
||||
def enabled(self, feature: Feature) -> bool:
|
||||
"""Return whether *feature* is enabled in this snapshot."""
|
||||
return feature in self.enabled_features
|
||||
|
||||
def frontend_dict(self) -> dict[str, bool]:
|
||||
"""Return every frontend-visible feature and its resolved value."""
|
||||
return {
|
||||
definition.feature.value: self.enabled(definition.feature)
|
||||
for definition in FEATURE_DEFINITIONS
|
||||
if definition.frontend_visible
|
||||
}
|
||||
|
||||
def enabled_names(self) -> tuple[str, ...]:
|
||||
"""Return enabled canonical names in stable order."""
|
||||
return tuple(sorted(feature.value for feature in self.enabled_features))
|
||||
|
||||
|
||||
def resolve_feature_flags(environ: Mapping[str, str] | None = None) -> FeatureFlags:
|
||||
"""Resolve ``OMNIGENT_FEATURES`` into an immutable feature snapshot.
|
||||
|
||||
The variable is a comma-separated enabled set, for example
|
||||
``usage_page,harness_install``. Unset or empty means every release feature
|
||||
is off. Unknown names fail startup instead of silently applying a typo.
|
||||
|
||||
:param environ: Environment mapping; defaults to :data:`os.environ`.
|
||||
:returns: Resolved immutable feature values.
|
||||
:raises ValueError: If the enabled set contains an unknown feature name.
|
||||
"""
|
||||
source = os.environ if environ is None else environ
|
||||
legacy_harness_install = source.get(_REMOVED_HARNESS_INSTALL_ENV_VAR, "").strip().lower()
|
||||
if legacy_harness_install not in {"", "0", "false", "no", "off"}:
|
||||
raise ValueError(
|
||||
f"{_REMOVED_HARNESS_INSTALL_ENV_VAR} is no longer supported; "
|
||||
f"enable harness installation with {FEATURES_ENV_VAR}=harness_install"
|
||||
)
|
||||
|
||||
raw = source.get(FEATURES_ENV_VAR, "")
|
||||
names = [name.strip() for name in raw.split(",") if name.strip()]
|
||||
|
||||
enabled: set[Feature] = set()
|
||||
unknown: list[str] = []
|
||||
for name in names:
|
||||
try:
|
||||
enabled.add(Feature(name))
|
||||
except ValueError:
|
||||
unknown.append(name)
|
||||
|
||||
if unknown:
|
||||
known = ", ".join(feature.value for feature in Feature)
|
||||
invalid = ", ".join(sorted(set(unknown)))
|
||||
raise ValueError(
|
||||
f"unknown feature(s) in {FEATURES_ENV_VAR}: {invalid}; known features: {known}"
|
||||
)
|
||||
|
||||
return FeatureFlags(frozenset(enabled))
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
@@ -45,10 +44,10 @@ from omnigent.onboarding.harness_install import (
|
||||
ui_install_key,
|
||||
ui_installable_harnesses,
|
||||
)
|
||||
from omnigent.process_logging import env_truthy
|
||||
from omnigent.runner.identity import token_bound_runner_id
|
||||
from omnigent.runtime.agent_cache import AgentCache
|
||||
from omnigent.server.auth import AuthProvider
|
||||
from omnigent.server.feature_flags import Feature, FeatureFlags, resolve_feature_flags
|
||||
from omnigent.server.host_registry import HostConnection, HostRegistry
|
||||
from omnigent.server.routes._auth_helpers import require_user
|
||||
from omnigent.server.routes._host_launch import resolve_host_launch
|
||||
@@ -80,10 +79,6 @@ _MODEL_OPTIONS_TIMEOUT_S = 15.0
|
||||
# headroom) keeps a genuine slow install from timing out at the server while
|
||||
# the host is still succeeding — a "504 but actually installed" outcome.
|
||||
_INSTALL_HARNESS_TIMEOUT_S = 420.0
|
||||
# Env var that opts a deployment into the UI harness-install feature (default
|
||||
# off). Named once here and shared by the route (this file) and the /v1/info
|
||||
# flag in app.py so the two reads can never diverge on a typo.
|
||||
HARNESS_INSTALL_ENABLED_ENV = "OMNIGENT_HARNESS_INSTALL_ENABLED"
|
||||
|
||||
|
||||
def _host_absent_error(host: Host) -> OmnigentError:
|
||||
@@ -554,6 +549,7 @@ def create_hosts_router(
|
||||
permission_store: PermissionStore | None = None,
|
||||
agent_store: AgentStore | None = None,
|
||||
agent_cache: AgentCache | None = None,
|
||||
feature_flags: FeatureFlags | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build the router for host REST endpoints.
|
||||
|
||||
@@ -574,8 +570,11 @@ def create_hosts_router(
|
||||
:func:`omnigent.server.app.create_app` always supplies it.
|
||||
:param agent_cache: Agent-spec cache used to read the agent's
|
||||
``os_env.cwd`` boundary. Paired with ``agent_store``.
|
||||
:param feature_flags: Immutable deployment release-feature snapshot.
|
||||
When omitted, resolves ``OMNIGENT_FEATURES`` at router construction.
|
||||
:returns: A FastAPI router with host endpoints.
|
||||
"""
|
||||
flags = feature_flags or resolve_feature_flags()
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/hosts")
|
||||
@@ -1256,8 +1255,8 @@ def create_hosts_router(
|
||||
may install onto it. Scoped to the UI-installable allowlist (claude,
|
||||
codex, pi, opencode, qwen) — curl/brew and interactive-auth harnesses
|
||||
are refused. The whole route is gated behind
|
||||
``OMNIGENT_HARNESS_INSTALL_ENABLED`` (default off): when disabled it
|
||||
returns 404 so the feature is invisible until opted in.
|
||||
``harness_install`` in ``OMNIGENT_FEATURES`` (default off): when
|
||||
disabled it returns 404 so the feature is invisible until opted in.
|
||||
|
||||
Concurrent requests for the same (host, harness) coalesce onto one
|
||||
in-flight install so a double-click can't fire two global npm installs.
|
||||
@@ -1275,9 +1274,9 @@ def create_hosts_router(
|
||||
caller is not the host owner, 409 when the host is offline, 502 on
|
||||
a host-side install failure, 504 on host timeout.
|
||||
"""
|
||||
# Feature flag (default off): a disabled route is indistinguishable
|
||||
# from a non-existent one, so the feature is fully dark until opted in.
|
||||
if not env_truthy(os.environ.get(HARNESS_INSTALL_ENABLED_ENV)):
|
||||
# A disabled route is indistinguishable from a non-existent one, so
|
||||
# the feature is fully dark until the deployment opts in.
|
||||
if not flags.enabled(Feature.HARNESS_INSTALL):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
# Allowlist (400) is checked before the ownership check (403) so error
|
||||
@@ -1367,7 +1366,7 @@ def create_hosts_router(
|
||||
Backs the Web UI setup dialog's "Add a credential" action so a user can
|
||||
configure a Claude / Codex / Pi credential on a connected host without a
|
||||
terminal. Owner-scoped, allowlisted, and gated behind
|
||||
``OMNIGENT_HARNESS_INSTALL_ENABLED`` exactly like the install route
|
||||
``harness_install`` release feature exactly like the install route
|
||||
(404 when disabled). The host daemon does the write with the same
|
||||
non-interactive core the ``omnigent setup`` wizard uses.
|
||||
|
||||
@@ -1391,7 +1390,7 @@ def create_hosts_router(
|
||||
the owner, 409 when offline, 502 on host-side failure, 504 on
|
||||
timeout.
|
||||
"""
|
||||
if not env_truthy(os.environ.get(HARNESS_INSTALL_ENABLED_ENV)):
|
||||
if not flags.enabled(Feature.HARNESS_INSTALL):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
# Allowlist before ownership (403) so error codes can't enumerate
|
||||
@@ -1484,7 +1483,7 @@ def create_hosts_router(
|
||||
:raises HTTPException: 404 when disabled or host unknown, 403 when not
|
||||
the owner, 409 when offline, 502/504 on host failure/timeout.
|
||||
"""
|
||||
if not env_truthy(os.environ.get(HARNESS_INSTALL_ENABLED_ENV)):
|
||||
if not flags.enabled(Feature.HARNESS_INSTALL):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
|
||||
user_id = require_user(request, auth_provider)
|
||||
|
||||
@@ -12,6 +12,7 @@ from omnigent._wrapper_labels import WRAPPER_LABEL_KEY
|
||||
from omnigent.entities import Conversation
|
||||
from omnigent.runtime.policies.builder import load_session_usage
|
||||
from omnigent.server.auth import RESERVED_USER_LOCAL, AuthProvider
|
||||
from omnigent.server.feature_flags import Feature, FeatureFlags, resolve_feature_flags
|
||||
from omnigent.server.routes._auth_helpers import require_user
|
||||
from omnigent.server.routes._sessions.helpers import (
|
||||
_resolve_harness_impl,
|
||||
@@ -100,6 +101,8 @@ def _session_cost(usage: dict[str, Any]) -> float:
|
||||
def _build_usage_report(
|
||||
conversation_store: ConversationStore,
|
||||
user_id: str | None,
|
||||
*,
|
||||
include_page_details: bool = False,
|
||||
) -> UsageReport:
|
||||
"""
|
||||
Build the usage report: a daily-rollup cost summary plus session detail.
|
||||
@@ -117,6 +120,8 @@ def _build_usage_report(
|
||||
:param conversation_store: Store to read the rollup and sessions from.
|
||||
:param user_id: The caller / ACL scope. ``None`` in single-user mode maps
|
||||
to the reserved local owner the daily rollup and grants are keyed by.
|
||||
:param include_page_details: Populate the timeline and display metadata
|
||||
used only by the release-gated web Usage page.
|
||||
:returns: The populated :class:`UsageReport`.
|
||||
"""
|
||||
# The daily rollup and session-permission grants key spend by the resolved
|
||||
@@ -155,16 +160,24 @@ def _build_usage_report(
|
||||
title=conv.title,
|
||||
cost_usd=_session_cost(usage),
|
||||
models=_session_models(usage),
|
||||
harness=_resolve_session_harness(conv),
|
||||
llm_model=conv.model_override or _resolve_llm_model(conv),
|
||||
agent_name=conv.sub_agent_name,
|
||||
harness=_resolve_session_harness(conv) if include_page_details else None,
|
||||
llm_model=(
|
||||
conv.model_override or _resolve_llm_model(conv)
|
||||
if include_page_details
|
||||
else None
|
||||
),
|
||||
agent_name=conv.sub_agent_name if include_page_details else None,
|
||||
)
|
||||
)
|
||||
if not page.has_more:
|
||||
break
|
||||
after = page.last_id
|
||||
|
||||
daily_costs_raw = conversation_store.list_daily_costs(rollup_user, _EPOCH_DAY)
|
||||
daily_costs_raw = (
|
||||
conversation_store.list_daily_costs(rollup_user, _EPOCH_DAY)
|
||||
if include_page_details
|
||||
else []
|
||||
)
|
||||
|
||||
return UsageReport(
|
||||
cost_today=cost_today,
|
||||
@@ -180,6 +193,7 @@ def create_usage_router(
|
||||
conversation_store: ConversationStore,
|
||||
*,
|
||||
auth_provider: AuthProvider | None = None,
|
||||
feature_flags: FeatureFlags | None = None,
|
||||
) -> APIRouter:
|
||||
"""
|
||||
Create the per-user usage-report router.
|
||||
@@ -190,8 +204,11 @@ def create_usage_router(
|
||||
:param conversation_store: Store for the daily rollup and session reads.
|
||||
:param auth_provider: Auth provider for user identity. ``None`` disables
|
||||
auth (single-user / local mode).
|
||||
:param feature_flags: Immutable deployment release-feature snapshot.
|
||||
When omitted, resolves ``OMNIGENT_FEATURES`` at router construction.
|
||||
:returns: The configured router (mounted under ``/v1``).
|
||||
"""
|
||||
flags = feature_flags or resolve_feature_flags()
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/usage", response_model=UsageReport)
|
||||
@@ -205,6 +222,11 @@ def create_usage_router(
|
||||
``None`` only when auth is disabled — the single-user / local case).
|
||||
"""
|
||||
user_id = require_user(request, auth_provider)
|
||||
return await asyncio.to_thread(_build_usage_report, conversation_store, user_id)
|
||||
return await asyncio.to_thread(
|
||||
_build_usage_report,
|
||||
conversation_store,
|
||||
user_id,
|
||||
include_page_details=flags.enabled(Feature.USAGE_PAGE),
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
@@ -7691,7 +7691,7 @@
|
||||
},
|
||||
"/v1/hosts/{host_id}/harnesses/{harness}/credential": {
|
||||
"post": {
|
||||
"description": "Write a harness provider credential onto a connected host.\n\nBacks the Web UI setup dialog's \"Add a credential\" action so a user can\nconfigure a Claude / Codex / Pi credential on a connected host without a\nterminal. Owner-scoped, allowlisted, and gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` exactly like the install route\n(404 when disabled). The host daemon does the write with the same\nnon-interactive core the `omnigent setup` wizard uses.\n\nSecurity: the server is an authz'd pass-through \u2014 it validates\nownership + the allowlist and forwards the secret over the (TLS) tunnel;\nit never persists the secret or logs it. The secret rides in the request\nbody (not the URL), and the frame field is redaction-named so it never\nlands on a telemetry span.\n\n**Parameters**\n\n- `body` \u2014 The credential payload (kind + secret / gateway / adopt).\n\n**Returns:** `{\"object\": \"harness_credential\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 refreshed readiness so the UI can flip the badge without a reconnect, plus the refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when disabled or host unknown, 400 when the harness isn't UI-configurable or the body is invalid, 403 when not the owner, 409 when offline, 502 on host-side failure, 504 on timeout.",
|
||||
"description": "Write a harness provider credential onto a connected host.\n\nBacks the Web UI setup dialog's \"Add a credential\" action so a user can\nconfigure a Claude / Codex / Pi credential on a connected host without a\nterminal. Owner-scoped, allowlisted, and gated behind\n`harness_install` release feature exactly like the install route\n(404 when disabled). The host daemon does the write with the same\nnon-interactive core the `omnigent setup` wizard uses.\n\nSecurity: the server is an authz'd pass-through \u2014 it validates\nownership + the allowlist and forwards the secret over the (TLS) tunnel;\nit never persists the secret or logs it. The secret rides in the request\nbody (not the URL), and the frame field is redaction-named so it never\nlands on a telemetry span.\n\n**Parameters**\n\n- `body` \u2014 The credential payload (kind + secret / gateway / adopt).\n\n**Returns:** `{\"object\": \"harness_credential\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 refreshed readiness so the UI can flip the badge without a reconnect, plus the refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when disabled or host unknown, 400 when the harness isn't UI-configurable or the body is invalid, 403 when not the owner, 409 when offline, 502 on host-side failure, 504 on timeout.",
|
||||
"operationId": "store_host_harness_credential_v1_hosts__host_id__harnesses__harness__credential_post",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -7757,7 +7757,7 @@
|
||||
},
|
||||
"/v1/hosts/{host_id}/harnesses/{harness}/install": {
|
||||
"post": {
|
||||
"description": "Install a missing, npm-installable harness CLI onto a host.\n\nBacks the Web UI's New Chat dialog \"Install\" action so a user can\ninstall a harness the connected host is missing without dropping to a\nterminal. Owner-scoped like the other host actions: only the host owner\nmay install onto it. Scoped to the UI-installable allowlist (claude,\ncodex, pi, opencode, qwen) \u2014 curl/brew and interactive-auth harnesses\nare refused. The whole route is gated behind\n`OMNIGENT_HARNESS_INSTALL_ENABLED` (default off): when disabled it\nreturns 404 so the feature is invisible until opted in.\n\nConcurrent requests for the same (host, harness) coalesce onto one\nin-flight install so a double-click can't fire two global npm installs.\n\n**Returns:** `{\"object\": \"harness_install\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 the host's refreshed readiness map so the UI can flip the badge without a reconnect, plus its refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when the feature is disabled or the host is unknown, 400 when the harness is not UI-installable, 403 when the caller is not the host owner, 409 when the host is offline, 502 on a host-side install failure, 504 on host timeout.",
|
||||
"description": "Install a missing, npm-installable harness CLI onto a host.\n\nBacks the Web UI's New Chat dialog \"Install\" action so a user can\ninstall a harness the connected host is missing without dropping to a\nterminal. Owner-scoped like the other host actions: only the host owner\nmay install onto it. Scoped to the UI-installable allowlist (claude,\ncodex, pi, opencode, qwen) \u2014 curl/brew and interactive-auth harnesses\nare refused. The whole route is gated behind\n`harness_install` in `OMNIGENT_FEATURES` (default off): when\ndisabled it returns 404 so the feature is invisible until opted in.\n\nConcurrent requests for the same (host, harness) coalesce onto one\nin-flight install so a double-click can't fire two global npm installs.\n\n**Returns:** `{\"object\": \"harness_install\", \"harness\": ..., \"configured_harnesses\": {...}, \"gateway_inference\": {...} | None}` \u2014 the host's refreshed readiness map so the UI can flip the badge without a reconnect, plus its refreshed gateway-inference map (`None` when the host didn't report one).\n\n**Raises**\n\n- `HTTPException` \u2014 404 when the feature is disabled or the host is unknown, 400 when the harness is not UI-installable, 403 when the caller is not the host owner, 409 when the host is offline, 502 on a host-side install failure, 504 on host timeout.",
|
||||
"operationId": "install_host_harness_v1_hosts__host_id__harnesses__harness__install_post",
|
||||
"parameters": [
|
||||
{
|
||||
|
||||
@@ -31,6 +31,9 @@ services:
|
||||
value: /data/admin-credentials
|
||||
- key: HOST
|
||||
value: 0.0.0.0
|
||||
# Comma-separated deployment-wide release features; empty is all off.
|
||||
- key: OMNIGENT_FEATURES
|
||||
value: ""
|
||||
- key: OMNIGENT_AUTH_PROVIDER
|
||||
value: accounts
|
||||
- key: OMNIGENT_ACCOUNTS_AUTO_OPEN
|
||||
|
||||
@@ -335,7 +335,7 @@ async def _drive_codex_badge(base_url: str) -> None:
|
||||
await _open_entry_config(page, "ag_polly_e2e")
|
||||
badge = page.get_by_test_id("new-chat-landing-harness-warning-codex").first
|
||||
await expect(badge).to_be_visible(timeout=30_000)
|
||||
# This test doesn't enable OMNIGENT_HARNESS_INSTALL_ENABLED, so the
|
||||
# This test doesn't enable harness_install in OMNIGENT_FEATURES, so the
|
||||
# picker runs on the feature-OFF default — where the badge keeps the
|
||||
# original per-reason text ("needs auth"). (With the feature ON the
|
||||
# badge collapses to a single "needs setup" and the reason moves into
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Playwright coverage for the deployment-wide Usage page release feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
|
||||
|
||||
def _stub_server_info(page: Page, *, usage_page: bool) -> None:
|
||||
"""Advertise one deterministic ``usage_page`` feature value."""
|
||||
body = json.dumps(
|
||||
{
|
||||
"accounts_enabled": False,
|
||||
"single_user": True,
|
||||
"login_url": None,
|
||||
"needs_setup": False,
|
||||
"features": {
|
||||
"usage_page": usage_page,
|
||||
"harness_install": False,
|
||||
},
|
||||
"harness_install_enabled": False,
|
||||
"installable_harnesses": [],
|
||||
}
|
||||
)
|
||||
|
||||
def handle_info(route: Route) -> None:
|
||||
route.fulfill(status=200, content_type="application/json", body=body)
|
||||
|
||||
page.route("**/v1/info", handle_info)
|
||||
|
||||
|
||||
def test_usage_page_route_and_navigation_are_absent_when_feature_is_off(
|
||||
page: Page,
|
||||
live_server: str,
|
||||
) -> None:
|
||||
"""A direct deep link cannot bypass the default-off navigation gate."""
|
||||
_stub_server_info(page, usage_page=False)
|
||||
|
||||
page.goto(f"{live_server}/usage")
|
||||
|
||||
expect(page.get_by_role("heading", name="Page not found")).to_be_visible(timeout=30_000)
|
||||
expect(page.get_by_test_id("usage-nav")).to_have_count(0)
|
||||
|
||||
|
||||
def test_usage_page_route_and_navigation_are_available_when_feature_is_on(
|
||||
page: Page,
|
||||
live_server: str,
|
||||
) -> None:
|
||||
"""The advertised feature enables both entry points and report rendering."""
|
||||
_stub_server_info(page, usage_page=True)
|
||||
report = json.dumps(
|
||||
{
|
||||
"cost_today": 0.0,
|
||||
"cost_last_7d": 0.0,
|
||||
"cost_last_30d": 0.0,
|
||||
"total_cost_usd": 0.0,
|
||||
"daily_costs": [],
|
||||
"sessions": [],
|
||||
}
|
||||
)
|
||||
|
||||
def handle_usage(route: Route) -> None:
|
||||
route.fulfill(status=200, content_type="application/json", body=report)
|
||||
|
||||
page.route("**/v1/usage", handle_usage)
|
||||
page.goto(f"{live_server}/usage")
|
||||
|
||||
expect(page.get_by_test_id("usage-nav")).to_be_visible(timeout=30_000)
|
||||
expect(page.get_by_role("heading", name="Usage", exact=True)).to_be_visible()
|
||||
expect(page.get_by_text("$0.00", exact=True)).to_be_visible()
|
||||
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 106 KiB |
@@ -207,6 +207,29 @@ def test_ensure_local_omnigent_server_respawns_on_config_drift(
|
||||
)
|
||||
|
||||
|
||||
def test_server_config_signature_changes_with_features(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Changing the startup feature set forces a managed-server respawn."""
|
||||
monkeypatch.delenv("OMNIGENT_FEATURES", raising=False)
|
||||
sig_off = local_server.server_config_signature()
|
||||
|
||||
monkeypatch.setenv("OMNIGENT_FEATURES", "usage_page")
|
||||
sig_on = local_server.server_config_signature()
|
||||
|
||||
assert sig_off != sig_on
|
||||
assert sig_on == local_server.server_config_signature()
|
||||
|
||||
|
||||
def test_remote_daemon_signature_ignores_local_server_features(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Remote host daemons do not parse config for a server they do not own."""
|
||||
monkeypatch.setenv("OMNIGENT_FEATURES", "not-a-feature")
|
||||
|
||||
assert local_server.server_config_signature(include_features=False)
|
||||
|
||||
|
||||
def test_server_config_signature_changes_with_version(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -9,10 +9,10 @@ owner-scoped, host-forwarded design.
|
||||
|
||||
These are the executable acceptance criteria for Milestone 1 of the
|
||||
"Setup From the UI" project: turning the dead-end "binary missing"
|
||||
warning into a working Install action. The route is gated behind
|
||||
``OMNIGENT_HARNESS_INSTALL_ENABLED``; the fixture enables it so the
|
||||
happy-path and validation cases can run, and one test asserts the route
|
||||
is 404 (invisible) when the flag is off.
|
||||
warning into a working Install action. The route is gated by
|
||||
``harness_install`` in ``OMNIGENT_FEATURES``; the fixture enables it so the
|
||||
happy-path and validation cases can run, and one test asserts the route is 404
|
||||
(invisible) when the flag is off.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,6 +35,7 @@ from omnigent.host.frames import (
|
||||
decode_host_frame,
|
||||
encode_host_frame,
|
||||
)
|
||||
from omnigent.server.feature_flags import FeatureFlags
|
||||
from omnigent.server.host_registry import HostRegistry
|
||||
from omnigent.server.routes.host_tunnel import create_host_tunnel_router
|
||||
from omnigent.server.routes.hosts import create_hosts_router
|
||||
@@ -58,10 +59,10 @@ _HOST_NAME = "install-test-laptop"
|
||||
def _enable_install_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Enable the feature flag for every test except the flag-off case.
|
||||
|
||||
The route is invisible (404) unless ``OMNIGENT_HARNESS_INSTALL_ENABLED``
|
||||
is truthy; the happy-path and validation tests need it on.
|
||||
The route is invisible (404) unless ``harness_install`` is in the enabled
|
||||
feature set; the happy-path and validation tests need it on.
|
||||
"""
|
||||
monkeypatch.setenv("OMNIGENT_HARNESS_INSTALL_ENABLED", "1")
|
||||
monkeypatch.setenv("OMNIGENT_FEATURES", "harness_install")
|
||||
|
||||
|
||||
def _websocket_scope(path: str) -> dict[str, object]:
|
||||
@@ -455,16 +456,24 @@ async def test_install_coalesces_concurrent_same_family(
|
||||
|
||||
async def test_install_harness_route_hidden_when_flag_off(
|
||||
install_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
With the flag off the route is 404 — the feature is invisible.
|
||||
|
||||
Ships dark by default; only opt-in deployments expose it.
|
||||
"""
|
||||
monkeypatch.setenv("OMNIGENT_HARNESS_INSTALL_ENABLED", "0")
|
||||
app, _reg, _hs, _cs = install_app
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
_app, registry, host_store, conv_store = install_app
|
||||
off_app = FastAPI()
|
||||
off_app.include_router(
|
||||
create_hosts_router(
|
||||
registry,
|
||||
host_store,
|
||||
conv_store,
|
||||
feature_flags=FeatureFlags(),
|
||||
),
|
||||
prefix="/v1",
|
||||
)
|
||||
async with AsyncClient(transport=ASGITransport(app=off_app), base_url="http://test") as client:
|
||||
resp = await client.post(f"/v1/hosts/{_HOST_ID}/harnesses/claude/install")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -34,6 +34,7 @@ from omnigent.host.frames import (
|
||||
decode_host_frame,
|
||||
encode_host_frame,
|
||||
)
|
||||
from omnigent.server.feature_flags import FeatureFlags
|
||||
from omnigent.server.host_registry import HostRegistry
|
||||
from omnigent.server.routes.host_tunnel import create_host_tunnel_router
|
||||
from omnigent.server.routes.hosts import create_hosts_router
|
||||
@@ -54,7 +55,7 @@ _HOST_NAME = "credential-test-laptop"
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Enable the feature flag for every test except the flag-off case."""
|
||||
monkeypatch.setenv("OMNIGENT_HARNESS_INSTALL_ENABLED", "1")
|
||||
monkeypatch.setenv("OMNIGENT_FEATURES", "harness_install")
|
||||
|
||||
|
||||
def _websocket_scope(path: str) -> dict[str, object]:
|
||||
@@ -395,12 +396,20 @@ async def test_concurrent_writes_to_one_host_are_serialized(
|
||||
|
||||
async def test_route_hidden_when_flag_off(
|
||||
cred_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With the flag off the route is 404 — the feature is invisible."""
|
||||
monkeypatch.setenv("OMNIGENT_HARNESS_INSTALL_ENABLED", "0")
|
||||
app, _reg, _hs, _cs = cred_app
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
_app, registry, host_store, conv_store = cred_app
|
||||
off_app = FastAPI()
|
||||
off_app.include_router(
|
||||
create_hosts_router(
|
||||
registry,
|
||||
host_store,
|
||||
conv_store,
|
||||
feature_flags=FeatureFlags(),
|
||||
),
|
||||
prefix="/v1",
|
||||
)
|
||||
async with AsyncClient(transport=ASGITransport(app=off_app), base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
f"/v1/hosts/{_HOST_ID}/harnesses/claude/credential",
|
||||
json={"kind": "key", "secret": "x"},
|
||||
@@ -548,11 +557,19 @@ async def test_detect_credentials_returns_non_secret_descriptors(
|
||||
|
||||
async def test_detect_credentials_hidden_when_flag_off(
|
||||
cred_app: tuple[FastAPI, HostRegistry, HostStore, SqlAlchemyConversationStore],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With the flag off the detect route is 404."""
|
||||
monkeypatch.setenv("OMNIGENT_HARNESS_INSTALL_ENABLED", "0")
|
||||
app, _reg, _hs, _cs = cred_app
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
_app, registry, host_store, conv_store = cred_app
|
||||
off_app = FastAPI()
|
||||
off_app.include_router(
|
||||
create_hosts_router(
|
||||
registry,
|
||||
host_store,
|
||||
conv_store,
|
||||
feature_flags=FeatureFlags(),
|
||||
),
|
||||
prefix="/v1",
|
||||
)
|
||||
async with AsyncClient(transport=ASGITransport(app=off_app), base_url="http://test") as client:
|
||||
resp = await client.get(f"/v1/hosts/{_HOST_ID}/credentials/detected")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -14,6 +14,9 @@ from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.server.feature_flags import Feature, FeatureFlags
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
@@ -82,8 +85,11 @@ async def test_info_returns_expected_fields(client: httpx.AsyncClient) -> None:
|
||||
assert data["needs_setup"] is False
|
||||
assert isinstance(data["databricks_features"], bool)
|
||||
assert isinstance(data["managed_sandboxes_enabled"], bool)
|
||||
# harness_install_enabled gates the UI Install action; default off unless
|
||||
# OMNIGENT_HARNESS_INSTALL_ENABLED is set, so it's false in the test app.
|
||||
assert data["features"] == {
|
||||
"usage_page": False,
|
||||
"harness_install": False,
|
||||
}
|
||||
# Compatibility field for frontend builds predating the nested map.
|
||||
assert data["harness_install_enabled"] is False
|
||||
# installable_harnesses is the allowlist the SPA offers setup for; blank
|
||||
# while the feature is off so the UI never offers an install the disabled
|
||||
@@ -117,7 +123,9 @@ async def test_info_single_user_false_without_marker(
|
||||
|
||||
|
||||
async def test_info_advertises_installable_harnesses_when_enabled(
|
||||
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
app: FastAPI,
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""With the feature on, ``/v1/info`` publishes the install allowlist.
|
||||
|
||||
@@ -126,12 +134,13 @@ async def test_info_advertises_installable_harnesses_when_enabled(
|
||||
declares (``codex-native``), not just the bare ids.
|
||||
"""
|
||||
from omnigent.onboarding.harness_install import ui_installable_harnesses
|
||||
from omnigent.server.routes.hosts import HARNESS_INSTALL_ENABLED_ENV
|
||||
|
||||
monkeypatch.setenv(HARNESS_INSTALL_ENABLED_ENV, "1")
|
||||
enabled = FeatureFlags(frozenset({Feature.HARNESS_INSTALL}))
|
||||
monkeypatch.setattr(app.state, "feature_flags", enabled)
|
||||
resp = await client.get("/v1/info")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["features"]["harness_install"] is True
|
||||
assert data["harness_install_enabled"] is True
|
||||
assert set(data["installable_harnesses"]) == set(ui_installable_harnesses())
|
||||
assert "codex-native" in data["installable_harnesses"]
|
||||
|
||||
@@ -113,6 +113,67 @@ def test_build_usage_report_summary_from_daily_rollup(
|
||||
assert report.cost_last_7d == 3.0 # today + 2026-07-18
|
||||
assert report.cost_last_30d == 7.0 # + 2026-07-01
|
||||
assert report.total_cost_usd == 15.0 # + 2026-05-01
|
||||
# The legacy CLI report remains available while page-only details stay dark.
|
||||
assert report.daily_costs == []
|
||||
|
||||
|
||||
def test_build_usage_report_includes_page_details_when_enabled(
|
||||
db_uri: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = SqlAlchemyConversationStore(db_uri)
|
||||
store.add_daily_cost(RESERVED_USER_LOCAL, "2026-07-22", 1.25)
|
||||
session_id = _add_session(
|
||||
store,
|
||||
monkeypatch,
|
||||
ts=1_784_678_400,
|
||||
cost=1.25,
|
||||
by_model={"model-a": {"total_cost_usd": 1.25}},
|
||||
title="page session",
|
||||
)
|
||||
monkeypatch.setattr("omnigent.db.utils.now_epoch", lambda: 1_784_678_400)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.server.routes.usage._resolve_session_harness",
|
||||
lambda _conv: "codex-native",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.server.routes.usage._resolve_llm_model",
|
||||
lambda _conv: "model-a",
|
||||
)
|
||||
|
||||
report = _build_usage_report(store, None, include_page_details=True)
|
||||
|
||||
assert [(item.day, item.cost_usd) for item in report.daily_costs] == [("2026-07-22", 1.25)]
|
||||
session = next(item for item in report.sessions if item.id == session_id)
|
||||
assert session.harness == "codex-native"
|
||||
assert session.llm_model == "model-a"
|
||||
|
||||
|
||||
def test_build_usage_report_skips_page_resolution_while_disabled(
|
||||
db_uri: str,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = SqlAlchemyConversationStore(db_uri)
|
||||
_add_session(
|
||||
store,
|
||||
monkeypatch,
|
||||
ts=1_700_000_000,
|
||||
cost=1.0,
|
||||
by_model={},
|
||||
title="legacy session",
|
||||
)
|
||||
|
||||
def _unexpected(_conv: object) -> str:
|
||||
pytest.fail("page-only metadata was resolved while usage_page was off")
|
||||
|
||||
monkeypatch.setattr("omnigent.server.routes.usage._resolve_session_harness", _unexpected)
|
||||
monkeypatch.setattr("omnigent.server.routes.usage._resolve_llm_model", _unexpected)
|
||||
|
||||
report = _build_usage_report(store, None)
|
||||
|
||||
assert report.daily_costs == []
|
||||
assert report.sessions[0].harness is None
|
||||
assert report.sessions[0].llm_model is None
|
||||
|
||||
|
||||
def test_build_usage_report_sessions_detail(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for deployment-wide release feature resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.server.feature_flags import (
|
||||
FEATURE_DEFINITIONS,
|
||||
FEATURES_ENV_VAR,
|
||||
Feature,
|
||||
FeatureFlags,
|
||||
resolve_feature_flags,
|
||||
)
|
||||
|
||||
|
||||
def test_features_default_off() -> None:
|
||||
flags = resolve_feature_flags({})
|
||||
|
||||
assert flags.enabled_features == frozenset()
|
||||
assert flags.frontend_dict() == {
|
||||
"usage_page": False,
|
||||
"harness_install": False,
|
||||
}
|
||||
|
||||
|
||||
def test_resolves_comma_separated_enabled_set() -> None:
|
||||
flags = resolve_feature_flags({FEATURES_ENV_VAR: " usage_page, harness_install,usage_page "})
|
||||
|
||||
assert flags.enabled(Feature.USAGE_PAGE)
|
||||
assert flags.enabled(Feature.HARNESS_INSTALL)
|
||||
assert flags.enabled_names() == ("harness_install", "usage_page")
|
||||
|
||||
|
||||
def test_empty_entries_are_ignored() -> None:
|
||||
assert resolve_feature_flags({FEATURES_ENV_VAR: " , , "}) == FeatureFlags()
|
||||
|
||||
|
||||
def test_removed_harness_install_variable_fails_with_migration_hint() -> None:
|
||||
with pytest.raises(ValueError, match="OMNIGENT_FEATURES=harness_install"):
|
||||
resolve_feature_flags({"OMNIGENT_HARNESS_INSTALL_ENABLED": "1"})
|
||||
|
||||
|
||||
def test_removed_harness_install_variable_allows_explicit_off() -> None:
|
||||
flags = resolve_feature_flags({"OMNIGENT_HARNESS_INSTALL_ENABLED": "0"})
|
||||
|
||||
assert not flags.enabled(Feature.HARNESS_INSTALL)
|
||||
|
||||
|
||||
def test_unknown_feature_fails_with_known_names() -> None:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
resolve_feature_flags({FEATURES_ENV_VAR: "usage-pgae"})
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "usage-pgae" in message
|
||||
assert "usage_page" in message
|
||||
assert "harness_install" in message
|
||||
|
||||
|
||||
def test_snapshot_is_immutable_and_does_not_follow_environment_mutation() -> None:
|
||||
environ = {FEATURES_ENV_VAR: "usage_page"}
|
||||
flags = resolve_feature_flags(environ)
|
||||
environ[FEATURES_ENV_VAR] = "harness_install"
|
||||
|
||||
assert flags.enabled(Feature.USAGE_PAGE)
|
||||
assert not flags.enabled(Feature.HARNESS_INSTALL)
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
flags.enabled_features = frozenset() # type: ignore[misc]
|
||||
|
||||
|
||||
def test_release_flags_have_lifecycle_metadata_and_default_off() -> None:
|
||||
assert {definition.feature for definition in FEATURE_DEFINITIONS} == set(Feature)
|
||||
for definition in FEATURE_DEFINITIONS:
|
||||
assert definition.owner
|
||||
assert definition.review_by_release
|
||||
assert not FeatureFlags().enabled(definition.feature)
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Outlet, MemoryRouter } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { FALLBACK_SERVER_INFO } from "@/lib/capabilities";
|
||||
import { CapabilitiesProvider } from "@/lib/CapabilitiesContext";
|
||||
|
||||
vi.mock("@/lib/analytics", () => ({ useOmnigentPageView: vi.fn() }));
|
||||
vi.mock("@/shell/AppShell", () => ({
|
||||
AppShell: () => (
|
||||
<div>
|
||||
<span>app shell</span>
|
||||
<Outlet />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/pages/ChatPage", () => ({ ChatPage: () => <div>chat page</div> }));
|
||||
vi.mock("@/pages/NotFoundPage", () => ({ NotFoundPage: () => <div>not found</div> }));
|
||||
vi.mock("@/pages/UsagePage", () => ({ UsagePage: () => <div>usage page</div> }));
|
||||
|
||||
import App from "./App";
|
||||
|
||||
function renderUsageRoute(enabled: boolean) {
|
||||
const info: typeof FALLBACK_SERVER_INFO = {
|
||||
...FALLBACK_SERVER_INFO,
|
||||
features: enabled ? { usage_page: true } : {},
|
||||
};
|
||||
return render(
|
||||
<CapabilitiesProvider info={info}>
|
||||
<MemoryRouter initialEntries={["/usage"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
</CapabilitiesProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Usage release feature route", () => {
|
||||
it("does not register /usage while the feature is off", async () => {
|
||||
renderUsageRoute(false);
|
||||
expect(await screen.findByText("not found")).toBeInTheDocument();
|
||||
expect(screen.queryByText("usage page")).toBeNull();
|
||||
});
|
||||
|
||||
it("registers /usage while the feature is on", async () => {
|
||||
renderUsageRoute(true);
|
||||
expect(await screen.findByText("usage page")).toBeInTheDocument();
|
||||
expect(screen.queryByText("not found")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ChatPage as ChatPageImpl } from "@/pages/ChatPage";
|
||||
import { NotFoundPage as NotFoundPageImpl } from "@/pages/NotFoundPage";
|
||||
import { useOmnigentPageView } from "@/lib/analytics";
|
||||
import { isFeatureEnabled } from "@/lib/capabilities";
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import { AppShell } from "@/shell/AppShell";
|
||||
|
||||
@@ -153,7 +154,9 @@ function App({ basename }: AppProps = {}) {
|
||||
<Route path={`${prefix}/c/:conversationId`} element={<ChatPage />} />
|
||||
<Route path={`${prefix}/inbox`} element={<InboxPage />} />
|
||||
<Route path={`${prefix}/tasks`} element={<TasksPage />} />
|
||||
{isFeatureEnabled(info, "usage_page") && (
|
||||
<Route path={`${prefix}/usage`} element={<UsagePage />} />
|
||||
)}
|
||||
{/* Settings renders into the chat outlet so the conversations
|
||||
sidebar stays put — entering settings only swaps the card's
|
||||
content (the section nav) and the main area. The active section
|
||||
|
||||
@@ -274,6 +274,7 @@ const DICTATION_INFO: ServerInfo = {
|
||||
server_version: "test",
|
||||
smart_routing_enabled: false,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
features: {},
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: true,
|
||||
|
||||
@@ -69,6 +69,7 @@ function serverInfo(overrides: Partial<ServerInfo> = {}): ServerInfo {
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
features: {},
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
|
||||
@@ -31,7 +31,7 @@ import { TooltipProvider } from "./components/ui/tooltip";
|
||||
import { ImageLightboxProvider } from "./components/ImageLightbox";
|
||||
import { RunnerHealthProvider } from "./hooks/RunnerHealthProvider";
|
||||
import { CapabilitiesContext } from "./lib/CapabilitiesContext";
|
||||
import { resolveServerInfo, type ServerInfo } from "./lib/capabilities";
|
||||
import { FALLBACK_SERVER_INFO, resolveServerInfo, type ServerInfo } from "./lib/capabilities";
|
||||
import { EmbeddedProvider } from "./lib/embedded";
|
||||
import { type OmnigentHostConfig, setEmbedRoot, setOmnigentHostConfig } from "./lib/host";
|
||||
import { resolveIdentity } from "./lib/identity";
|
||||
@@ -100,26 +100,6 @@ export interface OmnigentAppProps extends OmnigentHostConfig {
|
||||
* as the Radix portal root, so the host only renders this — no class/portal
|
||||
* wiring needed.
|
||||
*/
|
||||
// Sentinel used when the `/v1/info` probe is slow or missing — matches
|
||||
// `main.tsx`'s fallback (accounts off, no login).
|
||||
const SERVER_INFO_OFFLINE_FALLBACK: ServerInfo = {
|
||||
accounts_enabled: false,
|
||||
single_user: 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,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs `main.tsx`'s boot-time `/v1/info` probe inside the embed tree.
|
||||
*
|
||||
@@ -146,7 +126,7 @@ function EmbedCapabilitiesProvider({ children }: { children: ReactNode }) {
|
||||
// full re-navigation. resolveServerInfo never rejects (its failure path
|
||||
// resolves to the OFF sentinel), so the real value always arrives.
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
if (alive && !resolved) setInfo(SERVER_INFO_OFFLINE_FALLBACK);
|
||||
if (alive && !resolved) setInfo(FALLBACK_SERVER_INFO);
|
||||
}, 1500);
|
||||
void resolveServerInfo().then((real) => {
|
||||
resolved = true;
|
||||
|
||||
@@ -26,6 +26,7 @@ function info(overrides: Partial<ServerInfo>): ServerInfo {
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
features: {},
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
@@ -119,6 +120,36 @@ describe("resolveServerInfo sandbox_providers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerInfo release features", () => {
|
||||
it("keeps boolean feature values and drops malformed entries", async () => {
|
||||
const parsed = await probe({
|
||||
features: { usage_page: true, harness_install: false, malformed: "yes" },
|
||||
});
|
||||
expect(parsed.features).toEqual({ usage_page: true, harness_install: false });
|
||||
});
|
||||
|
||||
it("defaults missing features off", async () => {
|
||||
const { isFeatureEnabled } = await import("./capabilities");
|
||||
const parsed = await probe({});
|
||||
expect(isFeatureEnabled(parsed, "usage_page")).toBe(false);
|
||||
expect(isFeatureEnabled(parsed, "harness_install")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the legacy harness field from an older server", async () => {
|
||||
const { isFeatureEnabled } = await import("./capabilities");
|
||||
const parsed = await probe({ harness_install_enabled: true });
|
||||
expect(isFeatureEnabled(parsed, "harness_install")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails all release features closed when the probe fails", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"));
|
||||
const { isFeatureEnabled, resolveServerInfo } = await import("./capabilities");
|
||||
const parsed = await resolveServerInfo();
|
||||
expect(isFeatureEnabled(parsed, "usage_page")).toBe(false);
|
||||
expect(isFeatureEnabled(parsed, "harness_install")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveServerInfo smart_routing_sources", () => {
|
||||
it("reads an explicit field verbatim", async () => {
|
||||
const parsed = await probe({
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface SmartRoutingSources {
|
||||
oss: boolean;
|
||||
}
|
||||
|
||||
/** Release features understood by this frontend build. */
|
||||
export type FeatureKey = "usage_page" | "harness_install";
|
||||
|
||||
/** Deployment-wide release-feature values advertised by the server. */
|
||||
export type FeatureValues = Record<string, boolean>;
|
||||
|
||||
/** Shape of the response from ``GET /v1/info``. */
|
||||
export interface ServerInfo {
|
||||
accounts_enabled: boolean;
|
||||
@@ -132,8 +138,16 @@ export interface ServerInfo {
|
||||
*/
|
||||
smart_routing_sources: SmartRoutingSources;
|
||||
/**
|
||||
* Deployment-wide release features. Missing keys are disabled. The map is
|
||||
* the canonical gate for new frontend surfaces.
|
||||
*/
|
||||
features: FeatureValues;
|
||||
/**
|
||||
* Compatibility field for servers/frontends predating ``features``.
|
||||
* New consumers should use :func:`isFeatureEnabled`.
|
||||
*
|
||||
* True when the server accepts UI-driven harness installs
|
||||
* (``OMNIGENT_HARNESS_INSTALL_ENABLED=1``). Gates the New Chat dialog's
|
||||
* (``harness_install`` in ``OMNIGENT_FEATURES``). Gates the New Chat dialog's
|
||||
* one-click "Install" action for a missing harness. Fails to ``false`` so a
|
||||
* failed probe never offers an install the server would reject.
|
||||
*/
|
||||
@@ -156,8 +170,8 @@ export interface ServerInfo {
|
||||
dictation_available: boolean;
|
||||
}
|
||||
|
||||
/** Sentinel used when the probe fails — accounts is off, no login URL. */
|
||||
const FALLBACK_SERVER_INFO: ServerInfo = {
|
||||
/** Sentinel used when the probe fails — accounts and release features are off. */
|
||||
export const FALLBACK_SERVER_INFO: ServerInfo = {
|
||||
accounts_enabled: false,
|
||||
// Fail to multi-user: a failed probe must not hide account/sharing chrome.
|
||||
single_user: false,
|
||||
@@ -174,6 +188,7 @@ const FALLBACK_SERVER_INFO: ServerInfo = {
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
features: {},
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
@@ -194,6 +209,25 @@ function parseSmartRoutingSources(raw: unknown, routingEnabled: boolean): SmartR
|
||||
return { external: sources.external === true, oss: sources.oss === true };
|
||||
}
|
||||
|
||||
function parseFeatures(raw: unknown, harnessInstallEnabled: boolean): FeatureValues {
|
||||
const parsed: FeatureValues = {};
|
||||
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (typeof value === "boolean") parsed[key] = value;
|
||||
}
|
||||
}
|
||||
// A server predating the feature map exposed this one release gate as a
|
||||
// top-level field. Preserve mixed-version behavior without making it a
|
||||
// second source for new features.
|
||||
if (!("harness_install" in parsed)) parsed.harness_install = harnessInstallEnabled;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Return whether a known release feature is enabled; missing/loading is off. */
|
||||
export function isFeatureEnabled(info: ServerInfo | "loading", feature: FeatureKey): boolean {
|
||||
return info !== "loading" && info.features?.[feature] === true;
|
||||
}
|
||||
|
||||
let cachedServerInfo: ServerInfo | null = null;
|
||||
let pendingServerInfo: Promise<ServerInfo> | null = null;
|
||||
|
||||
@@ -217,6 +251,7 @@ export async function resolveServerInfo(): Promise<ServerInfo> {
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as Partial<ServerInfo>;
|
||||
const smartRoutingEnabled = data.smart_routing_enabled === true;
|
||||
const harnessInstallEnabled = data.harness_install_enabled === true;
|
||||
cachedServerInfo = {
|
||||
accounts_enabled: data.accounts_enabled === true,
|
||||
single_user: data.single_user === true,
|
||||
@@ -240,7 +275,8 @@ export async function resolveServerInfo(): Promise<ServerInfo> {
|
||||
data.smart_routing_sources,
|
||||
smartRoutingEnabled,
|
||||
),
|
||||
harness_install_enabled: data.harness_install_enabled === true,
|
||||
features: parseFeatures(data.features, harnessInstallEnabled),
|
||||
harness_install_enabled: harnessInstallEnabled,
|
||||
installable_harnesses: Array.isArray(data.installable_harnesses)
|
||||
? data.installable_harnesses.filter((h): h is string => typeof h === "string")
|
||||
: [],
|
||||
|
||||
@@ -25,6 +25,7 @@ const hostWith = (configured: Record<string, boolean | string> | null | undefine
|
||||
const info = (overrides: Partial<ServerInfo> = {}): ServerInfo =>
|
||||
({
|
||||
harness_install_enabled: true,
|
||||
features: { harness_install: overrides.harness_install_enabled ?? true },
|
||||
installable_harnesses: ["codex", "codex-native", "pi", "pi-native"],
|
||||
...overrides,
|
||||
}) as ServerInfo;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import type { SetupStepWire } from "@/lib/agentLabels";
|
||||
import type { Host } from "@/hooks/useHosts";
|
||||
import type { ServerInfo } from "@/lib/capabilities";
|
||||
import { isFeatureEnabled, type ServerInfo } from "@/lib/capabilities";
|
||||
|
||||
/** Whether a step is satisfied, still needed, or not locally determinable. */
|
||||
export type SetupStepStatus = "done" | "todo" | "unknown";
|
||||
@@ -124,7 +124,7 @@ export function harnessInstallableOnHost(
|
||||
): boolean {
|
||||
return (
|
||||
info !== "loading" &&
|
||||
info.harness_install_enabled &&
|
||||
isFeatureEnabled(info, "harness_install") &&
|
||||
!!harness &&
|
||||
info.installable_harnesses.includes(harness) &&
|
||||
host?.status === "online"
|
||||
@@ -182,7 +182,7 @@ export function harnessAuthableOnHost(
|
||||
): boolean {
|
||||
return (
|
||||
info !== "loading" &&
|
||||
info.harness_install_enabled &&
|
||||
isFeatureEnabled(info, "harness_install") &&
|
||||
harnessCredentialFamily(harness) !== null &&
|
||||
host?.status === "online"
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ImageLightboxProvider } from "./components/ImageLightbox";
|
||||
import { RunnerHealthProvider } from "./hooks/RunnerHealthProvider";
|
||||
import { QueueFlushProvider } from "./hooks/QueueFlushProvider";
|
||||
import { SessionUpdatesProvider } from "./hooks/SessionUpdatesProvider";
|
||||
import { resolveServerInfo, type ServerInfo } from "./lib/capabilities";
|
||||
import { FALLBACK_SERVER_INFO, resolveServerInfo, type ServerInfo } from "./lib/capabilities";
|
||||
import { CapabilitiesProvider } from "./lib/CapabilitiesContext";
|
||||
import { resolveIdentity } from "./lib/identity";
|
||||
import { initNativeInsets } from "./lib/nativeInsets";
|
||||
@@ -86,27 +86,7 @@ applyThemePalette(readThemePalette());
|
||||
const bootProbe: Promise<ServerInfo> = Promise.race([
|
||||
resolveServerInfo(),
|
||||
new Promise<ServerInfo>((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
accounts_enabled: false,
|
||||
single_user: 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,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
}),
|
||||
1500,
|
||||
);
|
||||
setTimeout(() => resolve(FALLBACK_SERVER_INFO), 1500);
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1644,6 +1644,7 @@ describe("routing eligibility gates", () => {
|
||||
server_version: null,
|
||||
smart_routing_enabled: smartRouting,
|
||||
smart_routing_sources: { external: smartRouting, oss: smartRouting },
|
||||
features: {},
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
|
||||
@@ -357,6 +357,7 @@ function serverInfo(overrides: Partial<ServerInfo> = {}): ServerInfo {
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
features: {},
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
|
||||
@@ -760,6 +760,7 @@ function renderLanding(infoOverrides: Partial<ServerInfo> = {}, route = "/") {
|
||||
// routing). Cases that exercise the built-in judge pass the field
|
||||
// explicitly.
|
||||
smart_routing_sources: { external: infoOverrides.smart_routing_enabled === true, oss: false },
|
||||
features: { harness_install: infoOverrides.harness_install_enabled === true },
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
|
||||
@@ -83,7 +83,7 @@ import {
|
||||
|
||||
// Re-exported for tests that import the readiness helpers from this module.
|
||||
export { harnessUnavailableReasonOnHost, harnessUnconfiguredOnHost, harnessWarningBadgeText };
|
||||
import { sandboxOptionLabel, sandboxProviderOptions } from "@/lib/capabilities";
|
||||
import { isFeatureEnabled, sandboxOptionLabel, sandboxProviderOptions } from "@/lib/capabilities";
|
||||
import {
|
||||
isSlashCommandText,
|
||||
rankedSlashCommandNames,
|
||||
@@ -1006,7 +1006,7 @@ export function AgentHarnessPicker({
|
||||
const queryClient = useQueryClient();
|
||||
const info = useServerInfo();
|
||||
// Feature ON → single "needs setup" badge; OFF → per-reason original text.
|
||||
const collapsedBadge = info !== "loading" && info.harness_install_enabled;
|
||||
const collapsedBadge = isFeatureEnabled(info, "harness_install");
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Touch devices can't hover, so the desktop submenu flyouts ("More",
|
||||
@@ -1436,7 +1436,7 @@ function HarnessConfigModal({
|
||||
}) {
|
||||
const info = useServerInfo();
|
||||
// Feature ON → single "needs setup" badge; OFF → per-reason original text.
|
||||
const collapsedBadge = info !== "loading" && info.harness_install_enabled;
|
||||
const collapsedBadge = isFeatureEnabled(info, "harness_install");
|
||||
const entryHarness = nativeCodingAgentForAvailableAgent(agent)?.harness ?? null;
|
||||
const hasPermission = nativeAgentHasCapability(agent, "permissionMode");
|
||||
const hasApproval = nativeAgentHasCapability(agent, "approvalMode");
|
||||
@@ -2015,7 +2015,7 @@ export function NewChatLandingScreen() {
|
||||
// Gates the whole UI-driven setup experience (Set up affordance + dialog +
|
||||
// collapsed badge). OFF → the composer/picker fall back to the original
|
||||
// "run omni setup" guidance, so a disabled flag is a no-op on the UI.
|
||||
const harnessInstallEnabled = info !== "loading" && info.harness_install_enabled;
|
||||
const harnessInstallEnabled = isFeatureEnabled(info, "harness_install");
|
||||
// Unfiltered brain-harness labels: safe for membership checks and for
|
||||
// labelling an existing pick, but the OPTIONS offered in the gear modal use
|
||||
// the gated `brainHarnessLabels` below, which drops the fully-auto row when
|
||||
|
||||
@@ -206,6 +206,7 @@ function serverInfo(overrides: Partial<ServerInfo> = {}): ServerInfo {
|
||||
server_version: null,
|
||||
smart_routing_enabled: false,
|
||||
smart_routing_sources: { external: false, oss: false },
|
||||
features: {},
|
||||
harness_install_enabled: false,
|
||||
installable_harnesses: [],
|
||||
dictation_available: false,
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 { Conversation } from "@/hooks/useConversations";
|
||||
import { FALLBACK_SERVER_INFO, type ServerInfo } from "@/lib/capabilities";
|
||||
import { CapabilitiesProvider } from "@/lib/CapabilitiesContext";
|
||||
|
||||
// Project mocks are declared via vi.hoisted so they exist before the hoisted
|
||||
// vi.mock factory runs. projectsMock is mutated per-test to drive project
|
||||
@@ -211,13 +213,19 @@ function mockConversations(convs: Conversation[]) {
|
||||
useConvMock.mockImplementation(() => result(convs));
|
||||
}
|
||||
|
||||
function renderSidebar(open = true, initialEntry = "/", onOpenSearch?: () => void) {
|
||||
function renderSidebar(
|
||||
open = true,
|
||||
initialEntry = "/",
|
||||
onOpenSearch?: () => void,
|
||||
info?: ServerInfo,
|
||||
) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const sidebar = <Sidebar open={open} onClose={vi.fn()} onOpenSearch={onOpenSearch} />;
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<TooltipProvider>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Sidebar open={open} onClose={vi.fn()} onOpenSearch={onOpenSearch} />
|
||||
{info ? <CapabilitiesProvider info={info}>{sidebar}</CapabilitiesProvider> : sidebar}
|
||||
</MemoryRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
@@ -631,6 +639,25 @@ describe("Sidebar session list", () => {
|
||||
expect(badge).not.toHaveClass("bg-[var(--sidebar-active)]");
|
||||
});
|
||||
|
||||
it("hides Usage navigation while the release feature is off", () => {
|
||||
mockConversations(THREE_TYPE_CONVERSATIONS);
|
||||
renderSidebar();
|
||||
|
||||
expect(screen.queryByTestId("usage-nav")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows and highlights Usage navigation when the release feature is on", () => {
|
||||
mockConversations(THREE_TYPE_CONVERSATIONS);
|
||||
renderSidebar(true, "/usage", undefined, {
|
||||
...FALLBACK_SERVER_INFO,
|
||||
features: { usage_page: true },
|
||||
});
|
||||
|
||||
const usage = screen.getByTestId("usage-nav");
|
||||
expect(usage).toHaveAttribute("href", "/usage");
|
||||
expect(usage).toHaveClass("bg-[var(--sidebar-active)]");
|
||||
});
|
||||
|
||||
it("keeps filtering visible while session selection remains hover-revealed", () => {
|
||||
mockConversations(THREE_TYPE_CONVERSATIONS);
|
||||
renderSidebar();
|
||||
|
||||
@@ -127,7 +127,7 @@ import {
|
||||
import { useHosts, type Host } from "@/hooks/useHosts";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useServerInfo } from "@/lib/CapabilitiesContext";
|
||||
import { isSingleUserMode, sandboxOptionLabel } from "@/lib/capabilities";
|
||||
import { isFeatureEnabled, isSingleUserMode, sandboxOptionLabel } from "@/lib/capabilities";
|
||||
import { relativeTime } from "@/lib/relativeTime";
|
||||
import { showToast } from "@/components/ui/toast";
|
||||
import { PermissionsModal } from "@/components/PermissionsModal";
|
||||
@@ -473,6 +473,8 @@ export function Sidebar({
|
||||
onOpenSearch,
|
||||
peek,
|
||||
}: SidebarProps) {
|
||||
const serverInfo = useServerInfo();
|
||||
const usagePageEnabled = isFeatureEnabled(serverInfo, "usage_page");
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
// Which rows the current selection targets: the flat "Sessions" list, or the
|
||||
// sessions nested inside project folders. Set when selection mode is entered
|
||||
@@ -923,6 +925,7 @@ export function Sidebar({
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
{usagePageEnabled && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
@@ -946,6 +949,7 @@ export function Sidebar({
|
||||
Usage
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav
|
||||
|
||||