From 1ad35ee7db82e889b49df7548960f3993d434b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:48:28 +0000 Subject: [PATCH 1/6] fix(gateway): restore missing get_terminal_cwd in session_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_agent.py imports get_terminal_cwd from gateway.session_context at module load time (lines 2339, 5892, 10645, 11105), but the symbol was absent — so test collection failed with ImportError and cascaded into ~275 test errors across the suite. Restored as a contextvar-backed compatibility shim: - New _TERMINAL_CWD ContextVar plus _VAR_MAP entry for concurrency-safe per-task working directories in the gateway. - set_session_vars/clear_session_vars now manage _TERMINAL_CWD too. - get_terminal_cwd(default) prefers the contextvar when set to a non-empty path, then falls back to the legacy TERMINAL_CWD env var (still set by cron/scheduler.py, the CLI, and gateway/run.py), then to the caller's default (defaulting to os.getcwd()). - Empty-string contextvar is treated as "unset" so the generic set_session_vars(platform="", chat_id="", ...) call in cron/scheduler.py:1253 doesn't shadow the workdir env var that the scheduler sets right after. --- gateway/session_context.py | 41 +++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/gateway/session_context.py b/gateway/session_context.py index b64f31de0816..6013fcd32424 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -36,8 +36,9 @@ platform = get_session_env("HERMES_SESSION_PLATFORM", "") """ +import os from contextvars import ContextVar -from typing import Any +from typing import Any, Optional # Sentinel to distinguish "never set in this context" from "explicitly set to empty". # When a contextvar holds _UNSET, we fall back to os.environ (CLI/cron compat). @@ -63,6 +64,12 @@ _CRON_AUTO_DELIVER_CHAT_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_CHAT_ID", default=_UNSET) _CRON_AUTO_DELIVER_THREAD_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_THREAD_ID", default=_UNSET) +# Per-task terminal working directory. The legacy env var is ``TERMINAL_CWD`` +# (no ``HERMES_`` prefix); cron/scheduler.py and the CLI still set that. This +# contextvar takes precedence when set so concurrent gateway tasks don't +# clobber each other's workdir. +_TERMINAL_CWD: ContextVar = ContextVar("HERMES_TERMINAL_CWD", default=_UNSET) + _VAR_MAP = { "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM, "HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID, @@ -75,6 +82,7 @@ "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM, "HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID, "HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID, + "HERMES_TERMINAL_CWD": _TERMINAL_CWD, } @@ -86,6 +94,7 @@ def set_session_vars( user_id: str = "", user_name: str = "", session_key: str = "", + terminal_cwd: str = "", ) -> list: """Set all session context variables and return reset tokens. @@ -103,6 +112,7 @@ def set_session_vars( _SESSION_USER_ID.set(user_id), _SESSION_USER_NAME.set(user_name), _SESSION_KEY.set(session_key), + _TERMINAL_CWD.set(terminal_cwd), ] return tokens @@ -126,6 +136,7 @@ def clear_session_vars(tokens: list) -> None: _SESSION_USER_ID, _SESSION_USER_NAME, _SESSION_KEY, + _TERMINAL_CWD, ): var.set("") @@ -145,8 +156,6 @@ def get_session_env(name: str, default: str = "") -> str: don't use ``set_session_vars`` at all). 3. *default* """ - import os - var = _VAR_MAP.get(name) if var is not None: value = var.get() @@ -154,3 +163,29 @@ def get_session_env(name: str, default: str = "") -> str: return value # Fall back to os.environ for CLI, cron, and test compatibility return os.getenv(name, default) + + +def get_terminal_cwd(default: Optional[str] = None) -> str: + """Backward-compatible accessor for the terminal working directory. + + Prefers the task-local context value when set, otherwise falls back to + the legacy ``TERMINAL_CWD`` environment variable (still set by + ``cron/scheduler.py``, the CLI, and ``gateway/run.py``), then to the + caller-supplied default (or the process cwd if no default is given). + + Resolution order: + 1. ``_TERMINAL_CWD`` contextvar — when set to a non-empty path via + ``set_session_vars``. An empty string is treated as "unset" so the + generic ``set_session_vars(platform="", chat_id="", ...)`` call from + ``cron/scheduler.py`` (which leaves ``terminal_cwd`` at its ``""`` + default) does not mask the legacy env var the cron then sets. + 2. ``os.environ["TERMINAL_CWD"]`` — the legacy convention shared with + the rest of the codebase. + 3. *default* (falling back to ``os.getcwd()`` when ``None``). + """ + value = _TERMINAL_CWD.get() + if value is not _UNSET and value: + return value + if default is None: + default = os.getcwd() + return os.environ.get("TERMINAL_CWD", default) From 3c2d6a6c05bf3e3ab69b296d619763d7b4e6c78d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 19:57:01 +0000 Subject: [PATCH 2/6] fix(gateway): preserve unset/cleared sentinel for _TERMINAL_CWD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review findings on PR #682: 1. set_session_vars: default terminal_cwd to None and translate to _UNSET internally. Previous default of "" marked _TERMINAL_CWD as "set" on every gateway/cron call that didn't pass it, destroying the never-set-vs-explicitly-cleared distinction. 2. get_terminal_cwd: restore the module invariant that an explicitly cleared contextvar suppresses os.environ fallback (matches the documented behavior of get_session_env). Empty-string in the contextvar now means "explicitly cleared" and returns the caller default; env-var fallback only happens when the contextvar is at the _UNSET sentinel. 3. Add regression tests in tests/gateway/test_session_env.py for: - _UNSET → env-var fallback (cron scheduler flow) - _UNSET → default when no env var - Explicit terminal_cwd via set_session_vars wins over env var - set_session_vars() without terminal_cwd leaves contextvar _UNSET (so the cron scheduler's later os.environ mutation still wins) - clear_session_vars() suppresses env-var fallback (no stale leak) - Concurrent asyncio tasks see isolated terminal_cwd values. --- gateway/session_context.py | 34 ++++++---- tests/gateway/test_session_env.py | 105 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 12 deletions(-) diff --git a/gateway/session_context.py b/gateway/session_context.py index 6013fcd32424..8d58bd7ce83f 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -94,13 +94,18 @@ def set_session_vars( user_id: str = "", user_name: str = "", session_key: str = "", - terminal_cwd: str = "", + terminal_cwd: Optional[str] = None, ) -> list: """Set all session context variables and return reset tokens. Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore the previous values when the handler exits. + ``terminal_cwd`` defaults to ``None`` (not provided) — in that case the + ``_TERMINAL_CWD`` contextvar is left at ``_UNSET`` so ``get_terminal_cwd`` + falls back to the legacy ``TERMINAL_CWD`` env var (still set by + ``cron/scheduler.py``). Pass an explicit string to scope a per-task cwd. + Returns a list of ``Token`` objects (one per variable) that can be passed to ``clear_session_vars``. """ @@ -112,7 +117,7 @@ def set_session_vars( _SESSION_USER_ID.set(user_id), _SESSION_USER_NAME.set(user_name), _SESSION_KEY.set(session_key), - _TERMINAL_CWD.set(terminal_cwd), + _TERMINAL_CWD.set(_UNSET if terminal_cwd is None else terminal_cwd), ] return tokens @@ -174,18 +179,23 @@ def get_terminal_cwd(default: Optional[str] = None) -> str: caller-supplied default (or the process cwd if no default is given). Resolution order: - 1. ``_TERMINAL_CWD`` contextvar — when set to a non-empty path via - ``set_session_vars``. An empty string is treated as "unset" so the - generic ``set_session_vars(platform="", chat_id="", ...)`` call from - ``cron/scheduler.py`` (which leaves ``terminal_cwd`` at its ``""`` - default) does not mask the legacy env var the cron then sets. - 2. ``os.environ["TERMINAL_CWD"]`` — the legacy convention shared with - the rest of the codebase. + 1. ``_TERMINAL_CWD`` contextvar when explicitly set via + ``set_session_vars(terminal_cwd=...)`` — that value wins, including + the empty-string "explicitly cleared" state from + ``clear_session_vars`` (which suppresses env-var fallback to avoid + leaking stale state from a prior gateway session, matching the + invariant documented on ``get_session_env``). + 2. ``os.environ["TERMINAL_CWD"]`` — consulted only when the contextvar + is at its ``_UNSET`` sentinel (CLI, cron scheduler, or any + ``set_session_vars`` call that didn't pass ``terminal_cwd``). 3. *default* (falling back to ``os.getcwd()`` when ``None``). """ - value = _TERMINAL_CWD.get() - if value is not _UNSET and value: - return value if default is None: default = os.getcwd() + value = _TERMINAL_CWD.get() + if value is not _UNSET: + # Explicitly set (or explicitly cleared). Return as-is when truthy; + # use the caller default for explicit clears so we don't leak stale + # os.environ values from a prior session. + return value if value else default return os.environ.get("TERMINAL_CWD", default) diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index 2b6c983a769b..6431a381b6e8 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -8,8 +8,10 @@ from gateway.session import SessionContext, SessionSource from gateway.session_context import ( get_session_env, + get_terminal_cwd, set_session_vars, clear_session_vars, + _TERMINAL_CWD, _VAR_MAP, _UNSET, ) @@ -322,3 +324,106 @@ def blow_up(): with pytest.raises(ValueError, match="boom"): await runner._run_in_executor_with_context(blow_up) + + +# --------------------------------------------------------------------------- +# get_terminal_cwd precedence tests +# --------------------------------------------------------------------------- + + +def test_get_terminal_cwd_unset_falls_back_to_env_var(monkeypatch, tmp_path): + """When the contextvar is _UNSET, the legacy TERMINAL_CWD env var wins. + + This is the CLI / cron-scheduler flow: cron mutates + os.environ["TERMINAL_CWD"] to point at the per-job workdir, then the + agent calls get_terminal_cwd() and must see that workdir. + """ + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + assert get_terminal_cwd() == str(tmp_path) + + +def test_get_terminal_cwd_unset_no_env_returns_default(monkeypatch): + """With nothing set and no default, returns os.getcwd().""" + monkeypatch.delenv("TERMINAL_CWD", raising=False) + assert get_terminal_cwd() == os.getcwd() + + +def test_get_terminal_cwd_explicit_default(monkeypatch): + """Explicit default is returned when neither contextvar nor env var is set.""" + monkeypatch.delenv("TERMINAL_CWD", raising=False) + assert get_terminal_cwd("/explicit/default") == "/explicit/default" + + +def test_get_terminal_cwd_contextvar_wins_over_env_var(monkeypatch, tmp_path): + """An explicit set_session_vars(terminal_cwd=...) call beats the env var. + + This is the gateway concurrency story: each asyncio task gets its own + cwd via the contextvar, so concurrent messages don't clobber each other + through process-global os.environ. + """ + monkeypatch.setenv("TERMINAL_CWD", "/cron/env/value") + tokens = set_session_vars(terminal_cwd=str(tmp_path)) + try: + assert get_terminal_cwd() == str(tmp_path) + finally: + clear_session_vars(tokens) + + +def test_set_session_vars_without_terminal_cwd_leaves_contextvar_unset( + monkeypatch, tmp_path +): + """set_session_vars() without terminal_cwd must not mask the env var. + + Without this, the cron scheduler's flow (set_session_vars(...) → then + os.environ['TERMINAL_CWD'] = workdir) would silently break: the + contextvar would hold "" and shadow the env var the scheduler just set. + """ + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + tokens = set_session_vars(platform="", chat_id="", chat_name="") + try: + # contextvar stays at _UNSET sentinel → env var fallback applies + assert _TERMINAL_CWD.get() is _UNSET + assert get_terminal_cwd() == str(tmp_path) + finally: + clear_session_vars(tokens) + + +def test_clear_session_vars_suppresses_env_var_fallback(monkeypatch): + """Explicitly cleared contextvar must not leak stale env-var state. + + Matches the invariant on get_session_env: once a session is torn down, + subsequent reads should return the caller default, NOT a value left + over in os.environ from a prior gateway session. + """ + monkeypatch.setenv("TERMINAL_CWD", "/stale/prior/session") + tokens = set_session_vars(terminal_cwd="/active") + clear_session_vars(tokens) + # _TERMINAL_CWD now holds "" (explicitly cleared). Default must win. + assert get_terminal_cwd("/expected/default") == "/expected/default" + + +@pytest.mark.asyncio +async def test_get_terminal_cwd_isolated_across_concurrent_tasks(monkeypatch): + """Two concurrent tasks must each read their own terminal_cwd. + + This is the actual concurrency bug the contextvar refactor exists to + fix. With process-global state, task B would overwrite task A's cwd. + """ + monkeypatch.delenv("TERMINAL_CWD", raising=False) + results = {} + + async def handler(label: str, cwd: str, delay: float): + tokens = set_session_vars(terminal_cwd=cwd) + try: + await asyncio.sleep(delay) + results[label] = get_terminal_cwd() + finally: + clear_session_vars(tokens) + + task_a = asyncio.create_task(handler("a", "/cwd/a", 0.15)) + await asyncio.sleep(0.05) + task_b = asyncio.create_task(handler("b", "/cwd/b", 0.05)) + await asyncio.gather(task_a, task_b) + + assert results["a"] == "/cwd/a" + assert results["b"] == "/cwd/b" From a66627521e95710e9d2e6d88f219095ef0a14897 Mon Sep 17 00:00:00 2001 From: Geoffrey R Plymale <106821302+badMade@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:57:06 -0400 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- gateway/session_context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gateway/session_context.py b/gateway/session_context.py index 8d58bd7ce83f..802276cf50cd 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -91,7 +91,6 @@ def set_session_vars( chat_id: str = "", chat_name: str = "", thread_id: str = "", - user_id: str = "", user_name: str = "", session_key: str = "", terminal_cwd: Optional[str] = None, From 6d2e31c87f2b578065ee9619be23bdb188c6108b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:24:17 +0000 Subject: [PATCH 4/6] fix: address broader CI regressions on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five pre-existing bugs were blocking the test job on PR #682. They predate the get_terminal_cwd restoration but the failing job mixes them with the import fix, so resolving here unblocks merge. 1. gateway/run.py: NameError in _is_user_authorized. `team_id` was referenced at lines 5467 and 5543 but never defined in scope, raising NameError on every authorization check. Define it alongside `user_id` near the top of the method as a getattr() with "" default so non-Slack platforms (which don't carry team_id on SessionSource) skip the team-scoped pairing/allowlist branches. Unblocks 22 tests in tests/gateway/test_unauthorized_dm_behavior.py. 2. gateway/platforms/webhook.py: reject unresolved ${VAR} secrets. The HMAC validator previously called hmac.new(secret.encode(), ...) directly even when `secret` was a literal "${WEBHOOK_SECRET}" left over from an unrendered config template — silently authorising any attacker who could compute the HMAC against that literal text. Add a small regex guard that rejects unresolved env-var placeholders before hashing. Fixes the test_validate_placeholder_secret_rejects _literal_hmac regression. 3. tools/web_tools.py: restore _ddgs_package_available. Tests monkeypatch tools.web_tools._ddgs_package_available, but the implementation had been renamed to _ddgs_package_importable, so the patches no-op'd. Rename the canonical helper to _available and keep _importable as a backward-compat alias so external callers don't regress. 4. tools/web_tools.py: stop auto-detecting ddgs as last-resort backend. _get_backend's fallback loop ended with the ddgs package presence check, making ddgs silently win whenever no env-driven backend was configured — surprising users and breaking a test that asserts ddgs must be opted into explicitly via web.backend. 5. tools/browser_tool.py + tests: normalise SSRF wording. The redirect branch said "private/internal address" while the pre- navigation branch said "private or internal address", and tests asserted on both substrings. Canonicalise on "private or internal address" everywhere and update the two redirect tests that explicitly looked for the slash form. Leaves gateway/platforms/* and vision_tools.py wording alone because their existing tests (test_wecom, test_slack, test_media_download_retry) lock in "private/internal" and aren't listed as failing here. --- gateway/platforms/webhook.py | 20 ++++++++++++++++++++ gateway/run.py | 6 ++++++ tests/tools/test_browser_ssrf_local.py | 4 ++-- tools/browser_tool.py | 2 +- tools/web_tools.py | 21 ++++++++++++++------- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 83aa93e94cb3..6c7e758bfa53 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -59,6 +59,16 @@ _INSECURE_NO_AUTH = "INSECURE_NO_AUTH" _DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json" +# Matches ``${VAR}`` env-var template literals that survived unresolved into +# a route's ``secret`` field — usually a config bug (the env var wasn't set +# when config was rendered). We must NOT HMAC the literal placeholder text. +_PLACEHOLDER_SECRET_RE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}") + + +def _looks_unresolved_secret(secret: str) -> bool: + s = (secret or "").strip() + return bool(_PLACEHOLDER_SECRET_RE.fullmatch(s)) + # Hostnames/IP literals that only serve connections originating on the same # machine. Anything else is treated as a public bind for safety-rail purposes. _LOOPBACK_HOSTS = frozenset({ @@ -590,6 +600,16 @@ def _validate_signature( self, request: "web.Request", body: bytes, secret: str ) -> bool: """Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256).""" + # An unresolved ${VAR} env-var placeholder is never a real secret. + # Treat it as a misconfiguration and refuse the request rather than + # silently HMACing the literal placeholder text. + if _looks_unresolved_secret(secret): + logger.warning( + "[webhook] Unresolved placeholder secret configured (%r) — rejecting", + secret, + ) + return False + # GitHub: X-Hub-Signature-256 = sha256= gh_sig = request.headers.get("X-Hub-Signature-256", "") if gh_sig: diff --git a/gateway/run.py b/gateway/run.py index 8c884307c1f4..e49e779edd34 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -5372,6 +5372,12 @@ def _is_user_authorized(self, source: SessionSource) -> bool: if not user_id: return False + # Optional Slack-style team scoping. When source carries a team_id + # (e.g. Slack), the pairing/allowlist checks below augment the bare + # auth id with a "{team_id}:{auth_user_id}" key. Other platforms + # don't populate team_id, so default to "" and skip those checks. + team_id = getattr(source, "team_id", None) or "" + platform_env_map = { Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS", Platform.DISCORD: "DISCORD_ALLOWED_USERS", diff --git a/tests/tools/test_browser_ssrf_local.py b/tests/tools/test_browser_ssrf_local.py index 9d208530080f..d9a16ba8ed55 100644 --- a/tests/tools/test_browser_ssrf_local.py +++ b/tests/tools/test_browser_ssrf_local.py @@ -253,7 +253,7 @@ def test_cloud_blocks_redirect_to_private(self, monkeypatch, _common_patches): result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL)) assert result["success"] is False - assert "redirect landed on a private/internal address" in result["error"] + assert "redirect landed on a private or internal address" in result["error"] def test_cloud_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches): """Redirects to private addresses pass in cloud mode with allow_private_urls.""" @@ -291,7 +291,7 @@ def test_local_blocks_redirect_to_private_by_default(self, monkeypatch, _common_ result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL)) assert result["success"] is False - assert "redirect landed on a private/internal address" in result["error"] + assert "redirect landed on a private or internal address" in result["error"] def test_local_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches): """Redirects to private addresses pass in local mode with allow_private_urls.""" diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 81a54842ccf5..c5329cf1cd2a 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -2406,7 +2406,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: _run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10) return json.dumps({ "success": False, - "error": "Blocked: redirect landed on a private/internal address", + "error": "Blocked: redirect landed on a private or internal address", }) response = { diff --git a/tools/web_tools.py b/tools/web_tools.py index e5344855bc83..679eb44fc524 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -133,8 +133,11 @@ def _get_backend() -> str: # Fallback for manual / legacy config — pick the highest-priority # available backend. Firecrawl also counts as available when the managed # tool gateway is configured for Nous subscribers. - # Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so - # existing paid setups are unaffected. + # Free-tier backends (searxng / brave-free) trail the paid ones so + # existing paid setups are unaffected. ddgs is intentionally NOT in + # this auto-detect list: it has no env var / config to signal intent, + # so picking it as a silent last-resort can surprise users. Users who + # want ddgs must set ``web.backend: ddgs`` explicitly. backend_candidates = ( ("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()), ("parallel", _has_env("PARALLEL_API_KEY")), @@ -142,7 +145,6 @@ def _get_backend() -> str: ("exa", _has_env("EXA_API_KEY")), ("searxng", _has_env("SEARXNG_URL")), ("brave-free", _has_env("BRAVE_SEARCH_API_KEY")), - ("ddgs", _ddgs_package_importable()), ) for backend, available in backend_candidates: if available: @@ -204,17 +206,17 @@ def _is_backend_available(backend: str) -> bool: if backend == "brave-free": return _has_env("BRAVE_SEARCH_API_KEY") if backend == "ddgs": - return _ddgs_package_importable() + return _ddgs_package_available() return False -def _ddgs_package_importable() -> bool: +def _ddgs_package_available() -> bool: """Return True when the ``ddgs`` Python package can be imported. ddgs is the only backend whose availability is driven by a package presence rather than an env var / config entry. Wrapped in a helper - so auto-detect and ``_is_backend_available`` share the same check - (and tests can monkeypatch a single symbol). + so ``_is_backend_available`` and any future auto-detect path share + the same check (and tests can monkeypatch a single symbol). """ try: import ddgs # noqa: F401 @@ -222,6 +224,11 @@ def _ddgs_package_importable() -> bool: except ImportError: return False + +# Backward-compat alias for older callers/tests that still import the +# previous name. Prefer ``_ddgs_package_available`` going forward. +_ddgs_package_importable = _ddgs_package_available + # ─── Firecrawl Client ──────────────────────────────────────────────────────── _firecrawl_client = None From c4159b061679e31a6f2f16109a10648a0e22c73f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:40:37 +0000 Subject: [PATCH 5/6] fix(gateway): restore user_id parameter on set_session_vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit a666275 ("Potential fix for pull request finding" — Copilot Autofix) removed the user_id parameter from set_session_vars, presumably acting on a spurious ty diagnostic. But user_id is still referenced in the body at line 116 (_SESSION_USER_ID.set(user_id)), so every caller of set_session_vars now raised NameError: name 'user_id' is not defined. This was the dominant cause of the test job failure on this PR — ~50 of the 87 failures in tests/gateway/test_session_env.py, tests/cron/test_scheduler.py, tests/cron/test_cron_workdir.py, tests/agent/test_skill_commands.py, tests/acp/test_approval_isolation.py, and tests/tools/test_cron_approval_mode.py all chained off this NameError. The "ty thinks user_id isn't defined" diagnostic at session_context.py:116 was a false positive (the parameter was clearly there in the signature before the autofix); restoring the parameter both removes the spurious warning and unbreaks the call sites. --- gateway/session_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway/session_context.py b/gateway/session_context.py index 802276cf50cd..8d58bd7ce83f 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -91,6 +91,7 @@ def set_session_vars( chat_id: str = "", chat_name: str = "", thread_id: str = "", + user_id: str = "", user_name: str = "", session_key: str = "", terminal_cwd: Optional[str] = None, From a12ab1fe660679b8b02a2bf567965432f1000b31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 20:57:47 +0000 Subject: [PATCH 6/6] fix(approval): add missing set_current_run_id / reset_current_run_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway/platforms/api_server.py:3034 imports ``set_current_run_id`` and ``reset_current_run_id`` from ``tools.approval``, but those functions don't exist there. The ImportError fires inside every API server run, surfacing as the cluster of ``run.failed`` events in tests/gateway/test_api_server_runs.py (test_start_returns_202, test_status_completed_run_includes_output_and_usage, test_status_reflects_explicit_session_id, test_events_stream_returns_completed, test_stop_running_agent, test_stop_interrupt_exception_does_not_crash, test_stop_sends_sentinel_to_events_stream) — all gone with this fix. Mirrors the existing ``set_current_session_key`` / ``reset_current_session_key`` pair: a per-task ContextVar bound at the start of an API run and reset in the finally block so concurrent runs don't share process-global state. Also exposes ``get_current_run_id`` for symmetry. --- tools/approval.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tools/approval.py b/tools/approval.py index df29578ba61e..6a96801bd8d2 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -33,6 +33,14 @@ default="", ) +# Per-run identity for approvals issued during a single agent turn (API server +# attaches a run_id; cron/CLI leave this empty). Used by approval-hook +# observers that need to correlate user prompts back to the originating run. +_approval_run_id: contextvars.ContextVar[str] = contextvars.ContextVar( + "approval_run_id", + default="", +) + def _fire_approval_hook(hook_name: str, **kwargs) -> None: """Invoke a plugin lifecycle hook for the approval system. @@ -70,6 +78,21 @@ def reset_current_session_key(token: contextvars.Token[str]) -> None: _approval_session_key.reset(token) +def set_current_run_id(run_id: str) -> contextvars.Token[str]: + """Bind the active approval run id to the current context.""" + return _approval_run_id.set(run_id or "") + + +def reset_current_run_id(token: contextvars.Token[str]) -> None: + """Restore the prior approval run id context.""" + _approval_run_id.reset(token) + + +def get_current_run_id(default: str = "") -> str: + """Return the active approval run id, or *default* when unset.""" + return _approval_run_id.get() or default + + def get_current_session_key(default: str = "default") -> str: """Return the active session key, preferring context-local state.