From 0c9aa10f41001607e6bdfbe51b8a7bcb81c685dc Mon Sep 17 00:00:00 2001 From: EloquentBrush0x Date: Sun, 13 Sep 2026 03:53:30 +0300 Subject: [PATCH] fix(kanban): install the assignee's secret scope before scrubbing worker env _default_spawn() called build_subprocess_env(scrub_secrets=is_multiplex_active()) with no profile secret scope installed. Under multiplex, any name registered via terminal.env_passthrough makes _filter_secret_env's resolve_passthrough_value() call get_secret() with no scope active, which fails closed with UnscopedSecretError -- crashing every Kanban worker spawn, for every profile, as soon as env_passthrough is configured anywhere. Mirror _resolve_worker_cli_toolsets's existing scope-then-read ordering a few functions up in the same file: resolve the assignee's HERMES_HOME first, install build_profile_secret_scope() around the env build, then set env["HERMES_HOME"] from the value already resolved instead of calling resolve_profile_env() twice. Co-Authored-By: Claude Sonnet 5 --- hermes_cli/kanban_db_dispatch.py | 40 +++++++++----- .../test_kanban_worker_spawn_toolsets.py | 54 +++++++++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/hermes_cli/kanban_db_dispatch.py b/hermes_cli/kanban_db_dispatch.py index 4312d9ccb9..9a27b8e1ec 100644 --- a/hermes_cli/kanban_db_dispatch.py +++ b/hermes_cli/kanban_db_dispatch.py @@ -2192,13 +2192,33 @@ def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) - profile_arg = normalize_profile_name(task.assignee) - from agent.secret_scope import is_multiplex_active + from agent.secret_scope import ( + build_profile_secret_scope, is_multiplex_active, reset_secret_scope, set_secret_scope) from tools.environments.local import build_subprocess_env, strip_launch_profile_env - env = build_subprocess_env( - scrub_secrets=is_multiplex_active(), - inherit_profile_home=True, - ) + try: + profile_home = resolve_profile_env(profile_arg) + except FileNotFoundError: + # No profile dir (isolated test fixtures) — the CLI resolves it from + # HERMES_PROFILE (set below) instead. + profile_home = None + + multiplex_active = is_multiplex_active() + # build_subprocess_env's secret scrub resolves terminal.env_passthrough vars + # through get_secret(), which raises UnscopedSecretError with no profile scope + # installed while multiplexing is on — mirrors _resolve_worker_cli_toolsets's + # own scope-then-read ordering a few functions up in this module. + secret_token = ( + set_secret_scope(build_profile_secret_scope(Path(profile_home))) + if multiplex_active and profile_home else None) + try: + env = build_subprocess_env( + scrub_secrets=multiplex_active, + inherit_profile_home=True, + ) + finally: + if secret_token is not None: + reset_secret_scope(secret_token) # The dispatcher is detached from every conversation; its worker must never # inherit routing mirrored by a previous gateway turn. from gateway.session_context import _VAR_MAP @@ -2209,15 +2229,11 @@ def _default_spawn(task: Task, workspace: str, *, board: Optional[str] = None) - # without it the child's get_hermes_home() falls back to the DEFAULT # profile root because `hermes -p` applies its override before # hermes_constants is imported. - try: - env["HERMES_HOME"] = resolve_profile_env(profile_arg) + if profile_home: + env["HERMES_HOME"] = profile_home # A multiplexer dispatching for another profile must not hand it the launch # profile's .env settings / TERMINAL_* policy — a standalone dispatcher never would. - strip_launch_profile_env(env, env["HERMES_HOME"]) - except FileNotFoundError: - # No profile dir (isolated test fixtures) — the CLI resolves it from - # HERMES_PROFILE (set below) instead. - pass + strip_launch_profile_env(env, profile_home) if task.tenant: env["HERMES_TENANT"] = task.tenant env["HERMES_KANBAN_TASK"] = task.id diff --git a/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py b/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py index 948f2b9f63..29cd7458a6 100644 --- a/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py +++ b/tests/hermes_cli/test_kanban_worker_spawn_toolsets.py @@ -136,6 +136,60 @@ def test_default_spawn_model_override_survives_real_cli_parse(monkeypatch, tmp_p assert args.query == "work kanban task t_spawn_tools" +def test_default_spawn_resolves_env_passthrough_under_multiplex(monkeypatch, tmp_path): + """Under multiplex, a worker spawn must not crash when ``terminal.env_passthrough`` + is configured, and the forwarded value must come from the ASSIGNEE profile's own + secret scope, not the dispatcher's ambient os.environ. + + Regression guard: ``_default_spawn`` built the worker env via + ``build_subprocess_env(scrub_secrets=True)`` with no profile secret scope + installed. Any registered ``env_passthrough`` var made + ``resolve_passthrough_value()`` call ``get_secret()`` with no scope while + multiplexing was active, which raises ``UnscopedSecretError`` and crashed the + spawn for every task, every profile, as soon as ``terminal.env_passthrough`` + was configured anywhere. + """ + root = tmp_path / ".hermes" + profile = root / "profiles" / "elias" + profile.mkdir(parents=True) + root.joinpath("config.yaml").write_text( + "terminal:\n env_passthrough:\n - MY_PASSTHROUGH_VAR\n", encoding="utf-8") + profile.joinpath("config.yaml").write_text("{}\n", encoding="utf-8") + profile.joinpath(".env").write_text("MY_PASSTHROUGH_VAR=elias-value\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(root)) + monkeypatch.setenv("MY_PASSTHROUGH_VAR", "dispatcher-value") + + from agent.secret_scope import set_multiplex_active + from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_dispatch as kbd + + monkeypatch.setattr(kbd, "_resolve_hermes_argv", lambda: ["hermes"]) + + captured = {} + + class FakeProc: + pid = 4243 + + def fake_popen(cmd, *args, **kwargs): + captured["env"] = dict(kwargs.get("env") or {}) + return FakeProc() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + workspace = tmp_path / "workspace" + workspace.mkdir() + + set_multiplex_active(True) + try: + pid = kbd._default_spawn(_make_task(kb, assignee="elias"), str(workspace)) + finally: + set_multiplex_active(False) + + assert pid == 4243 + # The assignee's own scoped value, not the dispatcher's ambient os.environ one. + assert captured["env"].get("MY_PASSTHROUGH_VAR") == "elias-value" + + def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path): root = tmp_path / ".hermes" profile = root / "profiles" / "elias"