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
28 changes: 25 additions & 3 deletions agent_assembly/core/gateway_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import httpx

from agent_assembly.core.transport_security import require_secure_grpc_target
from agent_assembly.exceptions import ConfigurationError, GatewayError

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -235,7 +236,7 @@ def resolve_gateway_url(explicit: str | None = None) -> str:
return DEFAULT_GATEWAY_URL


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

Agent registration goes over the gateway's **gRPC** port (:50051), which is a
Expand All @@ -248,15 +249,30 @@ def resolve_gateway_grpc_endpoint(gateway_url: str | None = None) -> str:

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).
value set for one path applies to both). As an explicit operator
escape hatch — the ``channel_factory`` analogue of the op-control path
(``op_control.py``) — it bypasses the TLS guard below; the operator has
taken responsibility for the transport they named.
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``.

A **derived** (branch 2) plaintext ``http://`` endpoint to a non-loopback host
is refused, mirroring the op-control stream's non-loopback→TLS contract
(``require_secure_grpc_target``, AAASM-3685): the ``Register`` call carries the
agent identity, so it must not travel unencrypted to a remote host by default.
``https://`` (TLS) and loopback targets always pass; pass ``allow_insecure`` to
opt into plaintext to a non-loopback host (loopback dev / trusted-network only),
exactly as op-control's ``connect`` does (AAASM-4655).

: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.
:param allow_insecure: Opt into a plaintext (non-TLS) register channel to a
non-loopback host. Defaults to ``False`` (fail-closed).
:returns: A gRPC endpoint URL (e.g. ``http://127.0.0.1:50051``).
:raises ValueError: When the derived endpoint is plaintext ``http://`` to a
non-loopback host and ``allow_insecure`` is not set.
"""
env_value = os.environ.get(ENV_GATEWAY_ENDPOINT)
if env_value:
Expand All @@ -266,7 +282,13 @@ def resolve_gateway_grpc_endpoint(gateway_url: str | None = None) -> str:
parsed = urlparse(gateway_url)
if parsed.hostname:
scheme = parsed.scheme or "http"
return f"{scheme}://{parsed.hostname}:{DEFAULT_GRPC_PORT}"
endpoint = f"{scheme}://{parsed.hostname}:{DEFAULT_GRPC_PORT}"
# Only plaintext http:// needs the guard; https:// carries its own TLS
# and loopback is the documented dev default (both pass through
# require_secure_grpc_target unconditionally).
if scheme == "http":
require_secure_grpc_target(endpoint, allow_insecure=allow_insecure)
return endpoint

return DEFAULT_GRPC_ENDPOINT

Expand Down
33 changes: 31 additions & 2 deletions test/unit/core/test_gateway_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,13 @@ def test_env_override_wins_verbatim(self, monkeypatch: pytest.MonkeyPatch) -> No
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"
# the REST port, which exposes no AgentLifecycleService. A non-loopback
# plaintext http:// target now requires the allow_insecure opt-in
# (AAASM-4655); the port-substitution behaviour itself is unchanged.
assert (
gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.example:7391", allow_insecure=True)
== "http://gw.example:50051"
)

def test_preserves_non_loopback_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False)
Expand All @@ -330,3 +335,27 @@ def test_falls_back_to_loopback_default_when_no_host(self, monkeypatch: pytest.M
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


class TestResolveGatewayGrpcEndpointTlsGuard:
"""The register endpoint mirrors op-control's non-loopback→TLS contract
(``require_secure_grpc_target``): a derived plaintext http:// target to a
non-loopback host is refused unless ``allow_insecure`` opts in (AAASM-4655)."""

def test_loopback_plaintext_allowed(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False)
# Loopback is the documented dev default — plaintext stays allowed.
assert gateway_resolver.resolve_gateway_grpc_endpoint("http://localhost:7391") == "http://localhost:50051"

def test_non_loopback_plaintext_allowed_with_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False)
assert (
gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.prod.example:7391", allow_insecure=True)
== "http://gw.prod.example:50051"
)

def test_non_loopback_plaintext_rejected_without_opt_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(gateway_resolver.ENV_GATEWAY_ENDPOINT, raising=False)
# Same secure-transport refusal op_control raises via require_secure_grpc_target.
with pytest.raises(ValueError, match="insecure"):
gateway_resolver.resolve_gateway_grpc_endpoint("http://gw.prod.example:7391")
9 changes: 7 additions & 2 deletions test/unit/core/test_init_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,17 @@
install_fake_core_with_connect,
)

_GW_URL = "http://gateway.test"
# A non-loopback gateway host over TLS (https) — remote gateways must be
# encrypted, since the register-endpoint TLS guard (AAASM-4655) fail-closes a
# plaintext http:// register channel to a non-loopback host. https keeps this a
# genuine non-loopback host (so the host→gRPC-port substitution below is still
# exercised) while passing the guard as correct remote usage.
_GW_URL = "https://gateway.test"
_API_KEY = "test-key"
# The gRPC register endpoint derived from _GW_URL's host with the gateway gRPC
# port (:50051) substituted for the REST port — see resolve_gateway_grpc_endpoint
# (AAASM-4547). register_agent forwards this as the 4th positional argument.
_GRPC_ENDPOINT = "http://gateway.test:50051"
_GRPC_ENDPOINT = "https://gateway.test:50051"


class _CapturingAdapter(FrameworkAdapter):
Expand Down
6 changes: 3 additions & 3 deletions test/unit/core/test_spawn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def fake_gateway_client(**kwargs: Any) -> Any:
spawn_context_scope(spawn_ctx),
):
ctx = assembly.init_assembly(
gateway_url="http://gw",
gateway_url="https://gw",
api_key="key",
agent_id="child-agent",
mode="sdk-only",
Expand Down Expand Up @@ -120,7 +120,7 @@ def fake_gateway_client(**kwargs: Any) -> Any:
spawn_context_scope(spawn_ctx),
):
ctx = assembly.init_assembly(
gateway_url="http://gw",
gateway_url="https://gw",
api_key="key",
agent_id="child",
mode="sdk-only",
Expand Down Expand Up @@ -154,7 +154,7 @@ def fake_gateway_client(**kwargs: Any) -> Any:
patch("agent_assembly.core.assembly._start_network_layer", return_value=("sdk-only", lambda: None)),
):
ctx = assembly.init_assembly(
gateway_url="http://gw",
gateway_url="https://gw",
api_key="key",
agent_id="solo-agent",
mode="sdk-only",
Expand Down
21 changes: 13 additions & 8 deletions test/unit/test_assembly.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,12 @@ def _fail_auto_start(_url: str = "") -> None:
)

context = init_assembly(
gateway_url="http://explicit.gw:9999",
gateway_url="https://explicit.gw:9999",
api_key="explicit-key",
agent_id="agent-x",
)
try:
assert context.client.gateway_url == "http://explicit.gw:9999"
assert context.client.gateway_url == "https://explicit.gw:9999"
assert context.client.api_key == "explicit-key"
assert context.client.agent_id == "agent-x"
finally:
Expand Down Expand Up @@ -503,7 +503,7 @@ def test_init_assembly_gateway_url_falls_back_to_env_var(
monkeypatch.setattr(gateway_resolver, "_probe_healthz", lambda _url: False)
monkeypatch.setattr(gateway_resolver, "resolve_gateway_url", lambda explicit=None: explicit or "")
monkeypatch.setattr(core_assembly, "resolve_gateway_url", lambda explicit=None: explicit or "")
monkeypatch.setenv(core_assembly.ENV_GATEWAY_URL, "http://env-gateway:7000")
monkeypatch.setenv(core_assembly.ENV_GATEWAY_URL, "https://env-gateway:7000")
monkeypatch.delenv(core_assembly.ENV_CONTROL_PLANE_URL, raising=False)
monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: [])
monkeypatch.setattr(
Expand All @@ -515,7 +515,7 @@ def test_init_assembly_gateway_url_falls_back_to_env_var(
context = init_assembly(api_key="test-api-key", agent_id="env-gw-agent")

try:
assert context.client.gateway_url == "http://env-gateway:7000"
assert context.client.gateway_url == "https://env-gateway:7000"
finally:
context.shutdown()

Expand Down Expand Up @@ -660,21 +660,26 @@ def test_init_assembly_enforcement_mode_defaults_to_none_to_preserve_wire_shape(
def test_init_assembly_warns_on_plaintext_http_with_api_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""AAASM-3725: a resolved non-loopback http:// gateway + API key warns."""
"""AAASM-3725: a resolved non-loopback http:// gateway + API key warns.

The early AAASM-3725 warning still fires for the plaintext non-loopback
gateway, but the AAASM-4655 register-endpoint TLS guard now fail-closes on
that same plaintext non-loopback target, so ``init_assembly`` raises after
the warning is emitted.
"""
monkeypatch.setattr(core_assembly, "_register_adapters", lambda **kwargs: [])
monkeypatch.setattr(
core_assembly,
"_start_network_layer",
lambda **kwargs: ("sdk-only", core_assembly._noop_shutdown),
)

with pytest.warns(UserWarning, match="unencrypted"):
context = init_assembly(
with pytest.warns(UserWarning, match="unencrypted"), pytest.raises(ConfigurationError):
init_assembly(
gateway_url="http://gw.remote:9999",
api_key="explicit-key",
agent_id="agent-warn",
)
context.shutdown()


def test_init_assembly_no_warning_for_loopback_http_with_api_key(
Expand Down