From 0e4380c414d4280cea5034810dc608093db8539a Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 13 Jul 2026 21:46:46 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20(python-sdk):=20Add=20resolve?= =?UTF-8?q?=5Fgateway=5Fgrpc=5Fendpoint=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration is the SDK's only direct gateway gRPC call and must target the gateway's gRPC port (:50051), not the REST gateway_url (:7391). Add a resolver that derives the gRPC endpoint from AA_GATEWAY_ENDPOINT, else the resolved REST host with the gRPC port substituted, else the loopback default. Refs AAASM-4547 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rk7iwwAuGv6HbwK8hY8Fbr --- agent_assembly/core/gateway_resolver.py | 48 ++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/agent_assembly/core/gateway_resolver.py b/agent_assembly/core/gateway_resolver.py index 8fc1e8d1..bc4cfecd 100644 --- a/agent_assembly/core/gateway_resolver.py +++ b/agent_assembly/core/gateway_resolver.py @@ -26,6 +26,7 @@ import warnings from pathlib import Path from typing import Any +from urllib.parse import urlparse import httpx @@ -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 @@ -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, @@ -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. From a6f093b057565c8a92261613fa2390807c27fa57 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 13 Jul 2026 21:49:08 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20(python-sdk):=20Register=20t?= =?UTF-8?q?argets=20derived=20gRPC=20endpoint,=20not=20REST=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent registration is the SDK's one direct gateway gRPC call and must reach the gateway's gRPC port (:50051). init_assembly now resolves that endpoint from the gateway host (via resolve_gateway_grpc_endpoint) and threads it into the native register instead of leaving it unset — so a non-loopback gateway host registers against that host's gRPC port rather than always 127.0.0.1, and the REST gateway_url (:7391) is never dialed for registration. Refs AAASM-4547 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rk7iwwAuGv6HbwK8hY8Fbr --- agent_assembly/core/assembly.py | 14 ++++++++++- test/unit/core/test_init_registration.py | 30 +++++++++++++++++------- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index d7c46d96..a608a812 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -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, @@ -255,6 +259,7 @@ def init_assembly( runtime_client=runtime_client, agent_id=resolved_agent_id, enforcement_mode=enforcement_mode, + gateway_endpoint=resolve_gateway_grpc_endpoint(gateway_url), team_id=team_id, parent_agent_id=parent_agent_id, ) @@ -316,6 +321,7 @@ def _register_agent_with_gateway( runtime_client: Any | None, agent_id: str, enforcement_mode: EnforcementMode | None, + gateway_endpoint: str, team_id: str | None = None, parent_agent_id: str | None = None, ) -> None: @@ -326,6 +332,11 @@ def _register_agent_with_gateway( ``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). @@ -344,6 +355,7 @@ def _register_agent_with_gateway( runtime_client, agent_id, framework, + gateway_endpoint=gateway_endpoint, team_id=team_id, parent_agent_id=parent_agent_id, ) diff --git a/test/unit/core/test_init_registration.py b/test/unit/core/test_init_registration.py index 460cce7e..1650067d 100644 --- a/test/unit/core/test_init_registration.py +++ b/test/unit/core/test_init_registration.py @@ -27,6 +27,10 @@ _GW_URL = "http://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" class _CapturingAdapter(FrameworkAdapter): @@ -73,7 +77,7 @@ def test_init_assembly_registers_agent_on_init(monkeypatch: pytest.MonkeyPatch) context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="agent-7", mode="sdk-only") try: - assert runtime_client.register_calls == [("agent-7", "agent-7", "python", None, None, None)] + assert runtime_client.register_calls == [("agent-7", "agent-7", "python", _GRPC_ENDPOINT, None, None)] finally: context.shutdown() @@ -96,7 +100,9 @@ def test_init_assembly_forwards_team_and_parent_on_register( parent_agent_id="parent-42", ) try: - assert runtime_client.register_calls == [("child-1", "child-1", "python", None, "team-payments", "parent-42")] + assert runtime_client.register_calls == [ + ("child-1", "child-1", "python", _GRPC_ENDPOINT, "team-payments", "parent-42") + ] finally: context.shutdown() @@ -118,7 +124,9 @@ def test_init_assembly_forwards_only_team_when_parent_absent( team_id="team-billing", ) try: - assert runtime_client.register_calls == [("team-only", "team-only", "python", None, "team-billing", None)] + assert runtime_client.register_calls == [ + ("team-only", "team-only", "python", _GRPC_ENDPOINT, "team-billing", None) + ] finally: context.shutdown() @@ -140,7 +148,9 @@ def test_init_assembly_forwards_only_parent_when_team_absent( parent_agent_id="orchestrator-1", ) try: - assert runtime_client.register_calls == [("parent-only", "parent-only", "python", None, None, "orchestrator-1")] + assert runtime_client.register_calls == [ + ("parent-only", "parent-only", "python", _GRPC_ENDPOINT, None, "orchestrator-1") + ] finally: context.shutdown() @@ -159,7 +169,7 @@ def test_init_assembly_no_lineage_when_neither_set(monkeypatch: pytest.MonkeyPat mode="sdk-only", ) try: - assert runtime_client.register_calls == [("solo", "solo", "python", None, None, None)] + assert runtime_client.register_calls == [("solo", "solo", "python", _GRPC_ENDPOINT, None, None)] finally: context.shutdown() @@ -188,7 +198,7 @@ def test_init_assembly_forwards_ambient_spawn_parent_on_register( ) try: assert runtime_client.register_calls == [ - ("spawned-child", "spawned-child", "python", None, None, "ambient-parent") + ("spawned-child", "spawned-child", "python", _GRPC_ENDPOINT, None, "ambient-parent") ] finally: context.shutdown() @@ -214,7 +224,7 @@ def test_explicit_parent_overrides_ambient_spawn_parent( ) try: assert runtime_client.register_calls == [ - ("override-child", "override-child", "python", None, None, "explicit-parent") + ("override-child", "override-child", "python", _GRPC_ENDPOINT, None, "explicit-parent") ] finally: context.shutdown() @@ -241,7 +251,7 @@ def test_register_falls_back_on_older_native_build_without_lineage_kwargs( try: # Lineage is dropped against an old core, but registration still succeeds # via the 4-arg legacy signature — no exception. - assert legacy_client.register_calls == [("legacy-agent", "legacy-agent", "python", None)] + assert legacy_client.register_calls == [("legacy-agent", "legacy-agent", "python", _GRPC_ENDPOINT)] finally: context.shutdown() @@ -266,7 +276,9 @@ def test_init_assembly_lineage_values_round_trip_verbatim( parent_agent_id=parent, ) try: - assert runtime_client.register_calls == [("unicode-child", "unicode-child", "python", None, team, parent)] + assert runtime_client.register_calls == [ + ("unicode-child", "unicode-child", "python", _GRPC_ENDPOINT, team, parent) + ] finally: context.shutdown() From f92fd6a90a1b7f5a80945016f372d300d91f2eff Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 13 Jul 2026 21:51:26 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20(python-sdk):=20Warn=20loudl?= =?UTF-8?q?y=20and=20expose=20unregistered=20state=20on=20register=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connect_runtime_client returning None (native _core absent on a pure-Python install) or a register failure was previously swallowed silently, so init returned a clean-looking context for an agent that never reached the gateway and will never appear in the dashboard. Emit a loud stderr warning (mirroring the Node SDK's warnAgentUnregistered) and surface a `registered` flag on the AssemblyContext so callers can detect the gap programmatically. Init still proceeds (proxy / eBPF stay authoritative); enforce still fails init closed on a register error. Refs AAASM-4547 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rk7iwwAuGv6HbwK8hY8Fbr --- agent_assembly/core/assembly.py | 69 ++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index a608a812..f69fddac 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -98,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) @@ -252,14 +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, ) @@ -281,6 +290,7 @@ def init_assembly( adapters=registered_adapters, network_mode=network_mode, _network_shutdown=network_shutdown, + registered=registered, ) _ACTIVE_CONTEXT = context return context @@ -316,15 +326,42 @@ 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 @@ -341,14 +378,25 @@ def _register_agent_with_gateway( 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( @@ -359,9 +407,12 @@ def _register_agent_with_gateway( 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( From 62dec25a87f52647dfe917d851c4a14557500856 Mon Sep 17 00:00:00 2001 From: Bryant Date: Mon, 13 Jul 2026 21:53:15 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=85=20(python-sdk):=20Test=20gRPC=20e?= =?UTF-8?q?ndpoint=20resolution=20and=20unregistered=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover resolve_gateway_grpc_endpoint (env override, REST->gRPC port swap, non-loopback host preserved, loopback default) and the loud unregistered path: native absent, native-present-no-client, and register-failure-under-observe all warn to stderr and set ctx.registered False; a successful register sets it True. Refs AAASM-4547 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rk7iwwAuGv6HbwK8hY8Fbr --- test/unit/core/test_gateway_resolver.py | 28 ++++++++ test/unit/core/test_init_registration.py | 82 ++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/test/unit/core/test_gateway_resolver.py b/test/unit/core/test_gateway_resolver.py index bd12a37e..2a8c474d 100644 --- a/test/unit/core/test_gateway_resolver.py +++ b/test/unit/core/test_gateway_resolver.py @@ -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 diff --git a/test/unit/core/test_init_registration.py b/test/unit/core/test_init_registration.py index 1650067d..90b76361 100644 --- a/test/unit/core/test_init_registration.py +++ b/test/unit/core/test_init_registration.py @@ -531,3 +531,85 @@ def _impl( return [adapter] return _impl + + +def test_successful_register_marks_context_registered(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful native register sets ``ctx.registered`` True (AAASM-4547).""" + runtime_client = FakeRuntimeClient(decision="allow") + install_fake_core(monkeypatch, runtime_client) + _no_network(monkeypatch) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + + context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="reg-ok", mode="sdk-only") + try: + assert context.registered is True + finally: + context.shutdown() + + +def test_native_absent_warns_loudly_and_marks_unregistered( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """With the native extension absent, init no longer silently skips register: + it warns loudly and reports ``ctx.registered`` False (AAASM-4547).""" + monkeypatch.setattr(core_assembly, "_native_core_available", lambda: False) + _no_network(monkeypatch) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + + context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="no-native", mode="sdk-only") + try: + assert context.registered is False + err = capsys.readouterr().err + assert "NOT registered" in err + assert "agent_assembly._core" in err + assert "AAASM-4547" in err + finally: + context.shutdown() + + +def test_native_present_but_no_runtime_client_warns_and_marks_unregistered( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Native present but the runtime client could not be established (e.g. a + distrusted socket) → warn + ``registered`` False, not a silent skip (AAASM-4547).""" + monkeypatch.setattr(core_assembly, "_native_core_available", lambda: True) + monkeypatch.setattr(core_assembly, "connect_runtime_client", lambda _agent_id: None) + _no_network(monkeypatch) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + + context = init_assembly(gateway_url=_GW_URL, api_key=_API_KEY, agent_id="no-client", mode="sdk-only") + try: + assert context.registered is False + assert "NOT registered" in capsys.readouterr().err + finally: + context.shutdown() + + +def test_register_failure_under_observe_warns_and_marks_unregistered( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Under observe a register failure no longer aborts init, but it is now loud + and reflected in ``ctx.registered`` False rather than swallowed (AAASM-4547).""" + runtime_client = FakeRuntimeClient(decision="allow") + runtime_client.register_should_raise = RuntimeError("gateway gRPC endpoint is unreachable") + install_fake_core(monkeypatch, runtime_client) + _no_network(monkeypatch) + monkeypatch.setattr(core_assembly, "_register_adapters", lambda **_kwargs: []) + + context = init_assembly( + gateway_url=_GW_URL, + api_key=_API_KEY, + agent_id="reg-fail", + mode="sdk-only", + enforcement_mode="observe", + ) + try: + assert context.registered is False + err = capsys.readouterr().err + assert "NOT registered" in err + assert "registration failed" in err + finally: + context.shutdown()