Skip to content

Served-profile children and background threads run with their own profile's env/scope; kanban never kills a recycled PID - #111617

Merged
teknium1 merged 3 commits into
mainfrom
fix/mux-child-env-and-thread-scope
Sep 15, 2026
Merged

teknium1 merged 3 commits into
mainfrom
fix/mux-child-env-and-thread-scope

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

A profile served by a multiplexed gateway / Desktop backend now gets its own env in every child process it spawns and its own scope on every background thread that works for it; the kanban dispatcher never treats a recycled PID as its worker.

Symptom → change → behaviour

Root cause (one sentence): under gateway.multiplex_profiles os.environ holds the LAUNCH profile's .env, and five spawn sites built a child env from it while three background paths started bare threads with an empty contextvars context — so profile B's children and workers ran with profile A's home, keys and settings.

Surface Before After
_SlashWorker (tui_gateway/server.py) for a B session B's HERMES_HOME, but A's provider keys, HERMES_MODEL, TERMINAL_* as fallback B's home + B's own secrets, no A residue
Bot Chat relay delivery child (tools/bot_relay.py::delivery_env, RPC + --run-delivery) dict(os.environ): A's HERMES_HOME, keys, settings B's env (served_profile_child_env)
agent-browser / browser-use child (tools/browser_tool.py::_build_browser_env) Browserbase/Firecrawl/Browser-Use keys re-added from os.environ (A's) re-added via get_secret → B's or none
A2A _forward_to_profile hermes chat child {**os.environ} + pinned home B's env
key_cmd token helper (agent/command_token_source.py::_mint) inherited A's environ + HERMES_HOME B's env (secrets overlaid: the helper signs in as B)
auto-title thread (agent/title_generator.py) resolved A's auxiliary.title_generation config, language and API key for B's session spawn_context_thread — B's config/key
session teardown (tui_gateway/session_lifecycle.py _finalize_session/_teardown_session) from ws-orphan Timer, idle reaper, atexit, session.close, superseded_by_resume, compute_host flush on_session_end / commit_memory_session / agent.close() ran unscoped: fail-closed under multiplex (tail never committed), launch tenant on the Desktop backend bound to _session_profile_runtime_scope(session) at the single chokepoint — covers every spawn site, incl. the one-line session.close RPC in methods_session.py (no change needed there)
Google Chat Pub/Sub bridge (_on_pubsub_message, _submit_on_loop) gRPC thread's EMPTY context copied onto the loop task → _dispatch_message, attachment cache, per-user OAuth token store (send→_send_file→_acquire_user_chat_api→_load_per_user_chat_api), TTS key, delivery ledger, bot-id cache resolved A connect() captures its scope; each callback and each loop hand-off runs under a copy of it
Kanban worker liveness/kill (hermes_cli/kanban_db_dispatch.py) bare _pid_alive(pid) gated claim-extend and SIGTERM/SIGKILL → after a reboot a recycled PID kept a task stuck running or got killed worker_started_at fingerprint (gateway.status.get_process_start_time) recorded at spawn; _worker_alive(pid, started_at) everywhere; mismatched pid → dead, never signalled (pid_recycled in the termination record); legacy NULL rows keep the old answer until their next spawn

Changes

  • tools/environments/local.py::served_profile_child_env — the one builder the five spawn sites use: pin target home, strip_launch_profile_env, and for children that run with the profile's credentials (agent worker, token helper, relay/A2A turn) overlay the target profile's own secrets (what a standalone hermes -p X loads itself). Browser keeps the provider scrub.
  • agent/memory_provider.py::spawn_context_thread gains kwargs=.
  • hermes_cli/kanban_db_connect.py: additive tasks.worker_started_at column; invalidate_descendants_for_parent_reopen terminations tuples carry it; dashboard plugin updated.

Live repro — child env, from INSIDE the child (temp HOME, launch A + served B, multiplex on)

A's .env: A_MARKER=a HERMES_MODEL=a-model TERMINAL_ENV=docker FIRECRAWL_API_KEY=a-firecrawl; B's .env: B_MARKER=b FIRECRAWL_API_KEY=b-firecrawl. Each child is a python -c probe printing its own environ.

Base (f5a457ad5beb) — every child leaks A, none sees B; relay + key_cmd even get A's HERMES_HOME:

{"site": "slash_worker", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": "a", "B_MARKER": null, "TERMINAL_ENV": "docker", "HERMES_MODEL": "a-model"}
{"site": "bot_relay", "HERMES_HOME": ".../.hermes", "A_MARKER": "a", "B_MARKER": null, "TERMINAL_ENV": "docker", "HERMES_MODEL": "a-model"}
{"site": "browser", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": "a", "B_MARKER": null, "TERMINAL_ENV": "docker", "HERMES_MODEL": "a-model", "FIRECRAWL_API_KEY": "a-firecrawl"}
{"site": "a2a", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": "a", "B_MARKER": null, "TERMINAL_ENV": "docker", "HERMES_MODEL": "a-model"}
{"site": "key_cmd", "HERMES_HOME": ".../.hermes", "A_MARKER": "a", "B_MARKER": null}

Head — B's home, B's marker, no A residue; browser gets B's Firecrawl key:

{"site": "slash_worker", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": null, "B_MARKER": "b", "TERMINAL_ENV": null, "HERMES_MODEL": null}
{"site": "bot_relay", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": null, "B_MARKER": "b", "TERMINAL_ENV": null, "HERMES_MODEL": null}
{"site": "browser", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": null, "B_MARKER": null, "TERMINAL_ENV": null, "HERMES_MODEL": null, "FIRECRAWL_API_KEY": "b-firecrawl"}
{"site": "a2a", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": null, "B_MARKER": "b", "TERMINAL_ENV": null, "HERMES_MODEL": null}
{"site": "key_cmd", "HERMES_HOME": ".../.hermes/profiles/b", "A_MARKER": null, "B_MARKER": "b"}

Live repro — threads (same two homes; A title_generation.language=en, B zh; TITLE_KEY a-key/b-key)

BASE  {"path": "title_thread",    "home": ".../.hermes",            "title_language": "en", "key": "UnscopedSecretError"}
BASE  {"path": "teardown_commit", "home": ".../.hermes",            "title_language": "en", "key": "UnscopedSecretError"}
BASE  {"path": "teardown_close",  "home": ".../.hermes",            "title_language": "en", "key": "UnscopedSecretError"}
HEAD  {"path": "title_thread",    "home": ".../.hermes/profiles/b", "title_language": "zh", "key": "b-key"}
HEAD  {"path": "teardown_commit", "home": ".../.hermes/profiles/b", "title_language": "zh", "key": "b-key"}
HEAD  {"path": "teardown_close",  "home": ".../.hermes/profiles/b", "title_language": "zh", "key": "b-key"}

(teardown = real _schedule_ws_orphan_reap Timer → _teardown_popped_session; title = real maybe_auto_title.)

Tests (6, all red on base by source swap, green on head)

  • tests/tui_gateway/test_served_profile_child_env.py — real _SlashWorker spawn observed from the child; key_cmd helper + browser env under _profile_runtime_scope(B).
  • tests/tui_gateway/test_background_thread_profile_scope.pymaybe_auto_title thread; ws-orphan reap Timer → teardown under B.
  • tests/hermes_cli/test_kanban_worker_pid_fingerprint.py — recycled PID reclaimed without a signal (max-runtime + stale-claim paths); matching fingerprint extends the claim and is signalled.

scripts/run_tests.sh tests/tools tests/agent tests/tui_gateway tests/plugins tests/hermes_cli/test_kanban*.py: 1776 files, 23.4k tests; remaining reds are pre-existing env-reds identical on base (test_hindsight_provider ×8 = missing hindsight_client_api module, test_delegate_timeout_cleanup, test_execution_flag_detection).

Sweep 1 — child-process spawn sites (rg subprocess.(Popen|run|check_output|call)|create_subprocess_(exec|shell)|os.environ.copy()|env=os.environ, 736 hits)

Totals: FIXED 4 hits (the 5 sites; _mint's Popen is one rg hit, bot_relay's builder has no spawn of its own) · builder-routed 72 · cannot run for a served profile 578 (standalone CLI/installer/scripts/evals, or the child reads nothing profile-dependent) · tests-tree 67 · residual 15 (below). Full 736-row table: https://gist.github.com/teknium1/72c7296b2df742448e15cd11ecc11227

FIXED / residual / builder-routed rows:

File Lines Class Note
agent/command_token_source.py 49 FIXED (this PR) _mint key_cmd shell=True, no env -> listed FIXED site
plugins/platforms/a2a/adapter.py 593 FIXED (this PR) _forward_to_profile hermes chat spawn — listed FIXED site
tui_gateway/methods_bot_relay.py 32 FIXED (this PR) _run_delivery env=turn_env from tools.bot_relay.delivery_env (FIXED builder site)
tui_gateway/server.py 257 FIXED (this PR) _SlashWorker Popen env from hermes_subprocess_env(..., extra HERMES_HOME=profile_home) — listed FIXED site
agent/skill_preprocessing.py 52,69 residual (unscoped, not this PR) inline-shell !cmd in skill: env=delegated_child_subprocess_env() -> None (inherits os.environ); 69 comment
hermes_cli/git_credentials.py 59 residual (unscoped, not this PR) gh auth token env=noninteractive_git_env() (os.environ): TUI plugins.manage install (profile=X) -> with_git_auth
hermes_cli/goals.py 401 residual (unscoped, not this PR) run_gate shell=True inherits os.environ; gateway post-turn goal gates run for served profiles
hermes_cli/kanban_pr_acceptance.py 31 residual (unscoped, not this PR) gh api with no env kwarg: reached from agent kanban_complete tool in a served-profile turn (PR completion_contract)
hermes_cli/web_routers/profiles.py 792,801 residual (unscoped, not this PR) dashboard open-terminal: terminal running <name> setup inherits backend os.environ (launch .env); no env kwarg
hermes_cli/worktree_ops.py 546 residual (unscoped, not this PR) gh pr list inherits os.environ (GH_TOKEN/GITHUB_TOKEN) from cron worktree GC under multiplex
plugins/google_meet/process_manager.py 94 residual (unscoped, not this PR) env={*os.environ,...}; HERMES_MEET_ + realtime key set from scope, but child falls back to launch OPENAI_API_KEY
plugins/platforms/photon/adapter.py 894,904 residual (unscoped, not this PR) os.environ.copy()+scope creds; sidecar reads un-overridden PHOTON_READ_RECEIPTS/PROBE_SPACE_ID/TELEMETRY/STREAM_*
plugins/platforms/telegram/adapter.py 4527 residual (unscoped, not this PR) no env kwarg: user gmail-triage shell script from served profile's HERMES_HOME runs with launch profile os.environ
tools/environments/base_output.py 256 residual (unscoped, not this PR) shared _popen_bash; docker/ssh pass scoped client env, singularity passes none (same leak as singularity.py:200)
tools/environments/singularity.py 200 residual (unscoped, not this PR) _run_bash -> _popen_bash(cmd, stdin_data) with NO env: apptainer exec inherits launch os.environ; no scoped passthrough
tools/skills_hub_github.py 106 residual (unscoped, not this PR) gh auth token with NO env kwarg: gh returns launch os.environ GH_TOKEN/GITHUB_TOKEN when served profile has no PAT
agent/copilot_acp_client.py 320,336,440 builder-routed env=_build_subprocess_env() -> hermes_subprocess_env(inherit_credentials=True) (320/440 are type refs)
agent/secret_sources/base.py 224,227 builder-routed run_cli: callers pass allowlisted env (run_secret_cli) or source_child_env(); 224 docstring
agent/secret_sources/command.py 78 builder-routed env=source_child_env() (listed builder)
agent/shell_hooks.py 312 builder-routed env=build_subprocess_env(scrub_secrets=is_multiplex_active())
agent/transports/codex_app_server.py 54,99 builder-routed env=hermes_subprocess_env(inherit_credentials=True) + delegated_child_subprocess_env; 54 comment
agent/vault_backends/base.py 66,82 builder-routed env from backend.env(): allowlist of os.environ infra + OP_CONNECT* via get_secret (scope-aware)
cli.py 3297 builder-routed CLI quick_commands exec: env=build_subprocess_env()
cron/scheduler.py 3206 builder-routed worker_env = strip_launch_profile_env(build_subprocess_env(...)) under profile secret scope
cron/scheduler_delivery.py 779 builder-routed env = strip_launch_profile_env(delegated_child_subprocess_env(os.environ)) + explicit HERMES_HOME=target home
cron/scheduler_script.py 358 builder-routed env = build_subprocess_env() + interpreter overlay
gateway/platforms/webhook_filters.py 187 builder-routed env=build_subprocess_env()
gateway/run_inbound.py 944 builder-routed env=build_subprocess_env()
gateway/run_shutdown.py 106,1319,1322,1361 builder-routed inside _WINDOWS_RESTART_WATCHER source string; runs in watcher child whose env came from build_subprocess_env (l.1284)
hermes_cli/bang_shell.py 97,118 builder-routed _bang_env() = build_subprocess_env(); CLI composer only
hermes_cli/dep_ensure.py 106 builder-routed env = hermes_subprocess_env(inherit_credentials=False)
hermes_cli/kanban_db_dispatch.py 2302 builder-routed _default_spawn worker env (already routed per instructions)
hermes_cli/main_dashboard.py 707 builder-routed env from build_subprocess_env(...) L693 + HERMES_HOME pinned to root (dashboard re-exec, CLI)
hermes_cli/main_tui_launch.py 800 builder-routed env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True) at L736 (CLI TUI exec)
hermes_cli/onepassword_secrets_cli.py 387 builder-routed env = secret_cli_env() -> build_subprocess_env(scrub_secrets=False)
hermes_cli/secrets_cli.py 455 builder-routed env=secret_cli_env() -> build_subprocess_env(scrub_secrets=False) + BWS token
hermes_cli/web_server.py 844,847 builder-routed not spawns: annotation + wrapper delegating to _spawn_hermes_action (builder-routed)
hermes_cli/web_server_gateway.py 275,400,412 builder-routed named-profile actions: build_subprocess_env(scrub)+HERMES_HOME pin (L368-386); 275/400 annotations
hermes_cli/web_server_memory.py 120 builder-routed env = _memory_provider_setup_env() -> build_subprocess_env(scrub_secrets=False)
plugins/memory/byterover/__init__.py 114 builder-routed env=_brv_child_env(brv_path) (listed routed builder)
plugins/web/ddgs/provider.py 105 builder-routed env from _sanitize_subprocess_env(dict(os.environ)) at line 139, passed via _spawn_worker(env)
tools/bot_mode_dm.py 377,514 builder-routed env=delivery_env(author) from tools.bot_relay (FIXED site); runs in --run-delivery CLI process
tools/browser_lightpanda.py 139,216 builder-routed env=_browser_env() -> tools.browser_tool._build_browser_env (FIXED builder)
tools/browser_tool_install.py 191,270 builder-routed env=_bt._build_browser_env() (FIXED builder) for npx agent-browser probe / chromium install
tools/browser_tool_real_profile.py 59,170,203 builder-routed env=_real_profile_daemon_env -> _agent_browser_command_env -> _build_browser_env (FIXED builder)
tools/browser_tool_session.py 149 builder-routed env from _agent_browser_command_env -> _bt._build_browser_env (FIXED builder); callers L515, lightpanda_fallback L153
tools/browser_use_cli.py 590 builder-routed env from _base_subprocess_env -> tools.browser_tool._build_browser_env (FIXED builder)
tools/checkpoint_manager.py 217 builder-routed env=_git_env -> _isolated_git_env -> build_subprocess_env(scrub_secrets=False)
tools/code_kernel.py 608 builder-routed env=child_env from tools/code_execution_env._build_child_env (+kernel RPC vars)
tools/computer_use/cua_backend.py 135 builder-routed _run_driver passes env=sanitized_cua_driver_env (_sanitize_subprocess_env); loginctl callers pass no env (inert)
tools/computer_use/cua_backend_daemon.py 168 builder-routed env=self._sanitized_env() -> _sanitize_subprocess_env(child_env())
tools/computer_use/cua_backend_session.py 105 builder-routed env param = _sanitize_subprocess_env(child_env) at caller L462
tools/computer_use/doctor.py 39,112 builder-routed env=_sanitized_cua_env (= permissions._child_env -> _sanitize_subprocess_env); also cli-only (computer-use doctor)
tools/computer_use/permissions.py 35,106 builder-routed env=_child_env() -> sanitized_cua_driver_env -> _sanitize_subprocess_env; L106 also cli-only (grant)
tools/environments/local.py 309,329,806 builder-routed these ARE the builders (hermes_subprocess_env / build_subprocess_env): snapshot os.environ then scrub/route
tools/file_operations_search.py 340 builder-routed env=_make_run_env(self.env.env) for direct rg spawn
tools/lazy_deps.py 479 builder-routed uv path env=hermes_subprocess_env; pip/ensurepip fallback inherits but pip reads nothing profile-dep
tools/process_registry.py 990 builder-routed spawn_env=_spawn_env -> _sanitize_subprocess_env(os.environ, env_vars) (+systemd_user_bus_env wrap)
tools/tts_command_provider.py 153 builder-routed env=hermes_subprocess_env(inherit_credentials=False); env_passthrough keys re-copied from os.environ (minor)
tools/voice_mode.py 1073 builder-routed env=hermes_subprocess_env(inherit_credentials=False) for system audio player
tui_gateway/host_supervisor.py 144,321,373,384,411,479 builder-routed compute host spawn env={**hermes_subprocess_env(inherit_credentials=True),**os.environ}; single launch-home process (hel
tui_gateway/methods_tools.py 497 builder-routed quick command exec env=build_subprocess_env()

Residual (unscoped, not this PR — same class, follow-up):

  • tools/environments/singularity.py:200 — terminal/execute_code tool call in a multiplexed gateway with terminal.backend=singularity serving profile B -> SingularityEnvironment._run_bash -> base_output.popen_bash(cmd, stdin_data) with no env kwarg -> apptainer exec instance://… bash -c inherits os.environ — launch profile's API keys/HERMES_HOME/TERMINAL* reach the apptainer client and (per apptainer default host-env passthrough) the sandbox shell; unlike docker/ssh there is no scope-resolved passthrough/unset handling
  • tools/environments/base_output.py:256 — same spawn as above (shared helper); docker.py:868 and ssh.py:272 pass client_env_with(scope-resolved values) so only the singularity caller leaks
  • tools/skills_hub_github.py:106 — dashboard router hermes_cli/web_routers/skills.py search/install (profile=B) -> create_source_router() -> GitHubAuth()._resolve_token -> _try_pat via get_secret (scoped, None for B) -> _try_gh_cli runs gh auth token with no env kwarg -> gh returns GH_TOKEN/GITHUB_TOKEN from os.environ (launch profile's token) and it is used as profile B's GitHub credential
  • plugins/google_meet/process_manager.py:94 — served-profile agent turn -> meet_join tool -> pm.start() -> Popen({**os.environ,...}) — launch profile's OPENAI_API_KEY (and full env) inherited; meet_bot uses it when the served profile has no HERMES_MEET_REALTIME_KEY/OPENAI_API_KEY in scope
  • plugins/platforms/photon/adapter.py:894,904 — served-profile Photon adapter connect/reconnect -> start_sidecar() -> Popen(node index.mjs, env=os.environ.copy()+PHOTON creds) — credentials are scope-overridden, but sidecar reads PHOTON_READ_RECEIPTS, PHOTON_PROBE_SPACE_ID, PHOTON_TELEMETRY, PHOTON_STREAM* / MAX_INLINE_ATTACHMENT_BYTES from the launch profile's env, plus every other launch-profile secret is exposed to the node child
  • plugins/platforms/telegram/adapter.py:4527 — served-profile Telegram callback gt:: -> _handle_gmail_triage_callback -> create_subprocess_exec(/scripts/gmail-triage/*.sh) with no env kwarg — user script runs with the launch profile's HERMES_HOME/credentials in env while its path was resolved from the served profile
  • hermes_cli/web_routers/profiles.py:792 — POST /api/profiles/{name}/open-terminal on the dashboard/desktop backend (multiplexed launch process) -> cmd.exe start <name> setup; no env kwarg — terminal + hermes -p <name> setup child inherit the LAUNCH profile's .env credentials / HERMES_HOME etc. (Windows)
  • hermes_cli/web_routers/profiles.py:801 — same route on Linux -> gnome-terminal/xterm sh -lc '<name> setup' with no env kwarg — child inherits launch profile os.environ (creds, HERMES_HOME, TERMINAL_*) while configuring profile
  • hermes_cli/git_credentials.py:59 — TUI gateway plugins.manage{install,profile=X} (or dashboard /api/dashboard/agent-plugins/install, or TUI mcp catalog) -> plugins_cmd.dashboard_install_plugin -> _clone_plugin_repo -> _run_plugin_git(auth_url) -> with_git_auth -> resolve_git_basic_auth -> _github_token -> gh auth token with env=noninteractive_git_env() (dict(os.environ)); leaks launch-profile GH_TOKEN/GITHUB_TOKEN/GH_CONFIG_DIR to gh and the resulting token authenticates X's clone (note: get_secret path above it IS scoped; only the gh fallback is raw)
  • hermes_cli/kanban_pr_acceptance.py:31 — agent kanban_complete tool call (tools/kanban_tools.py:587) in a served-profile turn -> kanban_db.complete_task -> prepare_acceptance -> collect_acceptance -> _api -> gh api … with no env kwarg; gh reads GH_TOKEN/GITHUB_TOKEN/GH_CONFIG_DIR from the launch profile's os.environ, not profile X's scope
  • hermes_cli/goals.py:401 — gateway/run_goals.py _post_turn_goal_continuation -> GoalManager.evaluate_after_turn -> check_gates -> run_gate (also tui_gateway prompt_turn) — operator gate shell command inherits launch profile os.environ (API keys, HERMES_HOME, TERMINAL*) while serving profile B
  • hermes_cli/worktree_ops.py:546 — multiplex cron ticker (scheduler_provider per-profile tick) -> cron.scheduler.tick -> _maybe_run_worktree_maintenance -> cli._prune_stale_worktrees -> _classify_prune_candidates -> _worktree_branch_pr_merged -> gh pr list — inherits launch profile GH_TOKEN/GITHUB_TOKEN for profile B's job-workdir repos (read-only, low severity)
  • agent/skill_preprocessing.py:52 — served-profile turn -> skill_view/skills_tool preprocess -> expand_inline_shell -> run_inline_shell(bash -c, env=delegated_child_subprocess_env()) -> None outside kanban => inherits launch os.environ — skill !cmd snippets see launch profile HERMES_HOME/creds/TERMINAL_*

Sweep 2 — threads / timers / executors (rg threading.(Thread|Timer)(|run_in_executor(|loop.call_soon_threadsafe|asyncio.to_thread( over gateway/ tools/ agent/ tui_gateway/ plugins/ cron/ hermes_cli/, 604 hits)

Totals: FIXED 3 (title thread, ws-orphan Timer, idle-reaper thread → teardown; the google_chat and atexit/RPC sites are not Thread/Timer lines so are not rg hits) · scope-carrying 349 · reads nothing profile-dependent 123 · process-global by design 100 · residual 29 (below). Full table in the gist.

FIXED / residual / process-global rows:

File Lines Class Note
agent/title_generator.py 491 FIXED (this PR) maybe_auto_title Thread — fixed in this PR
tui_gateway/server.py 367 FIXED (this PR) idle-reaper thread listed as FIXED
tui_gateway/session_lifecycle.py 611 FIXED (this PR) WS-orphan reap Timer listed as FIXED
agent/credits_tracker.py 421 residual bare Thread _bg_seed -> get_nous_portal_account_info -> get_provider_auth_state -> get_hermes_home()/auth.json in empty
agent/review_idle_queue.py 113 residual bare dispatcher thread; _still_enabled() load_config_readonly + _spawn_background_review_now (propagate_context_to_threa
cron/scheduler.py 3513 residual bare Thread from cron tick (under _profile_cron_scope); _run -> load_jobs() -> get_hermes_home() resolves launch profile
gateway/platforms/api_server.py 579 residual bare Thread(_reap_gateway_turn_processes) from scoped SSE-disconnect/run-stop handler; empty ctx
gateway/run_agent_cache.py 448 residual bare Thread(_reap_gateway_turn_processes) from _interrupt_running_turn (/stop,/new in scoped inbound); registry writes r
gateway/run_turn.py 3233,3354 residual bare Thread(_watch_gateway_turn_inactivity) from scoped turn; on timeout reaps via process_registry which resolves get_h
hermes_cli/approval_transport.py 138 residual Bare worker runs plugin present_fn (no in-repo transport exists to verify); plugin body likely reads get_secret/config
hermes_cli/local_runtime/endpoint.py 134 residual _boot thread: ensure_local_runtime(_load_config_if_none(config)) -> load_config() in empty ctx when config is None
hermes_cli/model_catalog.py 189 residual Bare SWR thread: fetches manifest then _write_disk_cache()->get_hermes_home()/cache/model_catalog.json in empty ctx
hermes_cli/observability/relay_shared_metrics.py 782 residual Bare send thread: still_consented() re-reads read_raw_config_readonly() in empty ctx; store is per-profile _Runtime
hermes_cli/plugins_dispatch.py 299 residual Long-lived per-manager event worker (bare thread, empty ctx) delivers plugin subscriber callbacks; manager.home_path not
hermes_cli/web_routers/audio.py 483 residual bare Thread(_produce): streamer.stream() re-resolves API keys at stream time (_resolve_key->get_env_value/load_env, reso
hermes_cli/web_routers/messaging.py 561 residual bare Thread; session_path pre-resolved under body.profile scope, but body calls resolve_whatsapp_bridge_dir()/with_herme
hermes_cli/web_routers/models.py 289 residual combined_selection_warning runs deliberately BEFORE _profile_scope and never re-binds; guards call load_config()/load_co
plugins/memory/honcho/oauth_flow.py 433 residual config_path/host pre-resolved under scope, but worker calls resolve_endpoints() -> from_global_config() -> get_hermes_ho
plugins/platforms/a2a/adapter.py 125 residual _daemon_thread helper spawns a2a-http serve_forever (+ harmless a2a-watchdog); request handlers write via get_hermes_hom
plugins/platforms/email/adapter.py 513 residual _fetch_new_messages -> _extract_attachments -> cache_image/document_from_bytes -> get_hermes_dir()/load_config_readonly(
tools/async_delegation.py 778 residual bare stale-monitor thread; _finalize->_persist_completion->_db_path()=get_hermes_home()/state.db in empty ctx
tools/discord_tool.py 185 residual bare thread; _bg_detect -> _save_caps_to_disk -> get_hermes_home()/cache/discord_capabilities.json resolves in empty ctx
tools/terminal_tool.py 701 residual bare idle-reaper thread; get_env_config() bridges/reads launch TERMINAL*; _teardown_env(env) runs env.cleanup() with n
tools/tts_tool_lifecycle.py 70 residual bare thread runs run_command_provider(): hermes_subprocess_env()->_apply_profile_home sees no override, env_passthrough
tools/tts_tool_speaker.py 188 residual bare prefetch thread drives the lazy streamer.stream(text) generator; its first next() resolves the provider key via _re
tui_gateway/methods_voice.py 109,278 residual streaming TTS consumer started inside scoped turn: stream_tts_to_speaker→_load_tts_config→load_config() (launch)
tui_gateway/prompt_turn.py 348 residual voice fallback from scoped turn thread: _speak_text_with_barge→speak_text→load_config() voice/tts (launch)
tui_gateway/server.py 2208,2644 residual late MCP refresh thread: refresh_agent_mcp_tools→get_tool_definitions/check_fns read launch config
tui_gateway/session_notifications.py 677 residual notif poller: LoopManager/HeartbeatManager→goals._get_session_db→get_hermes_home() = launch state.db
agent/agent_init.py 499 process-global by design OpenRouter metadata prewarm: once per process (_openrouter_prewarm_done Event), module-global in-memory cache; disk cach
agent/azure_identity_adapter.py 177 process-global by design _probe_token only reached via describe_active_credential/has_azure_identity_credentials from hermes_cli doctor/auth/setu
agent/curator.py 943 process-global by design curator LLM pass: every caller (gateway housekeeping chore, serve maintenance ticker with profile=None, hermes CLI) alre
agent/models_dev.py 377 process-global by design models.dev registry is one process-global in-memory cache; bg refresh reads models_dev.url config + writes cache under l
agent/monitoring/gateway_health_export.py 328 process-global by design gateway-wide health snapshot loop started once at gateway startup (run_startup, launch profile config); reads gateway st
agent/plugin_stream_hooks.py 94 process-global by design one process-wide queue+worker per registered plugin callback across all sessions/profiles; worker body dequeues and invo
cron/scheduler_provider.py 337 process-global by design fire_overdue_jobs misfire backstop: external providers only; multiplex forces InProcess (scheduler_for_profile_mode) so
cron/scheduler_thread.py 30 process-global by design SupervisedTickerThread: cron tick loop; _start_multiplex iterates profile_homes binding _profile_cron_scope(home) per ti
gateway/control_socket.py 233 process-global by design control-socket request handler (identify/status/pause-for-update/rescan-profiles) on default executor; process-level run
gateway/kanban_watchers_common.py 34 process-global by design asyncio.to_thread(Context().run, func): deliberately EMPTY ctx; kanban dispatcher uses machine-global kanban_home + expl
gateway/run.py 4400,4648,5049,5146,5274 process-global by design call_soon_threadsafe(shutdown_handler) from planned-stop watcher thread: drives whole-gateway shutdown
gateway/run_startup.py 120,1358 process-global by design boot warm-up (_warm_turn_machinery_sync: tool schemas, load_config_readonly, env probe) once per process on launch profi
gateway/shutdown_watchdog.py 139,288 process-global by design loop-liveness watchdog thread: waits/faulthandler/os._exit; mark_exited uses _process_hermes_home (env, ignores override
hermes_cli/banner.py 506 process-global by design _daemon(): update check/banner git+skills prefetch/update notice at process startup (hermes_cli.main, tui_gateway.server
hermes_cli/cli_chat_turn_mixin.py 86,225,254 process-global by design CLI agent turn thread; single-profile CLI frontend
hermes_cli/cli_commands_mixin.py 2019 process-global by design CLI /bg,/btw,/login side-panel worker; single-profile CLI frontend (one profile per process)
hermes_cli/cli_info_mixin.py 144,165,854 process-global by design CLI banner snapshot refresh writing get_hermes_home()/cache; single-profile CLI frontend
hermes_cli/cli_loops_mixin.py 517 process-global by design CLI heartbeat watchdog polling HeartbeatManager; single-profile CLI frontend
hermes_cli/cli_model_switch_mixin.py 154 process-global by design CLI model-switch confirm+apply off UI thread; single-profile CLI frontend
hermes_cli/cli_status_bar_mixin.py 924 process-global by design CLI pet animation loop (reads pet config periodically); single-profile CLI frontend
hermes_cli/cli_tui_mixin.py 842,880 process-global by design CLI voice stop+transcribe (reads stt config); single-profile CLI frontend
hermes_cli/cli_voice_mixin.py 156,194,294,298,768,814 process-global by design CLI voice level refresh loop; single-profile CLI frontend (UI only)
hermes_cli/free_tier_bootstrap.py 155 process-global by design web_server lifespan boot thread; one-per-process free-tier identity bootstrap for launch profile
hermes_cli/local_runtime/bootstrap.py 284 process-global by design idle-sweep loop calling sup.sweep_idle() on the machine-scoped managed llama-server
hermes_cli/local_runtime/supervisor.py 213 process-global by design llama-server crash watchdog; managed runtime is machine-scoped (runtimes_root=get_default_hermes_root, deliberately not
hermes_cli/main.py 1606 process-global by design CLI startup bundled-skills sync into launch profile home; single-profile CLI process
hermes_cli/mcp_startup.py 187 process-global by design Timer arming launch-profile MCP discovery 1s after web_server bind (boot); empty Timer ctx == launch profile, intended
hermes_cli/model_switch_providers.py 187 process-global by design picker prewarm at process start (cli.py, tui_gateway/entry.py) for launch profile; once per process
hermes_cli/nous_auth_keepalive.py 213 process-global by design process-wide Nous auth keepalive (docstring); refreshes LAUNCH profile's pool/auth state only; secondary profiles refres
hermes_cli/voice.py 420 process-global by design forced-stop transcription tail; caller tui_gateway/methods_voice voice.record is itself unscoped (process-level mic/voic
hermes_cli/web_routers/actions.py 150,294,308 process-global by design build_migration_plan enumerates all profiles' gateways (machine-wide plan), no profile param
hermes_cli/web_routers/cron.py 287 process-global by design chronos fire webhook (no profile param) loads launch-profile config to verify JWT, then forwards to gateway
hermes_cli/web_routers/dashboard_ui.py 63,69,92,100,134,191,270,296 process-global by design dashboard themes: load_config of the dashboard's own profile; no profile param
hermes_cli/web_routers/local_models.py 170,705,807 process-global by design download job thread: writes machine-scoped models_dir() (deliberately not profile) + refresh_local_runtime(load_config)
hermes_cli/web_routers/ops.py 326,384,436,459,476,643,697,729 process-global by design /api/credentials/pool has no profile param; reads launch auth.json/pool
hermes_cli/web_routers/status.py 638,707 process-global by design /api/portal has no profile param; load_config+auth snapshot of the dashboard's own profile
hermes_cli/web_server.py 152,187,213,241 process-global by design _eager_reconcile_own_session_db: schema-heal of the process's own state.db at startup
hermes_cli/web_server_dashboard.py 610 process-global by design check_fn warm probe feeds tools.registry process-wide TTL cache (check_fn is process-scoped by design); launch env
hermes_cli/web_server_sessions.py 295 process-global by design auto-archive/skill-maintenance ticker for THIS serve process's profile (_maybe_auto_archive_for_profile(None))
plugins/hermes-achievements/dashboard/plugin_api.py 795 process-global by design dashboard plugin route (/api/plugins/hermes-achievements/*) has no profile param/scope; scan reads launch home's state.d
plugins/kanban/dashboard/plugin_api.py 1431,1680,1686 process-global by design kanban_transfer export/import -> kanban_home() = HERMES_KANBAN_HOME/get_default_hermes_root(): board root shared across
tools/browser_tool_lifecycle.py 417 process-global by design browser janitor: each idle teardown re-enters _session_owner_scope(task_id) (recorded owner home + secret scope); orphan
tools/computer_use/cua_backend.py 214 process-global by design once-per-process update nudge for the shared cua-driver binary; runs check-update via sanitized env (reads launch comput
tools/mcp_tool_loop.py 257 process-global by design single shared mcp-event-loop thread for all profiles; _run_on_mcp_loop re-wraps the caller's HERMES_HOME override (+dash
tools/tirith_security.py 466 process-global by design one-shot background install of the shared tirith binary into get_hermes_home()/bin; only spawned from gateway/CLI startu
tools/transcription_tools.py 299 process-global by design single process-wide faster-whisper model cache; loop re-reads stt.local.unload_after_idle_seconds via load_config() (lau
tools/voice_mode.py 704 process-global by design silence-stop callback for AudioRecorder; recorder only used by hermes_cli voice frontends (single-profile CLI process):
tui_gateway/change_watcher.py 239 process-global by design process-wide change watcher; probes launch home + every _served_profile_homes for state.db
tui_gateway/compute_host.py 532 process-global by design compute-host stdin control reader; _build_server_session binds explicit scope from frame profile_home
tui_gateway/host_supervisor.py 329,465 process-global by design compute-host stdout/stderr/wait threads; singleton supervisor, respawn uses os.environ (launch) by design
tui_gateway/hosted_room_driver.py 160,470 process-global by design hosted-room supervisor loop; per-room turns pass explicit profile to server RPCs that bind scope
tui_gateway/methods_voice.py 72,155,379,774 process-global by design TTS lease warm from /voice toggle RPC (no session/profile); one speaker per process, launch tts config
tui_gateway/session_reaper.py 82,276,409 process-global by design exit flush iterates all in-memory sessions; agent._persist_session uses agent-bound _session_db

Residual (unscoped, not this PR — same class, follow-up):

  • agent/credits_tracker.py:421 — tui_gateway/server.py _build (under _bind_build_profile_scopes(profile_home)) -> _announce_built_agent -> seed_credits_at_session_start; also conversation_loop first turn under gateway _profile_runtime_scope — credits-seed thread reads LAUNCH profile's auth.json (Nous OAuth token/pool) and hydrates the served profile's agent._credits_state from the wrong account (provider==nous only)
  • agent/review_idle_queue.py:113 — gateway turn end under _profile_runtime_scope -> turn_finalizer -> agent._spawn_background_review -> _review_should_defer (managed local llama-server + defer:auto) -> QUEUE.enqueue -> bg-review-idle-queue thread (empty ctx) -> load_background_review_settings() reads LAUNCH config; item.agent._spawn_background_review_now -> spawn_background_review_thread/_resolve_review_runtime (get_secret_str fails closed/launch) and bg-review Thread inherits the EMPTY context so MEMORY.md/skill writes + config resolve to the launch profile
  • cron/scheduler.py:3513 — multiplex ticker _profile_cron_scope(home) -> tick() -> _maybe_run_worktree_maintenance() bare Thread — _worktree_maintenance_repos() calls cron.jobs.load_jobs() -> _current_cron_store() -> get_hermes_home() in EMPTY context = launch profile's jobs.json, so served profiles' job workdirs are never enumerated for worktree GC (hygiene only, no secrets; process-global 6h throttle)
  • gateway/platforms/api_server.py:579 — profile_prefix_middleware._profile_scope(profile) → _abandon_agent_task / api_server_runs._handle_run_stop:882 → _reap_disconnected_agent_processes → bare threading.Thread → gateway.run._reap_gateway_turn_processes → process_registry.kill_process → _terminate_host_pid reads terminal.daemon_term_grace_seconds via read_raw_config() and _write_checkpoint() → _checkpoint_path() → get_hermes_home(): both resolve to the LAUNCH profile's config.yaml/processes.json instead of the served profile's
  • gateway/run_agent_cache.py:448 — _interrupt_running_turn (from scoped /stop,/new inbound handlers & _interrupt_and_clear_session) spawns bare Thread(_reap_gateway_turn_processes) -> process_registry.kill_started_since -> _write_checkpoint(_checkpoint_path()), save_completed_result, _config_seconds(read_raw_config) resolve get_hermes_home() to LAUNCH profile instead of the session's profile
  • gateway/run_turn.py:3233 — scoped _run_agent turn (_profile_runtime_scope) spawns bare Thread -> _abandon_timed_out_gateway_turn -> process_registry.kill_started_since -> _write_checkpoint/_checkpoint_path(), save_completed_result, _config_seconds(read_raw_config) all resolve get_hermes_home() to LAUNCH profile (B's processes.json keeps stale entry; results/config read from launch home)
  • gateway/run_turn.py:3354 — scoped _run_agent turn (_profile_runtime_scope) spawns bare Thread -> _abandon_timed_out_gateway_turn -> process_registry.kill_started_since -> _write_checkpoint/_checkpoint_path(), save_completed_result, _config_seconds(read_raw_config) all resolve get_hermes_home() to LAUNCH profile (B's processes.json keeps stale entry; results/config read from launch home)
  • hermes_cli/approval_transport.py:138 — tools/approval_prompt.py:212 inside a scoped turn (profile B) — plugin present_fn's get_secret()/load_config() resolve LAUNCH profile (body is plugin-supplied; unverifiable in-repo)
  • hermes_cli/local_runtime/endpoint.py:134 — runtime_provider_custom._resolve_llamacpp_runtime()->resolve_llamacpp_endpoint() (config=None, wait 8s) during scoped model resolution for profile B — load_config() returns LAUNCH profile's local_runtime section (enabled/backend/tag/models_max/port), so B's enabled runtime may not boot or boots with launch settings
  • hermes_cli/model_catalog.py:189 — get_catalog() via hermes_cli.models.get_curated_*/picker under tui_gateway _session_profile_runtime_scope (profile B, stale disk cache) — _cache_path() resolves LAUNCH home; B's cache never refreshed, launch cache overwritten with B's manifest
  • hermes_cli/observability/relay_shared_metrics.py:782 — finish_task hook in scoped turn (profile B) -> _export -> _send_exported_packages -> _run_send_pass — consent re-check reads LAUNCH profile's telemetry.shared_metrics.send/endpoint: B's pass aborts when launch has send off, or continues after B revokes
  • hermes_cli/plugins_dispatch.py:299 — plugin.emit() from scoped plugin hook code (profile B) queues to _event_worker_loop — subscriber callbacks' get_hermes_home()/get_secret() resolve LAUNCH profile (bodies plugin-supplied; no in-repo subscriber)
  • hermes_cli/web_routers/audio.py:483 — /api/audio/speak-stream WS with ?profile= -> Thread(_produce) -> ElevenLabs/OpenAI/Gemini streamer.stream() — provider API key/base URL resolved via get_env_value/load_env/credential pool from the launch profile's .env, not ?profile= (line 398 comment assumes keys were captured at resolve time; only the config section is)
  • hermes_cli/web_routers/messaging.py:561 — POST /api/messaging/whatsapp/onboarding/start (body.profile) -> Thread(_run_whatsapp_pairing) — bridge-dir fallback and node PATH resolve from launch HERMES_HOME (get_hermes_home()) instead of body.profile's home; creds path itself is explicit and correct
  • hermes_cli/web_routers/models.py:289 — POST /api/model/set (body.profile/?profile) -> to_thread(combined_selection_warning) — cost/context guards read model.switch_context_confirm_tokens, pricing cache (get_hermes_home()/.models_dev cache) and config from the launch profile instead of body.profile; warning-only, low impact
  • plugins/memory/honcho/oauth_flow.py:433 — hermes_cli/memory_oauth.py start_memory_oauth(profile) _scope_to_profile -> start_loopback_flow_background -> bare Thread(_run) -> authorize_via_loopback -> resolve_endpoints() -> HonchoClientConfig.from_global_config()/resolve_config_path()/resolve_active_host() in EMPTY context — OAuth environment/base_url/token_url resolve from the LAUNCH profile's honcho.json (persist path/host are correct)
  • plugins/platforms/a2a/adapter.py:125 — A2AAdapter.connect() under _profile_runtime_scope (run_adapters.py:1016; a2a is not port-binding-listed so a secondary profile gets its own instance) -> _daemon_thread(self._httpd.serve_forever) -> ThreadingHTTPServer per-request threads -> do_POST -> _prepare_task/_record_outcome -> protocol.persist_message() + security.audit() -> get_hermes_home() in EMPTY context — a secondary profile's a2a_conversations/*.jsonl and a2a_audit.jsonl are written under the LAUNCH profile's home (watchdog thread: _fail_orphans_once reads only in-memory TaskStore + os.getenv, fine)
  • plugins/platforms/email/adapter.py:513 — connect() under _profile_runtime_scope (run_adapters.py:1016) -> create_task(_poll_loop) [scoped] -> _check_inbox -> run_in_executor(None, self._fetch_new_messages) drops ctx -> _extract_attachments -> cache_image_from_bytes/cache_document_from_bytes -> get_image/document_cache_dir() (get_hermes_home) + validate_inbound_media_size -> _config_section('gateway') (load_config_readonly) — a secondary profile's inbound attachments land in the LAUNCH profile's cache/ and use the launch config's max_inbound_media_bytes
  • tools/async_delegation.py:778 — started by _ensure_stale_monitor() from dispatch_async_delegation* inside a scoped delegate_task turn for profile B — stalled force-finalize writes/UPDATEs async_delegations in the LAUNCH profile's state.db (row lives in B's), so B's ledger row stays 'running' and is replayed as abandoned
  • tools/discord_tool.py:185 — _detect_capabilities_nonblocking() from _get_dynamic_schema() during scoped agent/schema build for profile B — capability disk cache for B's bot token is written under the LAUNCH profile's home (B's own processes never see it; perpetual cold-start miss). Network fetch itself uses the passed token, so no credential leak
  • tools/terminal_tool.py:701 — _start_cleanup_thread() from _acquire_env()/file_tools/code_execution_tool inside any scoped terminal turn — reaper tears down profile B's sandboxes with empty ctx: modal/singularity/vercel cleanup() write *_snapshots.json under get_hermes_home()=LAUNCH home, lifetime_seconds read from launch config (unlike browser janitor, no _session_owner_scope)
  • tools/tts_tool_lifecycle.py:70 — acquire/release_tts_lease -> warm/release_tts_provider -> _signal_user_tts_provider, reached from hermes_cli/web_routers/audio.py tts_lease under _config_profile_scope(profile) (desktop backend, profile B) — B's warm_command/release_command child is spawned with the LAUNCH profile's HERMES_HOME/HOME bridged and launch os.environ passthrough values instead of B's
  • tools/tts_tool_speaker.py:188 — stream_tts_to_speaker <- tui_gateway/methods_voice.py:101 _tts_stream_begin (desktop/TUI backend turn for profile B; NOTE that parent spawn is itself a bare Thread, other bucket) — ElevenLabs/OpenAI/Gemini/xAI streaming TTS key + tts.* config resolve from the launch env / raise UnscopedSecretError instead of B's scope
  • tui_gateway/methods_voice.py:109 — _prepare_turn_input (scoped) → _start_turn_voice → _tts_stream_begin → bare Thread(stream_tts_to_speaker) — tools.tts_tool._load_tts_config()→load_config() + provider key resolution use LAUNCH profile
  • tui_gateway/methods_voice.py:278 — _speak_text_with_barge (reached from prompt_turn:348 scoped turn) → bare Thread(_speak) — speak_text→load_config() voice/tts + provider secrets resolve against LAUNCH profile
  • tui_gateway/prompt_turn.py:348 — turn thread (scoped by _prepare_turn_input) → _after_complete_turn → bare Thread(_speak_text_with_barge) — hermes_cli.voice.speak_text→load_config() voice/tts section + TTS provider key resolve against LAUNCH profile
  • tui_gateway/server.py:2208 — _build (scoped via _bind_build_profile_scopes) → _announce_built_agent → _schedule_mcp_late_refresh → bare Thread — refresh_agent_mcp_tools→get_tool_definitions/check_fn availability + check_fn_cache_scope resolve against LAUNCH home/config for a profile-B session
  • tui_gateway/server.py:2644 — session.resume (deferred, profile param) → _schedule_resume_hydration → bare Thread — _maybe_schedule_auto_continue→_auto_continue_config()→_load_cfg()→_active_config_path() reads LAUNCH config.yaml desktop.auto_continue (marker/db use explicit profile_home correctly)
  • tui_gateway/session_notifications.py:677 — _build (scoped) / _init_session → _start_session_services → bare Thread — _maybe_fire_tui_loop_tick/_maybe_fire_tui_heartbeat_tick → LoopManager/HeartbeatManager → hermes_cli.goals._get_session_db()→get_hermes_home() = LAUNCH state.db (profile-B /loop,/heartbeat state never found)
  • FIXED sites present in the raw hit list: agent/title_generator.py:491, tui_gateway/server.py:367, tui_gateway/session_lifecycle.py:611. The other FIXED sites (server.py:370 atexit, server.py:2559 superseded_by_resume, methods_session.py session.close, google_chat _on_pubsub_message/_submit_on_loop) are not Thread/Timer/executor spawn lines and so did not match the rg pattern; google_chat hits in the list are all asyncio.to_thread.
  • Three residuals in hermes_cli (approval_transport.py:138, plugins_dispatch.py:299) depend on plugin-supplied callback bodies not present in-repo; flagged strictly, unverifiable in-repo.
  • gateway/run_turn.py:3233,3354, gateway/run_agent_cache.py:448 and gateway/platforms/api_server.py:579 share one root: bare reaper thread → process_registry checkpoint/results/read_raw_config under launch home.

lane4 row verdicts (/tmp/mux_retro/lane4_high.json, lane4_mid.json)

Row Verdict
high tui_gateway/server.py:243 _SlashWorker confirmed, FIXED
high tools/bot_relay.py:426 delivery_env confirmed, FIXED (both callers: relay RPC passes the live home; --run-delivery passes the roster home)
high tools/browser_tool.py:44 confirmed, FIXED
high plugins/platforms/a2a/adapter.py:588 confirmed, FIXED
high agent/command_token_source.py:49 confirmed, FIXED
high agent/title_generator.py:491-497 confirmed, FIXED
high tui_gateway/session_lifecycle.py:209-245, 303-317 (+5 spawn sites) confirmed, FIXED at the chokepoint; methods_session.py:1902 needs no wrap (goes through _teardown_popped_session)
high plugins/platforms/google_chat/adapter.py (Pub/Sub bridge + send chain) confirmed, FIXED via connect-time scope capture
high hermes_cli/kanban_db_dispatch.py:458 bare-PID kill confirmed (SIGKILL at :461 gated only on _pid_alive), FIXED
mid plugins/platforms/a2a/adapter.py:69 _reply_timeout os.getenv confirmed real, not fixed here (tuning-knob read, listed under thread-sweep residual a2a/adapter.py:125)
mid a2a security.py/protocol.py audit/conversation paths under launch home confirmed real, not fixed here (residual)
mid tui_gateway/server.py:2177-2208 _schedule_mcp_late_refresh confirmed real, not fixed here (residual server.py:2208)
mid tui_gateway/session_notifications.py:677 poller confirmed real, not fixed here (residual)
mid tui_gateway/methods_session.py rows (1081 oneshot, 1180 usage, 318 create, 1416 pet, 2110 spawn_tree), methods_session_control.py:234, hermes_cli/web_server*.py, web_routers/* out of this lane (sibling lane owns those files) — not touched
mid tools/browser_tool_lifecycle.py cleanup_all_browsers, tools/process_registry.py turn reaper, tools/terminal_tool.py:701 cleanup thread confirmed real, not fixed here (residual)

Infographic

infographic

… profile's env

Under gateway.multiplex_profiles (and the Desktop/dashboard backend serving named
profiles) os.environ holds the LAUNCH profile's .env. Five spawn sites built a
child's env from it while acting for another profile, so the child saw the
launch profile's HERMES_HOME (bot_relay, key_cmd), its credentials, HERMES_MODEL
and TERMINAL_* policy, and none of the served profile's own .env:

- tui_gateway/server.py _SlashWorker: pinned HERMES_HOME but kept the launch
  base with tier-2 credentials + settings.
- tools/bot_relay.py delivery_env (relay RPC + --run-delivery): dict(os.environ).
- tools/browser_tool.py _build_browser_env: re-added BROWSERBASE/FIRECRAWL/
  BROWSER_USE keys from os.environ after the scrub.
- plugins/platforms/a2a/adapter.py _forward_to_profile: {**os.environ}.
- agent/command_token_source.py _mint: key_cmd helper inherited os.environ.

tools.environments.local.served_profile_child_env is the one builder: pin the
target home, drop the launch profile's .env residue and bridged TERMINAL_*
(strip_launch_profile_env), and for children that legitimately run with the
profile's credentials (agent worker, token helper) overlay the target profile's
own secrets - what a standalone `hermes -p X` loads itself, never a sibling's.
The browser keeps the provider scrub and re-adds only its passthrough keys via
get_secret. Outside multiplex the env is unchanged.

Live proof from inside the child (launch A, served B, multiplex on): all five
children print HERMES_HOME == B, see B_MARKER=b from B's .env and do not see
A_MARKER; the browser child gets B's FIRECRAWL_API_KEY. On base every one leaked
A_MARKER and lacked B_MARKER; bot_relay and key_cmd also had A's HERMES_HOME.
…s profile scope

The profile scope (HERMES_HOME override, secret scope, terminal policy) is a
contextvar bundle bound per turn. A bare threading.Thread / Timer / gRPC
callback starts with an empty context and resolves the LAUNCH profile:

- agent/title_generator.py: the auto-title thread read
  auxiliary.title_generation (model, language, provider key) from the default
  profile's config and billed the default's key for a secondary's session.
  Spawn via agent.memory_provider.spawn_context_thread (copy_context).
- tui_gateway/session_lifecycle.py: every teardown caller is a bare Timer
  (ws-orphan reap), the idle-reaper thread, atexit _shutdown_sessions, the
  session.close pool RPC, superseded_by_resume or compute_host flush - none
  carries a scope, yet on_session_end / commit_memory_session / agent.close ->
  shutdown_memory_provider read the provider's config + credentials at call
  time. Under multiplex they failed closed (tail never committed, #110622
  class); on the Desktop backend a secondary's transcript went to the launch
  profile's memory tenant. _finalize_session and _teardown_session now bind
  _session_profile_runtime_scope(session) around those blocks, which covers
  every spawn site through the single chokepoint.
- plugins/platforms/google_chat/adapter.py: Pub/Sub callbacks run on the gRPC
  SubscriberClient's threads and run_coroutine_threadsafe copies THAT empty
  context onto the loop task, so _dispatch_message and everything under it
  (attachment cache, per-user OAuth token store via _acquire_user_chat_api ->
  _load_per_user_chat_api, TTS keys, delivery ledger, bot-id cache) resolved
  the launch profile. connect() captures its scope; _on_pubsub_message and
  _submit_on_loop run under a per-callback copy of it.

spawn_context_thread gains a kwargs passthrough for the title thread's
callbacks.
…ingerprint

tasks.worker_pid outlives a reboot; afterwards the number can belong to any
process. _pid_alive answered from bare existence, so reclaim_stale_claims kept
extending the claim of a "live" stranger (stuck running task), and
enforce_max_runtime / _terminate_reclaimed_worker SIGTERM'd then SIGKILL'd it.

_set_worker_pid now records gateway.status.get_process_start_time(pid) as
tasks.worker_started_at (additive column, NULL on legacy rows). _worker_alive
(pid, started_at) is the liveness check every reader uses (reclaim, defer,
reconcile, crash sweep, max-runtime, archive, reopen invalidation); a live pid
whose fingerprint disagrees is a recycled PID: treated as dead, never
signalled (termination reports pid_recycled). Legacy rows without a
fingerprint keep the existence answer until their next spawn.

Row hermes_cli/kanban_db_dispatch.py:458 (lane4_high) confirmed by tracing:
the SIGKILL at :461 was gated only on _pid_alive.
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 8317ddb — fix(kanban): worker liveness and kills require the spawn-tim

⚠️ Warnings

OSV vulnerability scan · View job

76 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 6m1s vs 6m3s (-0.6%). 5 job(s) slower, 6 faster, 3 unchanged.

  • OS-specific tests / Windows-only tests: +26.0s
  • Python tests / e2e: +10.0s
  • OS-specific tests / macOS-only tests: -10.0s
  • Python lints / Windows footguns (blocking): -8.0s
  • OSV scan / Scan lockfiles / osv-scan: -8.0s

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cron Cron scheduler and job management comp/plugins Plugin system and bundled plugins tool/browser Browser automation (CDP, Playwright) area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Sep 15, 2026

@andrexibiza andrexibiza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 8317ddbcbfb54d725bccde58ba41a39348b288e7 against current main 4d55ca91656ac5f83e1506679b7f81e0238e5e16 and the current synthetic merge result. The branch is 3 commits ahead / 95 behind its actual merge base (f5a457ad5bebd9d78bbf35ffaaf1c33866a03ca6); the only current-main overlap in the changed-file set is tools/environments/local.py, so I inspected the composed result rather than treating the PR head in isolation.

The central direction is right: one served-profile child-env builder, context-preserving background work, teardown scoped at the lifecycle chokepoint, and PID identity carried with Kanban ownership are much better shapes than more call-site predicates. I found four remaining P1 authority failures before this is safe to land.

P1 — routed child env still retains launch-only credentials that did not come from .env / a recorded source

served_profile_child_env(..., inherit_credentials=True) starts from hermes_subprocess_env(inherit_credentials=True). Under multiplex that still contains profile credentials injected directly into the launch process by systemd/Compose/the shell. strip_launch_profile_env() removes launch dotenv/source provenance, but it cannot remove a launch-only credential that was never recorded in those sets. If profile B does not define the same name, the A credential remains in the child after B's scope is overlaid.

That is the inverse of get_secret()'s multiplex contract: a scoped miss must not fall back to ambient process credentials. The child boundary needs the same semantics — start a routed credential-bearing child from a credential-scrubbed/non-profile base and then overlay B's authoritative scope (including managed policy), rather than retaining unknown ambient credentials first. I left the exact counterexample inline.

P1 — Desktop/dashboard served profiles still fall through the process-global multiplex predicate

The new helper delegates cleanup to strip_launch_profile_env(), which is deliberately a no-op unless is_multiplex_active() is true. That is correct for its original cron contract, but not for this PR's claimed Desktop/backend coverage: tui_gateway._session_profile_runtime_scope() installs the profile home + secret scope without turning on process-global multiplex semantics. A routed B child in that backend can therefore keep A's dotenv/settings, and _build_browser_env() can additionally resolve an absent B passthrough key through ambient A because get_secret() falls through when multiplex is inactive.

Open #111481 is complementary, not a duplicate: it is addressing the same “served routed profile even when the process flag is off” identity boundary for MCP and now has a serves_routed_profile()-style predicate. This PR needs equivalent routed-home authority for child env construction rather than keying isolation solely to the gateway-wide multiplex flag.

P1 — the persisted Kanban fingerprint is not reboot-stable on Linux

The stated threat is specifically a DB row surviving reboot. On Linux, gateway.status.get_process_start_time() preferentially stores /proc/<pid>/stat field 22, which is clock ticks since this boot. It resets when the machine boots. Persisting only (pid, start_ticks) therefore does not prove incarnation across a reboot: an unrelated process can later receive the same PID and the same boot-relative start tick and pass _start_times_agree().

The repo already has the missing shape in gateway/drain_control.current_instantiation_epoch(), which composes Linux boot_id with PID-1 start specifically because durable state can survive machine/container restart. Kanban needs a restart-stable incarnation witness as part of the persisted worker identity (or an absolute creation-time witness with equivalent semantics), and the hostile test should hold PID/start-time constant while changing the boot/instantiation identity and prove that no signal is sent.

P1 — a failed fingerprint capture creates a brand-new “legacy” kill-authority row

get_process_start_time() returns Optional[int]. _set_worker_pid() persists None when the lookup fails, while _pid_recycled() treats started_at is None as “legacy row, never recycled” and falls back to bare PID existence. So a transient lookup failure on a new worker silently recreates the exact unsafe authority this change is meant to remove.

That conflicts with the repository-wide destructive-process contract already landed through #99558 / #89614: missing or unavailable process identity is refusal, not permission to signal. Legacy migrated rows may need compatibility behavior, but a post-migration spawn whose fingerprint could not be captured must be distinguishable from that legacy state and must never authorize TERM/KILL. I left an inline witness for the None path.

Class closure / “other side of the shape”

The two sweeps are useful, but they also mean the publication claim is currently too broad. The PR body itself records 15 child-process residuals and 29 thread/timer residuals in the same defect class while the title/body say a served profile gets its own env in every child and its own scope on every background thread. Several residuals are not hygiene-only: Singularity child execution, skills-hub/GitHub auth fallback, Google Meet, Photon, inline skill shell expansion, credits/review workers, TTS, late MCP refresh, and process-registry reapers can cross a profile authority boundary.

I do not think every low-risk residual has to be stuffed into this carrier. I do think the security/identity-bearing residuals need explicit owned follow-up carriers/interlocks before this can be described as class closure; otherwise narrow the publication contract to the seams actually closed here. #109417 is the natural campaign-level tracker.

Topology / provenance

  • #111187 is merged complementary foundation, not duplicate work: it owns routed cron-fire multiplex semantics, launch-residue tracking, managed-env precedence, and the current strip_launch_profile_env() behavior that this PR composes with. It also explains why the current-main overlap in local.py matters.
  • #111082 is merged complementary scope work: gateway shutdown/session-end and cron cleanup acquire the owning profile scope; this PR handles distinct TUI/Desktop lifecycle and other background seams.
  • #111481 is open complementary routed-profile identity work for the Desktop/dashboard surface where the global multiplex flag is false. Its home-routing predicate is directly relevant to the second blocker above.
  • #99558 / #89614 are the merged destructive-PID authority class. The Kanban addition extends that class to persisted task workers, but must preserve its fail-closed rule for missing identity.
  • I found no competing PR carrying this exact combination of child-env + TUI/background + Kanban-worker changes. The three surviving commits are all authored by the current PR author; there is no contributor salvage/history collision to reconcile in this carrier.

Verification state

At the exact reviewed head, Docker 34938453586 and Nix 34938453614 are green; CI 34938454224 is still queued after reruns. More importantly, the first two surviving commits (7b8a44e53fcb5f4a7dfe70dc58ba4e8b148362f5, 13377543c1dafef36a41f35128a5fc85846de46f) have no workflow runs attached, so this is not every-commit proven green even if the head CI finishes successfully.

Required inverse witnesses before merge:

  1. launch A has an ambient-only credential (not in A .env or source provenance), B omits it, multiplex on → B child never receives A's value;
  2. Desktop/dashboard serves B with the multiplex flag off and target_home != process_home → no A dotenv/settings/credential fallback in slash worker/browser/helper children;
  3. same PID + same Linux start tick but different boot/instantiation identity → worker is foreign and never signalled;
  4. fingerprint capture returns None for a newly spawned worker → no later reclaim/timeout path may signal that PID;
  5. recompose on current main and establish exact-head and every-surviving-commit green evidence.

Once those authority boundaries are closed, the core refactor is a good reusable seam for the rest of the sweep.

snapshot."""
from agent.secret_scope import build_profile_secret_scope, current_secret_scope
from hermes_constants import get_hermes_home_override
env = dict(base) if base is not None else hermes_subprocess_env(inherit_credentials=inherit_credentials)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — ambient-only launch credentials can survive into the served profile. inherit_credentials=True starts from the launch process's provider/tool credentials, and strip_launch_profile_env() only knows names with dotenv/source provenance. A key injected by systemd/Compose/the shell (for example ambient OPENAI_API_KEY=A that is not in A's .env or recorded source names) survives here; if B does not define that key, the later B-scope overlay never removes it. Under multiplex, get_secret() deliberately treats that exact scoped miss as no credential, not ambient fallback. Build routed credential-bearing children from a credential-scrubbed base and then overlay the target profile's authoritative scope, or otherwise prove every non-global credential name is removed before the overlay. Please add the inverse test with an ambient-only A key and B missing it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on head 42e01d9 (#112685, daa084775245a): a routed target scrubs every Tier-1/2 credential from the base regardless of provenance before B's scope overlay — test_served_profile_child_env_authority.py::test_ambient_only_launch_credential_never_reaches_a_routed_child (ambient-only OPENAI_API_KEY on A, B lacks it, mux on; red on base).

target = str(target_home or get_hermes_home_override() or "")
if target:
env["HERMES_HOME"] = target
strip_launch_profile_env(env, target)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — this predicate misses the Desktop/dashboard served-profile case. strip_launch_profile_env() is intentionally a no-op when is_multiplex_active() is false, but the TUI/Desktop backend serves named profiles by installing HERMES_HOME/secret scope without enabling the gateway-wide multiplex flag. In that topology a B slash/helper child reaches this line with target != process_home, the strip does nothing, and A's dotenv/settings remain; browser passthrough also has an ambient get_secret() fallback on a B miss. The authority test needs to be “this task serves a routed home,” not only “the whole gateway is multiplexing.” #111481 is complementary work on exactly that routed-home predicate for MCP. Add a mux-flag-off B witness here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on head 42e01d9 (#112685, daa084775245a): the strip/scrub now keys on target != process home, not is_multiplex_active(); the browser passthrough resolves from the bound scope only under serves_routed_profile()…::test_routed_home_with_multiplex_flag_off_gets_no_launch_residue (flag-off B witness; red on base).

carrying them. The fingerprint is what lets every later liveness/kill decision tell OUR worker
from a process that recycled the PID after a reboot."""
from gateway.status import get_process_start_time
started_at = get_process_start_time(int(pid))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — this persisted fingerprint is not reboot-stable on Linux, so it does not prove the threat model in the docstring. gateway.status.get_process_start_time() first returns /proc/<pid>/stat field 22 on Linux: boot-relative clock ticks. The DB survives reboot; that counter does not. A later boot can therefore produce an unrelated process with the same PID and the same boot-relative tick value, and _start_times_agree() accepts it. Persist an incarnation witness with the process start (for example the existing gateway.drain_control.current_instantiation_epoch() / Linux boot_id, or an equivalent restart-stable creation-time identity), then require both to match before liveness extends a claim or any signal is authorized. Hostile test: same PID + same start tick, different boot identity => zero signals.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on head 42e01d9 (#112685, 9cb9fd0d0cb9c): fingerprint is now "<current_instantiation_epoch()>|<start>" (boot_id + PID-1 start), both halves must match — test_kanban_worker_pid_fingerprint.py::test_same_pid_and_start_tick_on_another_boot_is_foreign (same PID + same tick, other boot id → zero signals; red on base). Live: real sleeper child left untouched on the reboot-shaped row, SIGTERM'd on the matching one.

def _pid_recycled(pid: Optional[int], started_at: Optional[int]) -> bool:
"""True when a live ``pid`` is NOT the process fingerprinted at spawn (or the fingerprint can no
longer be read). Signalling it would hit a stranger. ``None`` fingerprint = legacy row, never recycled."""
if started_at is None or not pid:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — None is safe only for truly legacy rows, but _set_worker_pid() can create it today. get_process_start_time() is optional; if a new spawn's lookup fails, this PR persists worker_started_at=NULL, then this branch deliberately treats the live PID as our worker and the timeout/reclaim paths can signal it by bare number. That recreates the #89614/#99558 authority failure for a post-migration row. Preserve legacy compatibility separately, but a new unverified spawn must fail closed for destructive signaling. Add a test that forces fingerprint capture to None, then presents a live/reused PID and proves TERM/KILL is never issued.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on head 42e01d9 (#112685, 9cb9fd0d0cb9c): a failed capture persists 'unverified', never NULL — held while live, never signalled by timeout/stale-claim/manual reclaim/archive/reaper, reclaimed once gone; NULL stays legacy-only — …::test_unverified_fingerprint_capture_never_authorizes_a_signal (red on base).

@kvnloo kvnloo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — PR #111617 (teknium1)

"Served-profile children and background threads run with their own profile's env/scope; kanban never kills a recycled PID"

Verdict: COMMENT, concurring with andrexibiza's blockers. Read his review first — not repeating his four P1s (ambient-credential retention, multiplex-inactive fallthrough, reboot-unstable fingerprint, None-fingerprint legacy authority). Verified the mechanics independently against base; the new angles below are all his-review-adjacent gaps, not duplicates.

What I verified as sound

  • Fingerprint plumbing is consistent across every reclaim path: release_stale_claims, reclaim_task, detect_stale_running, reconcile_orphaned_running, _reclaim_dead_workers, enforce_max_runtime, archive_task, _set_status_direct, invalidate_descendants_for_parent_reopen all thread worker_started_at through. No path still kills on bare PID existence.
  • _start_times_agree is effectively exact equality on integer clock ticks (abs(r - cur) <= 0.001) — no tolerance slop that could merge two incarnations.
  • build_profile_secret_scope composes .env + external sources + managed env last, so the target overlay in served_profile_child_env is complete when a target is bound.
  • google_chat: per-callback ctx.copy() is the right call (a Context can't be entered concurrently), and ctx_bound copies at spawn time — the auto-title thread now genuinely carries the turn's scope instead of an empty context.
  • Gateway multiplex connects adapters inside _profile_runtime_scope (run_adapters.py:1016), so the _scope_ctx capture in connect() is correct on that path.

New finding 1 (medium): _mint fails open to launch credentials when no scope is bound

served_profile_child_env(inherit_credentials=True) with no target_home resolves the target from the ambient get_hermes_home_override() (tools/environments/local.py). When nothing is bound — which is exactly the background-thread situation this PR is fixing elsewhere — target is "", the overlay degrades to current_secret_scope() or {}, and the base is hermes_subprocess_env(inherit_credentials=True), i.e. the launch environ's provider credentials (os.environ.copy() minus Tier-1).

_mint (agent/command_token_source.py) calls it with no target_home, and CommandTokenSource.__call__ runs on whatever thread first needs the token — TTL refresh can fire minutes later on an arbitrary worker thread. An unscoped refresh mints the token with the launch profile's credentials while it's cached as the served profile's provider key: the op read / vault kv get helper signs in as the wrong profile, silently.

The docstring promises "never the multiplexer's launch environ", but unlike get_secret — which fails closed with UnscopedSecretError under multiplex — this helper fails open. Suggest: when multiplex is active and neither an override nor a scope is bound, raise instead of minting; or require target_home for credential-bearing children so the safe usage is the only usage.

New finding 2 (P2/nit): the fingerprint check and the signal are still check-then-act

_terminate_reclaimed_worker (kanban_db_dispatch.py) does _pid_recycled(pid, started_at) → then kill(pid, SIGTERM) as separate syscalls; enforce_max_runtime has the same shape. A recycle landing in that window still signals a stranger. The window is tiny and unexploitable in practice, but the PR title claims "never kills a recycled PID" — strictly, the guarantee is "never kills a detected recycled PID". Linux has the atomic primitive for this: pidfd_open + pidfd_send_signal. Worth naming as the residual even if it's accepted as-is.

Nit: archive_task clears worker_pid but not worker_started_at

The UPDATE nulls worker_pid/claim_lock/claim_expires but leaves the stale fingerprint on the row. Harmless today (NULL pid → _pid_alive false; every pid set goes through _set_worker_pid), but the column is part of the kill-authority tuple now — clear it with the rest so no future path can read a fingerprint that outlived its pid.

Question (not a finding): _scope_ctx on the TUI/Desktop backend

Verified the gateway multiplex path connects inside the profile scope, but the google_chat change is shared with the TUI/Desktop backend. If adapters there connect at startup under the launch scope and later serve a named profile, _scope_ctx permanently captures launch and every Pub/Sub callback resolves the wrong profile — the exact bug, persisting. The None fallback (contextvars.copy_context() on the gRPC thread = empty context) degrades the same way, silently. Is connect() guaranteed to run under the served profile's scope on every backend, or should the capture assert/fail-closed when it isn't?

Note extending andrexibiza's P1 #1 (not a new P1)

delivery_env (tools/bot_relay.py) calls served_profile_child_env(base=os.environ, ...) — the base replaces the hermes_subprocess_env snapshot, so this call site is the raw multiplexer environ minus strip_launch_profile_env (a no-op when multiplex is inactive), with target secrets overlaid. It's the strongest concrete instance of the ambient-credential retention he flagged, and the docstring's "never the multiplexer's raw os.environ" overclaims for exactly this call.

@teknium1

Copy link
Copy Markdown
Collaborator Author

Follow-up for the four P1s: #112685 (head 42e01d9b6b4cc).

Finding Fix Test (red on base)
P1 ambient-only launch credential survives into B's child routed target scrubs every Tier-1/2 credential regardless of provenance before B's scope overlay (served_profile_child_env, incl. bot_relay's base=os.environ) — daa084775245a test_served_profile_child_env_authority.py::test_ambient_only_launch_credential_never_reaches_a_routed_child
P1 multiplex-flag-off Desktop/dashboard served profile strip + scrub key on "target is a routed home" (≠ process home), not is_multiplex_active(); browser passthrough resolves from the bound scope only under serves_routed_profile()daa084775245a …::test_routed_home_with_multiplex_flag_off_gets_no_launch_residue, …::test_real_child_observes_only_the_routed_profile
P1 fingerprint not reboot-stable worker_started_at = "<current_instantiation_epoch()>|<start>" (boot_id + PID-1 start), both halves must match — 9cb9fd0d0cb9c test_kanban_worker_pid_fingerprint.py::test_same_pid_and_start_tick_on_another_boot_is_foreign
P1 failed capture → legacy kill authority failed capture persists 'unverified' (never NULL): held while live, never signalled by any reclaim/timeout/archive/reaper path, reclaimed once gone — 9cb9fd0d0cb9c …::test_unverified_fingerprint_capture_never_authorizes_a_signal

Also: inherit_credentials=True with no target and no scope under multiplex now raises UnscopedSecretError (kvnloo's _mint finding). Class-closure residuals stay on #109417 as you suggested; the PR body of #112685 narrows the claim to the seams closed.

@teknium1

Copy link
Copy Markdown
Collaborator Author

Follow-up: #112685 (head 42e01d9b6b4cc).

  • Finding 1 (_mint fails open with no scope bound) — fixed: served_profile_child_env(inherit_credentials=True) with no target and no bound scope under multiplex raises UnscopedSecretError, same contract as get_secret. daa084775245a.
  • delivery_env note — the base=os.environ call goes through the same routed-target credential scrub; covered by the ambient-key test.
  • Nit: archive_task leaves worker_started_at — fixed; every tasks UPDATE that nulls worker_pid nulls the fingerprint too. 9cb9fd0d0cb9c.
  • P2 check-then-act between _pid_recycled and kill — not changed. Agreed it is real; closing it atomically needs pidfd_open/pidfd_send_signal (Linux ≥ 5.3) and a psutil-free path for the other hosts. Documented in the commit body as the residual: the guarantee is "never kills a detected recycled PID".
  • Question: _scope_ctx on the TUI/Desktop backend — verified: connect() runs inside _profile_runtime_scope on the multiplex gateway (run_adapters.py), and the TUI/Desktop backend does not construct messaging adapters at all (grep -rn google_chat tui_gateway/ → none), so there is no path where the capture happens under the launch scope and later serves a named profile.

teknium1 added a commit that referenced this pull request Sep 16, 2026
…, with or without the multiplex flag

Two authority gaps in served_profile_child_env (#111617 review, andrexibiza P1 #1/#2,
kvnloo finding 1):

- The base was hermes_subprocess_env(inherit_credentials=True) = the launch environ's
  provider credentials; strip_launch_profile_env only knows names with .env/source
  provenance, so a key systemd/Compose/the shell injected into the launch process
  survived into profile B's child whenever B did not define the same name. Now a ROUTED
  target scrubs every Tier-1/Tier-2 credential from the base regardless of provenance
  before B's own scope is overlaid (the child boundary gets get_secret's contract: a
  scoped miss is no credential, never ambient fallback). The launch profile's own child
  keeps its env. bot_relay's base=os.environ goes through the same scrub.
- strip_launch_profile_env / the scrub keyed on is_multiplex_active(); the Desktop and
  dashboard backends serve ?profile=B by installing the HERMES_HOME override without
  that flag, so B's slash worker / helper children kept A's .env and settings. The
  authority test is now "is the target a routed home" (target != process home).
- _build_browser_env resolved the passthrough keys via get_secret, which falls through
  to os.environ on a scoped miss while multiplexing is inactive: a routed B with no
  Firecrawl key got A's. Under serves_routed_profile() the bound scope is the only source.
- served_profile_child_env(inherit_credentials=True) with no target and no scope bound
  under multiplex minted with the launch credentials (key_cmd TTL refresh on a worker
  thread); it now raises UnscopedSecretError like get_secret.

tests/tui_gateway/test_served_profile_child_env_authority.py: ambient-only A key + B
missing it (mux on), flag-off routed B (helper child + browser), real child observation.
3/3 red on base.
teknium1 added a commit that referenced this pull request Sep 16, 2026
… fingerprint never authorizes a signal

#111617 review (andrexibiza P1 #3/#4, kvnloo nit):

- worker_started_at persisted only gateway.status.get_process_start_time(): on Linux that
  is /proc/<pid>/stat field 22, clock ticks since THIS boot. The threat is a row surviving
  a reboot, and that counter does not, so an unrelated process on a later boot with the
  same PID and the same tick value passed _start_times_agree(). The fingerprint is now
  "<gateway.drain_control.current_instantiation_epoch()>|<start>" (boot_id + PID-1 start,
  the witness the drain marker already uses); both halves must match. Integer values on
  rows written before this change keep the start-time-only comparison.
- A failed capture persisted NULL, which _pid_recycled treats as the legacy pre-fingerprint
  row and falls back to bare PID existence - a new spawn silently recreated the #89614/
  #99558 kill authority. A failed capture now persists UNVERIFIED_WORKER_FINGERPRINT: the
  claim is held while the PID is live (never released beside it, never SIGTERM/SIGKILLed
  by timeout, stale-claim, manual reclaim, archive or the terminal reaper) and reclaimed
  once it is gone. NULL stays legacy-only.
- Every tasks UPDATE that nulls worker_pid nulls worker_started_at too (archive_task and
  the reclaim/timeout/reopen paths): the fingerprint is part of the kill-authority tuple
  and must not outlive its pid.

Live (real sleeper child): reboot-shaped row (same pid, same tick, other boot id) ->
reclaimed to ready, child untouched; matching fingerprint -> SIGTERM delivered, exit -15.
tests/hermes_cli/test_kanban_worker_pid_fingerprint.py: +2 hostile tests, both red on base.

Not changed: the check-then-act window between _pid_recycled and kill (kvnloo P2) is
real but needs pidfd_open/pidfd_send_signal (Linux 5.3+) to close atomically; left as
the documented residual of "never kills a DETECTED recycled PID".
teknium1 added a commit that referenced this pull request Sep 16, 2026
…nv restart requirement

kvnloo (#111620 review) asked for the operator-visible statement that once a serve /
dashboard process hosts a second profile, the launch profile's env-only credentials are
frozen at activation and a rotation in the process env needs a restart. Also states the
routed-child rule with and without the multiplex flag (#111617). Adds encoding='utf-8'
to the probe child in the child-env authority test (windows-footguns lint).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cron Cron scheduler and job management comp/plugins Plugin system and bundled plugins comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/browser Browser automation (CDP, Playwright) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants