diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index efe020ddb4..25015ed072 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -70,7 +70,6 @@ class NousPortalAdapter(UpstreamAdapter): state = self._read_state() if state is None: raise RuntimeError("Not logged into Nous Portal. Run `hermes auth add nous` first.") - try: refreshed = resolve_nous_runtime_credentials(force_refresh=force_refresh) except Exception as exc: @@ -78,14 +77,12 @@ class NousPortalAdapter(UpstreamAdapter): _quarantine_nous_oauth_state(state, exc, reason="proxy_refresh_failure") self._save_state(state, quarantine_error=exc, quarantine_reason="proxy_refresh_failure") raise RuntimeError(f"Failed to refresh Nous Portal credentials: {exc}") from exc - runtime_key = refreshed.get("api_key") if not runtime_key: raise RuntimeError( "Nous Portal refresh did not return a usable inference JWT. " "Try `hermes auth add nous` to re-authenticate." ) - # The returned base_url already honors the NOUS_INFERENCE_BASE_URL override (documented # dev/staging hatch); validating it against the prod allowlist would reject a legit # staging URL. So: env override wins, else network-validate the returned URL, else the @@ -95,7 +92,6 @@ class NousPortalAdapter(UpstreamAdapter): or _validate_nous_inference_url_from_network(refreshed.get("base_url")) or DEFAULT_NOUS_INFERENCE_URL ).rstrip("/") - return UpstreamCredential(bearer=runtime_key, base_url=base_url, expires_at=refreshed.get("expires_at")) # auth.json access — kept local so hermes_cli.auth's public surface does not grow. diff --git a/hermes_cli/proxy/adapters/xai.py b/hermes_cli/proxy/adapters/xai.py index fdda759ed7..8cefde9116 100644 --- a/hermes_cli/proxy/adapters/xai.py +++ b/hermes_cli/proxy/adapters/xai.py @@ -52,14 +52,12 @@ class XAIGrokAdapter(UpstreamAdapter): raise RuntimeError( "No xAI OAuth credentials found. Run `hermes auth add xai-oauth --type oauth` first." ) - entry = pool.select() if entry is None: raise RuntimeError( "No available xAI OAuth credentials found. Run " "`hermes auth reset xai-oauth` or re-authenticate with `hermes auth add xai-oauth --type oauth`." ) - self._pool = pool return self._credential_from_entry(entry) @@ -68,12 +66,10 @@ class XAIGrokAdapter(UpstreamAdapter): ) -> Optional[UpstreamCredential]: if status_code not in {401, 429}: return None - with self._lock: pool = self._pool or self._load_pool() if pool is None: return None - # 401: refresh the current key first. 429: never refresh — cooldown the rate-limited # key and rotate. None when the pool has nothing else → the status flows to the client. refreshed = pool.try_refresh_current() if status_code == 401 else None @@ -81,7 +77,6 @@ class XAIGrokAdapter(UpstreamAdapter): refreshed = pool.mark_exhausted_and_rotate(status_code=status_code) if refreshed is None: return None - retry_cred = self._credential_from_entry(refreshed) if retry_cred.bearer == failed_credential.bearer: return None @@ -102,11 +97,9 @@ class XAIGrokAdapter(UpstreamAdapter): "xAI OAuth credential pool entry did not contain an access token. " "Re-authenticate with `hermes auth add xai-oauth --type oauth`." ) - base_url = str( getattr(entry, "runtime_base_url", None) or entry.base_url or DEFAULT_XAI_OAUTH_BASE_URL ).strip().rstrip("/") - return UpstreamCredential( bearer=bearer, base_url=base_url or DEFAULT_XAI_OAUTH_BASE_URL, expires_at=entry.expires_at ) diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py index 24cec73a52..0f510049de 100644 --- a/hermes_cli/proxy/cli.py +++ b/hermes_cli/proxy/cli.py @@ -24,22 +24,18 @@ def cmd_proxy_start(args: Any) -> int: if not AIOHTTP_AVAILABLE: _err("hermes proxy requires aiohttp. Run `hermes setup` to install it.") return 1 - provider = getattr(args, "provider", None) or "nous" try: adapter = get_adapter(provider) except ValueError as exc: _err(f"Error: {exc}") return 2 - if not adapter.is_authenticated(): auth_hint = getattr(adapter, "auth_hint", f"hermes auth add {adapter.name}") _err(f"Not logged into {adapter.display_name}. Run `{auth_hint}` first.") return 2 - host = getattr(args, "host", None) or DEFAULT_HOST port = getattr(args, "port", None) or DEFAULT_PORT - _err( f"Starting Hermes proxy for {adapter.display_name}\n" f" Listening on: http://{host}:{port}/v1\n" @@ -48,7 +44,6 @@ def cmd_proxy_start(args: Any) -> int: f"\n" f"Press Ctrl+C to stop." ) - try: asyncio.run(run_server(adapter, host=host, port=port)) except KeyboardInterrupt: diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 8d43a0d54a..3baea21d17 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -132,7 +132,6 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": the single loop and every other in-flight streaming completion. """ _require_aiohttp() - app = web.Application(client_max_size=MAX_REQUEST_BYTES) # AppKey: forward-compat with aiohttp versions that strip bare-string keys. app[web.AppKey("adapter", UpstreamAdapter)] = adapter @@ -153,14 +152,12 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": except Exception as exc: logger.warning("proxy: credential resolution failed: %s", exc) return _json_error(401, str(exc), code="upstream_auth_failed") - # Body read into memory once (chat/embeddings payloads are small); switch to streaming # if large multipart uploads ever need forwarding. body = await request.read() session, upstream_resp = await _open_upstream(request, rel_path, body, cred) if upstream_resp is None: return session - if upstream_resp.status in {401, 429}: # One-shot retry with a refreshed/rotated credential (Nous: unconditional refresh # POST under the auth lock; xAI: pool rotation). @@ -177,7 +174,6 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": session, upstream_resp = await _open_upstream(request, rel_path, body, retry_cred) if upstream_resp is None: return session - return await _stream_back(request, session, upstream_resp) app.router.add_get("/health", handle_health) # never goes upstream @@ -193,15 +189,12 @@ async def run_server( ) -> None: """Run the proxy in the current event loop until shutdown_event is set.""" _require_aiohttp() - app = create_app(adapter) runner = web.AppRunner(app, access_log=None) await runner.setup() site = web.TCPSite(runner, host=host, port=port) await site.start() - logger.info("proxy: listening on http://%s:%d/v1 -> %s", host, port, adapter.display_name) - stop_event = shutdown_event or asyncio.Event() if shutdown_event is None: # we own the loop's lifetime → wire signal handlers loop = asyncio.get_running_loop() @@ -210,7 +203,6 @@ async def run_server( loop.add_signal_handler(sig, stop_event.set) # windows-footgun: ok except NotImplementedError: pass # Windows / restricted envs — Ctrl+C still raises KeyboardInterrupt - try: await stop_event.wait() finally: diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py index f5513f8005..399bd66e0f 100644 --- a/hermes_cli/proxy_cli.py +++ b/hermes_cli/proxy_cli.py @@ -147,7 +147,6 @@ def _setup_ca_cert(console: Console): def _setup_mint_tokens(console: Console, args: argparse.Namespace): """Discover providers, merge with existing tokens (rotating on request), print the table.""" _step(console, 3, "Mint proxy tokens for known providers") - available_env_names: List[str] = [] if args.from_bitwarden: available_env_names = _bitwarden_env_names(console) @@ -159,9 +158,7 @@ def _setup_mint_tokens(console: Console, args: argparse.Namespace): loaded = _load_env_file_into_environ() if loaded: console.print(f" [dim]Loaded {loaded} provider key name(s) from ~/.hermes/.env for discovery.[/dim]") - discovered = ip.discover_provider_mappings(available_env_names=available_env_names or None) - # Preserve existing tokens unless rotation was requested — re-running setup must not # invalidate tokens baked into already-running sandboxes. existing = ip.load_mappings() @@ -192,7 +189,6 @@ def _setup_mint_tokens(console: Console, args: argparse.Namespace): console.print( "[dim]Note: --rotate-tokens is a no-op on first-time setup (no existing tokens to rotate).[/dim]" ) - mappings = ip.merge_mappings(existing=existing, discovered=discovered, rotate=rotate) if not mappings: console.print(" [yellow]No known provider API keys found in env/Bitwarden.[/yellow]") @@ -200,7 +196,6 @@ def _setup_mint_tokens(console: Console, args: argparse.Namespace): for env_name in sorted(ip._BEARER_PROVIDERS): console.print(f" - {env_name}") return None - # Providers we recognise but can't proxy (SigV4, service-account OAuth) still work — they # just bypass the egress isolation, so say so. uncovered = ip.discover_uncovered_providers(available_env_names=available_env_names or None) @@ -214,7 +209,6 @@ def _setup_mint_tokens(console: Console, args: argparse.Namespace): "OAuth (SigV4, service-account files) and will hold real " "credentials inside the sandbox. Egress isolation is INCOMPLETE for these.[/dim]" ) - console.print(_mappings_table(mappings, "Provider env", "Upstream hosts", show_tokens=False)) return mappings @@ -222,7 +216,6 @@ def _setup_mint_tokens(console: Console, args: argparse.Namespace): def _setup_write_config(console: Console, args: argparse.Namespace, mappings, ca_crt, ca_key): """Write proxy.yaml + mappings, then enable the integration in config; returns ``proxy_cfg``.""" _step(console, 4, "Write config and persist mappings") - cfg = load_config() proxy_cfg = cfg.setdefault("proxy", {}) # None = flag not given. ``0`` is not a valid TCP listener, so it is a hard error rather @@ -237,10 +230,8 @@ def _setup_write_config(console: Console, args: argparse.Namespace, mappings, ca else: tunnel_port = int(proxy_cfg.get("tunnel_port", ip._DEFAULT_TUNNEL_PORT)) proxy_cfg["tunnel_port"] = tunnel_port - extra_hosts = list(proxy_cfg.get("extra_allowed_hosts") or []) allowed = list(ip._DEFAULT_ALLOWED_HOSTS) + [h for h in extra_hosts if h not in ip._DEFAULT_ALLOWED_HOSTS] - # Pre-create the audit log 0o600. The pinned v0.39 daemon never writes it (reserved for # v0.40+ per-request records), so a pre-create failure is a WARNING, not a setup abort. audit_log_path = ip._proxy_state_dir() / "audit.log" @@ -250,7 +241,6 @@ def _setup_write_config(console: Console, args: argparse.Namespace, mappings, ca except RuntimeError as exc: audit_log_ok = False console.print(f" [yellow]⚠ {exc}[/yellow]") - # ``proxy.upstream_deny_cidrs`` overrides the deny list; None yields the documented safe # default-deny set (loopback, IMDS, RFC1918). iron_cfg = ip.build_proxy_config( @@ -275,7 +265,6 @@ def _setup_write_config(console: Console, args: argparse.Namespace, mappings, ca f"[dim](reserved — not written by iron-proxy v0.39; " f"per-request records land in iron-proxy.log)[/dim]" ) - proxy_cfg["enabled"] = True proxy_cfg.setdefault("auto_install", True) proxy_cfg.setdefault("enforce_on_docker", True) @@ -310,7 +299,6 @@ def _setup_restart_daemon(console: Console, args: argparse.Namespace, proxy_cfg: was_running = ip.get_status().pid is not None if was_running: ip.stop_proxy() - restart_pref = getattr(args, "restart", None) if restart_pref is True or restart_pref is False: do_restart = restart_pref @@ -323,7 +311,6 @@ def _setup_restart_daemon(console: Console, args: argparse.Namespace, proxy_cfg: ) else: do_restart = False - if do_restart: try: new_status = ip.start_proxy(install_if_missing=bool(proxy_cfg.get("auto_install", True))) @@ -352,7 +339,6 @@ def cmd_start(args: argparse.Namespace) -> int: if not proxy_cfg.get("enabled"): console.print("[yellow]proxy.enabled is false — run `hermes egress setup` first.[/yellow]") return 1 - # ``credential_source: bitwarden`` refreshes upstream secrets from BSM at startup — that is # the rotation guarantee distinguishing it from ``env``. credential_source = proxy_cfg.get("credential_source", "env") @@ -380,7 +366,6 @@ def cmd_start(args: argparse.Namespace) -> int: if refresh_bw and bw_cfg is not None: bw_cfg = dict(bw_cfg) bw_cfg["allow_env_fallback"] = bool(proxy_cfg.get("allow_env_fallback", False)) - # (fail_on_uncovered_providers was removed: the fail-closed provider tier is now empty.) # Pre-check the BWS token + project here so bitwarden mode fails loud with actionable # messages BEFORE start_proxy could silently degrade to a stale/mismatched host env. @@ -400,7 +385,6 @@ def cmd_start(args: argparse.Namespace) -> int: "Run `hermes secrets bitwarden setup` to configure the " "project, or switch back via `hermes egress setup --no-bitwarden`.", ) - try: status = ip.start_proxy( install_if_missing=bool(proxy_cfg.get("auto_install", True)), @@ -463,32 +447,27 @@ def format_status_text(*, show_tokens: bool = False) -> str: cfg = load_config() proxy_cfg = cfg.get("proxy") or {} status = ip.get_status() - lines = ["Egress proxy status", ""] lines.extend( f"{label}: {value}" for label, value in _status_rows(proxy_cfg, status, yn=lambda v: "yes" if v else "no", dim=lambda t: t) ) lines.append("Scope: Docker backend only in this release") - mappings = ip.load_mappings() if mappings: lines.extend(["", "Token mappings:"]) for m in mappings: tok = m.proxy_token if show_tokens else _redact_token(m.proxy_token) lines.append(f" - {m.real_env_name}: {tok} ({', '.join(m.upstream_hosts)})") - uncovered = ip.discover_uncovered_providers() if uncovered: lines.extend(["", "Uncovered providers (real credentials still visible inside the sandbox):"]) for name in uncovered: lines.append(f" - {name}") - if bool(proxy_cfg.get("enabled")) and not status.configured: lines.extend(["", "Next: run `hermes egress setup` to mint tokens and write proxy.yaml."]) elif bool(proxy_cfg.get("enabled")) and not (status.pid and status.listening): lines.extend(["", "Next: run `hermes egress start` before launching Docker sandboxes."]) - return "\n".join(lines) @@ -497,14 +476,12 @@ def cmd_status(args: argparse.Namespace) -> int: cfg = load_config() proxy_cfg = cfg.get("proxy") or {} status = ip.get_status() - table = Table(show_header=False, box=None, padding=(0, 2)) table.add_column("", style="bold") table.add_column("") for label, value in _status_rows(proxy_cfg, status, yn=_yn, dim=lambda t: f"[dim]{t}[/dim]"): table.add_row(label, value) console.print(table) - mappings = ip.load_mappings() if mappings: console.print() @@ -515,7 +492,6 @@ def cmd_status(args: argparse.Namespace) -> int: "[yellow]⚠[/yellow] proxy tokens just printed in full — " "they may persist in your shell history. Consider clearing it after this command." ) - # Uncovered providers = the isolation boundary is incomplete for those upstreams. uncovered = ip.discover_uncovered_providers() if uncovered: @@ -523,7 +499,6 @@ def cmd_status(args: argparse.Namespace) -> int: console.print("[yellow]Uncovered providers[/yellow] (real credentials still visible inside the sandbox):") for name in uncovered: console.print(f" - {name}") - return 0 diff --git a/hermes_cli/psutil_android.py b/hermes_cli/psutil_android.py index 7cc0fac54b..274d4693de 100644 --- a/hermes_cli/psutil_android.py +++ b/hermes_cli/psutil_android.py @@ -34,22 +34,17 @@ def _safe_extract_tar_gz(archive: Path, destination: Path) -> None: for member in tf.getmembers(): parts = _normalize_member_parts(member.name) target = destination.joinpath(*parts) - if member.isdir(): target.mkdir(parents=True, exist_ok=True) continue - if not member.isfile(): raise PsutilAndroidInstallError(f"Unsupported archive member type: {member.name}") - target.parent.mkdir(parents=True, exist_ok=True) extracted = tf.extractfile(member) if extracted is None: raise PsutilAndroidInstallError(f"Cannot read archive member: {member.name}") - with extracted, open(target, "wb") as dst: shutil.copyfileobj(extracted, dst) - try: target.chmod(member.mode & 0o777) except OSError: @@ -59,11 +54,9 @@ def _safe_extract_tar_gz(archive: Path, destination: Path) -> None: def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path: """Safely extract the pinned psutil sdist and patch it for Android.""" _safe_extract_tar_gz(archive, destination) - src_roots = [path for path in destination.iterdir() if path.is_dir() and path.name.startswith("psutil-")] if not src_roots: raise PsutilAndroidInstallError("psutil sdist did not contain a psutil-* directory") - src_root = min(src_roots, key=lambda path: path.name) common_py = src_root / "psutil" / "_common.py" rel = common_py.relative_to(src_root) diff --git a/hermes_cli/relaunch.py b/hermes_cli/relaunch.py index de02f06381..0ae7ec57e9 100644 --- a/hermes_cli/relaunch.py +++ b/hermes_cli/relaunch.py @@ -15,7 +15,6 @@ def _build_inherited_flag_table() -> list[tuple[str, bool]]: """``(option_string, takes_value)`` for every parser Action carrying ``inherit_on_relaunch`` (set by ``_parser._inherited_flag``), plus the pre-argparse flags.""" parser, _subparsers, chat_parser = build_top_level_parser() - table: list[tuple[str, bool]] = [] seen: set[tuple[str, bool]] = set() for p in (parser, chat_parser): @@ -30,7 +29,6 @@ def _build_inherited_flag_table() -> list[tuple[str, bool]]: if key not in seen: seen.add(key) table.append(key) - table.extend(PRE_ARGPARSE_INHERITED_FLAGS) return table @@ -49,7 +47,6 @@ def _extract_inherited_flags(argv: Sequence[str]) -> list[str]: flags.append(arg) i += 1 continue - for flag, takes_value in _INHERITED_FLAGS_TABLE: if arg == flag: flags.append(arg) @@ -90,10 +87,8 @@ def build_relaunch_argv( bin_path = resolve_hermes_bin() argv = [bin_path] if bin_path else [sys.executable, "-m", "hermes_cli.main"] src = list(original_argv) if original_argv is not None else list(sys.argv[1:]) - if preserve_inherited: argv.extend(_extract_inherited_flags(src)) - argv.extend(extra_args) return argv diff --git a/hermes_cli/resource_limits.py b/hermes_cli/resource_limits.py index 2791f5423b..276d24f18a 100644 --- a/hermes_cli/resource_limits.py +++ b/hermes_cli/resource_limits.py @@ -30,21 +30,17 @@ def configured_nofile_soft_limit(config: Mapping[str, Any] | None = None) -> int try: # Profile-aware loader (applies managed-scope overlays and defaults). from hermes_cli.config import load_config_readonly - config = load_config_readonly() except Exception: logger.debug("Could not load config for RLIMIT_NOFILE", exc_info=True) return None - if not isinstance(config, Mapping): return None - runtime = config.get("runtime", _MISSING) if runtime is _MISSING: return DEFAULT_NOFILE_SOFT_LIMIT if not isinstance(runtime, Mapping): return None - raw_value = runtime.get("nofile_soft_limit", _MISSING) if raw_value is _MISSING: return DEFAULT_NOFILE_SOFT_LIMIT @@ -62,11 +58,9 @@ def apply_nofile_soft_limit(config: Mapping[str, Any] | None = None) -> bool: """ if _resource is None: return False - target = configured_nofile_soft_limit(config) if target is None: return False - try: nofile = _resource.RLIMIT_NOFILE current_soft, current_hard = _resource.getrlimit(nofile) @@ -78,7 +72,6 @@ def apply_nofile_soft_limit(config: Mapping[str, Any] | None = None) -> bool: new_soft = target if current_hard == infinity else min(target, current_hard) if new_soft <= current_soft: return False - _resource.setrlimit(nofile, (new_soft, current_hard)) return True except Exception: diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py index 9855a4dbb2..c338a6bfc7 100644 --- a/hermes_cli/service_manager.py +++ b/hermes_cli/service_manager.py @@ -61,7 +61,6 @@ def detect_service_manager() -> ServiceManagerKind: # Deferred so importing this module (Protocol type, validate_profile_name) doesn't drag in # the whole gateway dependency graph. from hermes_cli.gateway import is_macos, is_windows, supports_systemd_services - # Gate on _s6_running() alone, NOT is_container(): the latter only detects Docker/Podman/lxc # and is False on Fly's Firecracker microVMs even though s6-overlay is PID 1 there — that # made the s6 dispatch inert on Fly, so `hermes gateway start` spawned a foreground gateway @@ -112,9 +111,7 @@ class _HostServiceManager: def _backend_module(self): import importlib - import hermes_cli - importlib.import_module(f"hermes_cli.{self._backend}") return getattr(hermes_cli, self._backend) @@ -437,7 +434,6 @@ class S6ServiceManager: restarting would turn every normal exit into a reconnect storm) both map to 125 so s6 stops restarting; only other non-zero exits let s6 restart normally.""" from gateway.restart import GATEWAY_FATAL_CONFIG_EXIT_CODE - code = GATEWAY_FATAL_CONFIG_EXIT_CODE return ( "#!/command/with-contenv sh\n" @@ -489,7 +485,6 @@ class S6ServiceManager: service_dir = self.scandir / name if not service_dir.is_dir(): raise GatewayNotRegisteredError(_profile_from_service(name)) - try: _s6_run("s6-svc", action_flag, str(service_dir), check=True) except subprocess.CalledProcessError as exc: @@ -520,7 +515,6 @@ class S6ServiceManager: if pid is not None: try: from gateway.status import write_planned_stop_marker - write_planned_stop_marker(pid) except Exception: pass @@ -553,7 +547,6 @@ class S6ServiceManager: svc_dir = self._service_dir(profile) if svc_dir.exists(): raise ValueError(f"profile gateway {profile!r} already registered at {svc_dir}") - # Build atomically in a DOT-PREFIXED sibling (``.gateway-.tmp``) then rename: # s6-svscan skips dot entries, so a concurrent rescan (cont-init reconciler, sibling # register) cannot supervise the half-built slot. Otherwise s6-supervise would spawn AS @@ -581,12 +574,10 @@ class S6ServiceManager: # Mirrors container_boot._register_gateway_slot when start=False. if not start_now: (tmp_dir / "down").touch() - tmp_dir.rename(svc_dir) except Exception: shutil.rmtree(tmp_dir, ignore_errors=True) raise - result = _s6_run("s6-svscanctl", "-a", str(self.scandir)) if result.returncode != 0: # No supervisor is watching it — leaving the directory would be confusing. @@ -605,7 +596,6 @@ class S6ServiceManager: svc_dir = self._service_dir(profile) if not svc_dir.exists(): return - _s6_run("s6-svc", "-d", str(svc_dir)) _s6_run("s6-svwait", "-D", "-t", "10000", str(svc_dir), timeout=15) _s6_run("s6-svscanctl", "-an", str(self.scandir))