Files
hermes-agent/tests/agent/test_azure_identity_adapter.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

482 lines
19 KiB
Python

"""Tests for the Microsoft Entra ID adapter (agent/azure_identity_adapter.py).
Covers:
- Scope resolution per Azure host shape
- Display masking for callable + string + None inputs
- Cache-fingerprint stability under callable refresh
- is_token_provider truthiness on callables vs strings
- EntraIdentityConfig serialization round-trip
- Token provider construction with mocked azure-identity
- Credential cache reuse + reset
- has_azure_identity_credentials timeout / failure paths
- describe_active_credential structural reporting
- Lazy-install error path when azure-identity absent + lazy installs
disabled
We mock azure.identity at the import boundary rather than hitting any
real Azure endpoint. Tests must remain hermetic per AGENTS.md.
"""
from __future__ import annotations
import sys
from types import SimpleNamespace
import pytest
# Ensure we always import a fresh adapter module — credential caches in
# the adapter persist across tests otherwise, polluting assertions
# about cache invalidation.
@pytest.fixture(autouse=True)
def _reset_adapter_cache():
from agent.azure_identity_adapter import reset_credential_cache
reset_credential_cache()
yield
reset_credential_cache()
# ---------------------------------------------------------------------------
# Scope constant
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Cache fingerprint + http-bearer helpers
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# build_bearer_http_client — the Anthropic-on-Foundry bridge
# ---------------------------------------------------------------------------
class TestBuildBearerHttpClient:
"""``build_bearer_http_client`` returns an ``httpx.Client`` whose
request event hook mints a fresh JWT per outbound request. This is
how Entra ID auth reaches the Anthropic SDK (which does not accept
callable ``auth_token``)."""
def test_hook_overrides_authorization_header(self):
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
minted_tokens = []
def provider():
minted_tokens.append(f"jwt-{len(minted_tokens) + 1}")
return minted_tokens[-1]
client = build_bearer_http_client(provider)
try:
hook = client.event_hooks["request"][0]
# Build a request with conflicting pre-set headers and verify
# the hook strips them and installs the fresh bearer.
req = httpx.Request(
"POST", "https://example.com/v1/messages",
headers={
"Authorization": "Bearer stale-token",
"api-key": "static-key",
"x-api-key": "static-key",
},
json={"hello": "world"},
)
hook(req)
assert req.headers["Authorization"] == "Bearer jwt-1"
# The static-key headers must be stripped — sending both
# auth values would be ambiguous on Azure.
assert "api-key" not in req.headers
assert "x-api-key" not in req.headers
# Second invocation mints a fresh token.
req2 = httpx.Request("GET", "https://example.com/v1/models")
hook(req2)
assert req2.headers["Authorization"] == "Bearer jwt-2"
assert len(minted_tokens) == 2
finally:
client.close()
def test_hook_strips_auth_headers_and_warns_when_token_provider_fails(self, caplog):
"""When the token provider fails (chain exhausted, IMDS down, az
login expired), the hook must:
1. Log at WARNING level so the misconfiguration is visible at
default log level (not buried at DEBUG).
2. Strip any pre-set Authorization headers — including the
placeholder ``entra-id-bearer-via-http-hook`` sentinel that
:func:`_build_anthropic_client_with_bearer_hook` sets on the
Anthropic SDK constructor. This produces a clean
"missing auth" 401 from Azure rather than a sentinel-bearing
401 that's harder to diagnose AND avoids leaking the
sentinel string into upstream access logs.
"""
import logging
import httpx
from agent.azure_identity_adapter import build_bearer_http_client
def bad_provider():
return "" # empty token → materialize_bearer_for_http raises
client = build_bearer_http_client(bad_provider)
try:
hook = client.event_hooks["request"][0]
req = httpx.Request(
"POST", "https://example.com/v1/messages",
headers={
"Authorization": "Bearer entra-id-bearer-via-http-hook",
"api-key": "leaked-placeholder",
},
)
with caplog.at_level(logging.WARNING, logger="agent.azure_identity_adapter"):
hook(req) # Must not raise.
# Pre-set auth headers stripped — no sentinel makes it to Azure.
assert "Authorization" not in req.headers
assert "api-key" not in req.headers
# WARNING was logged so the user sees the misconfiguration.
assert any(
rec.levelno == logging.WARNING and "Entra ID token provider" in rec.message
for rec in caplog.records
)
finally:
client.close()
# ---------------------------------------------------------------------------
# EntraIdentityConfig
# ---------------------------------------------------------------------------
class TestEntraIdentityConfig:
"""The serializable config that crosses multiprocessing boundaries —
must round-trip through dict cleanly and never lose fields."""
def test_to_dict_round_trip(self):
from agent.azure_identity_adapter import EntraIdentityConfig
cfg = EntraIdentityConfig(
scope="https://ai.azure.com/.default",
exclude_interactive_browser=False,
)
rebuilt = EntraIdentityConfig.from_dict(cfg.to_dict())
assert rebuilt == cfg
# ---------------------------------------------------------------------------
# Credential / token provider construction
# ---------------------------------------------------------------------------
class _FakeAzureIdentity:
"""Stand-in for the ``azure.identity`` module.
Captures kwargs passed to ``DefaultAzureCredential`` so tests can
assert how config flows into the SDK.
"""
def __init__(self):
self.last_credential_kwargs = None
self.last_scope = None
self.credential_count = 0
self.scoped_calls = []
def DefaultAzureCredential(self, **kwargs): # noqa: N802 — match SDK
self.last_credential_kwargs = kwargs
self.credential_count += 1
return SimpleNamespace(
get_token=lambda scope: SimpleNamespace(token="fake-jwt", expires_on=9999999999),
kwargs=kwargs,
)
def ClientSecretCredential(self, tenant_id, client_id, client_secret): # noqa: N802
self.scoped_calls.append(("client_secret", tenant_id, client_id, client_secret))
return SimpleNamespace(kind="client_secret", tenant_id=tenant_id, client_id=client_id)
def WorkloadIdentityCredential(self, **kwargs): # noqa: N802
self.scoped_calls.append(("workload_identity", kwargs))
return SimpleNamespace(kind="workload_identity", kwargs=kwargs)
def ManagedIdentityCredential(self, **kwargs): # noqa: N802
self.scoped_calls.append(("managed_identity", kwargs))
return SimpleNamespace(kind="managed_identity", kwargs=kwargs)
def get_bearer_token_provider(self, credential, scope):
self.last_scope = scope
# Return a callable that mints a token when invoked.
return lambda: f"jwt-for-{scope}"
@pytest.fixture
def fake_azure_identity(monkeypatch):
"""Install a fake azure.identity into sys.modules and stub the
adapter's `_require_azure_identity` so all tests use the fake."""
fake = _FakeAzureIdentity()
fake_module = SimpleNamespace(
DefaultAzureCredential=fake.DefaultAzureCredential,
ClientSecretCredential=fake.ClientSecretCredential,
WorkloadIdentityCredential=fake.WorkloadIdentityCredential,
ManagedIdentityCredential=fake.ManagedIdentityCredential,
get_bearer_token_provider=fake.get_bearer_token_provider,
)
monkeypatch.setitem(sys.modules, "azure", SimpleNamespace(identity=fake_module))
monkeypatch.setitem(sys.modules, "azure.identity", fake_module)
# The adapter's `_require_azure_identity` does its own import, so
# patch that too to make sure tests never hit the real package's
# singleton state.
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "_require_azure_identity", lambda: fake_module)
return fake
class TestBuildCredential:
def test_credential_is_cached_per_config(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
cfg = EntraIdentityConfig(scope="s1")
c1 = build_credential(cfg)
c2 = build_credential(cfg)
assert c1 is c2
assert fake_azure_identity.credential_count == 1
def test_distinct_configs_get_distinct_credentials(self, fake_azure_identity):
from agent.azure_identity_adapter import EntraIdentityConfig, build_credential
c1 = build_credential(EntraIdentityConfig(scope="s1"))
c2 = build_credential(EntraIdentityConfig(scope="s2"))
assert c1 is not c2
assert fake_azure_identity.credential_count == 2
class TestScopedCredential:
"""A served multiplex profile never mints the launch profile's ambient chain (#116313)."""
def test_two_homes_multiplex_refuses_ambient_chain_and_keeps_standalone(
self, fake_azure_identity, tmp_path, monkeypatch,
):
"""A -> B -> A over two real homes: A (own AZURE_* in .env) builds its ClientSecretCredential,
cred-less B is refused instead of getting DefaultAzureCredential (which reads A's AZURE_* from
the process env), A again is unaffected; the probe thread runs under the caller's scope so the
doctor path surfaces the same refusal. Control: a standalone run keeps the ambient chain."""
from agent import secret_scope
from agent.azure_identity_adapter import EntraIdentityConfig, _probe_token, build_credential
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
home_a, home_b = tmp_path / "home-A", tmp_path / "home-B"
for home in (home_a, home_b):
home.mkdir()
(home_a / ".env").write_text("AZURE_TENANT_ID=tenant-A\nAZURE_CLIENT_ID=client-A\nAZURE_CLIENT_SECRET=secret-A\n")
(home_b / ".env").write_text("")
# The launch profile's .env is in the process env, exactly what DefaultAzureCredential reads.
monkeypatch.setenv("AZURE_TENANT_ID", "tenant-A")
monkeypatch.setenv("AZURE_CLIENT_ID", "client-A")
monkeypatch.setenv("AZURE_CLIENT_SECRET", "secret-A")
config = EntraIdentityConfig()
def in_scope(home, fn):
h_tok = set_hermes_home_override(str(home))
s_tok = secret_scope.set_secret_scope(secret_scope.build_profile_secret_scope(home))
try:
return fn()
finally:
secret_scope.reset_secret_scope(s_tok)
reset_hermes_home_override(h_tok)
# Control: standalone (no multiplex, no override) keeps today's ambient chain.
assert build_credential(config).kwargs is not None
assert fake_azure_identity.credential_count == 1
monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True)
assert in_scope(home_a, lambda: build_credential(config)).client_id == "client-A"
with pytest.raises(RuntimeError, match="refused for this profile"):
in_scope(home_b, lambda: build_credential(config))
probe = in_scope(home_b, lambda: _probe_token(config, 5.0))
assert "refused for this profile" in probe["error"]
assert in_scope(home_a, lambda: build_credential(config)).client_id == "client-A"
# Absence on the wrong side: B never reached the ambient chain.
assert fake_azure_identity.credential_count == 1
class TestBuildTokenProvider:
def test_returns_callable_for_scope(self, fake_azure_identity):
from agent.azure_identity_adapter import build_token_provider
provider = build_token_provider(scope="https://ai.azure.com/.default")
assert callable(provider)
assert provider() == "jwt-for-https://ai.azure.com/.default"
assert fake_azure_identity.last_scope == "https://ai.azure.com/.default"
def test_config_object_wins_over_kwargs(self, fake_azure_identity):
from agent.azure_identity_adapter import (
EntraIdentityConfig,
build_token_provider,
)
cfg = EntraIdentityConfig(scope="cfg-scope")
build_token_provider(scope="ignored", config=cfg)
assert fake_azure_identity.last_scope == "cfg-scope"
assert fake_azure_identity.last_credential_kwargs == {}
# ---------------------------------------------------------------------------
# Lazy-install / missing-package surface
# ---------------------------------------------------------------------------
class TestRequireAzureIdentityMissing:
def test_clear_error_when_lazy_install_disabled(self, monkeypatch):
"""When azure-identity isn't importable AND lazy installs are
off, the adapter must raise ImportError with an actionable
message, not propagate FeatureUnavailable."""
from agent import azure_identity_adapter as _adapter
# Force the import path to fail.
original_import = __builtins__["__import__"] if isinstance(__builtins__, dict) else __import__
def _fake_import(name, *args, **kwargs):
if name == "azure.identity" or name.startswith("azure.identity."):
raise ImportError("simulated missing azure-identity")
return original_import(name, *args, **kwargs)
monkeypatch.setattr("builtins.__import__", _fake_import)
# Simulate lazy installs disabled.
from pm import InstallError as FeatureUnavailable
def _fake_ensure(*args, **kwargs):
raise FeatureUnavailable(
"azure-identity",
"lazy installs disabled (test simulation)",
)
# The adapter calls ``ensure_import`` from ``pm``; intercept
# it by patching the actual symbol path.
monkeypatch.setattr("pm.ensure_import", _fake_ensure)
with pytest.raises(ImportError) as exc_info:
_adapter._require_azure_identity()
assert "azure-identity" in str(exc_info.value)
# ---------------------------------------------------------------------------
# has_azure_identity_credentials probe (timeout-bounded)
# ---------------------------------------------------------------------------
class TestHasAzureIdentityCredentials:
def test_lazy_install_triggered_when_package_missing(self, monkeypatch):
"""With allow_install=True (default), the probe must trigger the
lazy-install path before bailing — otherwise the wizard's
``preflight`` would silently fail for fresh installs that haven't
enabled the Azure identity extra yet."""
from agent import azure_identity_adapter as _adapter
installed = {"called": False}
def _fake_install():
installed["called"] = True
# After install, pretend the package is now importable.
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
return SimpleNamespace(
DefaultAzureCredential=lambda **kw: SimpleNamespace(
kwargs=kw,
get_token=lambda scope: SimpleNamespace(token="post-install-jwt", expires_on=0),
),
get_bearer_token_provider=lambda c, s: lambda: "x",
)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
monkeypatch.setattr(_adapter, "_require_azure_identity", _fake_install)
# Provide a credential factory so the probe proceeds after install.
monkeypatch.setattr(
_adapter, "build_credential",
lambda config: SimpleNamespace(
get_token=lambda scope: SimpleNamespace(token="probe-jwt", expires_on=0),
),
)
result = _adapter.has_azure_identity_credentials(
"https://x/.default", timeout_seconds=0.5,
)
assert installed["called"] is True, (
"has_azure_identity_credentials must trigger lazy install "
"before bailing"
)
assert result is True
def test_returns_false_on_timeout(self, monkeypatch):
"""Slow IMDS / network must time out, not hang the caller."""
import threading
from agent import azure_identity_adapter as _adapter
slow_release = threading.Event()
def _slow_credential(_config):
class _Cred:
def get_token(self, scope):
# Block forever from the test's perspective; the
# adapter must give up via its thread-bounded probe.
slow_release.wait(timeout=10)
return SimpleNamespace(token="never-returned", expires_on=0)
return _Cred()
monkeypatch.setattr(_adapter, "build_credential", _slow_credential)
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: True)
try:
assert _adapter.has_azure_identity_credentials(
"https://x/.default", timeout_seconds=0.1
) is False
finally:
slow_release.set()
# ---------------------------------------------------------------------------
# describe_active_credential — used by hermes doctor + hermes auth
# ---------------------------------------------------------------------------
class TestDescribeActiveCredential:
def test_reports_install_failure(self, monkeypatch):
"""When lazy install is allowed but fails (e.g. lazy installs
disabled), the diagnostic surfaces the failure as the error."""
from agent import azure_identity_adapter as _adapter
monkeypatch.setattr(_adapter, "has_azure_identity_installed", lambda: False)
def _fail_install():
raise ImportError("simulated: lazy installs disabled")
monkeypatch.setattr(_adapter, "_require_azure_identity", _fail_install)
info = _adapter.describe_active_credential(
scope="https://x/.default", allow_install=True,
)
assert info["ok"] is False
assert "lazy installs disabled" in info["error"]
assert "hermes pm install --extra azure-identity" in info["hint"]
def test_reports_env_sources_for_managed_identity(self, fake_azure_identity, monkeypatch):
from agent.azure_identity_adapter import describe_active_credential
monkeypatch.setenv("IDENTITY_ENDPOINT", "http://169.254.169.254")
info = describe_active_credential(scope="https://x/.default", timeout_seconds=0.5)
assert info["ok"] is True
sources = info.get("env_sources") or []
assert any("ManagedIdentity" in s for s in sources)