From 64bdf792e9da1a1e2e1ee1a4dc89268ec35707dd Mon Sep 17 00:00:00 2001 From: Michael Gandal Date: Mon, 21 Sep 2026 09:13:57 -0400 Subject: [PATCH] fix(curator): run the background review thread in a copy of the caller's contextvars Under gateway.multiplex_profiles the profile secret scope is a ContextVar. The asynchronous curator review started a bare threading.Thread, which begins with an empty contextvars context, so the fork's first get_secret("ANTHROPIC_TOKEN") was fail-closed ("could not read this profile's ANTHROPIC_TOKEN") even though the caller had a scope installed. Start the thread through contextvars.copy_context().run. Adds test_review_thread_inherits_secret_scope, which installs a scope, runs the review asynchronously and asserts the LLM pass observed it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AfnxdXwQoheA3hYjA25Exo --- agent/curator.py | 7 ++++++- tests/agent/test_curator.py | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/agent/curator.py b/agent/curator.py index 27c181d2ce16..bacdab945316 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -9,6 +9,7 @@ from __future__ import annotations import contextlib +import contextvars import json import logging import os @@ -960,7 +961,11 @@ def _llm_pass(): if synchronous: _llm_pass() else: - threading.Thread(target=_llm_pass, daemon=True, name="curator-review").start() + # A bare Thread starts with an empty contextvars context, dropping the caller's profile + # secret scope (fail-closed under multiplex_profiles); run the pass in a copy of it. + threading.Thread( + target=contextvars.copy_context().run, args=(_llm_pass,), daemon=True, name="curator-review", + ).start() return {"started_at": start.isoformat(), "auto_transitions": counts, "summary_so_far": auto_summary} diff --git a/tests/agent/test_curator.py b/tests/agent/test_curator.py index 68eb6622ecea..4822c0d1b77b 100644 --- a/tests/agent/test_curator.py +++ b/tests/agent/test_curator.py @@ -1202,3 +1202,45 @@ def close(self): "run_conversation, or every copied tool-worker context keeps private " "marks and the read-before-write guard refuses all patches" ) + + +# --------------------------------------------------------------------------- +# Review thread context (multiplexed gateway) +# --------------------------------------------------------------------------- + + +def test_review_thread_inherits_secret_scope(curator_env, monkeypatch): + """The daemon review thread must carry the caller's contextvars. + + Under ``gateway.multiplex_profiles`` the profile secret scope is a + ``ContextVar``; a bare ``threading.Thread`` starts with an empty context, + so the fork's first ``get_secret("ANTHROPIC_TOKEN")`` was fail-closed + ("could not read this profile's ANTHROPIC_TOKEN") even when the caller had + installed a scope. Start the thread through ``copy_context().run``. + """ + from agent import secret_scope + + c = curator_env["curator"] + u = curator_env["usage"] + _write_bundled_and_agent(curator_env, u) + + seen = {} + + def _stub(prompt): + seen["scope"] = secret_scope.current_secret_scope() + return {"final": "", "summary": "s", "model": "", "provider": "", + "tool_calls": [], "error": None} + + monkeypatch.setattr(c, "_run_llm_review", _stub) + + token = secret_scope.set_secret_scope({"ANTHROPIC_TOKEN": "scoped-token"}) + try: + c.run_curator_review(synchronous=False, consolidate=True, dry_run=True) + for t in threading.enumerate(): + if t.name == "curator-review": + t.join(timeout=10.0) + finally: + secret_scope.reset_secret_scope(token) + + assert "scope" in seen, "LLM review stub was never called" + assert seen["scope"] == {"ANTHROPIC_TOKEN": "scoped-token"}