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
21 changes: 18 additions & 3 deletions agent/credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1270,10 +1270,17 @@ def _is_suppressed(_p, _s): # type: ignore[misc]
token, source = resolve_copilot_token()
if token:
api_token = get_copilot_api_token(token)
source_name = "gh_cli" if "gh" in source.lower() else f"env:{source}"
source_name = "gh_cli" if source.lower() == "gh auth token" else f"env:{source}"
if not _is_suppressed(provider, source_name):
active_sources.add(source_name)
pconfig = PROVIDER_REGISTRY.get(provider)
env_url = ""
if pconfig and pconfig.base_url_env_var:
env_url = (
get_env_value(pconfig.base_url_env_var)
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")
Comment on lines +1278 to +1282
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

During pool seeding, we should prefer the .env file over os.environ to prevent stale inherited environment variables from being persisted into auth.json (consistent with the fix for NousResearch#18254). Additionally, get_env_value already falls back to .env if not in os.environ, making the or os.getenv(...) redundant.

We can use load_env() (which is already imported) to check the .env file first.

Suggested change
if pconfig and pconfig.base_url_env_var:
env_url = (
get_env_value(pconfig.base_url_env_var)
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")
if pconfig and pconfig.base_url_env_var:
env_file = load_env()
env_url = (
env_file.get(pconfig.base_url_env_var)
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")

base_url = env_url or (pconfig.inference_base_url if pconfig else "")
Comment on lines +1277 to +1283
changed |= _upsert_entry(
entries,
provider,
Expand All @@ -1282,7 +1289,7 @@ def _is_suppressed(_p, _s): # type: ignore[misc]
"source": source_name,
"auth_type": AUTH_TYPE_API_KEY,
"access_token": api_token,
"base_url": pconfig.inference_base_url if pconfig else "",
"base_url": base_url,
"label": source,
},
)
Expand Down Expand Up @@ -1468,6 +1475,14 @@ def _is_source_suppressed(_p, _s): # type: ignore[misc]
continue
active_sources.add(source)
auth_type = AUTH_TYPE_OAUTH if provider == "anthropic" and not token.startswith("sk-ant-api") else AUTH_TYPE_API_KEY
access_token = token
if provider == "copilot":
try:
from hermes_cli.copilot_auth import get_copilot_api_token

access_token = get_copilot_api_token(token)
except Exception:
access_token = token
base_url = env_url or pconfig.inference_base_url
if provider == "kimi-coding":
base_url = _resolve_kimi_base_url(token, pconfig.inference_base_url, env_url)
Expand All @@ -1480,7 +1495,7 @@ def _is_source_suppressed(_p, _s): # type: ignore[misc]
{
"source": source,
"auth_type": auth_type,
"access_token": token,
"access_token": access_token,
"base_url": base_url,
"label": env_var,
},
Expand Down
30 changes: 25 additions & 5 deletions gateway/platforms/qqbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,30 @@ async def _handle_c2c_message(
return

text = content
source = self.build_source(
chat_id=user_openid,
user_id=user_openid,
chat_type="dm",
)
if (
self.gateway_runner is not None
and hasattr(self.gateway_runner, "_is_user_authorized")
and not self.gateway_runner._is_user_authorized(source)
):
self._chat_type_map[user_openid] = "c2c"
event = MessageEvent(
source=source,
text=text,
message_type=self._detect_message_type([], []),
raw_message=d,
message_id=msg_id,
media_urls=[],
media_types=[],
timestamp=self._parse_qq_timestamp(timestamp),
)
await self.handle_message(event)
return

attachments_raw = d.get("attachments")
logger.info(
"[%s] C2C message: id=%s content=%r attachments=%s",
Expand Down Expand Up @@ -1195,11 +1219,7 @@ async def _handle_c2c_message(

self._chat_type_map[user_openid] = "c2c"
event = MessageEvent(
source=self.build_source(
chat_id=user_openid,
user_id=user_openid,
chat_type="dm",
),
source=source,
text=text,
message_type=self._detect_message_type(image_urls, image_media_types),
raw_message=d,
Expand Down
9 changes: 9 additions & 0 deletions gateway/platforms/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,15 @@ def _validate_signature(
self, request: "web.Request", body: bytes, secret: str
) -> bool:
"""Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
stripped_secret = secret.strip()
if (
not stripped_secret
or (stripped_secret.startswith("${") and stripped_secret.endswith("}"))
or (stripped_secret.startswith("{{") and stripped_secret.endswith("}}"))
):
logger.warning("[webhook] Secret is empty or unresolved placeholder")
return False

# GitHub: X-Hub-Signature-256 = sha256=<hex>
gh_sig = request.headers.get("X-Hub-Signature-256", "")
if gh_sig:
Expand Down
2 changes: 2 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5458,6 +5458,8 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
):
return True

team_id = (getattr(source, "guild_id", None) or "").strip()

# Check pairing store (always checked, regardless of allowlists)
platform_name = source.platform.value if source.platform else ""
auth_user_id = user_id
Expand Down
17 changes: 15 additions & 2 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
resolve_external_process_provider_credentials,
has_usable_secret,
)
from hermes_cli.config import get_compatible_custom_providers, load_config
from hermes_cli.config import get_compatible_custom_providers, get_env_value, load_config
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname

Expand Down Expand Up @@ -220,7 +220,20 @@ def _resolve_runtime_from_pool_entry(
api_mode = "chat_completions"
elif provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""))
base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url
pconfig = PROVIDER_REGISTRY["copilot"]
pool_url_is_default = base_url.rstrip("/") == pconfig.inference_base_url.rstrip("/")
if not base_url or pool_url_is_default:
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
cfg_base_url = ""
if cfg_provider == "copilot":
cfg_base_url = str(model_cfg.get("base_url") or "").strip().rstrip("/")
env_url = ""
if pconfig.base_url_env_var:
env_url = (
get_env_value(pconfig.base_url_env_var)
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")
Comment on lines +231 to +235
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

At runtime, get_env_value already checks os.environ first and then falls back to .env. Therefore, the or os.getenv(...) check is redundant and can be simplified.

            if pconfig.base_url_env_var:
                env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/")

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist, is this suggestion still valid? If so, what are the steps and line numbers to make this fix?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the suggestion remains valid. get_env_value is designed to handle the environment lookup internally, making the explicit os.getenv fallback redundant. You can apply this simplification to hermes_cli/runtime_provider.py at lines 231-235.

Suggested change
if pconfig.base_url_env_var:
env_url = (
get_env_value(pconfig.base_url_env_var)
or os.getenv(pconfig.base_url_env_var, "")
).strip().rstrip("/")
if pconfig.base_url_env_var:
env_url = (get_env_value(pconfig.base_url_env_var) or "").strip().rstrip("/")

Comment on lines +231 to +235
base_url = cfg_base_url or env_url or base_url or pconfig.inference_base_url
elif provider == "azure-foundry":
# Azure Foundry: read api_mode and base_url from config
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
Expand Down
55 changes: 55 additions & 0 deletions tests/agent/test_credential_pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,10 @@ def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):
"hermes_cli.copilot_auth.resolve_copilot_token",
lambda: ("gho_fake_token_abc123", "gh auth token"),
)
monkeypatch.setattr(
"hermes_cli.copilot_auth.get_copilot_api_token",
lambda token: token,
)

from agent.credential_pool import load_pool
pool = load_pool("copilot")
Expand All @@ -1170,6 +1174,57 @@ def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):
assert entries[0].base_url == "https://api.githubcopilot.com"


def test_load_pool_seeds_copilot_gh_token_as_env_source_with_base_url_override(tmp_path, monkeypatch):
"""GH_TOKEN should not mask COPILOT_API_BASE_URL behind a gh_cli entry."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("COPILOT_API_BASE_URL", "https://enterprise-proxy.example/copilot/")
monkeypatch.setenv("GH_TOKEN", "gho_fake_token_abc123")
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})

monkeypatch.setattr(
"hermes_cli.copilot_auth.resolve_copilot_token",
lambda: ("gho_fake_token_abc123", "GH_TOKEN"),
)
monkeypatch.setattr(
"hermes_cli.copilot_auth.get_copilot_api_token",
lambda token: f"api-{token}",
)

from agent.credential_pool import load_pool
pool = load_pool("copilot")

assert pool.has_credentials()
entries = pool.entries()
assert len(entries) == 1
assert entries[0].source == "env:GH_TOKEN"
assert entries[0].access_token == "api-gho_fake_token_abc123"
assert entries[0].base_url == "https://enterprise-proxy.example/copilot"


def test_load_pool_seeds_copilot_gh_cli_with_base_url_override(tmp_path, monkeypatch):
"""gh auth token credentials should honor COPILOT_API_BASE_URL."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("COPILOT_API_BASE_URL", "https://enterprise-proxy.example/copilot/")
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})

monkeypatch.setattr(
"hermes_cli.copilot_auth.resolve_copilot_token",
lambda: ("gho_fake_token_abc123", "gh auth token"),
)
monkeypatch.setattr(
"hermes_cli.copilot_auth.get_copilot_api_token",
lambda token: token,
)

from agent.credential_pool import load_pool
pool = load_pool("copilot")

entries = pool.entries()
assert len(entries) == 1
assert entries[0].source == "gh_cli"
assert entries[0].base_url == "https://enterprise-proxy.example/copilot"


def test_load_pool_does_not_seed_copilot_when_no_token(tmp_path, monkeypatch):
"""Copilot pool should be empty when resolve_copilot_token() returns nothing."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
Expand Down
53 changes: 53 additions & 0 deletions tests/hermes_cli/test_runtime_provider_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,59 @@ def _unexpected_anthropic_token():
assert resolved.get("credential_pool") is None


def test_resolve_runtime_provider_copilot_pool_respects_env_base_url(monkeypatch):
class _Entry:
access_token = "pool-token"
source = "gh_cli"
base_url = "https://api.githubcopilot.com"

class _Pool:
def has_credentials(self):
return True

def select(self):
return _Entry()

monkeypatch.setenv("COPILOT_API_BASE_URL", "https://enterprise-proxy.example/copilot/")
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "copilot")
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
monkeypatch.setattr(rp, "_copilot_runtime_api_mode", lambda *a, **k: "chat_completions")

Comment on lines +111 to +115
resolved = rp.resolve_runtime_provider(requested="copilot")

assert resolved["provider"] == "copilot"
assert resolved["api_key"] == "pool-token"
assert resolved["source"] == "gh_cli"
assert resolved["base_url"] == "https://enterprise-proxy.example/copilot"


def test_resolve_runtime_provider_copilot_pool_respects_config_base_url(monkeypatch):
class _Entry:
access_token = "pool-token"
source = "gh_cli"
base_url = "https://api.githubcopilot.com"

class _Pool:
def has_credentials(self):
return True

def select(self):
return _Entry()

monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "copilot")
monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
monkeypatch.setattr(rp, "_copilot_runtime_api_mode", lambda *a, **k: "chat_completions")
monkeypatch.setattr(
rp,
"_get_model_config",
lambda: {"provider": "copilot", "base_url": "https://config-proxy.example/copilot/"},
)

resolved = rp.resolve_runtime_provider(requested="copilot")

assert resolved["base_url"] == "https://config-proxy.example/copilot"


def test_resolve_runtime_provider_falls_back_when_pool_empty(monkeypatch):
class _Pool:
def has_credentials(self):
Expand Down
10 changes: 9 additions & 1 deletion tools/process_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,8 +1162,16 @@ def _check_stdin_guards(self, session: ProcessSession, payload: str) -> Optional
if not payload:
return None
approval = check_all_command_guards(self._stdin_guard_command(session, payload), "local")
if approval.get("approved"):
if not approval:
return None
approved = approval.get("approved")
if approved is True:
return None
if approved is None:
return {
"approved": False,
"message": "BLOCKED: malformed stdin approval response",
}
return approval

def write_stdin(self, session_id: str, data: str) -> dict:
Expand Down
Loading