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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user