From e14f33a67075eefdc674d05a85b7f4cf55914068 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 1 May 2026 16:26:58 -0700 Subject: [PATCH] feat(executor): forward --dangerously-load-development-channels to claude CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wheel-side push UX gates (capability + instructions, molecule-core PR #2463) only matter if the host claude CLI is willing to register a non-allowlisted experimental channel. During the channels research preview the CLI requires --dangerously-load-development-channels to bypass its allowlist; without it, every notifications/claude/channel fired by the inbox bridge arrives at the host and is silently dropped. claude-agent-sdk forwards arbitrary CLI flags to the spawned subprocess via ClaudeAgentOptions.extra_args (claude_agent_sdk/_internal/transport/ subprocess_cli.py:340). Wire the flag in unconditionally — the flag is harmless on builds that already allowlist the capability and required on builds during the research preview, so there is no version skew to guard. Remove the line once channels graduate to the default allowlist. Test pins the wiring with a stubbed ClaudeAgentOptions recorder; runs in CI without claude_agent_sdk / a2a / molecule_runtime installed via the same _ensure_module/_ensure_attr pattern as the existing adapter prevalidate test, but tolerates real packages being present locally. Verified by injection: removing the extra_args line makes the test fail with a message naming the missing flag and citing the SDK file that consumes it. Co-Authored-By: Claude Opus 4.7 (1M context) --- claude_sdk_executor.py | 8 ++ tests/test_dev_channels_flag.py | 146 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/test_dev_channels_flag.py diff --git a/claude_sdk_executor.py b/claude_sdk_executor.py index e54c24d..fb545c9 100644 --- a/claude_sdk_executor.py +++ b/claude_sdk_executor.py @@ -463,6 +463,14 @@ class ClaudeSDKExecutor(AgentExecutor): mcp_servers=mcp_servers, system_prompt=self._build_system_prompt(), resume=self._session_id, + # Forward --dangerously-load-development-channels to the spawned + # claude CLI so the host registers our experimental.claude/channel + # capability instead of dropping the notification on the allowlist + # check. The wheel ships the gates (PR molecule-core#2463) and the + # inbox bridge fires the notification, but without this flag the + # CLI silently filters it during the channels research preview. + # Remove once channels graduate to the default allowlist. + extra_args={"dangerously-load-development-channels": None}, ) # --- output_config: effort + task_budget (issue #652) --- diff --git a/tests/test_dev_channels_flag.py b/tests/test_dev_channels_flag.py new file mode 100644 index 0000000..9bd3898 --- /dev/null +++ b/tests/test_dev_channels_flag.py @@ -0,0 +1,146 @@ +"""Pin --dangerously-load-development-channels into ClaudeAgentOptions. + +The wheel-side push UX gates (capability + instructions, molecule-core +PR #2463) only fire if the host claude CLI loads non-allowlisted +channels. During the research preview the host requires the +``--dangerously-load-development-channels`` CLI flag to bypass its +allowlist; without it ``notifications/claude/channel`` arrives at the +host and is silently dropped. claude-agent-sdk forwards arbitrary +flags to the CLI subprocess via ``ClaudeAgentOptions.extra_args``, so +``_build_options`` must include this flag in every options object it +returns. + +This test pins that the flag is wired by stubbing ``claude_agent_sdk`` +to a recorder, then asserting the captured kwargs include the flag. +Regression-injection-checked: deleting the ``extra_args`` line from +``_build_options`` makes this test fail with a clear message naming the +missing flag. +""" + +import os +import sys +import types +from unittest.mock import MagicMock + + +# ---- Stubs ---- +# +# claude_sdk_executor.py imports a tall stack at module load: +# - claude_agent_sdk (the SDK we're trying to inspect kwargs for) +# - a2a.* (server.agent_execution, server.events, helpers) +# - molecule_runtime.executor_helpers (a long re-export bundle) +# - yaml +# +# yaml is real and available in CI. The rest get replaced with the +# minimum surface the executor module touches at import + ``__init__`` +# + ``_build_options`` time. Any attribute access we miss surfaces as +# ``AttributeError`` immediately, not silent test pass. + + +def _ensure_module(dotted: str) -> types.ModuleType: + """Return ``sys.modules[dotted]`` if real, else create + register a stub. + + Idempotent: re-running with the real package installed leaves it in + place; we only ever ADD attributes (via ``_ensure_attr``), never + overwrite anything the real module already exposed. + """ + if dotted not in sys.modules: + sys.modules[dotted] = types.ModuleType(dotted) + return sys.modules[dotted] + + +def _ensure_attr(mod: types.ModuleType, name: str, value: object) -> None: + """Install ``value`` as ``mod.name`` only if missing. + + Avoids clobbering a real package's symbols when the test runs in an + environment where the real dep is installed (CI: stubs win; + workstation: real package wins, we just fill in what's missing). + """ + if not hasattr(mod, name): + setattr(mod, name, value) + + +def _install_stubs(): + sdk = _ensure_module("claude_agent_sdk") + _ensure_attr(sdk, "ClaudeAgentOptions", MagicMock(name="ClaudeAgentOptions")) + _ensure_attr(sdk, "AssistantMessage", type("AssistantMessage", (), {})) + _ensure_attr(sdk, "TextBlock", type("TextBlock", (), {})) + _ensure_attr(sdk, "ResultMessage", type("ResultMessage", (), {})) + _ensure_attr(sdk, "query", MagicMock(name="query")) + + _ensure_module("a2a") + _ensure_module("a2a.server") + a2a_exec = _ensure_module("a2a.server.agent_execution") + _ensure_attr(a2a_exec, "AgentExecutor", type("AgentExecutor", (), {})) + _ensure_attr(a2a_exec, "RequestContext", type("RequestContext", (), {})) + a2a_events = _ensure_module("a2a.server.events") + _ensure_attr(a2a_events, "EventQueue", type("EventQueue", (), {})) + a2a_helpers = _ensure_module("a2a.helpers") + _ensure_attr(a2a_helpers, "new_text_message", lambda *_a, **_kw: None) + + _ensure_module("molecule_runtime") + helpers = _ensure_module("molecule_runtime.executor_helpers") + _ensure_attr(helpers, "CONFIG_MOUNT", "/configs") + _ensure_attr(helpers, "WORKSPACE_MOUNT", "/workspace") + _ensure_attr(helpers, "MEMORY_CONTENT_MAX_CHARS", 10000) + _ensure_attr(helpers, "auto_push_hook", lambda *a, **kw: None) + _ensure_attr(helpers, "brief_summary", lambda *a, **kw: "") + _ensure_attr(helpers, "collect_outbound_files", lambda *a, **kw: []) + _ensure_attr(helpers, "commit_memory", lambda *a, **kw: None) + _ensure_attr(helpers, "extract_attached_files", lambda *a, **kw: []) + _ensure_attr(helpers, "extract_message_text", lambda *a, **kw: "") + _ensure_attr(helpers, "get_a2a_instructions", lambda **kw: "") + _ensure_attr(helpers, "get_hma_instructions", lambda *a, **kw: "") + _ensure_attr(helpers, "get_mcp_server_path", lambda *a, **kw: "/dev/null") + _ensure_attr(helpers, "get_system_prompt", lambda *a, **kw: "") + _ensure_attr(helpers, "read_delegation_results", lambda *a, **kw: "") + _ensure_attr(helpers, "recall_memories", lambda *a, **kw: "") + _ensure_attr(helpers, "sanitize_agent_error", lambda e: str(e)) + _ensure_attr(helpers, "set_current_task", lambda *a, **kw: None) + + +def _load_executor(): + """Import claude_sdk_executor with stubs installed.""" + _install_stubs() + parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + sys.modules.pop("claude_sdk_executor", None) + import claude_sdk_executor # noqa: WPS433 + return claude_sdk_executor + + +def test_build_options_forwards_dev_channels_flag(tmp_path): + """``_build_options`` must include ``--dangerously-load-development-channels`` + in ``extra_args`` so the spawned claude CLI registers our experimental + channel capability instead of silently dropping the notification. + """ + mod = _load_executor() + sdk = sys.modules["claude_agent_sdk"] + sdk.ClaudeAgentOptions.reset_mock() + + executor = mod.ClaudeSDKExecutor( + system_prompt=None, + config_path=str(tmp_path), + heartbeat=None, + model="sonnet", + ) + executor._build_options() + + assert sdk.ClaudeAgentOptions.called, ( + "ClaudeAgentOptions was never called — _build_options likely raised " + "before reaching the constructor" + ) + kwargs = sdk.ClaudeAgentOptions.call_args.kwargs + assert "extra_args" in kwargs, ( + "extra_args missing from ClaudeAgentOptions kwargs — the host " + "claude CLI will never see --dangerously-load-development-channels " + "and notifications/claude/channel will be filtered by the allowlist" + ) + assert kwargs["extra_args"] == { + "dangerously-load-development-channels": None, + }, ( + "extra_args has wrong shape — claude-agent-sdk's " + "subprocess_cli.py:340-346 reads {flag: None} as a bare CLI " + "switch; got %r" % (kwargs["extra_args"],) + )