Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions omnigent/_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,103 @@ def resolve_cli_binary(name: str, *, env_var: str | None = None) -> str | None:
#: True on macOS specifically (the seatbelt sandbox platform).
IS_DARWIN = sys.platform == "darwin"

#: Windows console code page for UTF-8 (``chcp 65001``).
_WINDOWS_UTF8_CP = 65001


def _stream_is_tty(stream: object) -> bool:
"""Return whether *stream* is attached to an interactive terminal."""
try:
return bool(stream.isatty()) # type: ignore[attr-defined]
except (AttributeError, OSError, ValueError):
return False


def _set_windows_console_output_utf8() -> None:
"""Switch the attached Windows console output code page to UTF-8 temporarily."""
import atexit
import ctypes

kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
previous_cp = int(kernel32.GetConsoleOutputCP())
if previous_cp <= 0 or previous_cp == _WINDOWS_UTF8_CP:
return
if kernel32.SetConsoleOutputCP(_WINDOWS_UTF8_CP):
atexit.register(_restore_windows_console_output_cp, previous_cp)


def _restore_windows_console_output_cp(previous_cp: int) -> None:
"""Restore *previous_cp* when Omnigent still owns the UTF-8 console setting."""
import ctypes

with suppress(Exception):
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
if int(kernel32.GetConsoleOutputCP()) == _WINDOWS_UTF8_CP:
kernel32.SetConsoleOutputCP(previous_cp)


def configure_unicode_safe_stdio() -> bool:
"""Make CLI stdout/stderr safe for Unicode on legacy Windows code pages.

Chinese/Japanese Windows consoles often default to GBK (``cp936``). Writing
emoji or ``✓``/``✗`` then raises :exc:`UnicodeEncodeError`, which can crash
``omnigent setup`` / ``config list`` and tear down the host tunnel reconnect
loop. On native Windows this reconfigures terminal streams to UTF-8 and
redirected streams to replacement-safe output without changing their
encoding, and switches the console output code page when possible.

Idempotent and best-effort: failures are swallowed so a weird redirected
stream never blocks CLI startup. No-op on POSIX (including WSL).

:returns: ``True`` when running on native Windows (configuration attempted),
``False`` otherwise.
"""
if not IS_WINDOWS:
return False
has_console_stream = False
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
is_console = _stream_is_tty(stream)
has_console_stream = has_console_stream or is_console
with suppress(Exception):
if is_console:
reconfigure(encoding="utf-8", errors="replace")
else:
reconfigure(errors="replace")
if has_console_stream:
with suppress(Exception):
_set_windows_console_output_utf8()
Comment thread
scwf marked this conversation as resolved.
return True


def safe_console_print(
message: str,
*,
file: object | None = None,
flush: bool = True,
) -> None:
"""Print *message* without letting :exc:`UnicodeEncodeError` escape.

Long-lived loops (the host tunnel) must not reconnect-loop because a
success/failure banner used a glyph the active code page cannot encode.
Falls back to a replacement-encoded line when the primary write fails.

:param message: Text to print (may include emoji / box-drawing glyphs).
:param file: Stream to write to; defaults to :data:`sys.stdout`.
:param flush: Forwarded to :func:`print`.
"""
stream = sys.stdout if file is None else file
try:
print(message, file=stream, flush=flush)
except UnicodeEncodeError:
encoding = getattr(stream, "encoding", None) or "ascii"
fallback = message.encode(encoding, errors="replace").decode(encoding, errors="replace")
with suppress(Exception):
print(fallback, file=stream, flush=flush)


#: Non-sensitive Windows environment variables that a spawned omnigent
#: subprocess needs to function, for env-passthrough allowlists that otherwise
#: assume POSIX names. Python uppercases env keys on Windows, so these match
Expand Down
12 changes: 10 additions & 2 deletions omnigent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from rich.console import Console
from rich.table import Table

from omnigent._platform import IS_WINDOWS, resolve_repo_symlink
from omnigent._platform import IS_WINDOWS, configure_unicode_safe_stdio, resolve_repo_symlink
from omnigent.cli_common import (
RESUME_PICKER_SENTINEL as _RESUME_PICKER_SENTINEL,
)
Expand Down Expand Up @@ -1556,6 +1556,11 @@ def main() -> None:
so unhandled exceptions are captured even when the user didn't
enable ``--log`` or ``--debug-events``.
"""
# Windows GBK consoles raise UnicodeEncodeError on emoji / ✓ / ✗ and can
# tear down setup/config/host. Do this before any user-facing print
# (crash handler, Rich, click). No-op on POSIX.
configure_unicode_safe_stdio()

# Friendly crash handler: replaces Python's raw traceback with a
# calm, branded crash screen + a one-tap path to file a GitHub issue
# (browser opens the repo's pre-filled bug-report template with the
Expand Down Expand Up @@ -2650,6 +2655,9 @@ def _build_host_daemon_env(
for key, value in os.environ.items()
if key in _RUNNER_ENV_ALLOWLIST or key.startswith(daemon_env_prefixes)
}
# The daemon owns this binary log file. Keep raw stdout/stderr aligned with
# the UTF-8 logging handler instead of mixing locale-encoded print output.
env["PYTHONIOENCODING"] = "utf-8:replace"
return env


Expand Down Expand Up @@ -10113,4 +10121,4 @@ def _run_bundled_agent(name: str, run_args: tuple[str, ...]) -> None:


if __name__ == "__main__":
cli()
main()
4 changes: 4 additions & 0 deletions omnigent/host/_daemon_entry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ def main() -> None:
:raises SystemExit: If neither / both of ``--server`` and ``--local``
are provided.
"""
from omnigent._platform import configure_unicode_safe_stdio

configure_unicode_safe_stdio()

parser = argparse.ArgumentParser(
description="Background host daemon",
)
Expand Down
37 changes: 18 additions & 19 deletions omnigent/host/connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import websockets.asyncio.client
from websockets.exceptions import InvalidStatus, InvalidURI

from omnigent._platform import WINDOWS_ENV_PASSTHROUGH
from omnigent._platform import WINDOWS_ENV_PASSTHROUGH, safe_console_print
from omnigent.env_credentials import env_names_with_omnigent_prefix
from omnigent.harness_aliases import canonicalize_harness
from omnigent.harness_availability import HARNESS_BINARY_MISSING, HarnessAvailability
Expand Down Expand Up @@ -1018,11 +1018,10 @@ def _fatal_upgrade_error(self, exc: InvalidURI | InvalidStatus) -> HostConnectEr
# terminal — print once per redirect streak so a foreground
# `omnigent host` shows the auth problem and its fix instead
# of sitting silent while it retries.
print(
safe_console_print(
f"⚠ {cause} Retrying — this also happens briefly while "
f"the server restarts. {self._credentials_fix_hint()}",
file=sys.stderr,
flush=True,
)
return None
return self._classify_http_status(exc.response.status_code)
Expand Down Expand Up @@ -1194,11 +1193,10 @@ async def _handle_launch(
# host's own terminal shows lifecycle lines, but the runner's real
# output — the agent turn, tracebacks — lands only in this file.
session_line = f"\n session: {frame.session_id}" if frame.session_id else ""
print(
safe_console_print(
f" ↑ Runner started: {runner_id} (pid={proc.pid})\n"
f" log: {_display_log_path(log_path)}"
f"{session_line}",
flush=True,
)
return HostLaunchRunnerResultFrame(
request_id=frame.request_id,
Expand Down Expand Up @@ -1232,9 +1230,8 @@ def _handle_stop(
handle.proc.kill()
handle.proc.wait()
_logger.info("Stopped runner %s", frame.runner_id)
print(
safe_console_print(
f" ↓ Runner stopped: {frame.runner_id}",
flush=True,
)
return HostStopRunnerResultFrame(
request_id=frame.request_id,
Expand Down Expand Up @@ -2187,15 +2184,14 @@ async def _serve_frames(self, ws: websockets.asyncio.client.ClientConnection) ->
for runner_id, error in list(self._unreported_exits.items()):
del self._unreported_exits[runner_id]
await self._report_runner_exit(runner_id, error)
# ``print`` (not ``_logger.warning``) so the user always sees the
# success line after the noisy ``databricks.sdk`` warnings —
# otherwise the terminal goes silent after auth and there's no
# signal the WS handshake actually completed.
print(
# User-facing banner (not ``_logger``) so the line is visible after
# noisy ``databricks.sdk`` warnings. ``safe_console_print`` so a
# legacy Windows code page cannot UnicodeEncodeError-out of the
# tunnel loop and trigger reconnect flaps.
safe_console_print(
f"✓ Connected as {self._identity.name!r} "
f"({self._identity.host_id}), {len(hello.runners)} live runner(s). "
"Listening for sessions — Ctrl-C to disconnect.",
flush=True,
)

loop = asyncio.get_running_loop()
Expand Down Expand Up @@ -2356,20 +2352,20 @@ def run_host_process(
path = config_path or CONFIG_PATH
identity = load_or_create_host_identity(path)
if not path.exists():
print(f"Auto-generated {path} ({identity.host_id}, name: {identity.name})")
print(f"Connecting to {server_url} as {identity.name!r} ({identity.host_id})")
safe_console_print(f"Auto-generated {path} ({identity.host_id}, name: {identity.name})")
safe_console_print(f"Connecting to {server_url} as {identity.name!r} ({identity.host_id})")
# Tell the user where logs land up front — `omnigent host` used to run
# silently, so a stuck/quiet host gave no hint where to look. Session
# work goes to per-runner files under the runner dir (the exact
# file is printed when each runner launches). The host process's
# own diagnostics go to the host destination.
print(f"Session logs: {_display_log_path(_runner_log_dir())}/")
print(f"This host's log: {_display_log_path(host_log_path)}")
safe_console_print(f"Session logs: {_display_log_path(_runner_log_dir())}/")
safe_console_print(f"This host's log: {_display_log_path(host_log_path)}")
from omnigent.cli_diagnostics import current_cli_log_path

_cli_log = current_cli_log_path()
if _cli_log is not None and _cli_log != host_log_path:
print(f"CLI diagnostics: {_display_log_path(_cli_log)}")
safe_console_print(f"CLI diagnostics: {_display_log_path(_cli_log)}")

host = HostProcess(identity, server_url)
try:
Expand All @@ -2378,5 +2374,8 @@ def run_host_process(
# Fail loud: a permanent connection failure must not look like the
# process is still working. Print the cause + fix, then exit non-zero
# instead of the old behavior of reconnecting silently forever.
print(f"\n✗ Could not connect to {server_url}.\n{exc}", file=sys.stderr, flush=True)
safe_console_print(
f"\n✗ Could not connect to {server_url}.\n{exc}",
file=sys.stderr,
)
raise SystemExit(1) from exc
13 changes: 13 additions & 0 deletions tests/cli/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,19 @@ def test_ensure_host_daemon_local_inherits_data_dir_and_db_uri(
assert env["OMNIGENT_DATABASE_URI"] == "postgresql://u:pw@h/db"


@pytest.mark.parametrize("server_url", [None, "https://example.databricksapps.com"])
def test_build_host_daemon_env_forces_utf8_stdio(
monkeypatch: pytest.MonkeyPatch,
server_url: str | None,
) -> None:
"""Daemon print output uses the same UTF-8 encoding as its log handler."""
monkeypatch.setenv("PYTHONIOENCODING", "cp936")

env = _build_host_daemon_env(server_url=server_url)

assert env["PYTHONIOENCODING"] == "utf-8:replace"


def test_build_host_daemon_env_local_preserves_server_credentials(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
22 changes: 22 additions & 0 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,28 @@ def test_python_module_entrypoint_uses_unified_click_cli() -> None:
assert "Omnigent quick chat" not in result.stdout


def test_cli_module_entrypoint_configures_stdio() -> None:
"""``python -m omnigent.cli`` must use the hardened console entry point."""
marker = "__omnigent_stdio_configured__"
probe = (
"import runpy,sys;"
"from omnigent import _platform;"
f"_platform.configure_unicode_safe_stdio=lambda:print({marker!r});"
"sys.argv=['python -m omnigent.cli','--help'];"
"runpy.run_module('omnigent.cli',run_name='__main__')"
)

result = subprocess.run(
[sys.executable, "-c", probe],
check=True,
capture_output=True,
text=True,
timeout=20,
)

assert marker in result.stdout.splitlines()


@pytest.mark.parametrize(
("argv", "expected"),
[
Expand Down
66 changes: 66 additions & 0 deletions tests/host/test_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
from __future__ import annotations

import asyncio
import io
import logging
import subprocess
import sys
import time
from pathlib import Path
from unittest.mock import patch
Expand Down Expand Up @@ -481,6 +483,54 @@ def _fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
_cleanup_host(host)


async def test_handle_launch_returns_result_when_banner_is_not_gbk_encodable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Unicode log path cannot interrupt launch after the runner is tracked."""

class _LiveProcess:
pid = 4242
returncode: int | None = None

def poll(self) -> int | None:
return self.returncode

def terminate(self) -> None:
self.returncode = 0

def wait(self, timeout: float | None = None) -> int:
return self.returncode or 0

def kill(self) -> None:
self.returncode = -9

unicode_home = tmp_path / "home-😀"
unicode_home.mkdir()
workspace = tmp_path / "project"
workspace.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: unicode_home))
output = io.TextIOWrapper(io.BytesIO(), encoding="gbk", errors="strict", write_through=True)
monkeypatch.setattr(sys, "stdout", output)
monkeypatch.setattr(
"omnigent.host.connect.subprocess.Popen",
lambda *args, **kwargs: _LiveProcess(),
)
host = _make_host_process()

result = await host._handle_launch(
HostLaunchRunnerFrame(
request_id="req_unicode_log",
binding_token="tok_unicode_log",
workspace=str(workspace),
)
)

assert result.status == "launched"
assert result.runner_id in host._runners
_cleanup_host(host)


class _FakeTunnel:
"""In-memory stand-in for the host's WebSocket tunnel connection.

Expand Down Expand Up @@ -2566,6 +2616,22 @@ async def test_login_redirect_prints_warning_to_terminal(
assert "omnigent login https://app.example.databricks.com" in err


async def test_login_redirect_warning_survives_gbk_stderr(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The warning glyph cannot turn an authentication retry into a disconnect."""
stderr = io.TextIOWrapper(io.BytesIO(), encoding="gbk", errors="strict", write_through=True)
monkeypatch.setattr(sys, "stderr", stderr)
host = _host()

result = host._fatal_upgrade_error(
InvalidURI("https://w/oidc/authorize", "scheme isn't ws or wss")
)

assert result is None
assert host._login_redirect_streak == 1


async def test_fresh_host_fails_loud_after_persistent_login_redirects(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
Loading
Loading