Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion agent/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import contextlib
import contextvars
import json
import logging
import os
Expand Down Expand Up @@ -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}


Expand Down
42 changes: 42 additions & 0 deletions tests/agent/test_curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}