Skip to content
Merged
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
20 changes: 14 additions & 6 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1840,7 +1840,11 @@ async def _on_model_selected_scoped(
enrich_model_switch_warnings_for_gateway,
)

enrich_model_switch_warnings_for_gateway(
# Offload: merge_preflight_compression_warning()
# calls the sync resolve_display_context_length()
# provider probe ladder — must not run on the loop.
await asyncio.to_thread(
enrich_model_switch_warnings_for_gateway,
result,
_self,
session_key=_session_key,
Expand Down Expand Up @@ -2011,7 +2015,7 @@ async def _on_model_selected_scoped(
lines = [t("gateway.model.switched", model=format_model_for_display(result.new_model))]
lines.append(t("gateway.model.provider_label", provider=plabel))
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
from hermes_cli.model_switch import resolve_display_context_length_async
_sw_config_ctx = None
_sw_model_cfg = {}
try:
Expand All @@ -2025,7 +2029,7 @@ async def _on_model_selected_scoped(
pass
if not isinstance(_sw_model_cfg, dict):
_sw_model_cfg = {}
ctx = resolve_display_context_length(
ctx = await resolve_display_context_length_async(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
Expand Down Expand Up @@ -2145,7 +2149,11 @@ async def _on_model_selected(
enrich_model_switch_warnings_for_gateway,
)

enrich_model_switch_warnings_for_gateway(
# Offload: merge_preflight_compression_warning() calls the sync
# resolve_display_context_length() provider probe ladder — must
# not run on the loop.
await asyncio.to_thread(
enrich_model_switch_warnings_for_gateway,
result,
self,
session_key=session_key,
Expand Down Expand Up @@ -2333,7 +2341,7 @@ async def _finish_switch() -> str:
# Context: always resolve via the provider-aware chain so Codex OAuth,
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
from hermes_cli.model_switch import resolve_display_context_length_async
_sw2_config_ctx = None
_sw2_model_cfg = {}
try:
Expand All @@ -2347,7 +2355,7 @@ async def _finish_switch() -> str:
pass
if not isinstance(_sw2_model_cfg, dict):
_sw2_model_cfg = {}
ctx = resolve_display_context_length(
ctx = await resolve_display_context_length_async(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
Expand Down
42 changes: 42 additions & 0 deletions hermes_cli/model_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,48 @@ def resolve_display_context_length(
return None


async def resolve_display_context_length_async(
model: str,
provider: str,
base_url: str = "",
api_key: str = "",
model_info: Optional[ModelInfo] = None,
custom_providers: list | None = None,
config_context_length: int | None = None,
configured_model: str | None = None,
configured_provider: str | None = None,
configured_base_url: str | None = None,
) -> Optional[int]:
"""Async variant of :func:`resolve_display_context_length`.

The sync version runs two blocking chains: the route comparison in
``should_clear_context_pin`` and the full provider probe ladder in
``get_model_context_length`` (blocking ``requests`` calls to Anthropic
``/v1/models``, Copilot, Nous, Codex, GMI, Ollama, models.dev and
OpenRouter). Async gateway handlers must not run either on the event
loop — see ``agent.model_metadata.get_model_context_length_async`` and
``hermes_cli.route_identity.should_clear_context_pin_async``, which
offload the same chains for the message path.

Shares all logic with the sync version — no code duplication.
"""
import asyncio

return await asyncio.to_thread(
resolve_display_context_length,
model,
provider,
base_url=base_url,
api_key=api_key,
model_info=model_info,
custom_providers=custom_providers,
config_context_length=config_context_length,
configured_model=configured_model,
configured_provider=configured_provider,
configured_base_url=configured_base_url,
)


# ---------------------------------------------------------------------------
# Configured-provider detection for typed model names
# ---------------------------------------------------------------------------
Expand Down
66 changes: 52 additions & 14 deletions hermes_cli/profile_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any, Dict, List, Optional, Tuple

from agent.skill_utils import is_excluded_skill_path
Expand Down Expand Up @@ -557,22 +557,16 @@ def _copy_dist_payload(
``preserve_config`` is False (fresh install or ``--force-config`` update).
``.env.template`` is renamed to ``.env.EXAMPLE`` in the target to avoid
shadowing a real ``.env``.

When the manifest declares an explicit ``distribution_owned`` list, only
those paths are copied (path-aware: nested entries such as
``skills/research`` or ``cron/digest.json`` are honoured). When the list
is omitted the legacy behaviour is preserved: every staged entry outside
``USER_OWNED_EXCLUDE`` is copied.
"""
target.mkdir(parents=True, exist_ok=True)

for entry in staged.iterdir():
name = entry.name

if name in USER_OWNED_EXCLUDE:
continue
if name == ENV_TEMPLATE_FILENAME:
shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME)
continue
if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
# Leave user's config.yaml alone on update
continue

dest = target / name
def _copy_entry(entry: Path, dest: Path) -> None:
if entry.is_dir():
if dest.exists():
shutil.rmtree(dest)
Expand All @@ -589,6 +583,50 @@ def _copy_dist_payload(
else:
shutil.copy2(entry, dest)

explicit_owned = [p.strip().strip("/") for p in manifest.distribution_owned]
explicit_owned = [p for p in explicit_owned if p]

if explicit_owned:
# Path-aware allowlist: copy exactly the declared paths.
for rel in explicit_owned:
rel_parts = PurePosixPath(rel).parts
if not rel_parts or rel_parts[0] in USER_OWNED_EXCLUDE:
continue
if ".." in rel_parts or PurePosixPath(rel).is_absolute():
continue
src = staged.joinpath(*rel_parts)
if not src.exists():
continue
if len(rel_parts) == 1:
name = rel_parts[0]
if name == ENV_TEMPLATE_FILENAME:
shutil.copy2(src, target / ENV_EXAMPLE_FILENAME)
continue
if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
# Leave user's config.yaml alone on update
continue
dest = target.joinpath(*rel_parts)
dest.parent.mkdir(parents=True, exist_ok=True)
_copy_entry(src, dest)
else:
# Legacy behaviour: no explicit allowlist means the whole staged
# payload (minus USER_OWNED_EXCLUDE) is distribution-owned. Do NOT
# narrow to DEFAULT_DIST_OWNED here — existing distributions ship
# arbitrary extra top-level paths without declaring them.
for entry in staged.iterdir():
name = entry.name

if name in USER_OWNED_EXCLUDE:
continue
if name == ENV_TEMPLATE_FILENAME:
shutil.copy2(entry, target / ENV_EXAMPLE_FILENAME)
continue
if name == "config.yaml" and preserve_config and (target / "config.yaml").exists():
# Leave user's config.yaml alone on update
continue

_copy_entry(entry, target / name)

# Emit .env.EXAMPLE from manifest if the staged tree didn't ship one
if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists():
(target / ENV_EXAMPLE_FILENAME).write_text(
Expand Down
54 changes: 50 additions & 4 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,15 +559,61 @@ def _embedded_profile_env_path(config: dict[str, Any]):
return Path.home() / ".hindsight" / "profiles" / f"{_embedded_profile_name(config)}.env"


def _secure_write_profile_env(profile_env, content: str) -> None:
"""Create/overwrite *profile_env* with owner-only (0600) permissions.

The file carries the embedded daemon's plaintext LLM API key
(``HINDSIGHT_API_LLM_API_KEY``), so it must never be created with the
default umask-derived mode. A pre-existing file is tightened *before*
the new secret bytes are written.
"""
if profile_env.exists():
try:
os.chmod(profile_env, 0o600)
except OSError:
pass
fd = os.open(str(profile_env), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)


def _validate_profile_env_permissions(profile_env) -> None:
"""Post-write validation: the secret file must be owner-only on POSIX."""
if os.name != "posix":
# POSIX mode bits do not model Windows ACLs.
return
import stat

mode = stat.S_IMODE(profile_env.stat().st_mode)
if mode != 0o600:
try:
os.chmod(profile_env, 0o600)
except OSError:
pass
mode = stat.S_IMODE(profile_env.stat().st_mode)
if mode != 0o600:
raise PermissionError(
f"Embedded Hindsight profile environment is not owner-only: {profile_env}"
)


def _materialize_embedded_profile_env(config: dict[str, Any], *, llm_api_key: str | None = None):
"""Write the profile-scoped env file that standalone hindsight-embed uses."""
profile_env = _embedded_profile_env_path(config)
profile_env.parent.mkdir(parents=True, exist_ok=True)
env_values = _build_embedded_profile_env(config, llm_api_key=llm_api_key)
profile_env.write_text(
"".join(f"{key}={value}\n" for key, value in env_values.items()),
encoding="utf-8",
)
content = "".join(f"{key}={value}\n" for key, value in env_values.items())
try:
_secure_write_profile_env(profile_env, content)
_validate_profile_env_permissions(profile_env)
except BaseException:
# Never leave a plaintext API key behind in a file whose permissions
# could not be verified.
try:
profile_env.unlink()
except OSError:
pass
raise
return profile_env

def _sanitize_bank_segment(value: str) -> str:
Expand Down
Loading
Loading