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 <noreply@anthropic.com>
This commit is contained in:
EloquentBrush0x
2026-09-13 03:53:30 +03:00
committed by Teknium
parent ae076bc465
commit 0c9aa10f41
2 changed files with 82 additions and 12 deletions
+28 -12
View File
@@ -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
@@ -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"