diff --git a/deploy/databricks/README.md b/deploy/databricks/README.md index ef9c6ed1..73f30866 100644 --- a/deploy/databricks/README.md +++ b/deploy/databricks/README.md @@ -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`. diff --git a/deploy/databricks/databricks.yml b/deploy/databricks/databricks.yml index 1593e430..e8003063 100644 --- a/deploy/databricks/databricks.yml +++ b/deploy/databricks/databricks.yml @@ -21,6 +21,9 @@ variables: UC schema (catalog.schema) holding the OTel destination tables. The platform writes to .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: diff --git a/deploy/databricks/deploy.py b/deploy/databricks/deploy.py index 7e78f392..efe661d0 100644 --- a/deploy/databricks/deploy.py +++ b/deploy/databricks/deploy.py @@ -567,6 +567,14 @@ def _parse_args() -> argparse.Namespace: ".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}", ] diff --git a/deploy/docker/.env.example b/deploy/docker/.env.example index dd289a0e..52790b70 100644 --- a/deploy/docker/.env.example +++ b/deploy/docker/.env.example @@ -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. diff --git a/deploy/docker/README.md b/deploy/docker/README.md index bcfe57ef..ba761ae9 100644 --- a/deploy/docker/README.md +++ b/deploy/docker/README.md @@ -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. diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml index fd4b29c8..d0a38380 100644 --- a/deploy/docker/docker-compose.yaml +++ b/deploy/docker/docker-compose.yaml @@ -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 diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md index ab5cf2e2..97d971c4 100644 --- a/deploy/kubernetes/README.md +++ b/deploy/kubernetes/README.md @@ -91,6 +91,22 @@ Apply your chosen issuer with `kubectl apply -f `. 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.). diff --git a/deploy/kubernetes/base/configmap.yaml b/deploy/kubernetes/base/configmap.yaml index 01b21c17..38ba0c0c 100644 --- a/deploy/kubernetes/base/configmap.yaml +++ b/deploy/kubernetes/base/configmap.yaml @@ -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" diff --git a/deploy/railway/README.md b/deploy/railway/README.md index d56879df..7ba62efc 100644 --- a/deploy/railway/README.md +++ b/deploy/railway/README.md @@ -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 diff --git a/designs/FEATURE_FLAGS.md b/designs/FEATURE_FLAGS.md new file mode 100644 index 00000000..0881786c --- /dev/null +++ b/designs/FEATURE_FLAGS.md @@ -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. diff --git a/omnigent/cli.py b/omnigent/cli.py index 50c7cefd..73ea3e1d 100644 --- a/omnigent/cli.py +++ b/omnigent/cli.py @@ -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 diff --git a/omnigent/host/local_server.py b/omnigent/host/local_server.py index 23d00369..0ecc4bf3 100644 --- a/omnigent/host/local_server.py +++ b/omnigent/host/local_server.py @@ -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] diff --git a/omnigent/server/app.py b/omnigent/server/app.py index 9d7e8d57..bf5808ee 100644 --- a/omnigent/server/app.py +++ b/omnigent/server/app.py @@ -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 ``/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"], diff --git a/omnigent/server/feature_flags.py b/omnigent/server/feature_flags.py new file mode 100644 index 00000000..42a98fd6 --- /dev/null +++ b/omnigent/server/feature_flags.py @@ -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)) diff --git a/omnigent/server/routes/hosts.py b/omnigent/server/routes/hosts.py index 7b28623f..cc24acda 100644 --- a/omnigent/server/routes/hosts.py +++ b/omnigent/server/routes/hosts.py @@ -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) diff --git a/omnigent/server/routes/usage.py b/omnigent/server/routes/usage.py index 07a15d8f..8cb4e476 100644 --- a/omnigent/server/routes/usage.py +++ b/omnigent/server/routes/usage.py @@ -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 diff --git a/openapi.json b/openapi.json index b9ce8637..84b1aeee 100644 --- a/openapi.json +++ b/openapi.json @@ -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": [ { diff --git a/render.yaml b/render.yaml index 1824ae87..2dce35f3 100644 --- a/render.yaml +++ b/render.yaml @@ -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 diff --git a/tests/e2e_ui/chat/test_codex_auth_availability.py b/tests/e2e_ui/chat/test_codex_auth_availability.py index 607c6d47..f6bb7718 100644 --- a/tests/e2e_ui/chat/test_codex_auth_availability.py +++ b/tests/e2e_ui/chat/test_codex_auth_availability.py @@ -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 diff --git a/tests/e2e_ui/sessions/test_usage_page_feature.py b/tests/e2e_ui/sessions/test_usage_page_feature.py new file mode 100644 index 00000000..fc5da117 --- /dev/null +++ b/tests/e2e_ui/sessions/test_usage_page_feature.py @@ -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() diff --git a/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png b/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png index 24a334d4..9429e631 100644 Binary files a/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png and b/tests/e2e_ui/visual/snapshots/test_chat_snapshot/test_chat_conversation_matches_baseline/test_chat_conversation_matches_baseline[chromium][linux].png differ diff --git a/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png b/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png index a0273638..de299015 100644 Binary files a/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png and b/tests/e2e_ui/visual/snapshots/test_chat_turn_rail_snapshot/test_chat_turn_rail_matches_baseline/test_chat_turn_rail_matches_baseline[chromium][linux].png differ diff --git a/tests/e2e_ui/visual/snapshots/test_landing_snapshot/test_empty_landing_matches_baseline/test_empty_landing_matches_baseline[chromium][linux].png b/tests/e2e_ui/visual/snapshots/test_landing_snapshot/test_empty_landing_matches_baseline/test_empty_landing_matches_baseline[chromium][linux].png index d766d11c..d48e0f6c 100644 Binary files a/tests/e2e_ui/visual/snapshots/test_landing_snapshot/test_empty_landing_matches_baseline/test_empty_landing_matches_baseline[chromium][linux].png and b/tests/e2e_ui/visual/snapshots/test_landing_snapshot/test_empty_landing_matches_baseline/test_empty_landing_matches_baseline[chromium][linux].png differ diff --git a/tests/e2e_ui/visual/snapshots/test_sidebar_flyout_snapshot/test_pinned_project_flyout_matches_baseline/test_pinned_project_flyout_matches_baseline[chromium][linux].png b/tests/e2e_ui/visual/snapshots/test_sidebar_flyout_snapshot/test_pinned_project_flyout_matches_baseline/test_pinned_project_flyout_matches_baseline[chromium][linux].png index f2f3b88c..4b1345cb 100644 Binary files a/tests/e2e_ui/visual/snapshots/test_sidebar_flyout_snapshot/test_pinned_project_flyout_matches_baseline/test_pinned_project_flyout_matches_baseline[chromium][linux].png and b/tests/e2e_ui/visual/snapshots/test_sidebar_flyout_snapshot/test_pinned_project_flyout_matches_baseline/test_pinned_project_flyout_matches_baseline[chromium][linux].png differ diff --git a/tests/e2e_ui/visual/snapshots/test_sidebar_snapshot/test_populated_sidebar_matches_baseline/test_populated_sidebar_matches_baseline[chromium][linux].png b/tests/e2e_ui/visual/snapshots/test_sidebar_snapshot/test_populated_sidebar_matches_baseline/test_populated_sidebar_matches_baseline[chromium][linux].png index 09ebcf94..ab048a88 100644 Binary files a/tests/e2e_ui/visual/snapshots/test_sidebar_snapshot/test_populated_sidebar_matches_baseline/test_populated_sidebar_matches_baseline[chromium][linux].png and b/tests/e2e_ui/visual/snapshots/test_sidebar_snapshot/test_populated_sidebar_matches_baseline/test_populated_sidebar_matches_baseline[chromium][linux].png differ diff --git a/tests/host/test_local_server.py b/tests/host/test_local_server.py index dfb1cccc..708cbc2b 100644 --- a/tests/host/test_local_server.py +++ b/tests/host/test_local_server.py @@ -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: diff --git a/tests/server/integration/test_hosts_install_harness.py b/tests/server/integration/test_hosts_install_harness.py index b5b7dc5b..5bab293e 100644 --- a/tests/server/integration/test_hosts_install_harness.py +++ b/tests/server/integration/test_hosts_install_harness.py @@ -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 diff --git a/tests/server/integration/test_hosts_store_credential.py b/tests/server/integration/test_hosts_store_credential.py index 07dcf5b2..5893feab 100644 --- a/tests/server/integration/test_hosts_store_credential.py +++ b/tests/server/integration/test_hosts_store_credential.py @@ -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 diff --git a/tests/server/integration/test_utility_endpoints.py b/tests/server/integration/test_utility_endpoints.py index b31d45eb..ad8ec6fb 100644 --- a/tests/server/integration/test_utility_endpoints.py +++ b/tests/server/integration/test_utility_endpoints.py @@ -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"] diff --git a/tests/server/routes/test_usage_report.py b/tests/server/routes/test_usage_report.py index cc026c7e..1bfe6d11 100644 --- a/tests/server/routes/test_usage_report.py +++ b/tests/server/routes/test_usage_report.py @@ -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( diff --git a/tests/server/test_feature_flags.py b/tests/server/test_feature_flags.py new file mode 100644 index 00000000..6643d2db --- /dev/null +++ b/tests/server/test_feature_flags.py @@ -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) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 00000000..9abd597f --- /dev/null +++ b/web/src/App.test.tsx @@ -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: () => ( +
+ app shell + +
+ ), +})); +vi.mock("@/pages/ChatPage", () => ({ ChatPage: () =>
chat page
})); +vi.mock("@/pages/NotFoundPage", () => ({ NotFoundPage: () =>
not found
})); +vi.mock("@/pages/UsagePage", () => ({ UsagePage: () =>
usage page
})); + +import App from "./App"; + +function renderUsageRoute(enabled: boolean) { + const info: typeof FALLBACK_SERVER_INFO = { + ...FALLBACK_SERVER_INFO, + features: enabled ? { usage_page: true } : {}, + }; + return render( + + + + + , + ); +} + +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(); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 8f663106..75c412b2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = {}) { } /> } /> } /> - } /> + {isFeatureEnabled(info, "usage_page") && ( + } /> + )} {/* 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 diff --git a/web/src/components/ComposerMicButton.test.tsx b/web/src/components/ComposerMicButton.test.tsx index f57e0706..0a93de5f 100644 --- a/web/src/components/ComposerMicButton.test.tsx +++ b/web/src/components/ComposerMicButton.test.tsx @@ -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, diff --git a/web/src/components/PermissionsModal.test.tsx b/web/src/components/PermissionsModal.test.tsx index f600cf03..d7a1e940 100644 --- a/web/src/components/PermissionsModal.test.tsx +++ b/web/src/components/PermissionsModal.test.tsx @@ -69,6 +69,7 @@ function serverInfo(overrides: Partial = {}): 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, diff --git a/web/src/embed.tsx b/web/src/embed.tsx index 83d89564..7935e44e 100644 --- a/web/src/embed.tsx +++ b/web/src/embed.tsx @@ -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; diff --git a/web/src/lib/capabilities.test.ts b/web/src/lib/capabilities.test.ts index 272473cb..3ed965bf 100644 --- a/web/src/lib/capabilities.test.ts +++ b/web/src/lib/capabilities.test.ts @@ -26,6 +26,7 @@ function info(overrides: Partial): 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({ diff --git a/web/src/lib/capabilities.ts b/web/src/lib/capabilities.ts index 5c43c7fe..46859567 100644 --- a/web/src/lib/capabilities.ts +++ b/web/src/lib/capabilities.ts @@ -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; + /** 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 | null = null; @@ -217,6 +251,7 @@ export async function resolveServerInfo(): Promise { if (res.ok) { const data = (await res.json()) as Partial; 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 { 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") : [], diff --git a/web/src/lib/harnessSetup.test.ts b/web/src/lib/harnessSetup.test.ts index b36926b2..97f5dd37 100644 --- a/web/src/lib/harnessSetup.test.ts +++ b/web/src/lib/harnessSetup.test.ts @@ -25,6 +25,7 @@ const hostWith = (configured: Record | null | undefine const info = (overrides: Partial = {}): ServerInfo => ({ harness_install_enabled: true, + features: { harness_install: overrides.harness_install_enabled ?? true }, installable_harnesses: ["codex", "codex-native", "pi", "pi-native"], ...overrides, }) as ServerInfo; diff --git a/web/src/lib/harnessSetup.ts b/web/src/lib/harnessSetup.ts index c9b0ae6f..f056e160 100644 --- a/web/src/lib/harnessSetup.ts +++ b/web/src/lib/harnessSetup.ts @@ -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" ); diff --git a/web/src/main.tsx b/web/src/main.tsx index 7e4c4547..082de85d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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 = Promise.race([ resolveServerInfo(), new Promise((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); }), ]); diff --git a/web/src/pages/ChatPage.test.ts b/web/src/pages/ChatPage.test.ts index 6325b0f1..5a34015a 100644 --- a/web/src/pages/ChatPage.test.ts +++ b/web/src/pages/ChatPage.test.ts @@ -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, diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index af6b07e7..12e34a0c 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -357,6 +357,7 @@ function serverInfo(overrides: Partial = {}): 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, diff --git a/web/src/shell/NewChatDialog.test.tsx b/web/src/shell/NewChatDialog.test.tsx index a81de690..67ab0d1e 100644 --- a/web/src/shell/NewChatDialog.test.tsx +++ b/web/src/shell/NewChatDialog.test.tsx @@ -760,6 +760,7 @@ function renderLanding(infoOverrides: Partial = {}, 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, diff --git a/web/src/shell/NewChatDialog.tsx b/web/src/shell/NewChatDialog.tsx index 9ee595bb..30ee6bb4 100644 --- a/web/src/shell/NewChatDialog.tsx +++ b/web/src/shell/NewChatDialog.tsx @@ -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(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 diff --git a/web/src/shell/Sidebar.rowActions.test.tsx b/web/src/shell/Sidebar.rowActions.test.tsx index 0e23f1b8..b9354aa2 100644 --- a/web/src/shell/Sidebar.rowActions.test.tsx +++ b/web/src/shell/Sidebar.rowActions.test.tsx @@ -206,6 +206,7 @@ function serverInfo(overrides: Partial = {}): 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, diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index b6fb526c..93458224 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -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 = ; return render( - + {info ? {sidebar} : sidebar} , @@ -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(); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index 82195e5e..b1e8d7ec 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -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,29 +925,31 @@ export function Sidebar({ )} - + {usagePageEnabled && ( + + )}