feat(onboarding): enforce supported CLI version ranges for native harnesses (#3335)

N/A

- Added `min_version` and `max_version_exclusive` to `HarnessInstallSpec` and made `harness_cli_installed` probe `--version` when bounds are declared, so setup and dispatch fail loud for outdated CLIs.
- Implemented generic `--version` parsing + PEP 440 comparison with date-version normalization so Cursor and Hermes calendar-version strings compare correctly.
- Wired code- and changelog-derived version floors for all CLI-backed native harnesses (e.g. Claude >=2.1.161, Codex >=0.137.0, Cursor >=2026.06.02, Kimi >=1.47.0, Hermes >=2026.06.05).
- Updated the CLI setup overview and install prompt to show "Needs upgrade" and the detected/declared versions instead of claiming a present-but-outdated CLI is "not installed".
- Added the `version-too-low` readiness reason and surfaced it in the web UI badge/notice; also made Cursor native auth-aware so it now reports `needs-auth` when installed but not logged in.
- Fixed the readiness-layer lookup so `version-too-low` correctly surfaces for all native harnesses that declare a version floor (Claude, Cursor, OpenCode, Kiro, etc.) instead of falling back to `binary-missing`.
- Preserved the existing `antigravity-native` credential gate: an installed `agy` CLI without a stored Gemini credential still reports not-ready.
- Added E2E UI coverage for the new `version-too-low` warning and updated readiness unit tests for version-bound and credential-bound behavior.

```bash
uv run pytest tests/onboarding/test_harness_install.py \
              tests/onboarding/test_harness_readiness.py \
              tests/cli/test_configure_models.py \
              tests/test_codex_native.py -q

npm run --silent test -- --run src/lib/harnessSetup.test.ts src/shell/NewChatDialog.test.tsx
```

N/A — the change is mostly backend/UX copy; no new visual components.

- [x] Feature
- [ ] Bug fix
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [x] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

Manual verification: ran targeted backend/web test suites after each change and confirmed `omnigent setup`/`harness_cli_installed` now report “installed (vX) but not supported” rather than “missing” for outdated CLIs.

Native harness setup now checks the installed CLI version and prompts to upgrade if it is too old.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
This commit is contained in:
Zeyi (Rice) Fan
2026-07-27 14:22:06 -07:00
committed by GitHub
parent 7048f7a38b
commit 92b1e10e53
17 changed files with 994 additions and 136 deletions
+73 -21
View File
@@ -1345,6 +1345,7 @@ def _prompt_install_harness(family: str) -> bool:
"""
from omnigent.onboarding.configure_models import family_label
from omnigent.onboarding.harness_install import (
harness_cli_version,
harness_install_command,
install_harness_cli,
)
@@ -1352,8 +1353,17 @@ def _prompt_install_harness(family: str) -> bool:
label = family_label(family)
cmd = " ".join(harness_install_command(family))
detected_version, range_str = harness_cli_version(family)
if detected_version is None:
prompt = f"{label}'s CLI is missing. Install it now?"
else:
prompt = f"{label}'s CLI is installed ({detected_version}) but not supported."
if range_str:
prompt += f" Required version: {range_str}."
prompt += " Upgrade it now?"
choice = select(
f"{label}'s CLI isn't installed. Install it now?",
prompt,
[
f"Yes — install ({cmd})",
"No — back to harnesses",
@@ -1396,9 +1406,10 @@ def _manage_harness_providers(family: str) -> None:
from omnigent.onboarding.harness_install import harness_cli_installed
from omnigent.onboarding.interactive import select
# If the harness CLI isn't installed, offer to install it before showing
# the credential menu. Declining (or copy-the-command) returns to the
# harness picker — there's nothing to configure for a harness you can't run.
# If the harness CLI is missing or on an unsupported version, offer to
# install/upgrade it before showing the credential menu. Declining (or
# copy-the-command) returns to the harness picker — there's nothing to
# configure for a harness you can't run.
if not harness_cli_installed(family) and not _prompt_install_harness(family):
return
@@ -1651,17 +1662,20 @@ def _manage_cursor_harness() -> None:
CURSOR_KEY,
harness_cli_installed,
harness_cli_logged_in,
harness_install_spec,
)
from omnigent.onboarding.interactive import select
while True:
cli_status = (
"logged in"
if harness_cli_logged_in(CURSOR_KEY)
else "needs login"
if harness_cli_installed(CURSOR_KEY)
else "not installed"
)
from omnigent._platform import resolve_cli_binary
cli_installed = harness_cli_installed(CURSOR_KEY)
if cli_installed:
cli_status = "logged in" if harness_cli_logged_in(CURSOR_KEY) else "needs login"
elif resolve_cli_binary(harness_install_spec(CURSOR_KEY).binary) is not None:
cli_status = "needs upgrade"
else:
cli_status = "not installed"
sdk_status = "API key configured" if cursor_api_key_configured() else "not configured"
rows = [
_HarnessMenuRow(f"Cursor CLI — {cli_status}", action="cli"),
@@ -3051,8 +3065,8 @@ def _manage_opencode_harness() -> None:
synthesized into opencode's per-session config instead — set under
Claude / Codex.)
OpenCode is npm-installable, so a missing CLI gates the drill-in with an
install offer.
OpenCode is npm-installable, so a missing or outdated CLI gates the
drill-in with an install/upgrade offer.
:returns: None. Side effect: may ``npm install`` the opencode CLI.
"""
@@ -3067,7 +3081,7 @@ def _manage_opencode_harness() -> None:
if not harness_cli_installed(OPENCODE_KEY):
cmd = " ".join(harness_install_command(OPENCODE_KEY))
choice = select(
"OpenCode's CLI isn't installed. Install it now?",
"OpenCode's CLI is missing or on an unsupported version. Install/upgrade it now?",
[
f"Yes — install ({cmd})",
"No — back to harnesses",
@@ -3273,6 +3287,20 @@ def _run_configure_harnesses_interactive() -> None:
"action": ("", "cyan"),
}
def _cli_absence_label(key: str) -> str:
"""Return a status label that distinguishes "missing" from "outdated".
When the binary is on PATH but ``harness_cli_installed`` is False, the
CLI is installed but on an unsupported version; saying "Not installed"
in that case is confusing for a user who knows they have the CLI.
"""
from omnigent._platform import resolve_cli_binary
spec = harness_install_spec(key)
if spec is not None and resolve_cli_binary(spec.binary) is not None:
return "Needs upgrade"
return "Not installed"
def _install_hint(command: str) -> str:
# Selection-only tooltip. The command is escaped so a bracketed extra
# (e.g. ``pip install "omnigent[cursor]"``) renders literally instead of
@@ -3304,7 +3332,7 @@ def _run_configure_harnesses_interactive() -> None:
return (
fam,
name,
"Not installed",
_cli_absence_label(fam),
"missing",
_install_hint(" ".join(harness_install_command(fam))),
)
@@ -3376,7 +3404,7 @@ def _run_configure_harnesses_interactive() -> None:
(
_OPENCODE,
"OpenCode",
"Not installed",
_cli_absence_label(OPENCODE_KEY),
"missing",
_install_hint(" ".join(harness_install_command(OPENCODE_KEY))),
),
@@ -3409,7 +3437,13 @@ def _run_configure_harnesses_interactive() -> None:
else "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
)
rows.append(
(_HERMES, "Hermes", "Not installed", "missing", _install_hint(hermes_hint)),
(
_HERMES,
"Hermes",
_cli_absence_label(HERMES_KEY),
"missing",
_install_hint(hermes_hint),
),
)
elif hermes.ready:
rows.append((_HERMES, "Hermes", hermes.describe(), "ready", ""))
@@ -3459,7 +3493,7 @@ def _run_configure_harnesses_interactive() -> None:
(
_QWEN,
"Qwen Code",
"Not installed",
_cli_absence_label(QWEN_KEY),
"missing",
_install_hint(" ".join(harness_install_command(QWEN_KEY))),
),
@@ -3485,7 +3519,15 @@ def _run_configure_harnesses_interactive() -> None:
if goose_spec and goose_spec.install_hint
else "brew install block-goose-cli"
)
rows.append((_GOOSE, "Goose", "Not installed", "missing", _install_hint(goose_hint)))
rows.append(
(
_GOOSE,
"Goose",
_cli_absence_label(GOOSE_KEY),
"missing",
_install_hint(goose_hint),
)
)
else:
goose_summary = goose_config_summary()
if goose_summary.provider:
@@ -3535,7 +3577,9 @@ def _run_configure_harnesses_interactive() -> None:
if kiro_spec and kiro_spec.install_hint
else "curl -fsSL https://cli.kiro.dev/install | bash"
)
rows.append((_KIRO, "Kiro", "Not installed", "missing", _install_hint(kiro_hint)))
rows.append(
(_KIRO, "Kiro", _cli_absence_label(KIRO_KEY), "missing", _install_hint(kiro_hint))
)
# Kimi Code — native CLI, own auth via `kimi login`; there is no local
# login status probe yet. Curl-installed (no npm package), so use its
@@ -3547,7 +3591,15 @@ def _run_configure_harnesses_interactive() -> None:
else:
kimi_spec = harness_install_spec(KIMI_KEY)
kimi_hint = (kimi_spec.install_hint if kimi_spec else None) or "see Kimi Code docs"
rows.append((_KIMI, "Kimi Code", "Not installed", "missing", _install_hint(kimi_hint)))
rows.append(
(
_KIMI,
"Kimi Code",
_cli_absence_label(KIMI_KEY),
"missing",
_install_hint(kimi_hint),
)
)
# Custom ACP agents — the generic `acp` harness driving any user-configured
# ACP-agent command. Each configured agent gets its own overview row
+19 -1
View File
@@ -37,7 +37,6 @@ from omnigent.claude_native_bridge import url_component
from omnigent.codex_native_app_server import (
CodexAppServerClient,
CodexNativeAppServer,
_find_codex_cli,
build_codex_native_server,
build_codex_remote_args,
client_for_transport,
@@ -64,6 +63,7 @@ from omnigent.entities.session_resources import terminal_resource_id
from omnigent.harness_availability import (
HARNESS_BINARY_MISSING,
HARNESS_NEEDS_AUTH,
HARNESS_VERSION_TOO_LOW,
HarnessUnavailableReason,
)
from omnigent.host.daemon_launch import (
@@ -188,6 +188,17 @@ def _codex_auth_json_has_available_credential(auth_path: Path) -> bool:
return False
def _find_codex_cli() -> str | None:
"""Return the resolved path to the Codex CLI binary, if any."""
from omnigent._platform import resolve_cli_binary
from omnigent.onboarding.harness_install import OPENAI_FAMILY, harness_install_spec
spec = harness_install_spec(OPENAI_FAMILY)
if spec is None:
return None
return resolve_cli_binary(spec.binary)
def _codex_auth_unavailable_reason() -> HarnessUnavailableReason | None:
"""
Return why local Codex is unavailable, or ``None`` when available.
@@ -216,8 +227,15 @@ def _codex_auth_unavailable_reason() -> HarnessUnavailableReason | None:
Token *validity* (revoked/expired refresh, an unreachable gateway) is
not judged locally — it surfaces at the first turn via the executor.
"""
from omnigent.onboarding.harness_install import (
OPENAI_FAMILY,
harness_cli_installed,
)
if _find_codex_cli() is None:
return HARNESS_BINARY_MISSING
if not harness_cli_installed(OPENAI_FAMILY):
return HARNESS_VERSION_TOO_LOW
# On a host with no configured provider this may run ambient detection.
# configured_harness_map shares one probe across all Codex aliases.
try:
+14 -3
View File
@@ -6,9 +6,16 @@ from typing import Final, Literal, TypeGuard
HARNESS_BINARY_MISSING: Final[Literal["binary-missing"]] = "binary-missing"
HARNESS_NEEDS_AUTH: Final[Literal["needs-auth"]] = "needs-auth"
HARNESS_VERSION_TOO_LOW: Final[Literal["version-too-low"]] = "version-too-low"
HarnessUnavailableReason = Literal["binary-missing", "needs-auth"]
HarnessAvailability = Literal[True, False, "binary-missing", "needs-auth"]
HarnessUnavailableReason = Literal["binary-missing", "needs-auth", "version-too-low"]
HarnessAvailability = Literal[
True,
False,
"binary-missing",
"needs-auth",
"version-too-low",
]
# Readiness and model-family checks must agree on every Codex spelling.
CODEX_CANONICAL_HARNESSES: Final[frozenset[str]] = frozenset(
@@ -18,4 +25,8 @@ CODEX_CANONICAL_HARNESSES: Final[frozenset[str]] = frozenset(
def is_harness_availability(value: object) -> TypeGuard[HarnessAvailability]:
"""Return whether a decoded value is a supported readiness state."""
return isinstance(value, bool) or value in (HARNESS_BINARY_MISSING, HARNESS_NEEDS_AUTH)
return isinstance(value, bool) or value in (
HARNESS_BINARY_MISSING,
HARNESS_NEEDS_AUTH,
HARNESS_VERSION_TOO_LOW,
)
+4
View File
@@ -24,6 +24,10 @@ class HarnessInstallSpec:
login_status_key: str | None = None
auth_hint: str | None = None
install_command: tuple[str, ...] | None = None
min_version: str | None = None
"""Minimum supported CLI version (inclusive)."""
max_version_exclusive: str | None = None
"""Maximum supported CLI version (exclusive)."""
@dataclass(frozen=True)
+257 -25
View File
@@ -37,15 +37,22 @@ from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import NamedTuple
from packaging.version import InvalidVersion, Version
from omnigent._platform import resolve_cli_binary
from omnigent.harness_install_spec import HarnessInstallSpec, SetupStep
from omnigent.onboarding.provider_config import ANTHROPIC_FAMILY, GEMINI_FAMILY, OPENAI_FAMILY
from omnigent.opencode_native_client import (
OPENCODE_MAX_VERSION_EXCLUSIVE,
OPENCODE_MIN_VERSION,
)
# Pi is not a configure-menu family (the menu is Claude + Codex), but the
# first-run ``run`` flow falls back to it, so it has install metadata too.
@@ -69,6 +76,45 @@ KIMI_KEY = "kimi"
# installer, not an npm package managed by ``omni setup``.
KIRO_KEY = "kiro"
# Minimum CLI versions for native harnesses where the runtime has a known
# feature floor. These are intentionally conservative: the runtime may
# gracefully degrade on older CLIs, but setup enforces the floor so a user
# isn't surprised by missing behaviour (e.g. policy hooks, non-interactive
# approval, forwarder schema) after launching.
# Sources:
# - codex: native policy hook requires >= 0.129.0
# (`omnigent/codex_native_app_server.py`).
# - pi: non-interactive ``--approve`` override requires >= 0.79.0
# (``omnigent/pi_native.py``).
# - qwen: ``--input-file`` / ``--json-file`` bridge verified on v0.18.1
# (``omnigent/qwen_native_forwarder.py`` / ``docs/QWEN_NATIVE_DESIGN.md``).
# - goose: SQLite forwarder schema verified on Goose 1.38.0
# (``omnigent/goose_native_forwarder.py``).
# - hermes: parent_session_id schema introduced in v0.17.0
# (``omnigent/hermes_native_forwarder.py``).
# - kiro: MCP config schema (``{"mcpServers": ...}``) verified on kiro-cli 2.10.0
# (``omnigent/kiro_native_bridge.py``).
# - claude: `--mcp-config` (required by the native bridge) introduced long
# before 2026-06-01. The first Claude Code release after the cutoff is
# 2.1.161, so use that as the supported floor.
# - codex: native policy hook requires >= 0.129.0, but that shipped before
# 2026-06-01. The first Codex release after the cutoff is 0.137.0.
# - cursor: Cursor's CLI uses ``YYYY.MM.DD[-build]`` date versions. Default
# to the day after 2026-06-01 so we don't support stale pre-June builds.
# - kimi: first ``kimi-cli`` release after 2026-06-01 is 1.47.0
# (https://github.com/MoonshotAI/kimi-cli/blob/main/CHANGELOG.md).
# - hermes: parent_session_id schema was introduced in v0.17.0, but Hermes now
# ships date-tagged releases; the first one after 2026-06-01 is 2026.06.05.
_CODEX_MIN_VERSION = "0.137.0"
_PI_MIN_VERSION = "0.79.0"
_QWEN_MIN_VERSION = "0.18.1"
_GOOSE_MIN_VERSION = "1.38.0"
_HERMES_MIN_VERSION = "2026.06.05"
_KIRO_MIN_VERSION = "2.10.0"
_CLAUDE_MIN_VERSION = "2.1.161"
_CURSOR_MIN_VERSION = "2026.06.02"
_KIMI_MIN_VERSION = "1.47.0"
# OpenCode native harness CLI (``opencode serve`` / ``opencode attach``),
# installed via the ``opencode-ai`` npm package. No login/logout/status argv
# is wired yet — readiness is binary-only until an auth check exists.
@@ -109,6 +155,9 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
logout_args=("auth", "logout"),
status_args=("auth", "status"),
login_status_key="loggedIn",
# The native bridge injects Omnigent's MCP relay via `--mcp-config`;
# that flag first shipped in Claude Code 0.2.75.
min_version=_CLAUDE_MIN_VERSION,
),
OPENAI_FAMILY: HarnessInstallSpec(
"Codex",
@@ -117,13 +166,33 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
login_args=("login",),
logout_args=("logout",),
status_args=("login", "status"),
# The native Codex policy hook requires ``codex >= 0.129.0``;
# anything older silently disables tool-call enforcement. Setup
# enforces the same floor up-front.
min_version=_CODEX_MIN_VERSION,
),
PI_KEY: HarnessInstallSpec(
"Pi",
"pi",
"@earendil-works/pi-coding-agent",
# The ``--approve`` / non-interactive trust override requires
# ``pi >= 0.79.0``; older CLIs would prompt mid-session.
min_version=_PI_MIN_VERSION,
),
PI_KEY: HarnessInstallSpec("Pi", "pi", "@earendil-works/pi-coding-agent"),
# Pin the install to the supported 1.17.x range: opencode-ai's npm ``latest``
# is a ``0.0.0-beta-*`` pre-release, so a bare ``opencode-ai`` would install a
# version the runtime version-check (``check_opencode_version``,
# >=1.17.7,<1.18.0) then rejects. ``~1.17.7`` mirrors that exact range.
OPENCODE_KEY: HarnessInstallSpec("OpenCode", "opencode", "opencode-ai@~1.17.7"),
# The same version bounds are enforced in setup via ``min_version`` /
# ``max_version_exclusive`` so the install/upgrade prompt fires before
# the runtime gate does.
OPENCODE_KEY: HarnessInstallSpec(
"OpenCode",
"opencode",
"opencode-ai@~1.17.7",
min_version=OPENCODE_MIN_VERSION,
max_version_exclusive=OPENCODE_MAX_VERSION_EXCLUSIVE,
),
QWEN_KEY: HarnessInstallSpec(
"Qwen Code",
"qwen",
@@ -136,6 +205,7 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
# vars or the interactive ``/auth`` command; the setup wizard handles
# that in ``_manage_qwen_harness``. Leaving these None keeps
# harness_login/logout/cli_logged_in no-ops for qwen.
min_version=_QWEN_MIN_VERSION,
),
CURSOR_KEY: HarnessInstallSpec(
"Cursor",
@@ -146,6 +216,9 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
status_args=("status", "--format", "json"),
install_hint="curl https://cursor.com/install -fsS | bash",
login_status_key="isAuthenticated",
# Cursor CLI versions are calendar dates; only support builds from
# after 2026-06-01 for the native harness path.
min_version=_CURSOR_MIN_VERSION,
),
# Kimi Code CLI ships a single-binary ``kimi`` via a curl installer (no
# npm). ``kimi login`` is the interactive provider login (OAuth or a
@@ -161,12 +234,16 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
login_args=("login",),
logout_args=("logout",),
install_hint="curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash",
# First kimi-cli release after 2026-06-01. Older builds may lack
# newer TUI/session wiring needed by the native harness.
min_version=_KIMI_MIN_VERSION,
),
KIRO_KEY: HarnessInstallSpec(
"Kiro",
"kiro-cli",
package=None,
install_hint="curl -fsSL https://cli.kiro.dev/install | bash",
min_version=_KIRO_MIN_VERSION,
),
# The native Antigravity (agy) TUI bridge wraps the ``agy`` CLI. ``agy`` has
# no ``login`` / ``logout`` subcommand — the user authenticates via browser
@@ -194,6 +271,7 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
"goose",
package=None,
install_hint="brew install block-goose-cli",
min_version=_GOOSE_MIN_VERSION,
),
HERMES_KEY: HarnessInstallSpec(
"Hermes",
@@ -201,6 +279,7 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
package=None,
install_hint=_HERMES_INSTALL_HINT,
install_command=("bash", "-c", _HERMES_INSTALL_HINT),
min_version=_HERMES_MIN_VERSION,
),
}
@@ -514,26 +593,28 @@ def required_cli_for_harness(harness: str) -> HarnessInstallSpec | None:
def missing_harness_cli(harness: str) -> HarnessInstallSpec | None:
"""Return a harness's required CLI spec when that CLI can't be resolved.
"""Return a harness's required CLI spec when that CLI can't be used.
Combines :func:`required_cli_for_harness` with the same
:func:`resolve_cli_binary` probe :func:`harness_cli_installed` uses, so the
verdict matches what the harness's own launch will see (both check ``PATH``
plus the common global install dirs the host daemon's frozen ``PATH`` may
omit). Used by sub-agent dispatch to fail loud *before* spawning a worker
whose harness can never boot here, instead of letting the missing binary
surface as a lazy, generic turn failure.
Combines :func:`required_cli_for_harness` with the same probe
:func:`harness_cli_installed` uses, so the verdict matches what the
harness's own launch will see (both check ``PATH`` plus the common global
install dirs the host daemon's frozen ``PATH`` may omit, and now also the
declared version range). Used by sub-agent dispatch to fail loud *before*
spawning a worker whose harness can never boot here, instead of letting
the missing or incompatible binary surface as a lazy, generic turn failure.
:param harness: An executor harness identifier, e.g. ``"pi"`` or
``"claude-native"``.
:returns: The :class:`HarnessInstallSpec` for a CLI-backed harness whose
``binary`` is not on ``PATH``; ``None`` when the harness needs no CLI
(SDK-based / unknown) or the required binary is present.
``binary`` is not on ``PATH`` or is outside its supported version range;
``None`` when the harness needs no CLI (SDK-based / unknown) or the
required binary is present and version-compatible.
"""
spec = required_cli_for_harness(harness)
if spec is None:
return None
if resolve_cli_binary(spec.binary) is not None:
install_key = _all_harness_name_to_key().get(harness)
if install_key is not None and harness_cli_installed(install_key):
return None
return spec
@@ -569,6 +650,79 @@ def harness_setup_hint(harness: str | None) -> str:
return "run `omni setup` on that machine to install the CLI and set a default credential"
_VERSION_RE = re.compile(r"(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+)*)")
def _normalize_date_version(version: str) -> str:
"""Trim date-shaped versions (``YYYY.MM.DD[-build]``) to their date part.
Cursor's CLI reports versions like ``2026.07.01-777f564`` or
``2026.06.19-20-24-33-653a7fb`` on Windows. ``packaging`` rejects those
as PEP 440, but the leading ``YYYY.MM.DD`` is enough to compare chronology.
Normalizing keeps semver versions intact so existing parsing is unaffected.
"""
parts = re.split(r"[.-]", version.replace("_", "-"))
if len(parts) < 3:
return version
try:
year = int(parts[0])
month = int(parts[1])
day = int(parts[2])
except ValueError:
return version
# Treat plausible calendar dates (year 2000-2199) as date versions.
if 2000 <= year <= 2199 and 1 <= month <= 12 and 1 <= day <= 31:
return f"{year}.{month:02d}.{day:02d}"
return version
def _parse_harness_cli_version(text: str) -> str | None:
"""Extract a semver-ish string from ``<binary> --version`` output.
Mirrors the OpenCode-specific parser in
:func:`omnigent.opencode_native_app_server.parse_opencode_version` but is
kept generic so any harness can declare a version range in its install spec.
Date-shaped versions (e.g. Cursor's ``2026.06.22`` or
``2026.06.19-20-24-33-653a7fb``) are normalized to ``YYYY.MM.DD``.
"""
match = _VERSION_RE.search(text or "")
if match is None:
return None
return _normalize_date_version(match.group(1))
def _harness_cli_version_satisfies(spec: HarnessInstallSpec, binary: str) -> bool:
"""Check *binary*'s ``--version`` against *spec*'s declared range.
A missing/unparseable version or a subprocess error is treated as not
satisfying the range, so an installed but incompatible CLI is reported
as not ready and the setup flow prompts for an upgrade before the
runtime gate rejects it.
"""
if spec.min_version is None and spec.max_version_exclusive is None:
return True
version = _harness_cli_version_string(spec, binary)
if version is None:
return False
try:
parsed = Version(version)
except InvalidVersion:
return False
if spec.min_version is not None:
try:
if parsed < Version(spec.min_version):
return False
except InvalidVersion:
return False
if spec.max_version_exclusive is not None:
try:
if parsed >= Version(spec.max_version_exclusive):
return False
except InvalidVersion:
return False
return True
def harness_install_spec(key: str) -> HarnessInstallSpec | None:
"""Return the install spec for a family/harness key, or ``None``.
@@ -580,24 +734,102 @@ def harness_install_spec(key: str) -> HarnessInstallSpec | None:
return _all_harness_install().get(key)
def harness_cli_installed(key: str) -> bool:
"""Return whether the harness's CLI binary can be resolved.
def harness_cli_version_satisfies(key: str) -> bool:
"""Return whether the installed CLI for *key* satisfies its version range.
"Installed" is deliberately the CLI binary (:func:`resolve_cli_binary` —
``PATH`` plus the common global install dirs the host daemon's frozen
``PATH`` may omit), matching ucode and the npm install-prompt UX — even
though the SDK-based ``claude-sdk`` harness can run without the ``claude``
CLI.
Only harnesses that declare ``min_version`` / ``max_version_exclusive`` in
their install spec are probed. A missing binary, an unparsable version, or
a subprocess error is treated as not satisfying the range — the setup flow
will then prompt for an upgrade before the runtime gate rejects it.
:param key: A harness family (``"anthropic"`` / ``"openai"``) or
:data:`PI_KEY` / :data:`KIMI_KEY`.
:returns: ``True`` when the CLI resolves; ``False`` when it doesn't or
the key has no associated CLI.
:param key: A harness family key, e.g. :data:`OPENCODE_KEY`.
:returns: ``True`` when the binary is present and its version falls inside
the declared range, or when the spec has no version bounds.
"""
spec = harness_install_spec(key)
if spec is None:
return False
return resolve_cli_binary(spec.binary) is not None
binary = resolve_cli_binary(spec.binary)
if binary is None:
return False
return _harness_cli_version_satisfies(spec, binary)
def harness_cli_installed(key: str) -> bool:
"""Return whether the harness's CLI is present and meets its version range.
"Installed" now means the CLI binary (:func:`resolve_cli_binary`) is
resolvable **and**, when the harness declares ``min_version`` /
``max_version_exclusive`` in its install spec, the binary's
``--version`` output satisfies that range. This prevents an outdated
native CLI (e.g. an OpenCode release outside the supported 1.17.x band)
from being treated as ready during setup.
:param key: A harness family (``"anthropic"`` / ``"openai"``) or
:data:`PI_KEY` / :data:`KIMI_KEY`.
:returns: ``True`` when the CLI resolves and is version-compatible;
``False`` when it doesn't resolve, the key has no associated CLI,
or its version falls outside the declared range.
"""
spec = harness_install_spec(key)
if spec is None:
return False
binary = resolve_cli_binary(spec.binary)
if binary is None:
return False
return _harness_cli_version_satisfies(spec, binary)
def harness_cli_version(key: str) -> tuple[str | None, str | None]:
"""Return the installed CLI's version string plus the declared range.
Useful for human-readable status messages when the CLI is present but
outside its supported range, so the UI can say "installed vX, required >=Y"
instead of just "not installed".
:param key: A harness family key, e.g. :data:`OPENCODE_KEY`.
:returns: ``(version, range_str)``. ``version`` is ``None`` when the binary
is missing or its ``--version`` output is unparseable. ``range_str``
is a human-readable summary of the declared ``min_version`` /
``max_version_exclusive`` range, or ``None`` when the spec has no
version bounds.
"""
spec = harness_install_spec(key)
if spec is None:
return None, None
binary = resolve_cli_binary(spec.binary)
if binary is None:
return None, None
version = _harness_cli_version_string(spec, binary)
if version is None:
return None, _version_range_str(spec)
return version, _version_range_str(spec)
def _version_range_str(spec: HarnessInstallSpec) -> str | None:
"""Human-readable rendering of a spec's version range, or ``None``."""
if spec.min_version is None and spec.max_version_exclusive is None:
return None
if spec.min_version is not None and spec.max_version_exclusive is None:
return f">={spec.min_version}"
if spec.min_version is None and spec.max_version_exclusive is not None:
return f"<{spec.max_version_exclusive}"
return f">={spec.min_version}, <{spec.max_version_exclusive}"
def _harness_cli_version_string(spec: HarnessInstallSpec, binary: str) -> str | None:
"""Return the parsed, normalized version string from *binary* ``--version``."""
try:
completed = subprocess.run(
[binary, "--version"],
capture_output=True,
text=True,
timeout=30,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
return _parse_harness_cli_version((completed.stdout or "") + "\n" + (completed.stderr or ""))
def harness_install_command(key: str) -> list[str]:
+93 -36
View File
@@ -33,6 +33,8 @@ from omnigent._platform import resolve_cli_binary
from omnigent.harness_aliases import HARNESS_ALIASES, canonicalize_harness
from omnigent.harness_availability import (
CODEX_CANONICAL_HARNESSES,
HARNESS_BINARY_MISSING,
HARNESS_VERSION_TOO_LOW,
HarnessAvailability,
)
from omnigent.harness_plugins import harness_install_keys, valid_harnesses
@@ -47,6 +49,7 @@ from omnigent.onboarding.harness_install import (
PI_KEY,
QWEN_KEY,
harness_cli_installed,
harness_install_spec,
required_cli_for_harness,
)
from omnigent.onboarding.provider_config import (
@@ -180,23 +183,19 @@ def _install_key(canonical: str) -> str:
return _HARNESS_FAMILY.get(canonical) or PI_KEY
def harness_is_configured(harness: str) -> bool:
"""Return whether *harness* can be launched on this machine.
def _harness_availability_core(harness: str) -> HarnessAvailability:
"""Return the detailed availability state for *harness*.
Only CLI-wrapping harnesses are assessed (native Claude/Codex/Kiro and
``pi`` / ``pi-native``): they cannot run without their binary on
``PATH``, and that is the one thing the daemon can check reliably and
locally. SDK harnesses and unknown harnesses always return ``True`` —
their readiness depends on runtime/ambient credentials the daemon
can't enumerate, so blocking them would risk false negatives that
break working launches.
Mirrors :func:`harness_is_configured` but preserves the distinction
between "CLI missing", "CLI present but version too old", and other
structured states so the web UI and setup dialogs can show actionable
copy.
:param harness: A harness id, e.g. ``"claude-native"``, ``"codex"``,
``"openai-agents"``, ``"agents_sdk"``, ``"kiro-native"``, ``"pi"``,
``"pi-native"``, ``"qwen"``, or ``"qwen-code"``.
:returns: ``True`` when launchable (CLI installed, or a harness the
daemon doesn't gate); ``False`` only when a CLI-wrapping
harness's binary is missing from ``PATH``.
:returns: A :data:`HarnessAvailability` value.``True`` when launchable;
``False`` or a reason string otherwise.
"""
canonical = _canonical_harness(harness)
if canonical == "acp":
@@ -214,26 +213,16 @@ def harness_is_configured(harness: str) -> bool:
return True
if canonical in _CURSOR_NATIVE_HARNESSES:
# Native Cursor (``omni cursor``) wraps the ``cursor-agent`` CLI — gate
# on that binary, like ``claude-native`` / ``codex-native``. (Login
# state surfaces at run time; the daemon gates only on binary presence,
# mirroring the other native harnesses.)
return harness_cli_installed(CURSOR_KEY)
# on that binary. Keep the missing-binary case as the historical bare
# ``False`` sentinel, surfacing an outdated version only as
# ``"version-too-low"``.
return _installer_only_availability(CURSOR_KEY)
if canonical in _KIRO_NATIVE_HARNESSES:
return harness_cli_installed(KIRO_KEY)
return _installer_only_availability(KIRO_KEY)
if canonical in _GOOSE_NATIVE_HARNESSES or canonical == GOOSE_KEY:
# Goose — both the native TUI (``goose-native`` / ``native-goose``, via
# ``omni goose``) and the headless ACP harness (``goose``, drives
# ``goose acp``) — wraps the ``goose`` CLI, so gate on that binary.
# Auth/provider state surfaces at run time via Goose's own config; the
# daemon gates only on binary presence.
return harness_cli_installed(GOOSE_KEY)
return _installer_only_availability(GOOSE_KEY)
if canonical in _HERMES_NATIVE_HARNESSES or canonical == HERMES_KEY:
# Hermes — both the native TUI (``hermes-native`` / ``native-hermes``,
# via ``omni hermes``) and the headless subprocess harness (``hermes``)
# — wraps the ``hermes`` CLI (installed via a curl script from Nous
# Research). Auth/provider config surfaces at run time via Hermes' own
# ``hermes model`` flow; gate only on binary presence.
return harness_cli_installed(HERMES_KEY)
return _installer_only_availability(HERMES_KEY)
if canonical == CURSOR_KEY:
# Cursor runs in-process via ``cursor-sdk`` and authenticates with a
# ``CURSOR_API_KEY`` (a ``cursor-agent login`` does not apply). So,
@@ -283,12 +272,13 @@ def harness_is_configured(harness: str) -> bool:
# version skew).
return True
install_key = _install_key(canonical)
if not harness_cli_installed(install_key):
return False
availability = _installer_only_availability(install_key)
# Families that authenticate via file-based credentials (not a CLI login
# command) require both the binary AND a stored credential. The ``agy`` CLI
# falls into this category: it has no ``agy login`` subcommand and writes
# OAuth creds on the first interactive browser run instead.
if availability is not True:
return availability
credential_check = _FAMILY_CREDENTIAL_CHECK.get(install_key)
if credential_check is not None:
return credential_check()
@@ -306,10 +296,15 @@ def harness_is_configured(harness: str) -> bool:
# an omnigent-managed provider). Qwen is absent on purpose: its key lives in the
# harness's own env / interactive ``/auth``, which the daemon can't reduce to a
# provider check, so it reports binary presence only.
# Cursor native is included here too: ``cursor-agent`` has its own login command,
# so the picker can distinguish "not installed" from "installed but not signed
# in", while the launch gate stays binary-only.
_AUTH_AWARE_NATIVE_HARNESSES: dict[str, str] = {
"claude-native": "anthropic",
"native-claude": "anthropic",
"opencode-native": OPENCODE_KEY,
"cursor-native": CURSOR_KEY,
"native-cursor": CURSOR_KEY,
}
@@ -351,15 +346,47 @@ def _family_provider_configured(harness: str) -> bool:
return provider is not None and provider.kind != SUBSCRIPTION_KIND
def _installer_only_availability(install_key: str) -> HarnessAvailability:
"""Return availability for a binary-gated harness without login commands.
Mirrors :func:`_binary_availability_reason` but keeps the historical bare
``False`` shape for a missing binary, so existing web/clients that expect a
simple boolean get that and only learn about structured reasons when the
binary is present but on an unsupported version.
"""
state = _binary_availability_reason(install_key)
if state == HARNESS_BINARY_MISSING:
return False
return state
def _binary_availability_reason(install_key: str) -> HarnessAvailability:
"""Return the readiness reason when a CLI-backed harness can't be used.
Distinguishes a genuinely missing CLI from one that is on ``PATH`` but
outside the version range the native harness requires. The latter is
exposed to the web UI as ``"version-too-low"`` so the user sees a prompt
to upgrade rather than "binary-missing".
"""
if harness_cli_installed(install_key):
return True
spec = harness_install_spec(install_key)
if spec is not None and resolve_cli_binary(spec.binary) is not None:
return HARNESS_VERSION_TOO_LOW
return HARNESS_BINARY_MISSING
def _cli_family_availability(canonical: str, install_key: str) -> HarnessAvailability:
"""Two-step availability for a login-command CLI harness.
:returns: ``"binary-missing"`` when the CLI isn't installed,
``"version-too-low"`` when the CLI is present but too old,
``"needs-auth"`` when installed but neither a configured provider
credential nor a CLI login is present, else ``True``.
"""
if not harness_cli_installed(install_key):
return "binary-missing"
binary_state = _binary_availability_reason(install_key)
if binary_state is not True:
return binary_state
if install_key == OPENCODE_KEY:
from omnigent.onboarding.opencode_auth import opencode_auth_summary
@@ -385,16 +412,46 @@ def _harness_availability(canonical: str) -> HarnessAvailability:
return _codex_auth_unavailable_reason() or True
install_key = _AUTH_AWARE_NATIVE_HARNESSES.get(canonical)
if install_key is not None:
# Cursor is auth-aware like the other native CLI harnesses, so a missing
# binary surfaces as the structured ``"binary-missing"`` reason — not the
# bare ``False`` it historically reported. That keeps the picker badge /
# warning copy uniform across every CLI-backed native harness.
return _cli_family_availability(canonical, install_key)
if canonical in _PI_HARNESSES:
# pi has no CLI login — its only credential is an omnigent-managed
# provider (an API key / gateway, incl. one set from the UI). So the
# two-step signal is binary + provider: installed-but-no-provider is
# the yellow "needs-auth" state the setup dialog acts on.
if not harness_cli_installed(PI_KEY):
return "binary-missing"
binary_state = _binary_availability_reason(PI_KEY)
if binary_state is not True:
return binary_state
return True if _family_provider_configured(PI_SURFACE) else "needs-auth"
return harness_is_configured(canonical)
return _harness_availability_core(canonical)
def harness_is_configured(harness: str) -> bool:
"""Return whether *harness* can be launched on this machine.
Only CLI-wrapping harnesses are assessed (native Claude/Codex/Kiro and
``pi`` / ``pi-native``): they cannot run without their binary on
``PATH``, and that is the one thing the daemon can check reliably and
locally. SDK harnesses and unknown harnesses always return ``True`` —
their readiness depends on runtime/ambient credentials the daemon
can't enumerate, so blocking them would risk false negatives that
break working launches.
The check is binary-only: an installed-but-not-logged-in CLI still
returns ``True`` because auth failures surface at run time rather than
blocking dispatch.
:param harness: A harness id, e.g. ``"claude-native"``, ``"codex"``,
``"openai-agents"``, ``"agents_sdk"``, ``"kiro-native"``, ``"pi"``,
``"pi-native"``, ``"qwen"``, or ``"qwen-code"``.
:returns: ``True`` when launchable (CLI installed, or a harness the
daemon doesn't gate); ``False`` when the binary is missing or on
an unsupported version.
"""
return _harness_availability_core(harness) is True
def _is_codex_family_harness(canonical: str) -> bool:
+3 -2
View File
@@ -1686,8 +1686,9 @@ async def _execute_subagent_tool(
return (
f"Error: sub-agent {sub_agent_name!r} can't start on this "
f"machine: harness {child_harness!r} needs the "
f"{missing_cli.binary!r} CLI on PATH, which was not found. "
f"Install it with: {install} "
f"{missing_cli.binary!r} CLI on PATH and on a supported "
f"version, but it is missing or outdated. "
f"Install/upgrade it with: {install} "
f"(or don't dispatch to {sub_agent_name!r} here)."
)
# Create child session on the server (no initial items —
+1
View File
@@ -1954,6 +1954,7 @@ def test_overview_status_color_distinguishes_missing_from_unconfigured(
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed", lambda family: False
)
monkeypatch.setattr("omnigent._platform.resolve_cli_binary", lambda _name: None)
options, selectable, _descriptions, _compact, _max_visible = _capture_setup_overview(
monkeypatch
)
@@ -24,7 +24,7 @@ def _fulfill_hosts(route: Route) -> None:
"name": _HOST_NAME,
"owner": "e2e",
"status": "online",
"configured_harnesses": {"cursor-native": False},
"configured_harnesses": {"cursor-native": "binary-missing"},
}
]
}
@@ -85,10 +85,13 @@ def test_cursor_missing_cli_shows_install_and_login_guidance(
warning = page.get_by_test_id("new-chat-landing-harness-warning")
expect(warning).to_be_visible(timeout=30_000)
expect(warning).to_contain_text(f"Cursor needs cursor-agent on {_HOST_NAME}")
expect(warning).to_contain_text("curl https://cursor.com/install -fsS | bash")
expect(warning).to_contain_text("cursor-agent login")
expect(warning.locator("code")).to_have_count(2)
# A missing cursor-agent binary now reports the uniform "binary-missing"
# reason — the same structured signal as Codex / Claude / OpenCode — so the
# feature-OFF warning is the generic "run omni setup" guidance, not the
# Cursor-specific curl instructions (those live in the setup dialog when the
# install feature is ON, and in `omni cursor` on the CLI).
expect(warning).to_contain_text(f"Cursor isn't configured on {_HOST_NAME}")
expect(warning).to_contain_text("omni setup")
# The guidance is visible before launch and remains warning-only.
composer.fill("help me inspect this repository")
@@ -98,9 +101,8 @@ def test_cursor_missing_cli_shows_install_and_login_guidance(
page.get_by_test_id("new-chat-landing-agent-select").click()
badge = page.get_by_test_id(f"new-chat-landing-agent-warning-{_AGENT_ID}")
expect(badge).to_be_visible()
# This test doesn't enable OMNIGENT_HARNESS_INSTALL_ENABLED, so the picker
# runs on the feature-OFF default — where the badge keeps the original
# per-reason text ("install & login" for a missing cursor-agent CLI). (With
# the feature ON the badge collapses to a single "needs setup" and the steps
# move into the setup dialog.)
expect(badge).to_have_text("install & login")
# Feature-OFF picker keeps the per-reason badge text; with the structured
# "binary-missing" reason that is now "binary missing" — the same label
# Codex / Claude / OpenCode show for a missing CLI, instead of the legacy
# bespoke "install & login".
expect(badge).to_have_text("binary missing")
@@ -0,0 +1,132 @@
"""E2E: version-too-low warning in the New Chat landing screen.
The composer warns when a native harness is installed but its CLI version is
below the supported minimum (``"version-too-low"``). This test verifies the
NewChatDialog renders the updated copy: an under-composer message mentioning
"outdated CLI".
"""
from __future__ import annotations
import asyncio
import json
import re
import threading
from collections.abc import Coroutine
from typing import Any
from playwright.async_api import Route, async_playwright, expect
_HOST_ID = "host_version_e2e"
_HOST_NAME = "version-e2e-host"
def _run_in_fresh_loop(coro: Coroutine[Any, Any, None]) -> None:
captured: dict[str, Exception] = {}
def _worker() -> None:
try:
asyncio.run(coro)
except Exception as exc:
captured["error"] = exc
thread = threading.Thread(target=_worker)
thread.start()
thread.join()
if "error" in captured:
raise captured["error"]
def _codex_native_agents_body() -> str:
return json.dumps(
{
"data": [
{
"id": "ag_codex_version_e2e",
"name": "codex-native-ui",
"display_name": "Codex",
"description": "OpenAI's coding agent",
"harness": "codex-native",
"skills": [],
}
]
}
)
async def _register_routes(page, *, configured_harnesses: dict[str, Any]) -> None:
async def handle_hosts(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"hosts": [
{
"host_id": _HOST_ID,
"name": _HOST_NAME,
"owner": "e2e",
"status": "online",
"configured_harnesses": configured_harnesses,
}
]
}
),
)
async def handle_agents(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=_codex_native_agents_body(),
)
async def handle_agent_scan(route: Route) -> None:
await route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"data": []}),
)
await page.route("**/v1/hosts", handle_hosts)
await page.route("**/v1/agents", handle_agents)
await page.route(re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
def test_version_too_low_warns_with_outdated_cli_copy(
seeded_session: tuple[str, str],
) -> None:
"""A version-too-low Codex host renders the outdated CLI warning."""
base_url, session_id = seeded_session
del session_id
_run_in_fresh_loop(_drive_version_too_low(base_url))
async def _drive_version_too_low(base_url: str) -> None:
async with async_playwright() as pw:
browser = await pw.chromium.launch()
page = await browser.new_page()
try:
await _register_routes(
page,
configured_harnesses={"codex-native": "version-too-low"},
)
await page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID!r}: ["/work/repo"] }})
);"""
)
await page.goto(f"{base_url}/")
await page.get_by_test_id("new-chat-landing-input").wait_for(
state="visible", timeout=30_000
)
warning = page.get_by_test_id("new-chat-landing-harness-warning")
await expect(warning).to_be_visible(timeout=30_000)
await expect(warning).to_contain_text("has an outdated CLI")
await expect(warning).to_contain_text(_HOST_NAME)
await expect(warning).to_contain_text("omni setup")
finally:
await browser.close()
+243 -5
View File
@@ -24,9 +24,23 @@ def _stub_cli_fallback_dirs(monkeypatch: pytest.MonkeyPatch) -> None:
binary's presence/absence; stub the fallback dirs to empty too so a
developer's real claude/codex install can't flip a ``which``-returns-None
assertion.
Also stub ``--version`` probes so tests that simply need "binary present"
are not tripped up by an unexpected subprocess call once a harness spec
declares a version floor. Tests that care about the version can override
the stub explicitly.
"""
monkeypatch.setattr(_platform, "_cli_fallback_dirs", lambda: ())
def _stub_version_run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
return subprocess.CompletedProcess(
args=argv, returncode=0, stdout="9.9.9\n", stderr=""
)
raise AssertionError(f"unexpected subprocess in harness_install tests: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _stub_version_run)
@pytest.mark.parametrize(
"key,binary,package",
@@ -457,11 +471,14 @@ def test_try_install_harness_cli_success_when_binary_off_path(
# npm is on PATH; the installed codex binary never is — only the ladder finds it.
monkeypatch.setattr(hi.shutil, "which", lambda name: "/usr/bin/npm" if name == "npm" else None)
monkeypatch.setattr(_platform, "_cli_fallback_dirs", lambda: (fallback_dir,))
monkeypatch.setattr(
hi.subprocess,
"run",
lambda argv, **k: subprocess.CompletedProcess(args=argv, returncode=0),
)
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
out = "9.9.9\n"
return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="")
return subprocess.CompletedProcess(args=argv, returncode=0)
monkeypatch.setattr(hi.subprocess, "run", _run)
# Install verdict agrees with readiness: both see it installed.
assert hi.try_install_harness_cli(OPENAI_FAMILY) == (True, None)
@@ -1026,3 +1043,224 @@ def test_ui_setup_steps_generic_for_non_installable() -> None:
assert steps[0].action == "setup"
assert steps[0].command == "omni setup"
assert steps[0].status_key is None
# ── Version-aware installed check ────────────────────────
@pytest.mark.parametrize(
"key,min_version,max_version_exclusive",
[
(hi.OPENCODE_KEY, "1.17.7", "1.18.0"),
(hi.CURSOR_KEY, "2026.06.02", None),
(hi.KIMI_KEY, "1.47.0", None),
(ANTHROPIC_FAMILY, "2.1.161", None),
(OPENAI_FAMILY, "0.137.0", None),
(hi.PI_KEY, "0.79.0", None),
(hi.QWEN_KEY, "0.18.1", None),
(hi.GOOSE_KEY, "1.38.0", None),
(hi.HERMES_KEY, "2026.06.05", None),
(hi.KIRO_KEY, "2.10.0", None),
],
)
def test_versioned_specs_declare_bounds(
key: str, min_version: str, max_version_exclusive: str | None
) -> None:
"""Version-bounded harness specs expose the same floors setup enforces."""
spec = hi.harness_install_spec(key)
assert spec is not None
assert spec.min_version == min_version
assert spec.max_version_exclusive == max_version_exclusive
@pytest.mark.parametrize(
"version,expected",
[
("1.17.6", False), # below min
("1.18.0", False), # at max exclusive
("2.0.0", False), # above max
("1.17.8", True), # inside range
],
)
def test_harness_cli_installed_checks_version_for_versioned_specs(
monkeypatch: pytest.MonkeyPatch, version: str, expected: bool
) -> None:
"""A present CLI whose ``--version`` is outside the declared range reads as
not installed, so setup prompts for an upgrade before the runtime gate."""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
# OpenCode's supported range is [1.17.7, 1.18.0).
return subprocess.CompletedProcess(
args=argv, returncode=0, stdout=f"{version}\n", stderr=""
)
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run)
assert hi.harness_cli_installed(hi.OPENCODE_KEY) is expected
@pytest.mark.parametrize(
"key",
[
hi.CURSOR_KEY,
hi.KIMI_KEY,
ANTHROPIC_FAMILY,
OPENAI_FAMILY,
hi.PI_KEY,
hi.QWEN_KEY,
hi.GOOSE_KEY,
hi.HERMES_KEY,
hi.KIRO_KEY,
],
)
def test_harness_cli_installed_checks_minimum_for_other_versioned_specs(
monkeypatch: pytest.MonkeyPatch, key: str
) -> None:
"""Version-bounded harnesses treat a CLI older than their declared floor as
not installed, so setup prompts for an upgrade."""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
out = "0.0.1\n"
return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="")
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run)
assert hi.harness_cli_installed(key) is False
def test_harness_cli_installed_true_when_version_in_range(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A present CLI with a satisfying version reads as installed."""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
out = "1.17.8\n"
return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="")
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run)
assert hi.harness_cli_installed(hi.OPENCODE_KEY) is True
def test_harness_cli_installed_ignores_upper_bound_for_unversioned_specs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Harnesses without a version declaration are not probed with ``--version``."""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _explode(*a: object, **k: object) -> None:
raise AssertionError("version probe spawned for an unversioned harness")
monkeypatch.setattr(hi.subprocess, "run", _explode)
assert hi.harness_cli_installed(GEMINI_FAMILY) is True
@pytest.mark.parametrize(
"raw,expected",
[
("cursor-agent 2026.07.01-777f564", "2026.07.01"),
("2026.06.19-20-24-33-653a7fb", "2026.06.19"),
("2026.05.24.1.dda726e", "2026.05.24"),
("kimi version 1.47.0", "1.47.0"),
("1.17.7-rc1", "1.17.7-rc1"),
],
)
def test_parse_harness_cli_version_normalizes_date_versions(raw: str, expected: str) -> None:
"""Date-shaped Cursor versions are stripped to ``YYYY.MM.DD`` so PEP 440 can
compare them; normal semver versions stay unchanged."""
assert hi._parse_harness_cli_version(raw) == expected
@pytest.mark.parametrize(
"key,outdated,satisfying",
[
(hi.CURSOR_KEY, "2026.05.24", "2026.06.22"),
(hi.KIMI_KEY, "1.46.0", "1.48.0"),
(hi.HERMES_KEY, "2026.05.29", "2026.06.19"),
],
)
def test_harness_cli_installed_enforces_default_post_2026_06_01_floors(
monkeypatch: pytest.MonkeyPatch,
key: str,
outdated: str,
satisfying: str,
) -> None:
"""Cursor and Kimi default to the first release after 2026-06-01."""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
return subprocess.CompletedProcess(
args=argv, returncode=0, stdout=f"{outdated}\n", stderr=""
)
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run)
assert hi.harness_cli_installed(key) is False
def _run_ok(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
return subprocess.CompletedProcess(
args=argv, returncode=0, stdout=f"{satisfying}\n", stderr=""
)
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run_ok)
assert hi.harness_cli_installed(key) is True
def test_harness_cli_installed_false_when_version_unparseable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A present CLI whose ``--version`` output contains no parseable version is
treated as not installed, so setup prompts for an upgrade."""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
return subprocess.CompletedProcess(
args=argv, returncode=0, stdout="dev-SNAPSHOT\n", stderr=""
)
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run)
assert hi.harness_cli_installed(hi.OPENCODE_KEY) is False
def test_harness_cli_version_satisfies_short_circuits_when_binary_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``harness_cli_version_satisfies`` returns False when the binary is absent
without shelling out to a missing executable."""
monkeypatch.setattr(hi.shutil, "which", lambda name: None)
def _explode(*a: object, **k: object) -> None:
raise AssertionError("version probe spawned despite missing binary")
monkeypatch.setattr(hi.subprocess, "run", _explode)
assert hi.harness_cli_version_satisfies(hi.OPENCODE_KEY) is False
def test_missing_harness_cli_flags_outdated_version(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A CLI present but outside its declared version range is treated as
missing by the dispatch preflight, so the runner fails loud before launch."""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
def _run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
out = "1.16.0\n"
return subprocess.CompletedProcess(args=argv, returncode=0, stdout=out, stderr="")
raise AssertionError(f"unexpected subprocess: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _run)
spec = hi.missing_harness_cli("opencode-native")
assert spec is not None
assert spec.binary == "opencode"
+80 -7
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
import yaml
import omnigent.onboarding.harness_install as hi
from omnigent.harness_availability import HARNESS_VERSION_TOO_LOW
from omnigent.onboarding.harness_readiness import (
configured_harness_map,
harness_is_configured,
@@ -48,6 +50,29 @@ def _all_clis_installed(monkeypatch: pytest.MonkeyPatch) -> None:
# shutil.which (reverted by monkeypatch after the test).
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
# Some harnesses (OpenCode) now validate the CLI's ``--version``. Stub a
# satisfying version so tests that simply need "binary present" are not
# tripped up by an unexpected subprocess probe.
def _stub_run(argv: list[str], **k: object) -> subprocess.CompletedProcess[str]:
if len(argv) >= 2 and argv[1] == "--version":
# OpenCode's declared range is [1.17.7, 1.18.0); Cursor uses calendar
# versions and needs a build after 2026-06-01; everything else is
# fine with a generous semver placeholder.
if argv[0].endswith("opencode"):
version = "1.17.7\n"
elif argv[0].endswith("cursor-agent") or argv[0].endswith("hermes"):
version = "2026.07.01\n"
else:
version = "9.9.9\n"
return subprocess.CompletedProcess(args=argv, returncode=0, stdout=version, stderr="")
raise AssertionError(f"unexpected subprocess during readiness tests: {argv!r}")
monkeypatch.setattr(hi.subprocess, "run", _stub_run)
# Auth-aware native harnesses (now including Cursor native) check login state
# in the picker map. Treat them as logged in when the test just needs
# "binary present".
monkeypatch.setattr(hi, "harness_cli_logged_in", lambda _key: True)
def _no_clis_installed(monkeypatch: pytest.MonkeyPatch) -> None:
"""Make every harness CLI binary appear missing.
@@ -352,8 +377,6 @@ def test_configured_harness_map_gates_only_cli_harnesses(
# binary it reads False before its credential check is even reached.
for cli in (
"kimi",
"cursor-native",
"native-cursor",
"kiro-native",
"native-kiro",
"antigravity-native",
@@ -363,11 +386,13 @@ def test_configured_harness_map_gates_only_cli_harnesses(
"qwen",
"hermes",
):
assert result[cli] is False, f"{cli} should be gated on its CLI binary"
# Auth-aware harnesses (codex, claude, opencode, pi) carry a two-step signal
# in the picker map, so a missing binary is the structured ``"binary-missing"``
# (step 1 to-do), not a bare ``False``. Pi joined this group — it now reports
# the credential axis (no CLI login; its credential is a provider).
assert result[cli] is not True, f"{cli} should be gated on its CLI binary"
# Auth-aware harnesses (codex, claude, opencode, cursor, pi) carry a
# two-step signal in the picker map, so a missing binary is the structured
# ``"binary-missing"`` (step 1 to-do), not a bare ``False``. Cursor joined
# this group — it is now auth-aware like the other native CLI harnesses, so
# its missing binary surfaces as ``"binary-missing"`` too. Pi is also here —
# it reports the credential axis (no CLI login; its credential is a provider).
for missing in (
"codex",
"codex-native",
@@ -375,6 +400,8 @@ def test_configured_harness_map_gates_only_cli_harnesses(
"claude-native",
"native-claude",
"opencode-native",
"cursor-native",
"native-cursor",
"pi",
"pi-native",
):
@@ -512,3 +539,49 @@ def test_native_cursor_keys_off_binary_not_api_key(
monkeypatch.delenv("CURSOR_API_KEY", raising=False)
assert harness_is_configured("cursor-native") is True
assert harness_is_configured("native-cursor") is True
def test_configured_harness_map_reports_version_too_low_for_outdated_clis(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An outdated CLI for major native harnesses is flagged ``version-too-low``.
This exercises the readiness-layer wiring, which is where the binary is
on ``PATH`` but does not satisfy the declared ``min_version`` of the spec.
The core promise of the feature is that users see an upgrade prompt instead
of being told the binary is missing.
"""
monkeypatch.setattr(hi.shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(hi, "harness_cli_installed", lambda _key: False)
monkeypatch.setattr(hi, "harness_cli_logged_in", lambda _key: True)
result = configured_harness_map()
for harness in (
"claude-native",
"native-claude",
"opencode-native",
"native-opencode",
"cursor-native",
"native-cursor",
"kiro-native",
"native-kiro",
):
assert result[harness] == HARNESS_VERSION_TOO_LOW, (
f"{harness} should report version-too-low, not binary-missing"
)
def test_antigravity_native_requires_credential(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``antigravity-native`` needs both the ``agy`` binary and a stored credential."""
import omnigent.onboarding.gemini_auth as _ga
_all_clis_installed(monkeypatch)
# Binary installed but no credential → not ready.
monkeypatch.setattr(_ga, "gemini_login_detected", lambda: False)
assert harness_is_configured("antigravity-native") is False
assert harness_is_configured("native-antigravity") is False
# Stored credential present → ready.
monkeypatch.setattr(_ga, "gemini_login_detected", lambda: True)
assert harness_is_configured("antigravity-native") is True
assert harness_is_configured("native-antigravity") is True
+8
View File
@@ -64,6 +64,14 @@ def _point_codex_auth_check_at(
"_find_codex_cli",
lambda: "/tmp/codex" if binary_present else None,
)
if binary_present:
# The auth check now also validates the installed version. Treat a
# present binary as satisfying the version check so the tests focus
# on the auth-path decision.
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda _key: True,
)
def test_codex_auth_unavailable_reason_binary_missing(
+19 -3
View File
@@ -103,14 +103,22 @@ describe("harnessUnavailableReasonOnHost", () => {
"needs-auth",
);
expect(
harnessUnavailableReasonOnHost("cursor-native", hostWith({ "cursor-native": false })),
).toBe("cursor-cli-missing");
harnessUnavailableReasonOnHost(
"cursor-native",
hostWith({ "cursor-native": "binary-missing" }),
),
).toBe("binary-missing");
expect(harnessUnavailableReasonOnHost("pi", hostWith({ pi: false }))).toBe("unconfigured");
expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "version-too-low" }))).toBe(
"version-too-low",
);
});
it("returns null when ready, unknown, or no host", () => {
expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: true }))).toBe(null);
expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "future" }))).toBe(null);
expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "future" }))).toBe(
"unconfigured",
);
expect(harnessUnavailableReasonOnHost("codex", hostWith(null))).toBe(null);
expect(harnessUnavailableReasonOnHost(null, hostWith({ codex: false }))).toBe(null);
});
@@ -225,6 +233,14 @@ describe("resolveSetupSteps", () => {
expect(steps.map((s) => s.status)).toEqual(["done", "todo"]);
});
it("marks install todo + auth todo when the binary is present but too old", () => {
const steps = resolveSetupSteps(CODEX_STEPS, "codex", hostWith({ codex: "version-too-low" }));
expect(steps.map((s) => [s.kind, s.status])).toEqual([
["install", "todo"],
["auth", "todo"],
]);
});
it("marks both done when the harness is ready", () => {
const steps = resolveSetupSteps(CODEX_STEPS, "codex", hostWith({ codex: true }));
expect(steps.map((s) => s.status)).toEqual(["done", "done"]);
+18 -7
View File
@@ -43,7 +43,7 @@ export function isCodexHarness(harness: string): boolean {
return harness === "codex" || harness === "codex-native" || harness === "native-codex";
}
function isNativeCursorHarness(harness: string): boolean {
export function isNativeCursorHarness(harness: string): boolean {
return harness === "cursor-native" || harness === "native-cursor";
}
@@ -61,15 +61,23 @@ export function harnessUnavailableReasonOnHost(
const availability = host.configured_harnesses[harness];
if (availability === false) {
if (isCodexHarness(harness)) return "binary-missing";
if (isNativeCursorHarness(harness)) return "cursor-cli-missing";
return "unconfigured";
}
// Auth-aware CLI harnesses (codex, claude, opencode) report a structured
// string when installed-but-not-ready.
if (availability === "binary-missing" || availability === "needs-auth") {
// string when installed-but-not-ready. "version-too-low" can surface for
// any CLI-backed harness whose binary is present but too old.
if (
availability === "binary-missing" ||
availability === "needs-auth" ||
availability === "version-too-low"
) {
return availability;
}
// Unknown future reason strings fall through to no warning until the UI knows their copy.
// Any other string from a newer/older server still means "not ready";
// show a generic warning rather than silently treating it as available.
if (typeof availability === "string") {
return "unconfigured";
}
return null;
}
@@ -97,7 +105,7 @@ export function harnessWarningBadgeText(reason: string | null, collapsed = false
if (collapsed) return "needs setup";
if (reason === "binary-missing") return "binary missing";
if (reason === "needs-auth") return "needs auth";
if (reason === "cursor-cli-missing") return "install & login";
if (reason === "version-too-low") return "outdated";
return "needs setup";
}
@@ -195,7 +203,10 @@ function stepStatus(
availability: boolean | string | undefined,
): SetupStepStatus {
if (statusKey === null || availability === undefined) return "unknown";
const notInstalled = availability === false || availability === "binary-missing";
const notInstalled =
availability === false ||
availability === "binary-missing" ||
availability === "version-too-low";
if (statusKey === "installed") return notInstalled ? "todo" : "done";
if (statusKey === "authed") return availability === true ? "done" : "todo";
return "unknown";
+2 -2
View File
@@ -499,9 +499,9 @@ describe("harnessUnconfiguredOnHost", () => {
expect(harnessUnavailableReasonOnHost("codex-native", testHost)).toBe("binary-missing");
});
it("ignores unknown future reason strings", () => {
it("falls back to a generic warning for unknown reason strings", () => {
expect(harnessUnavailableReasonOnHost("codex", hostWith({ codex: "future-reason" }))).toBe(
null,
"unconfigured",
);
});
+15 -13
View File
@@ -76,6 +76,7 @@ import {
harnessUnconfiguredOnHost,
harnessWarningBadgeText,
isCodexHarness,
isNativeCursorHarness,
} from "@/lib/harnessSetup";
// Re-exported for tests that import the readiness helpers from this module.
@@ -549,15 +550,6 @@ function harnessWarningMessage(
reason: string | null,
harness: string | null | undefined,
): ReactNode {
if (reason === "cursor-cli-missing") {
return (
<>
{agentName} needs cursor-agent on {hostName} install it with{" "}
<code>curl https://cursor.com/install -fsS | bash</code>, then run{" "}
<code>cursor-agent login</code>.
</>
);
}
const isCodex = !!harness && isCodexHarness(harness);
if (reason === "needs-auth" && isCodex) {
return (
@@ -567,12 +559,22 @@ function harnessWarningMessage(
</>
);
}
if (reason === "binary-missing" && isCodex) {
if (reason === "needs-auth" && !!harness && isNativeCursorHarness(harness)) {
return (
<>
{agentName} can&apos;t find the Codex binary on {hostName} if codex is installed, restart
the host with <code>omnigent host</code> so it picks up your PATH, or set{" "}
<code>OMNIGENT_CODEX_PATH</code>. Otherwise run <code>omni setup</code>.
{agentName} needs Cursor login on {hostName} run <code>cursor-agent login</code> on that
machine.
</>
);
}
// ``version-too-low`` is a uniform state across all CLI harnesses now that
// the server checks supported version ranges. Keep the message generic so
// the user is nudged toward setup rather than being told the CLI is missing.
if (reason === "version-too-low") {
return (
<>
{agentName} has an outdated CLI on {hostName} run <code>omni setup</code>, or upgrade the
CLI directly on that machine.
</>
);
}