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
83 changes: 73 additions & 10 deletions agent_assembly/core/assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
from agent_assembly.adapters.langchain.runtime import get_active_callback_handler
from agent_assembly.adapters.registry import AdapterRegistry
from agent_assembly.client.gateway import GatewayClient
from agent_assembly.core.gateway_resolver import resolve_api_key, resolve_gateway_url
from agent_assembly.core.gateway_resolver import (
resolve_api_key,
resolve_gateway_grpc_endpoint,
resolve_gateway_url,
)
from agent_assembly.core.runtime_interceptor import (
_native_core_available,
build_governance_interceptor,
Expand Down Expand Up @@ -94,6 +98,13 @@ class AssemblyContext:
adapters: list[FrameworkAdapter]
network_mode: NetworkMode
_network_shutdown: Callable[[], None]
# Whether this SDK actually registered the agent with the governance gateway
# during init. False means the agent will NOT appear in the dashboard /
# ``GET /api/v1/agents`` — the native extension was absent or registration
# failed. Surfaced so callers can detect the unregistered state
# programmatically rather than relying on the stderr warning alone
# (AAASM-4547, mirroring the Node SDK's ``ctx.registered``).
registered: bool = True
_lock: Lock = field(default_factory=Lock, init=False, repr=False)
_is_shutdown: bool = field(default=False, init=False, repr=False)

Expand Down Expand Up @@ -248,13 +259,16 @@ def init_assembly(
registered_adapters: list[FrameworkAdapter] = []
network_mode: NetworkMode = "sdk-only"
network_shutdown: Callable[[], None] = _noop_shutdown
registered = False
try:
native_available = _native_core_available()
runtime_client = connect_runtime_client(resolved_agent_id) if native_available else None
_register_agent_with_gateway(
registered = _register_agent_with_gateway(
runtime_client=runtime_client,
agent_id=resolved_agent_id,
enforcement_mode=enforcement_mode,
gateway_endpoint=resolve_gateway_grpc_endpoint(gateway_url),
native_available=native_available,
team_id=team_id,
parent_agent_id=parent_agent_id,
)
Expand All @@ -276,6 +290,7 @@ def init_assembly(
adapters=registered_adapters,
network_mode=network_mode,
_network_shutdown=network_shutdown,
registered=registered,
)
_ACTIVE_CONTEXT = context
return context
Expand Down Expand Up @@ -311,45 +326,93 @@ def _validate_inputs(
return gateway_url, control_plane_url


def _warn_agent_unregistered(detail: str) -> None:
"""Emit a loud, unconditional stderr warning that the agent is unregistered.

Registration is what makes an agent visible to the gateway — it appears in
the dashboard / ``GET /api/v1/agents`` and gets policy/budget tracking. When
registration cannot happen (the native ``agent_assembly._core`` extension is
absent on a pure-Python / unsupported-platform install, or the gateway gRPC
endpoint is unreachable), a governance SDK must not report a clean init for an
agent that never registered. Previously this failure was silent (the
AAASM-4446 shape); now it mirrors the Node SDK's ``warnAgentUnregistered``
(AAASM-4468): written straight to ``sys.stderr`` so ``logging`` configuration
cannot silence it, once per ``init_assembly`` call (AAASM-4547).

:param detail: A short clause explaining *why* registration did not happen,
interpolated into the warning (must not contain credentials).
"""
sys.stderr.write(
"[agent-assembly] WARNING: the agent is NOT registered with the "
f"governance gateway ({detail}). It will NOT appear in the dashboard or "
"GET /api/v1/agents, and the gateway will not track its policy or budget "
"state; the proxy / eBPF layers remain authoritative. Inspect the "
"'registered' attribute on the returned assembly context to detect this "
"programmatically (AAASM-4547).\n"
)


def _register_agent_with_gateway(
*,
runtime_client: Any | None,
agent_id: str,
enforcement_mode: EnforcementMode | None,
gateway_endpoint: str,
native_available: bool,
team_id: str | None = None,
parent_agent_id: str | None = None,
) -> None:
) -> bool:
"""Register the agent with the gateway over the native gRPC ``register``.

Registration goes through the native runtime client (AAASM-3399) so the
issued credential token is stored on the same client the
``RuntimeQueryInterceptor`` later uses for ``query_policy`` — the SDK never
calls a core HTTP endpoint directly (ADR 0004).

``gateway_endpoint`` is the gateway's **gRPC** endpoint (:50051) the direct
register call dials — distinct from the REST ``gateway_url`` (:7391) the HTTP
client uses (AAASM-4547). It is resolved by
:func:`~agent_assembly.core.gateway_resolver.resolve_gateway_grpc_endpoint`.

``team_id`` and ``parent_agent_id`` are forwarded to the native register so
the gateway gets the agent's team-budget scoping and topology lineage on the
native path, restoring what the legacy REST register sent (AAASM-3415).

No native runtime (extension missing or socket unreachable) means there is
nothing to register against: the call is skipped. Under ``enforce`` a native
registration failure propagates so a misconfigured gateway surfaces at init;
under ``observe`` / ``disabled`` (or no mode) it is swallowed so the dry-run
/ hermetic-test layer never hard-fails on registration.
Returns whether the agent was registered. When there is no native runtime
client to register through (``native_available`` False → extension missing on
a pure-Python install; or True but the socket path was distrusted), or when
``register`` raises, the failure is no longer silent: a loud
:func:`_warn_agent_unregistered` fires and ``False`` is returned (AAASM-4547).
Init still proceeds so the proxy / eBPF layers stay authoritative — except
under ``enforce``, where a ``register`` failure propagates so a misconfigured
gateway fails init closed.
"""
if runtime_client is None:
return
if native_available:
detail = "the native runtime client could not be established"
else:
detail = (
"the native agent_assembly._core extension is not installed, so this "
"build cannot perform in-SDK gateway registration"
)
_warn_agent_unregistered(detail)
return False
framework = "python"
try:
register_agent(
runtime_client,
agent_id,
framework,
gateway_endpoint=gateway_endpoint,
team_id=team_id,
parent_agent_id=parent_agent_id,
)
except Exception:
except Exception as error:
if enforcement_mode == "enforce":
raise
_warn_agent_unregistered(f"registration failed: {error}")
return False
return True


def _register_adapters(
Expand Down
48 changes: 47 additions & 1 deletion agent_assembly/core/gateway_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import warnings
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import httpx

Expand All @@ -34,6 +35,15 @@
logger = logging.getLogger(__name__)

DEFAULT_GATEWAY_URL = "http://localhost:7391"

# Agent registration is the SDK's one *direct* gateway gRPC call
# (``AgentLifecycleService.Register`` via ``aa-sdk-client``). It targets the
# gateway's gRPC port, which is distinct from the REST port the ``gateway_url``
# HTTP client uses — dialing the REST port for registration hits a socket with no
# ``AgentLifecycleService`` and fails (AAASM-4547).
DEFAULT_GRPC_PORT = 50051
DEFAULT_GRPC_ENDPOINT = "http://127.0.0.1:50051"
ENV_GATEWAY_ENDPOINT = "AA_GATEWAY_ENDPOINT"
DEFAULT_HEALTHZ_PATH = "/healthz"
DEFAULT_PROBE_TIMEOUT_SECONDS = 0.5
DEFAULT_AUTO_START_TIMEOUT_SECONDS = 5.0
Expand Down Expand Up @@ -70,7 +80,7 @@ def _warn_if_world_readable(path: Path) -> None:
return
if mode & 0o077:
logger.warning(
"Config file %s has group/other-readable permissions (mode %o); " "run `chmod 600 %s` to secure it.",
"Config file %s has group/other-readable permissions (mode %o); run `chmod 600 %s` to secure it.",
path,
mode,
path,
Expand Down Expand Up @@ -225,6 +235,42 @@ def resolve_gateway_url(explicit: str | None = None) -> str:
return DEFAULT_GATEWAY_URL


def resolve_gateway_grpc_endpoint(gateway_url: str | None = None) -> str:
"""Resolve the gRPC endpoint the native ``register`` call should dial.

Agent registration goes over the gateway's **gRPC** port (:50051), which is a
different port from the **REST** ``gateway_url`` (:7391) the HTTP client uses.
Passing the REST URL into ``register`` would dial a port that exposes no
``AgentLifecycleService`` and fail, so the register endpoint is resolved
separately here (AAASM-4547).

Resolution precedence (highest first):

1. ``AA_GATEWAY_ENDPOINT`` — explicit operator override, honoured verbatim
(the same env var the native ``aa-sdk-client`` resolver reads, so a
value set for one path applies to both).
2. The resolved REST ``gateway_url``'s host with the gRPC port (:50051)
substituted — so a non-loopback gateway host registers against *that*
host's gRPC port rather than always ``127.0.0.1``.
3. The loopback default ``http://127.0.0.1:50051``.

:param gateway_url: The already-resolved REST gateway URL, used only for its
host. When it carries no usable host the loopback default is returned.
:returns: A gRPC endpoint URL (e.g. ``http://127.0.0.1:50051``).
"""
env_value = os.environ.get(ENV_GATEWAY_ENDPOINT)
if env_value:
return env_value

if gateway_url:
parsed = urlparse(gateway_url)
if parsed.hostname:
scheme = parsed.scheme or "http"
return f"{scheme}://{parsed.hostname}:{DEFAULT_GRPC_PORT}"

return DEFAULT_GRPC_ENDPOINT


def resolve_api_key(explicit: str | None = None) -> str:
"""Resolve the API key using the same 4-step precedence as the URL.

Expand Down
28 changes: 28 additions & 0 deletions test/unit/core/test_gateway_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,31 @@ def test_warns_when_mode_is_0644(self, tmp_path: Path, caplog: pytest.LogCapture
message = caplog.records[0].getMessage()
assert "chmod 600" in message
assert "644" in message


class TestResolveGatewayGrpcEndpoint:
"""The register gRPC endpoint (:50051) is resolved distinctly from the REST
``gateway_url`` (:7391) so registration never dials the REST port (AAASM-4547)."""

def test_env_override_wins_verbatim(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, "http://ep.example:9999")
assert gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.example:7391") == "http://ep.example:9999"

def test_derives_host_and_substitutes_grpc_port(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False)
# The REST :7391 URL becomes the same host on the gRPC :50051 port — not
# the REST port, which exposes no AgentLifecycleService.
assert gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.example:7391") == "http://gw.example:50051"

def test_preserves_non_loopback_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False)
# A remote gateway host registers against *that* host's gRPC port, not 127.0.0.1.
assert (
gateway_resolver.resolve_gateway_grpc_endpoint("https://gateway.internal")
== "https://gateway.internal:50051"
)

def test_falls_back_to_loopback_default_when_no_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False)
assert gateway_resolver.resolve_gateway_grpc_endpoint(None) == gateway_resolver.DEFAULT_GRPC_ENDPOINT
assert gateway_resolver.resolve_gateway_grpc_endpoint("") == gateway_resolver.DEFAULT_GRPC_ENDPOINT
Loading