Skip to content
Open
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
118 changes: 118 additions & 0 deletions omnigent/_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,88 @@ 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."""
import ctypes

kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
kernel32.SetConsoleOutputCP(_WINDOWS_UTF8_CP)


def configure_cli_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()
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 Expand Up @@ -304,3 +386,39 @@ def resolve_repo_symlink(path: Path) -> Path:
if candidate.exists():
return candidate.resolve()
return path


def static_api_key_print_command(api_key: str) -> str:
"""Return a shell command that prints *api_key* with no trailing newline.

Used as Claude/Codex ``apiKeyHelper`` / gateway auth commands when the
credential is a static secret. POSIX hosts use ``printf %s`` (no newline,
unlike ``echo``). Native Windows ``cmd.exe`` has no ``printf``, so a bare
``printf`` helper fails and Claude Code silently falls back to
``~/.claude/settings.json`` tokens — which produced false "rate_limit 429"
errors against leftover coding-plan quotas. On Windows we therefore emit a
``sys.executable -c …`` command with a base64 payload (shell-safe argv) so
both ``cmd.exe`` and Git Bash print the exact key bytes.

:param api_key: The bearer / API key to print, e.g. ``"sk-..."``.
:returns: A shell command string suitable for ``apiKeyHelper``.
"""
import base64
import shlex

if not IS_WINDOWS:
return f"printf %s {shlex.quote(api_key)}"
payload = base64.b64encode(api_key.encode("utf-8")).decode("ascii")
# Forward slashes: accepted by cmd.exe and safe under Git Bash (where
# ``D:\code`` would otherwise treat ``\c`` as an escape).
# Normalize explicitly instead of relying on Path.as_posix(): Linux CI
# exercises this branch with a mocked Windows path, which pathlib treats
# as an opaque POSIX filename and therefore leaves backslashes unchanged.
exe_path = str(sys.executable).replace("\\", "/")
exe = f'"{exe_path}"'
# argv[1] is base64 (safe charset); -c string stays ASCII-only.
return (
f"{exe} -c "
f'"import base64,sys;sys.stdout.buffer.write(base64.b64decode(sys.argv[1]))" '
f"{payload}"
)
6 changes: 3 additions & 3 deletions omnigent/claude_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import os
import re
import secrets
import shlex
import shutil
import signal
import subprocess
Expand Down Expand Up @@ -47,6 +46,7 @@
from websockets.frames import Close

from omnigent._native_resume_hint import echo_native_resume_hint
from omnigent._platform import static_api_key_print_command
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
from omnigent._startup_profile import StartupProfiler
from omnigent._terminal_picker_theme import (
Expand Down Expand Up @@ -1723,13 +1723,13 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
)
return None
# Token delivery mirrors the claude-sdk executor: a dynamic auth_command
# is used verbatim; a static key becomes a ``printf`` apiKeyHelper (the
# is used verbatim; a static key becomes a platform-safe apiKeyHelper (the
# runner env allowlist excludes ANTHROPIC_API_KEY, so the key must reach
# Claude Code via the helper, not the environment).
if family.auth_command:
api_key_helper = family.auth_command
elif family.api_key:
api_key_helper = f"printf %s {shlex.quote(family.api_key)}"
api_key_helper = static_api_key_print_command(family.api_key)
else:
_logger.warning(
"native-claude: provider %r is the Claude default but has no usable "
Expand Down
10 changes: 9 additions & 1 deletion 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_cli_stdio, resolve_repo_symlink
from omnigent.cli_common import (
RESUME_PICKER_SENTINEL as _RESUME_PICKER_SENTINEL,
)
Expand Down Expand Up @@ -1596,6 +1596,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_cli_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 @@ -2690,6 +2695,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
3 changes: 2 additions & 1 deletion omnigent/codex_native_app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
if TYPE_CHECKING:
from omnigent.onboarding.provider_config import ProviderEntry

from omnigent._platform import static_api_key_print_command
from omnigent.codex_native_bridge import write_policy_hook_config
from omnigent.codex_native_process_registry import (
CodexNativeProcessOwnerLock,
Expand Down Expand Up @@ -1369,7 +1370,7 @@ def _codex_provider_launch(entry: ProviderEntry, model: str | None) -> NativeCod
if family.auth_command:
auth_command = family.auth_command
elif family.api_key:
auth_command = f"printf %s {shlex.quote(family.api_key)}"
auth_command = static_api_key_print_command(family.api_key)
else:
# Serves openai but carries no usable credential.
return None
Expand Down
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_cli_stdio

configure_cli_stdio()

parser = argparse.ArgumentParser(
description="Background host daemon",
)
Expand Down
55 changes: 31 additions & 24 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 @@ -178,6 +178,11 @@ def _display_log_path(path: Path) -> str:
_ORPHAN_REAP_INTERVAL_S = 2.0


def _orphan_reaping_supported() -> bool:
"""Return whether this platform exposes the POSIX child-wait primitives."""
return hasattr(os, "waitpid") and hasattr(os, "WNOHANG")


def _install_child_subreaper() -> bool:
"""Make this process reap orphaned descendants (Linux only).

Expand Down Expand Up @@ -796,6 +801,8 @@ def _reap_orphans_once(self) -> int:

:returns: Count of orphan (non-runner) processes reaped this sweep.
"""
if not _orphan_reaping_supported():
return 0
if self._owned_subprocess_ops > 0:
# A host-owned subprocess (e.g. a git worktree command) is running
# in a worker thread. Its child is a DIRECT child of this process
Expand Down Expand Up @@ -1018,11 +1025,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 +1200,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 +1237,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 @@ -1915,11 +1919,12 @@ async def run(self) -> None:
# runner dies (this host is PID 1 in a container, or a subreaper
# otherwise). Without this they pile up as <defunct> zombies and can
# OOM the box on a long-blocked run (#1782).
if _install_child_subreaper():
_logger.debug("installed PR_SET_CHILD_SUBREAPER; host will reap orphans")
self._reaper_task = asyncio.create_task(
self._orphan_reaper_loop(), name="host-orphan-reaper"
)
if _orphan_reaping_supported():
if _install_child_subreaper():
_logger.debug("installed PR_SET_CHILD_SUBREAPER; host will reap orphans")
self._reaper_task = asyncio.create_task(
self._orphan_reaper_loop(), name="host-orphan-reaper"
)
backoff = _RECONNECT_BASE_S
try:
while True:
Expand Down Expand Up @@ -2187,15 +2192,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 +2360,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 +2382,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
Loading
Loading