Files
Serena Ruan 42f3d703c4 feat(cli): add omnigent diagnose environment snapshot (#3928)
* feat(cli): add `omnigent diagnose` environment snapshot

Add a read-only `omnigent diagnose` command that prints a small, secret-free
environment snapshot for bug reports: CLI version, OS/Python, and the server's
auth mode. With `--server <url>` (or a resolvable configured/local server) it
reads the server version and real auth mode from the unauthed `GET /v1/info`
endpoint, so version skew between CLI and server is visible — the same reason
the session-info popover shows `server · host`.

The auth mode doubles as the OSS-vs-managed signal: accounts | single_user |
oidc | header, derived from `/v1/info` when the server is reachable and falling
back to the local environment otherwise (tagged by `auth_source_origin` so the
two are never confused). The snapshot carries no secrets — only versions, OS,
and the coarse auth mode.

`omnigent doctor` (install-ledger migration) is left untouched.

Co-authored-by: Isaac

* fix(cli): address diagnose review — redact server_url, e2e test, help caution

Review follow-ups on the `omnigent diagnose` PR:

- Redact userinfo and query/fragment from the reported `server_url` so a
  `--server https://user:pass@host` value can't leak credentials into the
  snapshot (the "safe to paste into an issue" invariant).
- Add CLI-level tests (CliRunner + respx over /v1/info) exercising the command
  wiring and output format end-to-end, alongside the existing unit tests.
- Note in `--help` that `--server` should point only at a trusted server, since
  reaching a managed server may attach stored/ambient credentials to the request
  (same behavior as `session export` / `run --server`).

Auth is intentionally still attached to the /v1/info probe: a managed server
sits behind an auth proxy that 401s an unauthenticated request, so dropping it
would break the OSS-vs-managed signal for exactly the managed case. Attaching
credentials to the request does not put secrets in the output, which is what the
"secret-free" guarantee covers.

Co-authored-by: Isaac

* fix(cli): harden diagnose URL redaction + register in subcommand allowlist

- _redact_url: fix two leaks the review found. Scheme-less inputs with userinfo
  (`user:pass@host:6767`) were returned unchanged because urlsplit reads the
  `user:` as a scheme — now scrubbed. IPv6 literals lost their required `[...]`
  brackets when netloc was rebuilt from hostname/port — now the userinfo is
  dropped off the authority in place, preserving brackets and host casing.
- Add `diagnose` to `_CLICK_SUBCOMMANDS` so `omnigent diagnose` is reachable
  from main() (a registered command missing from the allowlist is rejected as
  removed ad-hoc chat). Fixes test_click_subcommands_allowlist_covers_registered_commands.

Co-authored-by: Isaac

* fix(cli): make diagnose URL redaction leak-proof on malformed/scheme-less input

Follow-up on review: _redact_url used urlsplit, which raises ValueError on a
malformed IPv6 URL (the fallback then returned the raw string, leaking any
user:pass@) and left query/fragment intact on scheme-less inputs. Rewrote it as
pure string surgery — cut at the first ?/#, then drop a user:pass@ prefix from
the authority — so credentials and tokens are stripped uniformly regardless of
URL shape, with no parser that can raise. IPv6 brackets and host casing are
preserved.

Co-authored-by: Isaac
2026-08-03 16:07:23 +08:00

132 lines
5.3 KiB
Python

"""Tests for the read-only ``omnigent diagnose`` snapshot."""
from __future__ import annotations
import pytest
from omnigent import diagnostics
from omnigent.diagnostics import collect_snapshot
from omnigent.version import VERSION
def test_snapshot_local_only_has_no_server_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
snap = collect_snapshot(server_url=None)
assert snap["cli_version"] == VERSION
assert snap["server_url"] is None
assert snap["server_version"] is None # can't know a server's version without asking
assert snap["auth_source_origin"] == "local-env"
assert snap["auth_source"] # env-derived, always present
assert snap["os"]
assert snap["python"]
def test_snapshot_reports_only_known_keys() -> None:
snap = collect_snapshot(server_url=None)
assert set(snap) == {
"cli_version",
"server_version",
"server_url",
"auth_source",
"auth_source_origin",
"os",
"python",
}
def test_local_auth_source_reflects_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "OIDC")
snap = collect_snapshot(server_url=None)
assert snap["auth_source"] == "oidc"
assert snap["auth_source_origin"] == "local-env"
def test_server_info_populates_version_and_auth(monkeypatch: pytest.MonkeyPatch) -> None:
# A reachable server supplies BOTH version and the real auth mode.
monkeypatch.setattr(
diagnostics,
"_fetch_server_info",
lambda url, *, timeout: {"server_version": "9.9.9", "accounts_enabled": True},
)
snap = collect_snapshot(server_url="http://example:6767")
assert snap["server_version"] == "9.9.9"
assert snap["server_url"] == "http://example:6767"
assert snap["auth_source"] == "accounts"
assert snap["auth_source_origin"] == "server"
@pytest.mark.parametrize(
("info", "expected"),
[
({"accounts_enabled": True}, "accounts"),
# single_user must win over the header fallback: a local one-user server
# and a header-auth deploy both have accounts_enabled=false / no login_url.
({"accounts_enabled": False, "single_user": True}, "single_user"),
({"accounts_enabled": False, "single_user": False, "login_url": "https://idp"}, "oidc"),
({"accounts_enabled": False, "single_user": False, "login_url": None}, "header"),
],
)
def test_auth_source_derived_from_info(info: dict, expected: str) -> None:
assert diagnostics._auth_source_from_info(info) == expected
def test_unreachable_server_falls_back_to_local(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "header")
monkeypatch.setattr(diagnostics, "_fetch_server_info", lambda url, *, timeout: None)
snap = collect_snapshot(server_url="http://127.0.0.1:6767")
assert snap["server_version"] is None
assert snap["auth_source"] == "header"
assert snap["auth_source_origin"] == "local-env"
def test_fetch_server_info_degrades_on_transport_error(monkeypatch: pytest.MonkeyPatch) -> None:
# A failing HTTP call returns None, never raises.
import httpx
def _boom(*args: object, **kwargs: object) -> object:
raise httpx.ConnectError("nope")
monkeypatch.setattr("httpx.get", _boom)
assert diagnostics._fetch_server_info("http://127.0.0.1:6767", timeout=1.0) is None
def test_snapshot_contains_no_secrets(monkeypatch: pytest.MonkeyPatch) -> None:
# Even with a secret-looking env var set, the snapshot never carries it.
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-should-not-appear")
blob = repr(collect_snapshot(server_url=None))
assert "sk-should-not-appear" not in blob
@pytest.mark.parametrize(
("raw", "expected"),
[
("http://user:pass@host:6767/api", "http://host:6767/api"),
("https://tok@example.com/path?token=abc#frag", "https://example.com/path"),
("http://localhost:6767", "http://localhost:6767"),
("localhost:6767", "localhost:6767"), # bare host, no userinfo; left as-is
# Scheme-less input with userinfo: urlsplit misparses it, but creds must
# still be stripped (the ``user:`` looks like a scheme to urlsplit).
("user:pass@host:6767", "host:6767"),
# IPv6 literals must keep their required [...] brackets.
("http://[::1]:6767/x", "http://[::1]:6767/x"),
("https://u:p@[2001:db8::1]:443/api?t=x", "https://[2001:db8::1]:443/api"),
# Malformed URL (urlsplit would raise) must still drop userinfo, never
# fall back to the raw string.
("http://user:pass@[::1", "http://[::1"),
# Scheme-less inputs must also drop query/fragment, not just userinfo.
("user:pass@host?token=abc#frag", "host"),
("host:6767/p?token=xyz#f", "host:6767/p"),
(None, None),
],
)
def test_redact_url(raw: str | None, expected: str | None) -> None:
assert diagnostics._redact_url(raw) == expected
def test_snapshot_redacts_server_url_userinfo(monkeypatch: pytest.MonkeyPatch) -> None:
# A --server URL with embedded creds must not survive into the snapshot.
monkeypatch.setattr(diagnostics, "_fetch_server_info", lambda url, *, timeout: None)
snap = collect_snapshot(server_url="https://user:secretpw@host:6767/")
assert snap["server_url"] == "https://host:6767/"
assert "secretpw" not in repr(snap)