From 08a0b1674e518a154c92b20bb2d9a6253309ebd4 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 06:02:50 -0400 Subject: [PATCH 01/26] fix: config dir re-reads Path.home at call time resolve_config_dir() returned the import-time ~/.quorus, so a HOME faked after import (tests, wrappers) was ignored and a quorus join test wrote a config into the process's real HOME. A monkeypatched DEFAULT_CONFIG_DIR still wins; otherwise Path.home() is read now. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 084bd2f7e9e553156a04d9573cc70973990f858e) --- quorus/config.py | 19 ++++++++++++++----- quorus/profiles.py | 4 +--- tests/test_config_resolution.py | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/quorus/config.py b/quorus/config.py index f3e418f8..34174af7 100644 --- a/quorus/config.py +++ b/quorus/config.py @@ -26,6 +26,19 @@ # resolution re-evaluates Path.home() at call time. DEFAULT_CONFIG_DIR = Path.home() / ".quorus" LEGACY_CONFIG_DIR = Path.home() / "mcp-tunnel" +_IMPORT_TIME_DEFAULT = DEFAULT_CONFIG_DIR + + +def _default_config_dir() -> Path: + """``~/.quorus`` as of now, not as of import. A test that monkeypatches + ``DEFAULT_CONFIG_DIR`` still wins; otherwise ``Path.home()`` is re-read, so a + HOME faked after import (tests, wrappers) is honoured. The import-time value + used to be returned instead, which is how a `quorus join` test wrote a config + into the process's real HOME (2026-10-20, PR #15 CI).""" + current = globals().get("DEFAULT_CONFIG_DIR") + if current is not None and Path(current) != _IMPORT_TIME_DEFAULT: + return Path(current) + return Path.home() / ".quorus" def _trusted_proxy_count() -> int: @@ -131,11 +144,7 @@ def resolve_config_dir() -> Path: # Re-evaluate at call time so HOME changes (sudo -E, test monkeypatch of # $HOME) are honoured — but respect test monkeypatch of the module-level # constants, too. - default_dir = ( - DEFAULT_CONFIG_DIR - if "DEFAULT_CONFIG_DIR" in globals() - else Path.home() / ".quorus" - ) + default_dir = _default_config_dir() legacy_murmur = Path.home() / _LEGACY_MURMUR_DIRNAME legacy_mcp = ( LEGACY_CONFIG_DIR diff --git a/quorus/profiles.py b/quorus/profiles.py index af79be72..a0afb1ee 100644 --- a/quorus/profiles.py +++ b/quorus/profiles.py @@ -175,9 +175,7 @@ def migrate_legacy_if_needed(self) -> str | None: # (~/.murmur, ~/mcp-tunnel) — always land it in the modern # config dir (~/.quorus), even when resolve_config_dir() # returned the legacy path this manager was built from. - modern = getattr(_config, "DEFAULT_CONFIG_DIR", None) or ( - Path.home() / ".quorus" - ) + modern = _config._default_config_dir() target = ProfileManager(config_dir=Path(modern)) target.save(slug, raw) target.set_current(slug) diff --git a/tests/test_config_resolution.py b/tests/test_config_resolution.py index 80f507fc..62eda69b 100644 --- a/tests/test_config_resolution.py +++ b/tests/test_config_resolution.py @@ -164,3 +164,23 @@ def test_profile_migration_writes_to_modern_dir_not_legacy(tmp_path): assert not (tmp_path / ".murmur" / "profiles").exists() legacy_raw = json.loads((tmp_path / ".murmur" / CONFIG_FILENAME).read_text()) assert legacy_raw["api_key"] == "k" + + +def test_the_default_dir_follows_path_home_at_call_time(tmp_path, monkeypatch): + """A HOME faked after import (what tests and wrappers do) must move the default + config dir. The import-time constant used to win, so a `quorus join` test wrote a + config into the process's real HOME (2026-10-20, PR #15 CI).""" + import quorus.config as config_mod + + monkeypatch.delenv("QUORUS_CONFIG_DIR", raising=False) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + assert config_mod._default_config_dir() == tmp_path / ".quorus" + assert config_mod.resolve_config_dir() == tmp_path / ".quorus" + + +def test_a_monkeypatched_default_dir_still_wins(tmp_path, monkeypatch): + import quorus.config as config_mod + + monkeypatch.delenv("QUORUS_CONFIG_DIR", raising=False) + monkeypatch.setattr(config_mod, "DEFAULT_CONFIG_DIR", tmp_path / "patched") + assert config_mod.resolve_config_dir() == tmp_path / "patched" From e7a88ed49dab23a00169212edb77e78c1a9ec1eb Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 06:05:35 -0400 Subject: [PATCH 02/26] test: relay wrapper test stubs Popen, not run Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 64e5c8724cdb6dcc4c255bf72722e57fa7c00820) --- tests/test_cli_consumer_path.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_cli_consumer_path.py b/tests/test_cli_consumer_path.py index c22ccf39..49018af5 100644 --- a/tests/test_cli_consumer_path.py +++ b/tests/test_cli_consumer_path.py @@ -30,11 +30,20 @@ def test_relay_uses_profile_secret_installed_entrypoint_and_safe_defaults( monkeypatch.setattr(cli, "load_config", lambda: {"relay_secret": "from-profile"}) seen: dict[str, Any] = {} - def fake_run(argv, env=None, check=False): - seen["argv"], seen["env"] = argv, env - return subprocess.CompletedProcess(argv, 0) + class FakeChild: + """The relay child as `_run_relay_child` sees it (BUG 30 made the wrapper + use Popen and wait, so a stubbed subprocess.run would spawn a real relay).""" + def __init__(self, argv, env=None): + seen["argv"], seen["env"] = argv, env - monkeypatch.setattr(cli.subprocess, "run", fake_run) + def wait(self): + return 0 + + def send_signal(self, signum): + pass + + monkeypatch.setattr(cli.subprocess, "Popen", FakeChild) + monkeypatch.setattr(cli, "_port_holder", lambda host, port: None) # port 9123 is free cli._cmd_relay(SimpleNamespace(port=9123, host=None)) assert seen["argv"] == [sys.executable, "-m", "quorus.relay_cli"] # no uv, no checkout env = seen["env"] From 291ffb9de93597451608a566d3d4aa2c409cb128 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 05:51:05 -0400 Subject: [PATCH 03/26] test: room bindings never read the real HOME bindings_path() is the one place the CLI resolves room-bindings.json; tests/conftest.py points it at a temp file for every test (a faked HOME is respected), so the account-owner agent-add test can no longer pass only on a machine whose real file has a 'build' room (PR #15 CI). The test now covers mode=None (adds) and mode=default with no binding (refused), and a new test proves the isolation. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit d4d5f235a787413360436f94285d8cf3f5616833) --- packages/cli/quorus_cli/agent_cmd.py | 16 ++++++++++----- packages/cli/quorus_cli/cli.py | 4 +++- tests/conftest.py | 29 ++++++++++++++++++++++++++++ tests/test_room_bindings_isolated.py | 24 +++++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/test_room_bindings_isolated.py diff --git a/packages/cli/quorus_cli/agent_cmd.py b/packages/cli/quorus_cli/agent_cmd.py index 9aacb92c..80088228 100644 --- a/packages/cli/quorus_cli/agent_cmd.py +++ b/packages/cli/quorus_cli/agent_cmd.py @@ -274,11 +274,18 @@ def _drop_lead_everywhere(name: str) -> None: _set_lead(room, None) +def bindings_path() -> Path: + """Where this machine's room → workspace bindings live. One function, resolved + at call time, so tests can point every reader and writer at a temp file: a CI + run found `test_account_owner_can_add_agent_to_room` passing only because the + developer's real ~/.quorus/room-bindings.json had a "build" room (2026-10-20).""" + return Path.home() / ".quorus" / "room-bindings.json" + + def _bound_path(room: str) -> Path | None: """The workspace *room* is bound to on this machine, if it still exists.""" try: - prev = json.loads((Path.home() / ".quorus" / "room-bindings.json") - .read_text(encoding="utf-8")).get(room) + prev = json.loads(bindings_path().read_text(encoding="utf-8")).get(room) except (OSError, ValueError, AttributeError): return None raw = prev.get("path") if isinstance(prev, dict) else prev @@ -289,7 +296,7 @@ def _bound_path(room: str) -> Path | None: def _bind(room: str, repo: Path, mode: str | None, trust: str | None = None) -> str: - path = Path.home() / ".quorus" / "room-bindings.json" + path = bindings_path() try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): @@ -483,8 +490,7 @@ def _add(args: Any, console: Any) -> None: def _room_bound(room: str) -> bool: try: - data = json.loads((Path.home() / ".quorus" / "room-bindings.json") - .read_text(encoding="utf-8")) + data = json.loads(bindings_path().read_text(encoding="utf-8")) except (OSError, ValueError): return False return isinstance(data, dict) and bool(data.get(room)) diff --git a/packages/cli/quorus_cli/cli.py b/packages/cli/quorus_cli/cli.py index e4442f03..5f37d319 100644 --- a/packages/cli/quorus_cli/cli.py +++ b/packages/cli/quorus_cli/cli.py @@ -1197,7 +1197,9 @@ def _cmd_room(args): ~/.quorus/room-bindings.json (0600).""" import json as _json - bindings_path = Path.home() / ".quorus" / "room-bindings.json" + from quorus_cli.agent_cmd import bindings_path as _bindings_path + + bindings_path = _bindings_path() action = getattr(args, "room_action", None) if action is None: _ui.error( diff --git a/tests/conftest.py b/tests/conftest.py index b0b44d50..eaa103bf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -180,6 +180,35 @@ def _agent_memory_in_tmp(tmp_path): yield +@pytest.fixture(autouse=True) +def _room_bindings_in_tmp(tmp_path): + """The CLI's room → workspace bindings (`quorus_cli.agent_cmd.bindings_path`) + default to ~/.quorus/room-bindings.json. A test that forgot to isolate them + passed on the developer's machine, where the real file had a "build" room, and + failed on every CI Python (2026-10-20, PR #15). Every test now sees an empty + temp file instead; a test that wants a binding writes it there.""" + import os as _os + from pathlib import Path as _Path + + from quorus_cli import agent_cmd + + real_home = _Path(_os.path.expanduser("~")) + target = tmp_path / "room-bindings.json" + + def isolated() -> _Path: + # A test that points HOME somewhere else (monkeypatch.setenv or a patched + # Path.home) gets the file under that HOME, as the product would; only + # the real HOME is diverted. + home = _Path.home() + if home != real_home: + return home / ".quorus" / "room-bindings.json" + return target + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(agent_cmd, "bindings_path", isolated) + yield + + _OPT_IN_MARKERS = {"real_harness", "cross_harness"} diff --git a/tests/test_room_bindings_isolated.py b/tests/test_room_bindings_isolated.py new file mode 100644 index 00000000..3176d38f --- /dev/null +++ b/tests/test_room_bindings_isolated.py @@ -0,0 +1,24 @@ +"""Every test sees a temp room-bindings file (tests/conftest.py), never the real +~/.quorus one. PR #15's CI failed on all four Pythons because one test passed only +on a machine whose real bindings had a "build" room (2026-10-20).""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +from quorus_cli import agent_cmd + + +def test_the_bindings_path_is_never_the_real_home_during_tests(tmp_path): + path = agent_cmd.bindings_path() + real = Path(os.path.expanduser("~")) / ".quorus" / "room-bindings.json" + assert path != real and str(tmp_path) in str(path) + assert agent_cmd._bound_path("build") is None and not agent_cmd._room_bound("build") + + +def test_a_test_can_still_bind_a_room_in_its_own_file(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + agent_cmd.bindings_path().write_text(json.dumps({"build": {"path": str(repo)}})) + assert agent_cmd._bound_path("build") == repo and agent_cmd._room_bound("build") From a5c4677ffc96a2e93eca744133afe1e4058ab6e2 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 06:30:01 -0400 Subject: [PATCH 04/26] fix: one resolver for room-bindings, isolated in tests quorus/runtime/bindings.bindings_path() is the only way the CLI and the relay find room-bindings.json; replay_cmd, the scorecard route and scripts/scenarios.py read it there, the conftest fixture isolates that one function, Codex's sentinel repro is a test, and an ast invariant fails any module that builds the path on its own. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 068a635bab37a345dd26f880dceb16bdc869dc6d) --- packages/cli/quorus_cli/agent_cmd.py | 11 ++--- packages/cli/quorus_cli/replay_cmd.py | 5 ++- quorus/routes/room_scorecard.py | 5 ++- quorus/runtime/bindings.py | 18 ++++++++ scripts/scenarios.py | 4 +- tests/conftest.py | 6 ++- tests/test_room_bindings_isolated.py | 63 ++++++++++++++++++++++++--- 7 files changed, 95 insertions(+), 17 deletions(-) create mode 100644 quorus/runtime/bindings.py diff --git a/packages/cli/quorus_cli/agent_cmd.py b/packages/cli/quorus_cli/agent_cmd.py index 80088228..d8c2fd06 100644 --- a/packages/cli/quorus_cli/agent_cmd.py +++ b/packages/cli/quorus_cli/agent_cmd.py @@ -275,11 +275,12 @@ def _drop_lead_everywhere(name: str) -> None: def bindings_path() -> Path: - """Where this machine's room → workspace bindings live. One function, resolved - at call time, so tests can point every reader and writer at a temp file: a CI - run found `test_account_owner_can_add_agent_to_room` passing only because the - developer's real ~/.quorus/room-bindings.json had a "build" room (2026-10-20).""" - return Path.home() / ".quorus" / "room-bindings.json" + """Where this machine's room → workspace bindings live: the shared resolver in + ``quorus.runtime.bindings``, looked up at call time so a test's isolation of it + (tests/conftest.py) covers every reader in this module too.""" + from quorus.runtime import bindings + + return bindings.bindings_path() def _bound_path(room: str) -> Path | None: diff --git a/packages/cli/quorus_cli/replay_cmd.py b/packages/cli/quorus_cli/replay_cmd.py index faed608a..f7ce20c2 100644 --- a/packages/cli/quorus_cli/replay_cmd.py +++ b/packages/cli/quorus_cli/replay_cmd.py @@ -47,9 +47,10 @@ def _ts(value: Any) -> float | None: def bound_repo(room: str) -> Path | None: + from quorus.runtime.bindings import bindings_path + try: - data = json.loads((Path.home() / ".quorus" / "room-bindings.json") - .read_text(encoding="utf-8")) + data = json.loads(bindings_path().read_text(encoding="utf-8")) except (OSError, ValueError): return None entry = data.get(room) if isinstance(data, dict) else None diff --git a/quorus/routes/room_scorecard.py b/quorus/routes/room_scorecard.py index 6b4a13ff..f4856bea 100644 --- a/quorus/routes/room_scorecard.py +++ b/quorus/routes/room_scorecard.py @@ -31,9 +31,10 @@ def bound_repo(room: str) -> Path | None: """The repo this machine's agents work in for *room*, if any.""" + from quorus.runtime.bindings import bindings_path + try: - data = json.loads((Path.home() / ".quorus" / "room-bindings.json") - .read_text(encoding="utf-8")) + data = json.loads(bindings_path().read_text(encoding="utf-8")) except (OSError, ValueError): return None entry = data.get(room) if isinstance(data, dict) else None diff --git a/quorus/runtime/bindings.py b/quorus/runtime/bindings.py new file mode 100644 index 00000000..1f73b9ba --- /dev/null +++ b/quorus/runtime/bindings.py @@ -0,0 +1,18 @@ +"""The one resolver for this machine's room -> workspace bindings file. + +Every reader and writer in the CLI and the relay goes through :func:`bindings_path`, +and the daemon's ``ROOM_BINDINGS_PATH`` (``scripts/reflexd.py``) is the only other +name for it. ``tests/conftest.py`` points this function at a temp file for every +test, so a module that spelled the path itself would read the developer's real +``~/.quorus/room-bindings.json`` (Codex, review of d4d5f235: ``replay_cmd`` did); +``tests/test_room_bindings_isolated.py`` greps the tree so none can.""" +from __future__ import annotations + +from pathlib import Path + +FILENAME = "room-bindings.json" + + +def bindings_path() -> Path: + """``~/.quorus/room-bindings.json`` as of now (``Path.home()`` at call time).""" + return Path.home() / ".quorus" / FILENAME diff --git a/scripts/scenarios.py b/scripts/scenarios.py index dc4b867c..624e6710 100644 --- a/scripts/scenarios.py +++ b/scripts/scenarios.py @@ -39,7 +39,9 @@ ROOM2 = ROOM + "-b" # second project room ROOM3 = ROOM + "-safe" # manual-mode room ROOM4 = ROOM + "-nobind" # unbound room -BINDINGS = Path.home() / ".quorus" / "room-bindings.json" +from quorus.runtime.bindings import bindings_path as _bindings_path # noqa: E402 + +BINDINGS = _bindings_path() RESULTS: list[dict[str, Any]] = [] diff --git a/tests/conftest.py b/tests/conftest.py index eaa103bf..9b0a67b0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -190,7 +190,7 @@ def _room_bindings_in_tmp(tmp_path): import os as _os from pathlib import Path as _Path - from quorus_cli import agent_cmd + from quorus.runtime import bindings real_home = _Path(_os.path.expanduser("~")) target = tmp_path / "room-bindings.json" @@ -205,7 +205,9 @@ def isolated() -> _Path: return target with pytest.MonkeyPatch.context() as mp: - mp.setattr(agent_cmd, "bindings_path", isolated) + # The one resolver (quorus/runtime/bindings.py); agent_cmd, replay_cmd and + # the scorecard route all call it at run time, so this covers them all. + mp.setattr(bindings, "bindings_path", isolated) yield diff --git a/tests/test_room_bindings_isolated.py b/tests/test_room_bindings_isolated.py index 3176d38f..38a6d78b 100644 --- a/tests/test_room_bindings_isolated.py +++ b/tests/test_room_bindings_isolated.py @@ -1,24 +1,77 @@ """Every test sees a temp room-bindings file (tests/conftest.py), never the real ~/.quorus one. PR #15's CI failed on all four Pythons because one test passed only -on a machine whose real bindings had a "build" room (2026-10-20).""" +on a machine whose real bindings had a "build" room (2026-10-20), and Codex then +found `replay_cmd.bound_repo()` reading the real file past the first fixture. +""" from __future__ import annotations +import ast import json import os from pathlib import Path -from quorus_cli import agent_cmd +from quorus_cli import agent_cmd, replay_cmd + +from quorus.routes import room_scorecard +from quorus.runtime import bindings + +ROOT = Path(__file__).resolve().parents[1] def test_the_bindings_path_is_never_the_real_home_during_tests(tmp_path): - path = agent_cmd.bindings_path() + path = bindings.bindings_path() real = Path(os.path.expanduser("~")) / ".quorus" / "room-bindings.json" assert path != real and str(tmp_path) in str(path) + assert agent_cmd.bindings_path() == path assert agent_cmd._bound_path("build") is None and not agent_cmd._room_bound("build") def test_a_test_can_still_bind_a_room_in_its_own_file(tmp_path): repo = tmp_path / "repo" - repo.mkdir() - agent_cmd.bindings_path().write_text(json.dumps({"build": {"path": str(repo)}})) + (repo / ".git").mkdir(parents=True) # the scorecard reader wants a repo, not a dir + bindings.bindings_path().write_text(json.dumps({"build": {"path": str(repo)}})) assert agent_cmd._bound_path("build") == repo and agent_cmd._room_bound("build") + assert replay_cmd.bound_repo("build") == repo + assert room_scorecard.bound_repo("build") == repo + + +def test_direct_readers_never_see_a_sentinel_in_the_baseline_home(tmp_path, monkeypatch): + """Codex's repro: a sentinel binding planted where an un-isolated reader would + look. `Path.home()` is faked to a second dir that holds the sentinel; the fixture + follows a faked HOME, so readers see that dir's file, and nothing ever reaches + the process's baseline HOME (the real one on a developer's machine).""" + sentinel_home = tmp_path / "sentinel-home" + (sentinel_home / ".quorus").mkdir(parents=True) + (tmp_path / ".git").mkdir() # so every reader, the scorecard's included, accepts it + (sentinel_home / ".quorus" / "room-bindings.json").write_text( + json.dumps({"build": {"path": str(tmp_path)}})) + monkeypatch.setattr("pathlib.Path.home", lambda: sentinel_home) + # Readers follow the faked HOME, consistently, through the one resolver. + assert bindings.bindings_path() == sentinel_home / ".quorus" / "room-bindings.json" + assert replay_cmd.bound_repo("build") == tmp_path + assert room_scorecard.bound_repo("build") == tmp_path + assert agent_cmd._bound_path("build") == tmp_path + + +def test_no_module_spells_the_bindings_file_name_on_its_own(): + """The invariant behind the fixture: only the resolver, the daemon's constant and + the demo's write into its own temp HOME may build the path. A new direct reader + (`Path.home() / ".quorus" / "room-bindings.json"`) fails here before it can ever + read a developer's real bindings. Prose that merely mentions the file (help text, + docstrings) is fine: only a string constant equal to the file name is a path part.""" + allowed = { + "quorus/runtime/bindings.py", # the resolver + "scripts/reflexd.py", # ROOM_BINDINGS_PATH, isolated in conftest + "packages/cli/quorus_cli/demo_cmd.py", # writes into the demo's own temp HOME + } + offenders = [] + for path in list((ROOT / "quorus").rglob("*.py")) + list((ROOT / "packages").rglob("*.py")) \ + + list((ROOT / "scripts").rglob("*.py")): + rel = str(path.relative_to(ROOT)) + if rel in allowed or "/tests/" in rel: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=rel) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and node.value == "room-bindings.json": + offenders.append(f"{rel}:{node.lineno}") + assert offenders == [], f"spell the path through quorus.runtime.bindings: {offenders}" From e52ddb1a3d9247ffd8118d441f9c6cd58caed50f Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 08:30:20 -0400 Subject: [PATCH 05/26] test: move HOME off the real one for the session Three rounds of per-API read guards (open, Path.read_*, bytes paths, "+" modes, os.open) were an arms race; the overseer asked for the real file to be unreachable instead. conftest now redirects HOME to a session tmp dir before any quorus import, fails the session if Path.home() still resolves to the real home, gives git subprocesses a throwaway identity, and removes the builtins.open wrapper. The config snapshot guard keeps proving the real ~/.quorus is untouched; bindings_path() and the AST invariant stay as cheap belts. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 18ace002558a2519813ddd8bc75329cde07510e7) --- tests/conftest.py | 61 ++++++++++++++++++++++++++-- tests/test_room_bindings_isolated.py | 21 ++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9b0a67b0..00970dc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,34 @@ """Set required environment variables before any test module imports.""" +import atexit import os import re +import shutil import sys +import tempfile +from pathlib import Path import pytest +# The real HOME is unreachable for the whole session (overseer, 2026-10-20). +# Three rounds of per-API read guards (open, Path.read_*, bytes paths, "+" +# modes, os.open) were an arms race against mmap, subprocess `cat` and the next +# reader; redirecting HOME before any quorus import ends it: no read API, +# subprocess or dynamically built path can reach ~/.quorus, ~/.claude or any +# other host config. Tests that fake their own HOME still do, under this one. +# REAL_HOME is kept only so the session guard below can prove nothing changed. +REAL_HOME = Path(os.path.expanduser("~")) +SESSION_HOME = Path(tempfile.mkdtemp(prefix="quorus-tests-home-")) +os.environ["HOME"] = str(SESSION_HOME) +os.environ["QUORUS_TESTS_REAL_HOME"] = str(REAL_HOME) +# git subprocesses in tests lose the developer's global identity with HOME; +# give them one, and nothing else of the developer's config. +(SESSION_HOME / ".gitconfig").write_text( + "[user]\n\tname = quorus-tests\n\temail = tests@quorus.invalid\n", encoding="utf-8") +if Path.home() == REAL_HOME or Path(os.path.expanduser("~")) == REAL_HOME: + raise RuntimeError(f"tests/conftest.py could not move HOME off {REAL_HOME}") +atexit.register(shutil.rmtree, SESSION_HOME, ignore_errors=True) + os.environ.setdefault("RELAY_SECRET", "test-secret") os.environ.setdefault("JWT_SECRET", "test-jwt-secret-that-is-at-least-32-bytes-long") os.environ.setdefault("BOOTSTRAP_SECRET", "test-bootstrap-secret") @@ -192,15 +215,15 @@ def _room_bindings_in_tmp(tmp_path): from quorus.runtime import bindings - real_home = _Path(_os.path.expanduser("~")) + session_home = _Path(_os.path.expanduser("~")) # the session tmp HOME, never the real one target = tmp_path / "room-bindings.json" def isolated() -> _Path: # A test that points HOME somewhere else (monkeypatch.setenv or a patched # Path.home) gets the file under that HOME, as the product would; only - # the real HOME is diverted. + # the shared session HOME is diverted to a per-test file. home = _Path.home() - if home != real_home: + if home != session_home: return home / ".quorus" / "room-bindings.json" return target @@ -261,3 +284,35 @@ def _no_live_opencode_catalog(): subprocess.CompletedProcess(argv, 1, stdout="", stderr="")) yield opencode_free.reset_cache() + + +@pytest.fixture(autouse=True, scope="session") +def _the_real_home_config_is_never_touched(): + """A test rewrote the overseer's real ``~/.quorus/config.json`` (2026-10-20: + the hub resolved its config path at import time, so a fixture's + ``QUORUS_CONFIG_DIR`` came too late). Snapshot the real config files before + the session and fail loudly at the end if any changed. HOME itself is moved + off the real one at import (top of this file); this is the proof.""" + real = REAL_HOME / ".quorus" + + def snapshot() -> dict[str, tuple[int, int]]: + files = [real / "config.json", *sorted((real / "profiles").glob("*.json"))] + out: dict[str, tuple[int, int]] = {} + for f in files: + try: + st = f.stat() + except FileNotFoundError: + continue + out[str(f)] = (st.st_mtime_ns, st.st_size) + return out + + before = snapshot() + yield + after = snapshot() + if before != after: + changed = sorted(set(before) ^ set(after) + | {k for k in before if k in after and before[k] != after[k]}) + pytest.fail("a test touched the real ~/.quorus config: " + ", ".join(changed) + + " (isolate HOME and QUORUS_CONFIG_DIR in that test)") + + diff --git a/tests/test_room_bindings_isolated.py b/tests/test_room_bindings_isolated.py index 38a6d78b..a3a77bf0 100644 --- a/tests/test_room_bindings_isolated.py +++ b/tests/test_room_bindings_isolated.py @@ -75,3 +75,24 @@ def test_no_module_spells_the_bindings_file_name_on_its_own(): if isinstance(node, ast.Constant) and node.value == "room-bindings.json": offenders.append(f"{rel}:{node.lineno}") assert offenders == [], f"spell the path through quorus.runtime.bindings: {offenders}" + + +def test_the_whole_session_runs_in_a_throwaway_home(): + """Overseer, 2026-10-20: instead of guarding each read API (open, Path.read_*, + bytes paths, "+" modes, os.open; mmap and `cat` next), tests/conftest.py moves + HOME off the real one before any quorus import. A dynamically built path, a + subprocess and the product's own resolver all land in the throwaway HOME.""" + import subprocess + import tempfile + + real_home = Path(os.environ["QUORUS_TESTS_REAL_HOME"]) + home = Path.home() + assert home != real_home and real_home not in home.parents + assert Path(tempfile.gettempdir()).resolve() in home.resolve().parents + + sneaky = Path(os.path.expanduser("~")) / ".quorus" / ("room-" + "bindings.json") + assert real_home not in sneaky.parents + assert real_home not in bindings.bindings_path().parents + + child = subprocess.run(["sh", "-c", 'printf %s "$HOME"'], capture_output=True, text=True) + assert Path(child.stdout) == home, child From f6546608efe475517a222a191d82731acae67dd3 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 08:34:19 -0400 Subject: [PATCH 06/26] test: scrub legacy config-dir alias before imports Codex (review of 18ace002): an inherited MCP_TUNNEL_CONFIG_DIR survived the ambient-variable scrub and pointed config reads past the session HOME. It joins AMBIENT_IDENTITY_VARS; a regression asserts every *_CONFIG_DIR env var config.py consults is on that list and unset, and that resolve_config_dir() lands under the session HOME. Comments now say HOME-based test isolation, not an unreachable real HOME. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit c0c6402faa5559f4a9ee95f68df1203460ccd5ff) --- tests/conftest.py | 19 ++++++++++++------- tests/test_room_bindings_isolated.py | 24 ++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 00970dc4..ee5db01e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,13 +10,15 @@ import pytest -# The real HOME is unreachable for the whole session (overseer, 2026-10-20). +# HOME-based test isolation for the whole session (overseer, 2026-10-20). # Three rounds of per-API read guards (open, Path.read_*, bytes paths, "+" # modes, os.open) were an arms race against mmap, subprocess `cat` and the next -# reader; redirecting HOME before any quorus import ends it: no read API, -# subprocess or dynamically built path can reach ~/.quorus, ~/.claude or any -# other host config. Tests that fake their own HOME still do, under this one. -# REAL_HOME is kept only so the session guard below can prove nothing changed. +# reader; redirecting HOME before any quorus import ends it: no HOME-relative +# read, subprocess or dynamically built path lands in ~/.quorus, ~/.claude or +# any other host config. An explicit absolute path still can (Codex), which is +# why the resolver + AST invariant stay. Tests that fake their own HOME still +# do, under this one. REAL_HOME is kept only so the session guard below can +# prove nothing changed. REAL_HOME = Path(os.path.expanduser("~")) SESSION_HOME = Path(tempfile.mkdtemp(prefix="quorus-tests-home-")) os.environ["HOME"] = str(SESSION_HOME) @@ -43,9 +45,12 @@ # relay instead of their defaults, and the daemon's REFLEXD_* settings (e.g. # REFLEXD_CODEX_SANDBOX) change the argv tests pin (found 2026-10-17). Tests # that need these set them with monkeypatch. +# MCP_TUNNEL_CONFIG_DIR is the legacy alias of QUORUS_CONFIG_DIR that config.py still +# honours; inherited, it overrode the session HOME (Codex, review of 18ace002). AMBIENT_IDENTITY_VARS = ( - "QUORUS_API_KEY", "QUORUS_RELAY_SECRET", "QUORUS_CONFIG_DIR", "QUORUS_RELAY_URL", - "QUORUS_INSTANCE_NAME", "QUORUS_PROFILE", "QUORUS_PARTICIPANT", "QUORUS_APPROVAL_ROOM", + "QUORUS_API_KEY", "QUORUS_RELAY_SECRET", "QUORUS_CONFIG_DIR", "MCP_TUNNEL_CONFIG_DIR", + "QUORUS_RELAY_URL", "QUORUS_INSTANCE_NAME", "QUORUS_PROFILE", "QUORUS_PARTICIPANT", + "QUORUS_APPROVAL_ROOM", ) for _var in [*AMBIENT_IDENTITY_VARS, *(v for v in os.environ if v.startswith("REFLEXD_"))]: os.environ.pop(_var, None) diff --git a/tests/test_room_bindings_isolated.py b/tests/test_room_bindings_isolated.py index a3a77bf0..ac3bde9c 100644 --- a/tests/test_room_bindings_isolated.py +++ b/tests/test_room_bindings_isolated.py @@ -80,8 +80,9 @@ def test_no_module_spells_the_bindings_file_name_on_its_own(): def test_the_whole_session_runs_in_a_throwaway_home(): """Overseer, 2026-10-20: instead of guarding each read API (open, Path.read_*, bytes paths, "+" modes, os.open; mmap and `cat` next), tests/conftest.py moves - HOME off the real one before any quorus import. A dynamically built path, a - subprocess and the product's own resolver all land in the throwaway HOME.""" + HOME off the real one before any quorus import: HOME-based test isolation, not + a wall against explicit absolute paths. A dynamically built path, a subprocess + and the product's own resolver all land in the throwaway HOME.""" import subprocess import tempfile @@ -96,3 +97,22 @@ def test_the_whole_session_runs_in_a_throwaway_home(): child = subprocess.run(["sh", "-c", 'printf %s "$HOME"'], capture_output=True, text=True) assert Path(child.stdout) == home, child + + +def test_no_inherited_config_dir_alias_overrides_the_session_home(): + """Codex, review of 18ace002: an inherited MCP_TUNNEL_CONFIG_DIR (the legacy alias + config.py still honours) survived the ambient-variable scrub and pointed every + config read back at a planted baseline. Every env var config.py consults for the + config dir must be in conftest's scrub list, and none may be set now.""" + import re + + from quorus import config + from tests.conftest import AMBIENT_IDENTITY_VARS + + src = (ROOT / "quorus" / "config.py").read_text(encoding="utf-8") + aliases = set(re.findall(r'os\.environ\.get\("([A-Z_]*CONFIG_DIR)"', src)) + assert "MCP_TUNNEL_CONFIG_DIR" in aliases # the finding; the regex sees it + missing = sorted(aliases - set(AMBIENT_IDENTITY_VARS)) + assert missing == [], f"add to tests/conftest.py AMBIENT_IDENTITY_VARS: {missing}" + assert not [a for a in aliases if a in os.environ] + assert Path.home() in config.resolve_config_dir().parents From e838d476cffa0d2a027fdeb14fe9bc30ea0e5083 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 06:17:55 -0400 Subject: [PATCH 07/26] test: named reviewer stub waits for the fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The human's ask ('…then ask @alice-opencode to review it') wakes the reviewer too; its stub fixed calc.py in parallel and whichever agent published first won, so the other's publish diverged (PR #15 F5). Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 9360d018c2b3bfcc24a27521642231ca4cc6d79c) --- tests/firstrun/stub_agents.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/firstrun/stub_agents.py b/tests/firstrun/stub_agents.py index 7ef6265d..7220d91e 100644 --- a/tests/firstrun/stub_agents.py +++ b/tests/firstrun/stub_agents.py @@ -59,6 +59,11 @@ def act(prompt, can_write): return "I can't edit here: %%s" %% (cmd.group(1) if cmd else "no write access") if "fix the bug in calc.py" not in asked: return "Hi: I work in this repo. Ask me to fix or build something." + me = re.search(r"You are `([^`]+)`", prompt) + if me and re.search(r"ask @%%s to review" %% re.escape(me.group(1)), asked): + # Named as the reviewer in the human's ask, which wakes this agent too: wait + # for the fix. Fixing it as well raced the other agent's publish (PR #15 F5). + return "I'll review it once the fix is posted." if can_write: p = "calc.py" src = open(p).read().replace("return a - b", "return a + b") From 1a39cd66b7b938f7266ad7e1b2ac2b05258e6db2 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 07:43:35 -0400 Subject: [PATCH 08/26] test: invitee helper closes sessions on failure _invitee() started alice's relay before returning; a failing step left it running, since the caller's finally never saw the session (orphans 73397/73417 from 2026-10-19 20:41). Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 9511a523cbba3c3237a1d5ad3e3344f61f2a7dd7) --- tests/firstrun/test_first_result.py | 61 +++++++++++++++++------------ 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/tests/firstrun/test_first_result.py b/tests/firstrun/test_first_result.py index e8502071..38b29466 100644 --- a/tests/firstrun/test_first_result.py +++ b/tests/firstrun/test_first_result.py @@ -128,32 +128,41 @@ def _invitee(base: Path, add: str) -> tuple[PtySession, PtySession, Path, Path, home_a, home_b = base / "alice", base / "bob" repo, bin_dir = setup(home_b, ("codex",), owner="bob") alice = PtySession(home_a) - alice.expect(r"Your name", timeout=30) - alice.type("alice" + ENTER) - alice.expect(r"Invite, or Enter to run it here") - alice.type(ENTER) - alice.expect(r"You're in as @alice", timeout=90) - alice.secret = relay_secret(home_a) - shared = _quorus(alice, None, "quorus share general") - assert shared.returncode == 0, (shared.stdout, shared.stderr) - code = _CODE.search(shared.stdout) - assert code, shared.stdout - bob = PtySession(home_b, cwd=repo, extra_path=bin_dir, relay_url=alice.relay_url) - bob.expect(r"Your name", timeout=30) - bob.type("bob" + ENTER) - bob.expect(r"Invite, or Enter to run it here") - bob.type(code.group(1) + ENTER) - bob.expect(r"relay address", timeout=20) - bob.type(alice.relay_url + ENTER) - bob.expect(r"Add them to #general", timeout=60) - bob.type(add + ENTER) - if add == "y": - screen = bob.expect(r"\[y/n\] \(n\)", timeout=30) - assert "teammate's relay" in screen and "your own key" in screen, screen[-1500:] - bob.type("y" + ENTER) - bob.expect(r"You're in as @bob", timeout=90) - bob.settle(quiet=1.0, timeout=10) - return alice, bob, repo, bin_dir, plain(bob.text()) + bob = None + # Close what started if any step fails: the caller only sees sessions this + # returns, and a failure here once left alice's relay running overnight. + try: + alice.expect(r"Your name", timeout=30) + alice.type("alice" + ENTER) + alice.expect(r"Invite, or Enter to run it here") + alice.type(ENTER) + alice.expect(r"You're in as @alice", timeout=90) + alice.secret = relay_secret(home_a) + shared = _quorus(alice, None, "quorus share general") + assert shared.returncode == 0, (shared.stdout, shared.stderr) + code = _CODE.search(shared.stdout) + assert code, shared.stdout + bob = PtySession(home_b, cwd=repo, extra_path=bin_dir, relay_url=alice.relay_url) + bob.expect(r"Your name", timeout=30) + bob.type("bob" + ENTER) + bob.expect(r"Invite, or Enter to run it here") + bob.type(code.group(1) + ENTER) + bob.expect(r"relay address", timeout=20) + bob.type(alice.relay_url + ENTER) + bob.expect(r"Add them to #general", timeout=60) + bob.type(add + ENTER) + if add == "y": + screen = bob.expect(r"\[y/n\] \(n\)", timeout=30) + assert "teammate's relay" in screen and "your own key" in screen, screen[-1500:] + bob.type("y" + ENTER) + bob.expect(r"You're in as @bob", timeout=90) + bob.settle(quiet=1.0, timeout=10) + return alice, bob, repo, bin_dir, plain(bob.text()) + except BaseException: + for s in (bob, alice): + if s is not None: + s.close() + raise def test_an_invitee_adds_their_agent_from_the_wizard(tmp_path_factory): From 0d5cbd09839a2d80187aaf980944658f79a60622 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 07:48:39 -0400 Subject: [PATCH 09/26] test: invitee setup closes sessions on failure A failure inside _invitee (share, bob's wizard) happened before the test's try/finally, so alice's relay outlived the run; the overseer's sweep found two such relays from a 20:41 run. The helper now tracks the sessions it opened and closes them before re-raising. Regression breaks the share step and asserts no started relay is still alive. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit af1cadffb267bff494e787965a718979a6c14721) --- tests/firstrun/test_first_result.py | 37 +++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/firstrun/test_first_result.py b/tests/firstrun/test_first_result.py index 38b29466..d4c83bee 100644 --- a/tests/firstrun/test_first_result.py +++ b/tests/firstrun/test_first_result.py @@ -130,7 +130,8 @@ def _invitee(base: Path, add: str) -> tuple[PtySession, PtySession, Path, Path, alice = PtySession(home_a) bob = None # Close what started if any step fails: the caller only sees sessions this - # returns, and a failure here once left alice's relay running overnight. + # returns, and a failure here once left alice's relay running overnight + # (overseer's orphan sweep, 2026-10-20: two invitee relays from 20:41). try: alice.expect(r"Your name", timeout=30) alice.type("alice" + ENTER) @@ -160,8 +161,12 @@ def _invitee(base: Path, add: str) -> tuple[PtySession, PtySession, Path, Path, return alice, bob, repo, bin_dir, plain(bob.text()) except BaseException: for s in (bob, alice): - if s is not None: + if s is None: + continue + try: s.close() + except Exception: + pass # the original failure is the one to report; still close the other raise @@ -228,3 +233,31 @@ def test_an_invitee_gets_a_tested_fix_in_their_own_repo(tmp_path_factory): for s in (bob, alice): if s is not None: s.close() + + +def test_a_failure_inside_the_invitee_setup_leaves_no_relay_behind(tmp_path_factory, monkeypatch): + """Overseer's orphan sweep (2026-10-20): two invitee relays from a 20:41 run were + still serving the next morning. `_invitee` starts alice's relay before any test + `try/finally` exists, so a failing share used to leak it. Now the helper closes + every session it opened before re-raising.""" + from tests.firstrun import pty_session + + base = tmp_path_factory.mktemp("invitee-leak") + monkeypatch.setattr(sys.modules[__name__], "_quorus", + lambda *a, **k: subprocess.CompletedProcess(a, 1, "", "share broke")) + with pytest.raises(AssertionError, match="share broke"): + _invitee(base, "n") + started = pty_session.spawned_relays(base / "alice") + assert started, "the helper never got as far as starting alice's relay" + still_up = [pid for pid in started if _alive(pid)] + assert still_up == [], f"invitee relay(s) survived the failed setup: {still_up}" + + +def _alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True From 642d838df5c5735aa443ddc515e9d837c9d11528 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 04:28:33 -0400 Subject: [PATCH 10/26] fix: quorus relay forwards signals, refuses a bound port Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 83c8e7c856b3ee6c7c86477b73de252d123d8657) --- packages/cli/quorus_cli/cli.py | 71 ++++++++++++++++++- tests/test_relay_wrapper.py | 123 +++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/test_relay_wrapper.py diff --git a/packages/cli/quorus_cli/cli.py b/packages/cli/quorus_cli/cli.py index 5f37d319..e2551955 100644 --- a/packages/cli/quorus_cli/cli.py +++ b/packages/cli/quorus_cli/cli.py @@ -7557,15 +7557,82 @@ def _cmd_relay(args): "wasn't stopped", hint="stop it yourself, or use another --port") sys.exit(1) console.print(f"[dim]stopped the relay quorus started on port {args.port}[/]") + # Something that is not a healthy relay may hold the port (a crashed relay's + # orphan, another program). The child would die on bind and this wrapper used + # to exit 0 saying nothing, while the old process kept serving (BUG 30). + holder = _port_holder(host, args.port) + if holder: + _ui.error(f"Port {args.port} on {host} is already in use{holder}", + hint="stop that process, or start this relay with another --port") + sys.exit(1) state = os.environ.get("MESSAGES_FILE") or str(resolve_config_dir() / "relay-state.json") env = {**os.environ, "RELAY_SECRET": secret, "PORT": str(args.port), "HOST": host, "ALLOW_LEGACY_AUTH": "1", "MESSAGES_FILE": state} console.print(f"[bold]Starting Quorus relay on http://{host}:{args.port}[/bold]") console.print(f" [dim]state: {state} · Ctrl+C to stop[/]\n") + code = _run_relay_child(env) + if code: + _ui.error(f"The relay exited with code {code}", + hint=f"if the port was taken meanwhile, free it or use another --port " + f"({host}:{args.port})") + sys.exit(code) + + +def _port_holder(host: str, port: int) -> str | None: + """"" when the port can be bound, else a short note about who holds it (or + "" when nothing more is known), so `quorus relay` can refuse before forking.""" + import errno + import socket + try: - subprocess.run([sys.executable, "-m", "quorus.relay_cli"], env=env, check=False) - except KeyboardInterrupt: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + probe.bind((host, port)) + except OSError as exc: + if exc.errno not in (errno.EADDRINUSE, errno.EACCES): + return None + try: + out = subprocess.run(["lsof", "-nP", "-iTCP:%d" % port, "-sTCP:LISTEN", "-Fcp"], + capture_output=True, text=True, timeout=3).stdout + pid = next((ln[1:] for ln in out.splitlines() if ln.startswith("p")), "") + cmd = next((ln[1:] for ln in out.splitlines() if ln.startswith("c")), "") + return f" by pid {pid} ({cmd})" if pid else "" + except Exception: + return "" + return None + + +def _run_relay_child(env: dict) -> int: + """Run `python -m quorus.relay_cli` in the foreground and return its exit code. + + SIGTERM and SIGINT sent to this wrapper are forwarded to the child and the + wrapper waits for it, so `kill ` frees the port instead of + leaving an orphan relay serving (BUG 30, 0.6.3 multi-person audit).""" + import signal + + child = subprocess.Popen([sys.executable, "-m", "quorus.relay_cli"], env=env) + + def forward(signum, _frame): + try: + child.send_signal(signum) + except ProcessLookupError: + pass + + previous = {sig: signal.signal(sig, forward) for sig in (signal.SIGTERM, signal.SIGINT)} + try: + while True: + try: + code = child.wait() + break + except KeyboardInterrupt: # the signal handler already forwarded it + continue + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + if code in (-signal.SIGTERM, -signal.SIGINT, 0): console.print("\n[dim]relay stopped[/]") + return 0 + return int(code) def _cmd_version(args): from quorus import __version__ diff --git a/tests/test_relay_wrapper.py b/tests/test_relay_wrapper.py new file mode 100644 index 00000000..6ffe0b2f --- /dev/null +++ b/tests/test_relay_wrapper.py @@ -0,0 +1,123 @@ +"""BUG 30 (0.6.3 multi-person audit): `quorus relay` is a wrapper around +`python -m quorus.relay_cli`. Killing the wrapper left the child serving (the port +stayed bound), and a second relay on a taken port died silently with exit 0 while +the old one kept answering. The wrapper now forwards SIGTERM/SIGINT and waits, and +refuses a bound port before forking. +""" +from __future__ import annotations + +import os +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PYTHONPATH = os.pathsep.join(str(ROOT / p) for p in ( + ".", "packages/cli", "packages/mcp", "packages/sdk", "packages/tui")) + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _bindable(port: int) -> bool: + with socket.socket() as s: + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(("127.0.0.1", port)) + except OSError: + return False + return True + + +def test_a_bound_port_is_refused_before_the_child_starts(monkeypatch, capsys, tmp_path): + from quorus_cli import cli + + from quorus.runtime import local_relay + + monkeypatch.setenv("RELAY_SECRET", "test-secret") + monkeypatch.setenv("QUORUS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(local_relay, "_healthy", lambda url, timeout=2.0: False) # not a relay + started: list = [] + monkeypatch.setattr(cli, "_run_relay_child", lambda env: started.append(env) or 0) + holder = socket.socket() + holder.bind(("127.0.0.1", 0)) + holder.listen(1) + port = holder.getsockname()[1] + try: + with pytest.raises(SystemExit) as exc: + cli._cmd_relay(SimpleNamespace(port=port, host="127.0.0.1", replace=False)) + finally: + holder.close() + assert exc.value.code == 1 and started == [] + captured = capsys.readouterr() + text = captured.out + captured.err + assert f"Port {port}" in text and "already in use" in text and "--port" in text + + +def test_a_child_that_exits_nonzero_is_reported_not_swallowed(monkeypatch, capsys, tmp_path): + from quorus_cli import cli + + from quorus.runtime import local_relay + + monkeypatch.setenv("RELAY_SECRET", "test-secret") + monkeypatch.setenv("QUORUS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(local_relay, "_healthy", lambda url, timeout=2.0: False) + monkeypatch.setattr(cli, "_port_holder", lambda host, port: None) + monkeypatch.setattr(cli, "_run_relay_child", lambda env: 1) + with pytest.raises(SystemExit) as exc: + cli._cmd_relay(SimpleNamespace(port=_free_port(), host="127.0.0.1", replace=False)) + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "exited with code 1" in captured.out + captured.err + + +@pytest.mark.timeout(90) +def test_killing_the_wrapper_frees_the_port(tmp_path): + """The overseer's acceptance test: SIGTERM to the wrapper PID, then the port is free.""" + port = _free_port() + env = {**os.environ, "HOME": str(tmp_path), "QUORUS_CONFIG_DIR": str(tmp_path / ".quorus"), + "RELAY_SECRET": "test-secret", "QUORUS_NO_LAUNCHD": "1", "QUORUS_NOTIFY": "0", + "PYTHONPATH": PYTHONPATH, "PYTHONDONTWRITEBYTECODE": "1"} + for k in ("RELAY_URL", "QUORUS_API_KEY", "MESSAGES_FILE"): + env.pop(k, None) + code = ("import sys; sys.argv = ['quorus', 'relay', '--port', '%d', '--host', '127.0.0.1']; " + "from quorus_cli.cli import main; main()" % port) + wrapper = subprocess.Popen([sys.executable, "-c", code], env=env, cwd=str(tmp_path), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + try: + deadline = time.monotonic() + 40 + while time.monotonic() < deadline: + try: + if httpx.get(f"http://127.0.0.1:{port}/health", timeout=1).status_code == 200: + break + except httpx.HTTPError: + pass + assert wrapper.poll() is None, "the wrapper exited before the relay came up" + time.sleep(0.3) + else: + pytest.fail("relay never answered /health") + assert not _bindable(port) + os.kill(wrapper.pid, signal.SIGTERM) # the wrapper only, never its group + wrapper.wait(timeout=20) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not _bindable(port): + time.sleep(0.2) + assert _bindable(port), "the relay child kept the port after its wrapper died" + finally: + if wrapper.poll() is None: + wrapper.kill() + try: # belt and braces: nothing of ours may survive the test + os.killpg(wrapper.pid, signal.SIGKILL) + except ProcessLookupError: + pass From e7a30c93a85c155cd542e3d6a3cb9da6ae41005a Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 05:27:04 -0400 Subject: [PATCH 11/26] fix: a taken port with no known holder is refused Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 5cda69807d05e53309cb831b9ef240ebccd0ccc6) --- packages/cli/quorus_cli/cli.py | 8 ++++--- tests/test_relay_wrapper.py | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/cli/quorus_cli/cli.py b/packages/cli/quorus_cli/cli.py index e2551955..d6420bff 100644 --- a/packages/cli/quorus_cli/cli.py +++ b/packages/cli/quorus_cli/cli.py @@ -7561,7 +7561,7 @@ def _cmd_relay(args): # orphan, another program). The child would die on bind and this wrapper used # to exit 0 saying nothing, while the old process kept serving (BUG 30). holder = _port_holder(host, args.port) - if holder: + if holder is not None: # "" means taken by an unknown holder: still refuse (Codex) _ui.error(f"Port {args.port} on {host} is already in use{holder}", hint="stop that process, or start this relay with another --port") sys.exit(1) @@ -7579,8 +7579,10 @@ def _cmd_relay(args): def _port_holder(host: str, port: int) -> str | None: - """"" when the port can be bound, else a short note about who holds it (or - "" when nothing more is known), so `quorus relay` can refuse before forking.""" + """None when the port can be bound; otherwise a short note about who holds it, + or "" when the port is taken but nothing more is known (no `lsof`, or it found + no listener), so `quorus relay` can refuse before forking. Callers must test + `is not None`: the empty string is still "taken".""" import errno import socket diff --git a/tests/test_relay_wrapper.py b/tests/test_relay_wrapper.py index 6ffe0b2f..22c74be4 100644 --- a/tests/test_relay_wrapper.py +++ b/tests/test_relay_wrapper.py @@ -64,6 +64,48 @@ def test_a_bound_port_is_refused_before_the_child_starts(monkeypatch, capsys, tm assert f"Port {port}" in text and "already in use" in text and "--port" in text +@pytest.mark.parametrize("lsof", ["missing", "silent"]) +def test_a_taken_port_with_an_unknown_holder_is_still_refused(monkeypatch, capsys, tmp_path, + lsof): + """Codex, review of 65245e6e: `_port_holder` says "" when the port is taken but + `lsof` is unavailable or names no listener, and `if holder:` then started the + child anyway. The empty string is still "taken".""" + from quorus.runtime import local_relay + from quorus_cli import cli + + monkeypatch.setenv("RELAY_SECRET", "test-secret") + monkeypatch.setenv("QUORUS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(local_relay, "_healthy", lambda url, timeout=2.0: False) + started: list = [] + monkeypatch.setattr(cli, "_run_relay_child", lambda env: started.append(env) or 0) + + def fake_lsof(*a, **kw): + if lsof == "missing": + raise FileNotFoundError("lsof") + return subprocess.CompletedProcess(a, 1, stdout="", stderr="") + + monkeypatch.setattr(cli.subprocess, "run", fake_lsof) + holder = socket.socket() + holder.bind(("127.0.0.1", 0)) + holder.listen(1) + port = holder.getsockname()[1] + try: + assert cli._port_holder("127.0.0.1", port) == "" + with pytest.raises(SystemExit) as exc: + cli._cmd_relay(SimpleNamespace(port=port, host="127.0.0.1", replace=False)) + finally: + holder.close() + assert exc.value.code == 1 and started == [] + captured = capsys.readouterr() + assert f"Port {port}" in captured.out + captured.err + + +def test_a_free_port_reports_no_holder(): + from quorus_cli import cli + + assert cli._port_holder("127.0.0.1", _free_port()) is None + + def test_a_child_that_exits_nonzero_is_reported_not_swallowed(monkeypatch, capsys, tmp_path): from quorus_cli import cli From 89efc259cc18f5812216dbbdb441d7e43767abf8 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 05:27:16 -0400 Subject: [PATCH 12/26] style: sort imports in the relay wrapper test Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 22680480e7c5639b0cf72f0b621a8ff77f3dfec6) --- tests/test_relay_wrapper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_relay_wrapper.py b/tests/test_relay_wrapper.py index 22c74be4..288f23a9 100644 --- a/tests/test_relay_wrapper.py +++ b/tests/test_relay_wrapper.py @@ -70,9 +70,10 @@ def test_a_taken_port_with_an_unknown_holder_is_still_refused(monkeypatch, capsy """Codex, review of 65245e6e: `_port_holder` says "" when the port is taken but `lsof` is unavailable or names no listener, and `if holder:` then started the child anyway. The empty string is still "taken".""" - from quorus.runtime import local_relay from quorus_cli import cli + from quorus.runtime import local_relay + monkeypatch.setenv("RELAY_SECRET", "test-secret") monkeypatch.setenv("QUORUS_CONFIG_DIR", str(tmp_path)) monkeypatch.setattr(local_relay, "_healthy", lambda url, timeout=2.0: False) From b7f5fed486072f5cfef6693691b709333a134d2e Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 04:24:42 -0400 Subject: [PATCH 13/26] fix: wake text stops claiming a shared object store Co-Authored-By: Claude Fable 5.1 (cherry picked from commit adb9a45b1016e52ff06f5fe6481488ffd6b5ad63) --- scripts/reflexd_wake.py | 9 +++++++-- tests/test_reflexd_wake_contract.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/scripts/reflexd_wake.py b/scripts/reflexd_wake.py index e8a85e47..eef9c7b6 100644 --- a/scripts/reflexd_wake.py +++ b/scripts/reflexd_wake.py @@ -582,8 +582,13 @@ def worktree_instructions(wt: Worktree, *, dirty: bool = False) -> str: "That is normal: don't mention it in the room. " "Do NOT run git rebase, merge, reset or push: Quorus rebases your branch " f"onto `{wt.main}` and publishes it when you finish, and wakes you if " - "there is a conflict. Reviewers: inspect a commit with `git show " - "` (all worktrees share one object store)." + "there is a conflict. Reviewing a commit: it is on this machine only if " + "`git cat-file -e ` succeeds in your worktree (the worktrees here " + "share one object store); otherwise it came from another owner's " + "machine, so run the `quorus fetch --room ` line that " + "was posted with it (or the fetch_bundle tool) before `git show`. Never " + "tell a reviewer that you share a store with them: when you ask another " + "owner for a review, give them the fetch line, not a hash." ) diff --git a/tests/test_reflexd_wake_contract.py b/tests/test_reflexd_wake_contract.py index 1592b845..14c93895 100644 --- a/tests/test_reflexd_wake_contract.py +++ b/tests/test_reflexd_wake_contract.py @@ -1155,3 +1155,16 @@ def test_the_wake_intent_says_never_to_write_the_token_otherwise(): kind="agent_room", teammates=[], has_workspace=False, from_agent=True) assert "exactly NO_REPLY and nothing else" in text and "protocol word" in text + + +def test_the_worktree_text_never_claims_a_shared_store_across_owners(tmp_path: Path) -> None: + """0.6.3 multi-person audit, BUG 23: an agent on another laptop told its reviewer + "a plain git show works since we share the object store", which is false across + machines. The text now gives the test (`git cat-file -e`) and the cross-owner path + (`quorus fetch --room `), and says to hand out fetch lines, not hashes.""" + wt = wake.Worktree(path=tmp_path / "wt", branch="quorus/x", main="weekend/quorus", + repo=tmp_path / "repo") + text = wake.worktree_instructions(wt) + assert "all worktrees share one object store" not in text + assert "git cat-file -e " in text and "quorus fetch --room " in text + assert "Never tell a reviewer that you share a store" in text From 614d1d1f18c9c1e1fba9f39de4e937afe29695e2 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 05:22:16 -0400 Subject: [PATCH 14/26] fix: room-post redaction masks participant keys redact_secrets covered mct_ and quorus_sk_ but not quorus_pk_. Since H4 the owner's own participant key sits in ~/.quorus, which an agent running as the owner can read; a prompt-injected agent could post it unmasked. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 0df1dbf7c37aad59cca85372edb067ec74d6cd53) --- quorus/observability/redaction.py | 3 +++ tests/test_prompt_injection_guard.py | 1 + 2 files changed, 4 insertions(+) diff --git a/quorus/observability/redaction.py b/quorus/observability/redaction.py index 45696fbe..cb83c613 100644 --- a/quorus/observability/redaction.py +++ b/quorus/observability/redaction.py @@ -16,6 +16,9 @@ r"|\bAIza[0-9A-Za-z_-]{35}" r"|\bxox[abposr]-[A-Za-z0-9-]{10,}" r"|\b(?:mct|quorus_sk)_[A-Za-z0-9_-]{16,}" + # Participant keys (H4: the owner's own key now lives in ~/.quorus, where a + # prompt-injected agent running as the owner can read it). + r"|\bquorus_pk_[A-Za-z0-9_-]{16,}" r"|\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}" ) _SECRET_ENV = re.compile(r"(?i)(SECRET|TOKEN|PASSWORD|API_?KEY|PRIVATE_KEY|CREDENTIAL)") diff --git a/tests/test_prompt_injection_guard.py b/tests/test_prompt_injection_guard.py index c7f31b2f..91b94af1 100644 --- a/tests/test_prompt_injection_guard.py +++ b/tests/test_prompt_injection_guard.py @@ -113,6 +113,7 @@ def test_every_wake_carries_guard_rails(tmp_path: Path, monkeypatch) -> None: "ghp_" + "c" * 36, "AKIA" + "ABCDEFGHIJKLMNOP", "mct_abcd1234_" + "z" * 32, + "quorus_pk_" + "Q" * 43, # a participant key "AIza" + "d" * 35, "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.c2lnbmF0dXJlc2lnbmF0dXJl", "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----", From 0f8f0edbb41e9377642eca3f03f20bd9a31d83f1 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 06:19:51 -0400 Subject: [PATCH 15/26] fix: a lost @open bid posts no /defer line One @open with four agents posted four '/defer -> @winner' lines (Arav's multi-owner run). The claim already names the winner and nothing routes on these edges; failover handoffs keep their defer. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit b9052a773dadb05ec99f47428222b485145dccba) --- scripts/reflexd.py | 24 ++++----------------- tests/test_open_bid_read_only.py | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/scripts/reflexd.py b/scripts/reflexd.py index b9e79dd5..8b297e6c 100644 --- a/scripts/reflexd.py +++ b/scripts/reflexd.py @@ -2940,26 +2940,10 @@ async def handle_room_message( "lost or no claim room=%s id=%s winner=%s", room, message_id, winner, ) - # Stream B defer-announce: post `defer(to=)` so the - # room state machine records who took an open task. Best-effort. - # Only for open tasks: on agent chatter it was a stray "/ defer" - # line after every message (fresh-user run, 2026-10-16). - if (winner and winner != self.config.participant_name - and triage.kind == "open_todo"): - try: - await relay.post_social_defer( - room_id=room, - actor=self.config.participant_name, - target=winner, - ref_message_id=message_id or None, - ttl_seconds=300, - ) - logger.info( - "posted defer to=%s ref=%s after bid loss", - winner, message_id, - ) - except Exception as exc: - logger.debug("defer-announce failed (non-fatal): %s", exc) + # No `/defer → @winner` post on a lost bid: with four agents in a room every + # @open produced four such lines (multi-owner audit, Arav), and the winner's + # claim already tells the room who took the task. Nothing reads these edges + # for routing; a failover handoff still posts its own defer. return True if won.get("read_only") and triage.kind == "open_todo": diff --git a/tests/test_open_bid_read_only.py b/tests/test_open_bid_read_only.py index 43f98760..ebf0fe8c 100644 --- a/tests/test_open_bid_read_only.py +++ b/tests/test_open_bid_read_only.py @@ -118,3 +118,40 @@ async def wake(*a, **kw): [notice] = daemon.posts assert notice["message_type"] == "status" and "@sam" in notice["content"] assert "no agent here can make changes" in notice["content"] + + +def test_a_lost_bid_posts_no_defer_line(daemon) -> None: + """Arav's multi-owner run: one @open with four bidders posted four + '/defer → @winner' lines. A losing bidder now stays silent.""" + daemon.bind({"mode": "autonomous"}) + deferred: list[dict[str, Any]] = [] + msg = {"id": "m2", "room": "dev", "from_name": "arav", + "content": "@open add a type hint", "message_type": "chat"} + + class Relay: + async def submit_bid(self, **kw): + return {} + + async def claim(self, *, room_id, message_id): + return {"claimed": True, "winner": "aarya-codex", "bid": 0.5, + "candidates": ["arav-codex", "aarya-codex"]} + + async def post_social_defer(self, **kw): + deferred.append(kw) + return {} + + async def post_reply(self, **kw): + return {} + + async def fetch_members(self, *, room): + return ["arav", "arav-codex", "aarya-codex"] + + async def fetch_recent(self, *, room, limit=10): + return [msg] + + async def fetch_member_roles(self, *, room): + return {"arav": "member", "arav-codex": "member", "aarya-codex": "member"} + + asyncio.run(daemon.handle_room_message( + Relay(), {**msg, "id": "delivery-m2", "message_id": "m2"})) + assert deferred == [] From 32f809acbd442386b0f1b22bc8d05e77b353fd6c Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 06:22:00 -0400 Subject: [PATCH 16/26] test: lost auctions post no defer, chatter or @open b9052a77 left test_losing_an_auction_on_agent_chatter_posts_no_defer expecting one defer for an @open; weekend went red. Both cases now expect none, under a name that says so. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 07ae343a609e963a4726fbc88115eba5dab5e75d) --- tests/test_reflexd_wake_contract.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_reflexd_wake_contract.py b/tests/test_reflexd_wake_contract.py index 14c93895..542f8780 100644 --- a/tests/test_reflexd_wake_contract.py +++ b/tests/test_reflexd_wake_contract.py @@ -1012,7 +1012,9 @@ def test_commit_all_skips_generated_files(tmp_path: Path) -> None: assert files == ["tip.py"] -def test_losing_an_auction_on_agent_chatter_posts_no_defer(tmp_path: Path) -> None: +def test_losing_an_auction_posts_no_defer(tmp_path: Path) -> None: + """Neither agent chatter nor a lost @open posts `/defer → @winner`: one @open + with four bidders posted four of them (b9052a77); the claim names the winner.""" d = _daemon(tmp_path, "x-claude") defers: list[dict[str, Any]] = [] @@ -1029,11 +1031,11 @@ async def post_social_defer(self, **kw: Any) -> None: async def fetch_recent(self, **kw: Any) -> list: return [] - for content, expected in (("pushed the fix", 0), ("@open add csv export", 1)): - env = {"from_name": "x-gemini" if expected == 0 else "arav", "room": "r", + for content, sender in (("pushed the fix", "x-gemini"), ("@open add csv export", "arav")): + env = {"from_name": sender, "room": "r", "message_id": content, "content": content, "message_type": "chat"} asyncio.run(d.handle_room_message(Relay(), env)) - assert len(defers) == expected, content + assert defers == [], content def test_agent_mention_may_be_ignored_but_human_mention_may_not() -> None: From fab2f5625f14530b69d63181bed3f0a956dda713 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 08:45:20 -0400 Subject: [PATCH 17/26] fix: stale held window no longer holds a retry Multi-person audit BUG 24: nadia's only message woke nadia-opencode, the wake crashed before committing and left its held-window file dated before her message. The retry wake for that same message committed, and the publish guard held it as "@nadia wrote to me mid-turn". A window whose turn left nothing unpublished (no commit ahead of main, clean tree) is stale; mark_window now replaces it with the new turn's start and view. A window with an unpublished commit behind it still stands (codex's rule). Co-Authored-By: Claude Fable 5.1 (cherry picked from commit ba92db59475ee0f36c936fd754763825ac25d3da) --- scripts/reflexd_held.py | 22 ++++++++++++++++++++-- tests/test_held_wakes.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/scripts/reflexd_held.py b/scripts/reflexd_held.py index cf351297..c0b3e611 100644 --- a/scripts/reflexd_held.py +++ b/scripts/reflexd_held.py @@ -84,8 +84,14 @@ def mark_window(held_dir: Path, wt, started_at: float | None = None, window: its start and the ids it had in view (never mid-turn for it). Later turns change neither. Adding their context let a stand-down that a later wake happened to see excuse the older turn's commits (codex, review of - 3ba308a4).""" - if _read_window(held_dir, wt) is not None: + 3ba308a4). + + A window whose turn left nothing behind (no commit ahead of main, a clean + tree: it crashed, or made no change) is stale, and this turn opens its own. + Left standing, it dated the window before the owner's one message, so the + retry wake for that very message held its commit as "wrote to me mid-turn" + (multi-person audit BUG 24, nadia-opencode, 2026-10-20).""" + if _read_window(held_dir, wt) is not None and _unpublished(wt): return ids = list(dict.fromkeys(i for i in seen if i)) _write_json(_file(held_dir, wt.path, "window"), @@ -114,6 +120,18 @@ def seen_ids(held_dir: Path, wt) -> set[str]: return {i for i in data.get("seen", []) if isinstance(i, str)} +def _unpublished(wt) -> bool: + """Anything of an earlier turn still on the branch: commits ahead of main + or uncommitted changes. Unknown (git failed) counts as yes: keep the window.""" + try: + if _ahead(wt): + return True + except OSError: + return True + status = _git(wt.path, "status", "--porcelain") + return status.returncode != 0 or bool(status.stdout.strip()) + + def _ahead(wt) -> list[str]: out = _git(wt.repo, "rev-list", "--reverse", f"{wt.main}..{wt.branch}") if out.returncode != 0: diff --git a/tests/test_held_wakes.py b/tests/test_held_wakes.py index 9be91ea9..345df6f9 100644 --- a/tests/test_held_wakes.py +++ b/tests/test_held_wakes.py @@ -338,3 +338,40 @@ def test_a_later_wakes_context_doesnt_excuse_the_oldest_turn(monkeypatch, tmp_pa assert "s1" not in held.seen_ids(d._held_dir, wt) asyncio.run(d._publish_sweep(_Relay([stand_down]))) assert _main_log(repo) == "init" and len(_held_refs(repo)) == 1 + + +def test_a_crashed_turn_that_left_nothing_doesnt_hold_the_next_turns_trigger( + monkeypatch, tmp_path): + """Multi-person audit BUG 24: nadia's only message woke nadia-opencode; that + wake crashed before committing, leaving its window file behind with a start + before her message and no sight of it. The retry wake for the same message + committed, and the guard held the commit: "@nadia wrote to me mid-turn". A + window from a turn that left nothing unpublished is stale: the next turn + opens its own.""" + repo = _repo(tmp_path) + d = _daemon(monkeypatch, tmp_path, repo) + wt = reflexd.reflexd_wake.agent_worktree(repo, ME) + held.mark_window(d._held_dir, wt, started_at=time.time() - 120, seen={"older"}) + only_message = datetime.fromtimestamp(time.time() - 60, timezone.utc).isoformat() + relay = _Relay([{"id": "e-m", "from_name": "arav", "content": f"@{ME} write the notes", + "timestamp": only_message}]) + _wake(monkeypatch, d, relay, reply="✅", edit="mine\n") + assert _main_log(repo).splitlines()[0] == "docs: notes" + assert _held_refs(repo) == [] and not [p for p in _posts(relay) if "held" in p] + + +def test_a_crashed_turns_unpublished_commit_keeps_its_window(monkeypatch, tmp_path): + """The other half of BUG 24's fix: when the earlier turn did leave a commit, + its window stands (codex, review of 3ba308a4), and an owner message after + it still holds everything.""" + repo = _repo(tmp_path) + d = _daemon(monkeypatch, tmp_path, repo) + wt = reflexd.reflexd_wake.agent_worktree(repo, ME) + held.mark_window(d._held_dir, wt, started_at=time.time() - 120, seen={"older"}) + (wt.path / "notes.md").write_text("unpublished\n") + _git(wt.path, "commit", "-qam", "docs: unpublished") + relay = _Relay([{"id": "e-m", "from_name": "arav", "content": f"@{ME} write the notes", + "timestamp": datetime.fromtimestamp(time.time() - 60, + timezone.utc).isoformat()}]) + _wake(monkeypatch, d, relay, reply="✅", edit="mine\n") + assert _main_log(repo) == "init" and len(_held_refs(repo)) == 1 From 988c742fa1740374a094f6c6335f082a3d7edd4f Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 12:51:46 -0400 Subject: [PATCH 18/26] fix: scorecard survival counts every file Codex: _surviving blamed only the first 25 files at checkout HEAD while counting every added line, so a 26-file commit kept 25 of 26. Survival now blames every file at one pinned end commit, follows renames, counts deleted files' lines as gone, and leaves unblameable files out of the denominator, reported as coverage. 0.6.5 queue. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 04a96e5755ca10e156b87c78df9075cb4acf1653) --- packages/cli/quorus_cli/scorecard_cmd.py | 6 +- quorus/ledger.py | 90 ++++++++++++++++++++---- tests/test_ledger.py | 51 ++++++++++++++ 3 files changed, 133 insertions(+), 14 deletions(-) diff --git a/packages/cli/quorus_cli/scorecard_cmd.py b/packages/cli/quorus_cli/scorecard_cmd.py index c9c410e4..19f3bcd7 100644 --- a/packages/cli/quorus_cli/scorecard_cmd.py +++ b/packages/cli/quorus_cli/scorecard_cmd.py @@ -60,6 +60,8 @@ def cmd_scorecard(args: Any, console: Any) -> None: table.add_column(col, justify="left" if col == by else "right", no_wrap=True) for r in ledger.scorecard(rows, key=by): surv = "-" if r["survival"] is None else f"{round(100 * r['survival'])}%" + if r["survival"] is not None and r.get("coverage") is not None and r["coverage"] < 1: + surv += f" ({round(100 * r['coverage'])}% measured)" duels = (f"{r['duels_won']}-{r['duels_lost']}" if r["duels_won"] or r["duels_lost"] else "-") table.add_row(r[by], str(r["commits"]), _pct(r["with_tests"], r["commits"]), @@ -69,7 +71,9 @@ def cmd_scorecard(args: Any, console: Any) -> None: # 0.6.3 audit BUG 14: "tests 0%" read as "its tests didn't run" for a fix whose # tests were green; the column counts commits that add or change a test file. console.print("[dim]+tests = share of its commits that add or change a test (not whether " - "tests ran); kept = share of the lines it added still in the code at HEAD" + "tests ran); kept = share of the lines it added still in the code at HEAD, over " + "the files that could be measured (renames followed; \"measured\" shows " + "when some couldn't be)" + (f"; {inferred} commits attributed from the room's publish lines " "(made before provenance notes)" if inferred else "") + "[/]") flagged = [o for o in rows if o.review == "flagged" or o.reverted] diff --git a/quorus/ledger.py b/quorus/ledger.py index d57ebb99..c8964a9b 100644 --- a/quorus/ledger.py +++ b/quorus/ledger.py @@ -7,7 +7,11 @@ * tests: did the commit touch tests? * review: did a teammate approve it or flag it in the room? * reverted: did a later commit revert it? -* survival: how much of what it added is still in the code at HEAD? +* survival: how much of what it added is still in the code at the end rev? Every file + the commit touched is blamed at one pinned end commit. A renamed file is followed to its + new path, a deleted file's lines count as gone, and a file that can't be blamed is + "uncomputed": it stays out of the denominator and shows in ``coverage``, never as + deleted lines. Why: the weekend agents' commits mostly carried the human's git identity, so nobody could tell which model's work held up (2026-10-17 research: no tool @@ -99,13 +103,20 @@ class Outcome: reverted: bool = False added: int = 0 surviving: int = 0 + computed: int = 0 # added lines in files whose fate is known (the denominator) + files_uncomputed: int = 0 reviewers: list[str] = field(default_factory=list) duel_id: str | None = None # set on a duel winner's commits duel_against: str | None = None @property def survival(self) -> float | None: - return self.surviving / self.added if self.added else None + return self.surviving / self.computed if self.computed else None + + @property + def coverage(self) -> float | None: + """Share of the added lines whose survival was computed.""" + return self.computed / self.added if self.added else None def _commits(repo: Path, rev_range: str) -> list[tuple[str, str, int]]: @@ -129,12 +140,56 @@ def _files_and_added(repo: Path, sha: str) -> tuple[list[str], int, dict[str, in return files, total, per_file -def _surviving(repo: Path, sha: str, per_file: dict[str, int], max_files: int = 25) -> int: - alive = 0 - for path in list(per_file)[:max_files]: - blame = _git(repo, "blame", "--line-porcelain", "HEAD", "--", path) - alive += sum(1 for ln in blame.splitlines() if ln.startswith(sha + " ")) - return alive +MAX_BLAME_FILES = 500 # beyond this a commit's other files are reported uncomputed + + +def _renames(repo: Path, sha: str, end: str) -> dict[str, str]: + """Paths a commit touched that live under another name at *end*.""" + out = {} + for line in _git(repo, "diff", "--name-status", "-M", sha, end).splitlines(): + parts = line.split("\t") + if len(parts) == 3 and parts[0].startswith("R"): + out[parts[1]] = parts[2] + return out + + +def _surviving(repo: Path, sha: str, per_file: dict[str, int], + end: str) -> tuple[int, int, dict[str, str]]: + """(lines still present at *end*, added lines whose fate is known, per-file status). + + Status is "counted" (blamed at *end*), "deleted" (no longer at *end*, so its lines + are gone) or "uncomputed" (blame failed or the cap was hit: excluded from the + denominator, never read as deleted).""" + alive, computed, status = 0, 0, {} + renames: dict[str, str] | None = None + for i, (path, added) in enumerate(per_file.items()): + if i >= MAX_BLAME_FILES: + status[path] = "uncomputed" + continue + target = path + if _git_ok(repo, "cat-file", "-e", f"{end}:{path}") is False: + if renames is None: + renames = _renames(repo, sha, end) + target = renames.get(path, "") + if not target: + status[path] = "deleted" + computed += added + continue + blame = subprocess.run( + ["git", "-c", "core.hooksPath=/dev/null", "blame", "--line-porcelain", end, + "--", target], cwd=repo, capture_output=True, text=True, timeout=60) + if blame.returncode != 0: + status[path] = "uncomputed" + continue + alive += sum(1 for ln in blame.stdout.splitlines() if ln.startswith(sha + " ")) + computed += added + status[path] = "counted" + return alive, computed, status + + +def _git_ok(repo: Path, *args: str) -> bool: + return subprocess.run(["git", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, + capture_output=True, timeout=60).returncode == 0 def _reverted(repo: Path, rev_range: str) -> set[str]: @@ -228,8 +283,12 @@ def _branches_in_line(repo: Path, history: list[dict[str, Any]]) -> set[str]: def outcomes(repo: Path, rev_range: str, - history: list[dict[str, Any]] | None = None) -> list[Outcome]: + history: list[dict[str, Any]] | None = None, + end: str = "HEAD") -> list[Outcome]: + """Outcomes for *rev_range*, with survival measured at *end*, resolved once to a + commit so a checkout moving mid-run can't change the answer.""" history = history or [] + end = _git(repo, "rev-parse", "--verify", f"{end}^{{commit}}", check=True).strip() commits = _commits(repo, rev_range) inferred = infer_from_room(history, commits, _branches_in_line(repo, history)) reverted = _reverted(repo, rev_range) @@ -250,7 +309,8 @@ def outcomes(repo: Path, rev_range: str, files, o.added, per_file = _files_and_added(repo, sha) o.has_tests = any(_TEST_PATH.search(f) for f in files) o.reverted = any(sha.startswith(r) or r.startswith(sha) for r in reverted) - o.surviving = _surviving(repo, sha, per_file) + o.surviving, o.computed, status = _surviving(repo, sha, per_file, end) + o.files_uncomputed = sum(v == "uncomputed" for v in status.values()) o.review, o.reviewers = _review(history, sha, o.agent) out.append(o) return out @@ -273,9 +333,11 @@ def scorecard(rows: list[Outcome], key: str = "agent") -> list[dict[str, Any]]: table = [] for name, items in sorted(groups.items(), key=lambda kv: -len(kv[1])): added = sum(o.added for o in items) + computed = sum(o.computed for o in items) if not items: # only lost duels in this range table.append({key: name, "commits": 0, "with_tests": 0, "approved": 0, "flagged": 0, - "reverted": 0, "survival": None, "inferred": 0, "duels_won": 0, + "reverted": 0, "survival": None, "coverage": None, "inferred": 0, + "duels_won": 0, "duels_lost": len(lost.get(name, ()))}) continue table.append({ @@ -285,7 +347,8 @@ def scorecard(rows: list[Outcome], key: str = "agent") -> list[dict[str, Any]]: "approved": sum(o.review == "approved" for o in items), "flagged": sum(o.review == "flagged" for o in items), "reverted": sum(o.reverted for o in items), - "survival": round(sum(o.surviving for o in items) / added, 3) if added else None, + "survival": round(sum(o.surviving for o in items) / computed, 3) if computed else None, + "coverage": round(computed / added, 3) if added else None, "inferred": sum(o.inferred for o in items), "duels_won": len(won.get(name, ())), "duels_lost": len(lost.get(name, ())), @@ -294,4 +357,5 @@ def scorecard(rows: list[Outcome], key: str = "agent") -> list[dict[str, Any]]: def to_json(rows: list[Outcome]) -> str: - return json.dumps([{**asdict(o), "survival": o.survival} for o in rows], indent=2) + return json.dumps([{**asdict(o), "survival": o.survival, "coverage": o.coverage} + for o in rows], indent=2) diff --git a/tests/test_ledger.py b/tests/test_ledger.py index e812e54e..a0057182 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -202,3 +202,54 @@ def test_scorecard_says_its_tests_column_counts_test_changes(tmp_path: Path) -> room=None, json=False, by="agent"), console) text = console.export_text() assert "+tests" in text and "add or change a test (not whether tests ran)" in text + + +# ── survival: every file, one pinned end, renames followed (Codex, 2026-10-20) ── + +def test_survival_counts_every_file_not_just_the_first_25(tmp_path: Path) -> None: + """Codex's probe: 26 files with one intact line each reported 25 of 26 surviving.""" + repo, base = _repo(tmp_path) + sha = _commit(repo, "feat: wide", {f"f{i:02}.py": f"line {i}\n" for i in range(26)}) + [o] = ledger.outcomes(repo, f"{base}..HEAD") + assert o.sha == sha and o.added == 26 and o.surviving == 26 + assert o.computed == 26 and o.survival == 1.0 and o.coverage == 1.0 + + +def test_a_renamed_file_is_followed_and_a_deleted_one_counts_as_gone(tmp_path: Path) -> None: + repo, base = _repo(tmp_path) + sha = _commit(repo, "feat: two files", {"keep.py": "a\nb\n", "drop.py": "c\nd\n"}) + _git(repo, "mv", "keep.py", "kept.py") + _git(repo, "rm", "-q", "drop.py") + _git(repo, "-c", "user.name=t", "-c", "user.email=t@x", "commit", "-qm", "move + delete") + o = {r.sha: r for r in ledger.outcomes(repo, f"{base}..HEAD")}[sha] + assert o.added == 4 and o.surviving == 2 and o.computed == 4 # renamed lines kept + assert o.survival == 0.5 and o.files_uncomputed == 0 + + +def test_an_unblameable_file_is_uncomputed_not_deleted(tmp_path: Path, monkeypatch) -> None: + """Codex: renamed or uncomputed lines must never silently become deleted lines.""" + repo, base = _repo(tmp_path) + sha = _commit(repo, "feat: two", {"ok.py": "a\nb\n", "bad.py": "c\nd\n"}) + real = ledger.subprocess.run + + def flaky(argv, *a, **kw): + if "blame" in argv and argv[-1] == "bad.py": + return subprocess.CompletedProcess(argv, 128, "", "fatal: boom") + return real(argv, *a, **kw) + + monkeypatch.setattr(ledger.subprocess, "run", flaky) + o = {r.sha: r for r in ledger.outcomes(repo, f"{base}..HEAD")}[sha] + assert o.added == 4 and o.computed == 2 and o.surviving == 2 + assert o.survival == 1.0 and o.coverage == 0.5 and o.files_uncomputed == 1 + card = ledger.scorecard([o])[0] + assert card["survival"] == 1.0 and card["coverage"] == 0.5 + + +def test_survival_is_measured_at_the_pinned_end_not_the_checkout(tmp_path: Path) -> None: + repo, base = _repo(tmp_path) + sha = _commit(repo, "feat: x", {"x.py": "1\n2\n"}) + end = _git(repo, "rev-parse", "HEAD") + _commit(repo, "refactor: gone", {"x.py": "z\n"}) + at_end = {r.sha: r for r in ledger.outcomes(repo, f"{base}..{end}", end=end)}[sha] + at_head = {r.sha: r for r in ledger.outcomes(repo, f"{base}..{end}")}[sha] + assert at_end.survival == 1.0 and at_head.survival == 0.0 From 292563c14494c5346be51bad380f1d270c602f31 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 12:55:12 -0400 Subject: [PATCH 19/26] fix: survival parses renames, errors uncomputed Codex on f033a72d: numstat's 'old => new' was read as a path, so a rename-and-edit commit kept 0% at 100% coverage; and any failed tree read counted as deletion while a blame timeout escaped. Numstat and renames are parsed from -z output, existence comes from ls-tree, and any git error or timeout marks the file uncomputed. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit c5126cbf5ebc47bd1b9041c9c2edd8b53a25ac01) --- quorus/ledger.py | 96 +++++++++++++++++++++++++++++++------------- tests/test_ledger.py | 42 +++++++++++++++++++ 2 files changed, 111 insertions(+), 27 deletions(-) diff --git a/quorus/ledger.py b/quorus/ledger.py index c8964a9b..d03c6b5d 100644 --- a/quorus/ledger.py +++ b/quorus/ledger.py @@ -130,55 +130,102 @@ def _commits(repo: Path, rev_range: str) -> list[tuple[str, str, int]]: def _files_and_added(repo: Path, sha: str) -> tuple[list[str], int, dict[str, int]]: + """Paths as they stand after *sha*, with lines added. Parsed from ``-z`` numstat, so a + rename (``old => new`` in plain output) or an unusual name is never read as a path + (Codex on f033a72d). Binary files ("-") add no countable lines.""" files, total, per_file = [], 0, {} - for line in _git(repo, "show", "--numstat", "--format=", sha).splitlines(): - parts = line.split("\t") - if len(parts) == 3 and parts[0].isdigit(): - files.append(parts[2]) - per_file[parts[2]] = int(parts[0]) - total += int(parts[0]) + fields = _git(repo, "show", "--numstat", "-z", "-M", "--format=", sha).split("\0") + i = 0 + while i < len(fields): + head = fields[i].lstrip("\n") + i += 1 + parts = head.split("\t") + if len(parts) != 3: + continue + added, _deleted, path = parts + if not path: # a rename: the old and new paths follow as their own fields + if i + 1 >= len(fields): + break + path = fields[i + 1] + i += 2 + files.append(path) + if added.isdigit(): + per_file[path] = int(added) + total += int(added) return files, total, per_file MAX_BLAME_FILES = 500 # beyond this a commit's other files are reported uncomputed -def _renames(repo: Path, sha: str, end: str) -> dict[str, str]: - """Paths a commit touched that live under another name at *end*.""" - out = {} - for line in _git(repo, "diff", "--name-status", "-M", sha, end).splitlines(): - parts = line.split("\t") - if len(parts) == 3 and parts[0].startswith("R"): - out[parts[1]] = parts[2] +def _run(repo: Path, *args: str) -> subprocess.CompletedProcess | None: + """A git call that never raises: None on a timeout, so callers can mark the file + uncomputed instead of crashing the scorecard.""" + try: + return subprocess.run(["git", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, + capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return None + + +def _renames(repo: Path, sha: str, end: str) -> dict[str, str] | None: + """Paths after *sha* that live under another name at *end*; None if git failed.""" + got = _run(repo, "diff", "--name-status", "-z", "-M", sha, end) + if got is None or got.returncode != 0: + return None + out, fields, i = {}, got.stdout.split("\0"), 0 + while i < len(fields): + status = fields[i] + if status.startswith(("R", "C")) and i + 2 < len(fields): + if status.startswith("R"): + out[fields[i + 1]] = fields[i + 2] + i += 3 + else: + i += 2 return out +def _present_at(repo: Path, end: str, path: str) -> bool | None: + """True or False when git can say whether *path* exists at *end*; None when it can't + (a failing tree read is not proof the file was deleted: Codex on f033a72d).""" + got = _run(repo, "ls-tree", "-z", "--name-only", end, "--", path) + if got is None or got.returncode != 0: + return None + return path in got.stdout.split("\0") + + def _surviving(repo: Path, sha: str, per_file: dict[str, int], end: str) -> tuple[int, int, dict[str, str]]: """(lines still present at *end*, added lines whose fate is known, per-file status). - Status is "counted" (blamed at *end*), "deleted" (no longer at *end*, so its lines - are gone) or "uncomputed" (blame failed or the cap was hit: excluded from the - denominator, never read as deleted).""" + Status is "counted" (blamed at *end*), "deleted" (git confirms it's gone, with no + rename, so its lines are gone) or "uncomputed" (any git error or timeout, or the + cap: excluded from the denominator, never read as deleted).""" alive, computed, status = 0, 0, {} renames: dict[str, str] | None = None + renames_read = False for i, (path, added) in enumerate(per_file.items()): if i >= MAX_BLAME_FILES: status[path] = "uncomputed" continue target = path - if _git_ok(repo, "cat-file", "-e", f"{end}:{path}") is False: + present = _present_at(repo, end, path) + if present is None: + status[path] = "uncomputed" + continue + if not present: + if not renames_read: + renames, renames_read = _renames(repo, sha, end), True if renames is None: - renames = _renames(repo, sha, end) + status[path] = "uncomputed" + continue target = renames.get(path, "") if not target: status[path] = "deleted" computed += added continue - blame = subprocess.run( - ["git", "-c", "core.hooksPath=/dev/null", "blame", "--line-porcelain", end, - "--", target], cwd=repo, capture_output=True, text=True, timeout=60) - if blame.returncode != 0: + blame = _run(repo, "blame", "--line-porcelain", end, "--", target) + if blame is None or blame.returncode != 0: status[path] = "uncomputed" continue alive += sum(1 for ln in blame.stdout.splitlines() if ln.startswith(sha + " ")) @@ -187,11 +234,6 @@ def _surviving(repo: Path, sha: str, per_file: dict[str, int], return alive, computed, status -def _git_ok(repo: Path, *args: str) -> bool: - return subprocess.run(["git", "-c", "core.hooksPath=/dev/null", *args], cwd=repo, - capture_output=True, timeout=60).returncode == 0 - - def _reverted(repo: Path, rev_range: str) -> set[str]: text = _git(repo, "log", "--format=%B", rev_range) return set(re.findall(r"This reverts commit ([0-9a-f]{7,40})", text)) diff --git a/tests/test_ledger.py b/tests/test_ledger.py index a0057182..8a27c0c6 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -253,3 +253,45 @@ def test_survival_is_measured_at_the_pinned_end_not_the_checkout(tmp_path: Path) at_end = {r.sha: r for r in ledger.outcomes(repo, f"{base}..{end}", end=end)}[sha] at_head = {r.sha: r for r in ledger.outcomes(repo, f"{base}..{end}")}[sha] assert at_end.survival == 1.0 and at_head.survival == 0.0 + + +def test_a_rename_with_edits_is_parsed_as_a_path_not_old_arrow_new(tmp_path: Path) -> None: + """Codex on f033a72d: numstat's `old.py => new.py` was read as one filename, so the + rename commit's intact added line reported 0% survival at 100% coverage.""" + repo, base = _repo(tmp_path) + _commit(repo, "feat: old", {"old.py": "".join(f"line {i}\n" for i in range(20))}) + _git(repo, "mv", "old.py", "new.py") + (repo / "new.py").write_text("".join(f"line {i}\n" for i in range(20)) + "added\n") + _git(repo, "add", "-A") + _git(repo, "-c", "user.name=t", "-c", "user.email=t@x", "commit", "-qm", "rename + edit") + sha = _git(repo, "rev-parse", "HEAD") + o = {r.sha: r for r in ledger.outcomes(repo, f"{base}..HEAD")}[sha] + assert o.added == 1 and o.surviving == 1 and o.survival == 1.0 and o.coverage == 1.0 + + +def test_unusual_file_names_are_measured(tmp_path: Path) -> None: + repo, base = _repo(tmp_path) + sha = _commit(repo, "feat: odd", {'a b"cé.py': "x\ny\n"}) + o = {r.sha: r for r in ledger.outcomes(repo, f"{base}..HEAD")}[sha] + assert o.added == 2 and o.surviving == 2 and o.survival == 1.0 + + +def test_a_failed_tree_read_or_blame_timeout_is_uncomputed_not_deleted( + tmp_path: Path, monkeypatch) -> None: + """Codex on f033a72d: a fatal tree error counted two lines as deleted at 100% coverage, + and a blame timeout escaped instead of becoming uncomputed.""" + repo, base = _repo(tmp_path) + sha = _commit(repo, "feat: three", {"ok.py": "a\n", "tree.py": "b\nc\n", "slow.py": "d\n"}) + real = ledger.subprocess.run + + def broken(argv, *a, **kw): + if "ls-tree" in argv and argv[-1] == "tree.py": + return subprocess.CompletedProcess(argv, 128, "", "fatal: bad tree") + if "blame" in argv and argv[-1] == "slow.py": + raise subprocess.TimeoutExpired(argv, 60) + return real(argv, *a, **kw) + + monkeypatch.setattr(ledger.subprocess, "run", broken) + o = {r.sha: r for r in ledger.outcomes(repo, f"{base}..HEAD")}[sha] + assert o.added == 4 and o.computed == 1 and o.surviving == 1 + assert o.files_uncomputed == 2 and o.coverage == 0.25 and o.survival == 1.0 From 326f7a7bdd0fcea417348c6253806de406caf208 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 19:10:43 -0400 Subject: [PATCH 20/26] Reapply "fix: the local owner gets a participant identity" This reverts commit 2edaee25cbe878cd57df02f0e46421d620226964. --- packages/cli/quorus_cli/cli.py | 29 +++- packages/cli/quorus_cli/invites_cmd.py | 4 +- packages/cli/quorus_cli/room_add.py | 2 +- packages/tui/quorus_tui/hub.py | 77 +++++++-- tests/test_h4_owner_identity.py | 207 +++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 21 deletions(-) create mode 100644 tests/test_h4_owner_identity.py diff --git a/packages/cli/quorus_cli/cli.py b/packages/cli/quorus_cli/cli.py index d6420bff..234c3bc5 100644 --- a/packages/cli/quorus_cli/cli.py +++ b/packages/cli/quorus_cli/cli.py @@ -343,6 +343,17 @@ def _auth_headers() -> dict[str, str]: return {"Authorization": f"Bearer {RELAY_SECRET}"} +def _admin_headers() -> dict[str, str]: + """The operator credential for admin routes (share, invites --revoke, kick, + destroy, rename, room add): the relay secret when this profile has one, else + the same login as everything else. Since 0.6.4 a local owner also holds a + participant key for everyday identity (H4); that key is a member, never an + admin, so these commands must not send it when the secret is at hand.""" + if RELAY_SECRET: + return {"Authorization": f"Bearer {RELAY_SECRET}"} + return _auth_headers() + + def _get_sse_token(recipient: str) -> str: """Get a short-lived SSE stream token.""" headers = _auth_headers() @@ -3570,7 +3581,7 @@ async def _kick(room_name: str, participant: str) -> None: resp = await client.post( f"{RELAY_URL}/rooms/{room['id']}/kick", json={"participant": participant, "requested_by": INSTANCE_NAME}, - headers=_auth_headers(), + headers=_admin_headers(), ) resp.raise_for_status() console.print(f"[green]Kicked '{participant}' from {room_name}[/green]") @@ -3596,7 +3607,7 @@ async def _destroy(room_name: str) -> None: "DELETE", f"{RELAY_URL}/rooms/{room['id']}", json={"requested_by": INSTANCE_NAME}, - headers=_auth_headers(), + headers=_admin_headers(), ) resp.raise_for_status() console.print(f"[green]Room '{room_name}' destroyed[/green]") @@ -3621,7 +3632,7 @@ async def _rename(room_name: str, new_name: str) -> None: resp = await client.patch( f"{RELAY_URL}/rooms/{room['id']}", json={"new_name": new_name, "requested_by": INSTANCE_NAME}, - headers=_auth_headers(), + headers=_admin_headers(), ) resp.raise_for_status() console.print(f"[green]Room renamed: {room_name} -> {new_name}[/green]") @@ -5887,7 +5898,7 @@ def _cmd_share(args): try: resp = httpx.post( f"{RELAY_URL}/v1/join/mint", - headers=_auth_headers(), + headers=_admin_headers(), json={"room": room, "ttl_days": ttl_days, "max_uses": uses, "role": "observer" if getattr(args, "observer", False) else "member"}, timeout=15, @@ -9027,6 +9038,16 @@ def _cmd_whoami(args) -> None: _ui.console.print(f" [bold]@{data.get('instance_name', '?')}[/]") _ui.console.print(f" [dim]workspace:[/] {label} [dim]({slug})[/]") _ui.console.print(f" [dim]relay:[/] {data.get('relay_url', '?')}") + has_key, has_secret = bool(data.get("api_key")), bool(data.get("relay_secret")) + if has_key and has_secret: + signs = "participant key (your identity) · relay secret (operator, for share/kick)" + elif has_key: + signs = "participant key" + elif has_secret: + signs = "relay secret only (no identity of your own: social verbs need one)" + else: + signs = "nothing yet" + _ui.console.print(f" [dim]signs in with:[/] {signs}") def _force_utf8_output() -> None: diff --git a/packages/cli/quorus_cli/invites_cmd.py b/packages/cli/quorus_cli/invites_cmd.py index b6ad7c73..0d0de99a 100644 --- a/packages/cli/quorus_cli/invites_cmd.py +++ b/packages/cli/quorus_cli/invites_cmd.py @@ -41,10 +41,10 @@ def cmd_invites(args) -> int: revoke = getattr(args, "revoke", None) try: if revoke: - resp = httpx.post(f"{base}/{revoke}/revoke", headers=cli._auth_headers(), + resp = httpx.post(f"{base}/{revoke}/revoke", headers=cli._admin_headers(), timeout=15, follow_redirects=False) else: - resp = httpx.get(base, headers=cli._auth_headers(), timeout=15, + resp = httpx.get(base, headers=cli._admin_headers(), timeout=15, follow_redirects=False) except httpx.HTTPError: ui.error_with_retry("Can't reach relay", relay_url=cli.RELAY_URL) diff --git a/packages/cli/quorus_cli/room_add.py b/packages/cli/quorus_cli/room_add.py index 40f63fcf..117692f5 100644 --- a/packages/cli/quorus_cli/room_add.py +++ b/packages/cli/quorus_cli/room_add.py @@ -26,7 +26,7 @@ async def add_members(room: str, members: list[str]) -> int: try: resp = await client.post(f"{cli.RELAY_URL}/rooms/{room}/join", json={"participant": name}, - headers=cli._auth_headers()) + headers=cli._admin_headers()) except httpx.ConnectError as exc: cli._relay_unreachable(exc=exc) return len(members) diff --git a/packages/tui/quorus_tui/hub.py b/packages/tui/quorus_tui/hub.py index ea11903a..748cb4ce 100644 --- a/packages/tui/quorus_tui/hub.py +++ b/packages/tui/quorus_tui/hub.py @@ -505,6 +505,12 @@ def _first_launch_setup(console: Console) -> dict: console.print("[success]✓[/]" if relay_ok else "[error]✗[/]") if note: console.print(f" [dim]{note}[/]") + if relay_ok: + # The human gets a normal participant claim, like every invitee: the + # secret alone names nobody, so social verbs, owner memory and whoami + # had no identity to work with (0.6.3 audit, BUG H4). The secret stays + # the operator credential for share, revoke, kick and destroy. + api_key = _claim_human_key(relay_url, secret, name, console) if not relay_ok: if raw: @@ -541,6 +547,53 @@ def _first_launch_setup(console: Console) -> dict: return profile +def _claim_human_key(relay_url: str, secret: str, name: str, console: Console) -> str: + """Claim *name* for the human on their own relay and return its participant + key, or "" when the claim fails. A name already claimed is never taken over + (Codex, H4 design): the wizard says how to recover and goes on with the + operator credential, so nothing that worked before stops working.""" + try: + r = httpx.post(f"{relay_url}/v1/auth/claim-name", json={"name": name}, + headers={"Authorization": f"Bearer {secret}"}, timeout=10, + follow_redirects=False) + except Exception: + return "" + if r.status_code == 200: + return str((r.json() or {}).get("participant_key") or "") + if r.status_code == 409: + console.print( + f" [warning]The name @{name} is already claimed on this relay.[/] [dim]Its " + f"owner can free it with [accent]quorus release-name {name}[/]; until then " + "you can read and post, but /vote, /claim and the other verbs need a " + "name of your own.[/]" + ) + return "" + + +def session_credentials(profile: dict) -> tuple[str, str]: + """(everyday identity, operator secret) for a hub session. + + The identity is the profile's participant or API key when it has one, else + the relay secret (a pre-0.6.4 local setup). The operator secret is the relay + secret or "", and only share, delete and other admin actions use it (H4, + Codex's design: a participant key must never carry implicit admin authority, + and the secret is not proof of authorship).""" + api_key_val = (profile.get("api_key") or "").strip() + legacy_secret = (profile.get("relay_secret") or "").strip() + return (api_key_val or legacy_secret, legacy_secret) + + +# The operator credential of the running session, for admin actions only. +_ADMIN_SECRET = "" + + +def _operator_credential(secret: str) -> str: + """The credential for an admin route: the session's relay secret when it has + one, else whatever the caller holds (an invitee's key, which the relay then + refuses with a clear 403).""" + return _ADMIN_SECRET or secret + + # ── API helpers ─────────────────────────────────────────────────────────────── # Cache for exchanged JWT tokens (api_key -> jwt) @@ -896,7 +949,8 @@ def _destroy_room( r = httpx.request( "DELETE", f"{relay}/rooms/{room}", - headers={**_auth_headers(secret), "Content-Type": "application/json"}, + headers={**_auth_headers(_operator_credential(secret)), + "Content-Type": "application/json"}, content=json.dumps({"requested_by": requested_by}), timeout=5, follow_redirects=True, @@ -934,7 +988,7 @@ def _mint_join_token(relay_url: str, secret: str, room: str) -> str: try: r = httpx.post( f"{relay_url.rstrip('/')}/v1/join/mint", - headers=_auth_headers(secret), + headers=_auth_headers(_operator_credential(secret)), json={"room": room, "ttl_days": 7}, timeout=5, follow_redirects=False, @@ -3935,19 +3989,14 @@ def _run_session( relay_url: str = profile.get("relay_url", DEFAULT_RELAY).rstrip("/") agent_name: str = profile.get("instance_name", "agent") - # Auth precedence: prefer api_key over legacy relay_secret. The - # opposite order silently breaks the TUI when a profile carries - # BOTH (common when a fresh `quorus init --api-key` runs against a - # profile that still has an old dev `relay_secret: "quorus-dev"`). - # Production relay rejects the legacy secret and the user sees - # "No rooms yet" with no error. Production api_keys are prefixed - # `mct_` and `_get_auth_token` exchanges them for a JWT. + # Auth precedence: the key (participant or API) is the everyday identity; + # the relay secret, when the profile has one, is kept apart as the operator + # credential for admin actions (H4). A profile with only a secret works as + # before. A hosted `mct_` key next to a stale dev secret used to break the + # TUI silently when the secret won. + global _ADMIN_SECRET + raw_secret, _ADMIN_SECRET = session_credentials(profile) api_key_val = (profile.get("api_key") or "").strip() - legacy_secret = (profile.get("relay_secret") or "").strip() - if api_key_val.startswith("mct_"): - raw_secret: str = api_key_val - else: - raw_secret = legacy_secret or api_key_val workspace_label: str = profile.get("workspace_label") or "" # Identity disambiguation: when the active profile is for an AGENT diff --git a/tests/test_h4_owner_identity.py b/tests/test_h4_owner_identity.py new file mode 100644 index 00000000..5548d5e0 --- /dev/null +++ b/tests/test_h4_owner_identity.py @@ -0,0 +1,207 @@ +"""BUG H4 (0.6.3 hub audit): the human on the default local setup had only the relay +secret, so every social verb answered 403 "legacy auth not permitted". Fix, to +Codex's design: the wizard claims a normal participant key for the human (no takeover +on a collision), the hub and CLI use it as the everyday identity, and the secret stays +the operator credential for share, revoke, kick and destroy. +""" +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient + +from quorus.auth.participant_keys import ParticipantKeyStore +from quorus.relay import _reset_state, app + +ADMIN = {"Authorization": "Bearer test-secret"} + + +# ── relay: a human's key is a member identity, the secret stays the operator ────── + + +@pytest.fixture +async def client(tmp_path): + _reset_state() + app.state.participant_keys = ParticipantKeyStore(tmp_path / "relay") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: + yield c + _reset_state() + + +async def _human(c: AsyncClient, name: str) -> dict: + r = await c.post("/v1/auth/claim-name", json={"name": name}, headers=ADMIN) + assert r.status_code == 200, r.text + r = await c.post("/v1/auth/token", json={"api_key": r.json()["participant_key"]}) + assert r.status_code == 200, r.text + return {"Authorization": f"Bearer {r.json()['token']}"} + + +async def test_the_owners_key_votes_where_the_secret_could_not(client) -> None: + alice = await _human(client, "alice") + r = await client.post("/rooms", json={"name": "general", "created_by": "alice"}, headers=alice) + assert r.status_code == 200, r.text + rid = r.json()["id"] + body = {"actor": "alice", "room_id": rid, + "payload": {"task_id": "t-1", "eta_seconds": 600, "scope": "audit"}} + assert (await client.post("/v1/social/claim", json=body, headers=ADMIN)).status_code == 403 + r = await client.post("/v1/social/claim", json=body, headers=alice) + assert r.status_code == 200, r.text + assert r.json()["envelope"]["verb"] == "claim" + + +async def test_the_key_is_a_member_not_an_operator(client) -> None: + alice = await _human(client, "alice") + r = await client.post("/rooms", json={"name": "general", "created_by": "alice"}, headers=alice) + assert r.status_code == 200 + # share (mint) stays an operator action: the secret may, the key may not. + assert (await client.post("/v1/join/mint", json={"room": "general"}, + headers=alice)).status_code == 403 + assert (await client.post("/v1/join/mint", json={"room": "general"}, + headers=ADMIN)).status_code == 200 + + +async def test_a_claimed_name_is_never_taken_over(client) -> None: + await _human(client, "alice") + r = await client.post("/v1/auth/claim-name", json={"name": "alice"}, headers=ADMIN) + assert r.status_code == 409 and "release-name" in r.text + + +# ── wizard: the local path claims the key; a collision says how to recover ──────── + + +class _Resp: + def __init__(self, status: int, body: dict | None = None, text: str = ""): + self.status_code, self._body, self.text = status, body or {}, text + + def json(self): + return self._body + + +def _wizard(monkeypatch, tmp_path: Path, claim: _Resp, capsys): + from quorus_tui import hub + from rich.console import Console + + from quorus.runtime import local_relay + + monkeypatch.setenv("QUORUS_CONFIG_DIR", str(tmp_path / ".quorus")) + monkeypatch.setenv("RELAY_URL", "http://127.0.0.1:1") + monkeypatch.setattr(hub, "_prompt_name", lambda console: "alice") + monkeypatch.setattr(hub.Prompt, "ask", staticmethod(lambda *a, **k: "")) + monkeypatch.setattr(local_relay, "ensure_local_relay", lambda url, secret: (True, "")) + calls: list[dict] = [] + + def fake_post(url, **kw): + calls.append({"url": url, **kw}) + return claim + + monkeypatch.setattr(hub.httpx, "post", fake_post) + saved: list[dict] = [] + monkeypatch.setattr(hub, "_save_instance_config", + lambda name, relay_url, secret="", api_key="": saved.append( + {"name": name, "secret": secret, "api_key": api_key})) + with open(tmp_path / "out.txt", "w") as fh: + profile = hub._first_launch_setup(Console(file=fh, width=400)) + out = (tmp_path / "out.txt").read_text() + return profile, calls, out, saved + + +def test_the_wizards_local_path_gives_the_human_a_participant_key(monkeypatch, tmp_path, capsys): + profile, calls, _, saved = _wizard( + monkeypatch, tmp_path, _Resp(200, {"name": "alice", "participant_key": "quorus_pk_x"}), + capsys) + [call] = calls + assert call["url"].endswith("/v1/auth/claim-name") and call["json"] == {"name": "alice"} + assert call["headers"]["Authorization"].startswith("Bearer ") and \ + call["headers"]["Authorization"] != "Bearer " + assert profile["api_key"] == "quorus_pk_x" and profile["relay_secret"] + [cfg] = saved + assert cfg == {"name": "alice", "secret": profile["relay_secret"], "api_key": "quorus_pk_x"} + + +def test_a_taken_name_is_explained_not_taken_over(monkeypatch, tmp_path, capsys): + profile, _, out, saved = _wizard(monkeypatch, tmp_path, _Resp(409, text="claimed"), capsys) + assert profile["api_key"] == "" and profile["relay_secret"] and saved[0]["api_key"] == "" + assert "already claimed" in out and "quorus release-name alice" in out + + +# ── hub session: the key is the identity, the secret the operator credential ───── + + +def test_session_credentials_split_identity_from_operator(): + from quorus_tui import hub + + assert hub.session_credentials({"api_key": "quorus_pk_x", "relay_secret": "s"}) == \ + ("quorus_pk_x", "s") + assert hub.session_credentials({"relay_secret": "s"}) == ("s", "s") # pre-0.6.4 setup + assert hub.session_credentials({"api_key": "quorus_pk_x"}) == ("quorus_pk_x", "") # invitee + hosted = {"api_key": "mct_h", "relay_secret": "stale"} + assert hub.session_credentials(hosted) == ("mct_h", "stale") + + +def test_the_hub_mints_and_destroys_with_the_operator_credential(monkeypatch): + from quorus_tui import hub + + seen: list[dict] = [] + + class _C: + def __init__(self, status): + self.status_code = status + + def json(self): + return {"code": "ABCD-EFGH", "install_url": "http://r/i.sh"} + + def raise_for_status(self): + return None + + def fake_post(url, **kw): + seen.append(kw) + return _C(200) + + monkeypatch.setattr(hub.httpx, "post", fake_post) + monkeypatch.setattr(hub, "_ADMIN_SECRET", "operator-secret") + hub._mint_join_token("http://r", "identity-jwt", "general") + assert seen and seen[-1]["headers"]["Authorization"] == "Bearer operator-secret" + monkeypatch.setattr(hub, "_ADMIN_SECRET", "") + hub._mint_join_token("http://r", "identity-jwt", "general") + # an invitee has no operator secret: the key goes and the relay answers 403 + assert seen[-1]["headers"]["Authorization"] == "Bearer identity-jwt" + + +# ── CLI: admin commands send the operator credential, everything else the key ─── + + +def test_cli_admin_headers_prefer_the_relay_secret(monkeypatch): + from quorus_cli import cli + + monkeypatch.setattr(cli, "RELAY_SECRET", "s") + monkeypatch.setattr(cli, "API_KEY", "quorus_pk_x") + monkeypatch.setattr(cli, "_cached_jwt", "jwt-for-key") + assert cli._admin_headers() == {"Authorization": "Bearer s"} + assert cli._auth_headers() == {"Authorization": "Bearer jwt-for-key"} + monkeypatch.setattr(cli, "RELAY_SECRET", "") + assert cli._admin_headers() == {"Authorization": "Bearer jwt-for-key"} + + +def test_whoami_says_which_credential_signs_in(monkeypatch, capsys): + from quorus_cli import cli + + class _PM: + def current(self): + return "default" + + def list(self): + return ["default"] + + def get(self, slug): + return {"instance_name": "alice", "relay_url": "http://r", + "api_key": "quorus_pk_x", "relay_secret": "s"} + + import quorus.profiles as profiles + + monkeypatch.setattr(profiles, "ProfileManager", _PM) + cli._cmd_whoami(SimpleNamespace(workspace=None)) + out = capsys.readouterr().out + assert "participant key (your identity)" in out and "relay secret (operator" in out + assert "quorus_pk_x" not in out and "Bearer" not in out From dd7ede8330e0ecf1ed097c9108bfa397ff5fb628 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 19:10:48 -0400 Subject: [PATCH 21/26] Reapply "fix: wizard config path resolves at call time" This reverts commit 8387d46b05e7f411f0ef7f0eb945dca33e3ee85c. --- packages/tui/quorus_tui/hub.py | 9 +++++++-- tests/test_h4_owner_identity.py | 15 +++++++++++---- tests/test_tui_hub.py | 7 +++---- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/tui/quorus_tui/hub.py b/packages/tui/quorus_tui/hub.py index 748cb4ce..da025287 100644 --- a/packages/tui/quorus_tui/hub.py +++ b/packages/tui/quorus_tui/hub.py @@ -186,8 +186,13 @@ def _save_instance_config( secret: str = "", api_key: str = "", ) -> None: - """Persist the TUI's instance config through the shared ConfigManager.""" - ConfigManager(CONFIG_FILE).save( + """Persist the TUI's instance config through the shared ConfigManager. + + The path is resolved now, not at import: ``CONFIG_FILE`` is fixed when the + module loads, so a ``QUORUS_CONFIG_DIR`` set later (a test, a wrapper that + picks the dir before calling in) was ignored and the wizard wrote into the + real ``~/.quorus`` (2026-10-20: a test overwrote the overseer's config).""" + ConfigManager(resolve_config_dir() / "config.json").save( { "relay_url": relay_url.rstrip("/"), "instance_name": name, diff --git a/tests/test_h4_owner_identity.py b/tests/test_h4_owner_identity.py index 5548d5e0..7dc7146d 100644 --- a/tests/test_h4_owner_identity.py +++ b/tests/test_h4_owner_identity.py @@ -6,6 +6,7 @@ """ from __future__ import annotations +import json from pathlib import Path from types import SimpleNamespace @@ -85,6 +86,10 @@ def _wizard(monkeypatch, tmp_path: Path, claim: _Resp, capsys): from quorus.runtime import local_relay + # Everything the wizard could write goes under tmp_path: HOME as well as the + # config dir (2026-10-20: this fixture once rewrote the overseer's real + # ~/.quorus because the hub resolved its config path at import time). + monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("QUORUS_CONFIG_DIR", str(tmp_path / ".quorus")) monkeypatch.setenv("RELAY_URL", "http://127.0.0.1:1") monkeypatch.setattr(hub, "_prompt_name", lambda console: "alice") @@ -97,13 +102,15 @@ def fake_post(url, **kw): return claim monkeypatch.setattr(hub.httpx, "post", fake_post) - saved: list[dict] = [] - monkeypatch.setattr(hub, "_save_instance_config", - lambda name, relay_url, secret="", api_key="": saved.append( - {"name": name, "secret": secret, "api_key": api_key})) with open(tmp_path / "out.txt", "w") as fh: profile = hub._first_launch_setup(Console(file=fh, width=400)) out = (tmp_path / "out.txt").read_text() + # What the wizard really saved, wherever ConfigManager put it under tmp_path. + saved = [{"name": d["instance_name"], "secret": d.get("relay_secret", ""), + "api_key": d.get("api_key", "")} + for f in sorted((tmp_path / ".quorus").rglob("*.json")) + for d in [json.loads(f.read_text())] + if isinstance(d, dict) and "instance_name" in d] return profile, calls, out, saved diff --git a/tests/test_tui_hub.py b/tests/test_tui_hub.py index 160bc6bb..29f0019b 100644 --- a/tests/test_tui_hub.py +++ b/tests/test_tui_hub.py @@ -298,8 +298,8 @@ def test_save_instance_config(tmp_path, monkeypatch): """_save_instance_config writes config through ConfigManager.""" cfg_dir = tmp_path / ".quorus" cfg_file = cfg_dir / "config.json" - monkeypatch.setattr(tui_hub, "CONFIG_DIR", cfg_dir) - monkeypatch.setattr(tui_hub, "CONFIG_FILE", cfg_file) + # The save resolves its path at call time (never the import-time constant). + monkeypatch.setenv("QUORUS_CONFIG_DIR", str(cfg_dir)) _save_instance_config("mybot", "http://relay:8080", "topsecret") @@ -317,8 +317,7 @@ def test_save_instance_config_strips_trailing_slash(tmp_path, monkeypatch): """Trailing slash on relay URL is stripped before saving.""" cfg_dir = tmp_path / ".quorus" cfg_file = cfg_dir / "config.json" - monkeypatch.setattr(tui_hub, "CONFIG_DIR", cfg_dir) - monkeypatch.setattr(tui_hub, "CONFIG_FILE", cfg_file) + monkeypatch.setenv("QUORUS_CONFIG_DIR", str(cfg_dir)) _save_instance_config("bot", "http://relay:8080/", "secret") From 8472fe1d466e62228bcb6c0867184bb50e4ac96a Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 19:10:48 -0400 Subject: [PATCH 22/26] Reapply "fix: hosted login never yields to a stale secret" This reverts commit 9849aa4580388466da9e4923c2b3aeec159bbf4d. --- packages/cli/quorus_cli/cli.py | 7 +++++-- packages/tui/quorus_tui/hub.py | 4 ++++ tests/test_h4_owner_identity.py | 10 +++++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/cli/quorus_cli/cli.py b/packages/cli/quorus_cli/cli.py index 234c3bc5..b533b8fe 100644 --- a/packages/cli/quorus_cli/cli.py +++ b/packages/cli/quorus_cli/cli.py @@ -348,8 +348,11 @@ def _admin_headers() -> dict[str, str]: destroy, rename, room add): the relay secret when this profile has one, else the same login as everything else. Since 0.6.4 a local owner also holds a participant key for everyday identity (H4); that key is a member, never an - admin, so these commands must not send it when the secret is at hand.""" - if RELAY_SECRET: + admin, so these commands must not send it when the secret is at hand. A hosted + login (`mct_` API key) is the exception: its role carries the admin right and + the hosted relay refuses a legacy secret, so a stale dev secret left in the + profile must never win over it (Codex, review of 2c0bc92a).""" + if RELAY_SECRET and not API_KEY.startswith("mct_"): return {"Authorization": f"Bearer {RELAY_SECRET}"} return _auth_headers() diff --git a/packages/tui/quorus_tui/hub.py b/packages/tui/quorus_tui/hub.py index da025287..e5e5a4f8 100644 --- a/packages/tui/quorus_tui/hub.py +++ b/packages/tui/quorus_tui/hub.py @@ -585,6 +585,10 @@ def session_credentials(profile: dict) -> tuple[str, str]: and the secret is not proof of authorship).""" api_key_val = (profile.get("api_key") or "").strip() legacy_secret = (profile.get("relay_secret") or "").strip() + if api_key_val.startswith("mct_"): + # A hosted login carries its own role; a stale dev secret next to it would + # only be refused by the hosted relay (Codex, review of 2c0bc92a). + return (api_key_val, "") return (api_key_val or legacy_secret, legacy_secret) diff --git a/tests/test_h4_owner_identity.py b/tests/test_h4_owner_identity.py index 7dc7146d..65331eef 100644 --- a/tests/test_h4_owner_identity.py +++ b/tests/test_h4_owner_identity.py @@ -143,8 +143,10 @@ def test_session_credentials_split_identity_from_operator(): ("quorus_pk_x", "s") assert hub.session_credentials({"relay_secret": "s"}) == ("s", "s") # pre-0.6.4 setup assert hub.session_credentials({"api_key": "quorus_pk_x"}) == ("quorus_pk_x", "") # invitee + # Codex, review of 2c0bc92a: a hosted login never falls back to a stale dev secret, + # not even for admin actions; the hosted relay would refuse it. hosted = {"api_key": "mct_h", "relay_secret": "stale"} - assert hub.session_credentials(hosted) == ("mct_h", "stale") + assert hub.session_credentials(hosted) == ("mct_h", "") def test_the_hub_mints_and_destroys_with_the_operator_credential(monkeypatch): @@ -189,6 +191,12 @@ def test_cli_admin_headers_prefer_the_relay_secret(monkeypatch): assert cli._auth_headers() == {"Authorization": "Bearer jwt-for-key"} monkeypatch.setattr(cli, "RELAY_SECRET", "") assert cli._admin_headers() == {"Authorization": "Bearer jwt-for-key"} + # A hosted `mct_` login with a stale dev secret in the profile: the key's JWT + # is the admin credential, never the secret (Codex, review of 2c0bc92a). + monkeypatch.setattr(cli, "RELAY_SECRET", "quorus-dev") + monkeypatch.setattr(cli, "API_KEY", "mct_hosted") + monkeypatch.setattr(cli, "_cached_jwt", "jwt-for-hosted") + assert cli._admin_headers() == {"Authorization": "Bearer jwt-for-hosted"} def test_whoami_says_which_credential_signs_in(monkeypatch, capsys): From 54d37a6c44217f22ce004b9397a8369bcfbe3b7d Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 19:10:48 -0400 Subject: [PATCH 23/26] Revert "chore: hold H4 out of 0.6.4" This reverts commit 8e81fa532de3fe3ec7617e546fb80aaf086096d1. --- CHANGELOG.md | 7 +++++++ tests/test_room_add.py | 1 + 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab298a32..17cafbb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ agents in one room. ### Fixed +- On the default local setup (Enter in the wizard) the human held only the relay secret, + so every social verb in the hub (`/vote`, `/claim`, `/disagree`, …) answered `403 legacy + auth not permitted`. The wizard now claims a normal participant key for you on your own + relay; the hub and CLI sign in with it, and the secret stays the operator credential for + share, invites --revoke, kick, destroy and rename (a hosted login keeps its own). A name + already claimed is never taken over: the wizard says how to recover. `quorus whoami` says + which credential signs in. - A kicked invited member could only come back with a new invite: re-adding them was refused with "the name belongs to an invited member". The room's creator or an admin can now put an invitee back in the room their own invite was for, at that invite's role, unless the invite diff --git a/tests/test_room_add.py b/tests/test_room_add.py index f782015e..de44fc27 100644 --- a/tests/test_room_add.py +++ b/tests/test_room_add.py @@ -25,6 +25,7 @@ async def relay(tmp_path, monkeypatch): headers = dict(ADMIN) monkeypatch.setattr(cli, "RELAY_URL", "http://t") monkeypatch.setattr(cli, "_auth_headers", lambda: headers) + monkeypatch.setattr(cli, "_admin_headers", lambda: headers) # room add sends this (H4) monkeypatch.setattr(cli, "_get_client", lambda: AsyncClient( transport=ASGITransport(app=app), base_url="http://t")) async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c: From c7c3a2943524c407e067536986adf9555cb28c71 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 19:12:48 -0400 Subject: [PATCH 24/26] fix: own relay isn't a teammate's (H4 port) Port for 0.6.5 per docs/internal/release-0.6.5-plan.md E, with no owner-signature code: - quorus/runtime/own_relays.py from 9971eeb7 (blob 53f320f5). - relay writer: own_relay_urls, record_own_relay and the startup call from 9971eeb7:quorus/relay.py (blob 24ec60dd); docstring drops the signature wording. - 54da38fe's agent_cmd (_on_teammates_relay, the owner's own-relay credential) and hub (_invited_elsewhere) hunks, owner_sig replaced by own_relays; its triage part is already in 0.6.4 (e453d030). - tests: 54da38fe's agent_cmd regressions patched to own_relays, and test_own_relays (9971eeb7 minus owner_sig re-exports, plus the relay writer test from test_owner_sig_daemon). Co-Authored-By: Claude Opus 5.5 --- packages/cli/quorus_cli/agent_cmd.py | 22 +++++++++- packages/tui/quorus_tui/hub.py | 10 ++++- quorus/relay.py | 41 ++++++++++++++++++- quorus/runtime/own_relays.py | 61 ++++++++++++++++++++++++++++ tests/test_agent_cmd.py | 29 ++++++++++++- tests/test_own_relays.py | 42 +++++++++++++++++++ 6 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 quorus/runtime/own_relays.py create mode 100644 tests/test_own_relays.py diff --git a/packages/cli/quorus_cli/agent_cmd.py b/packages/cli/quorus_cli/agent_cmd.py index d8c2fd06..9d602793 100644 --- a/packages/cli/quorus_cli/agent_cmd.py +++ b/packages/cli/quorus_cli/agent_cmd.py @@ -123,6 +123,13 @@ def _serves_relay(pid: str, relay_url: str) -> bool: def _credentials(cfg: dict[str, Any], name: str, harness: str) -> tuple[str, bool]: """(credential, legacy) for the agent. Account relays mint a key per agent (cached 0600 so re-runs reuse it); shared-secret relays use the secret.""" + if (cfg.get("relay_secret") and (cfg.get("api_key") or "").startswith("quorus_pk_") + and not _on_teammates_relay(cfg)): + # H4: on their own relay the owner holds a participant key for who they + # are and the secret for running the relay. Their agents keep the secret, + # as before H4: the key path is for invited members' agents, and the + # relay refused it here ("only an invited person's own key can add agents"). + return cfg["relay_secret"], True if cfg.get("api_key"): cache = _agents_dir() / f"{name}.json" if cache.exists(): @@ -145,6 +152,19 @@ def _credentials(cfg: dict[str, Any], name: str, harness: str) -> tuple[str, boo raise SystemExit("agent: not configured — run `quorus init` or `quorus join` first") +def _on_teammates_relay(cfg: dict[str, Any]) -> bool: + """This profile signs in with a participant key to a relay this machine + didn't start. The key alone no longer says so: since H4 the relay's own + owner signs in with one too, and was shown the teammate note and pinned + as an invitee on their own relay (0.6.3 audit replay).""" + if not (cfg.get("api_key") or "").startswith("quorus_pk_"): + return False + from quorus.runtime import own_relays + + mine = own_relays.normalize_relay(cfg.get("relay_url") or "") + return not (mine and mine in own_relays.own_relays(own_relays.own_relays_path())) + + def _claim_member_agent(cfg: dict[str, Any], name: str) -> str: """An invited member's own key claims *name* as their agent on a teammate's shared-secret relay (quorus/auth/member_agents.py). Before this, an invitee @@ -438,7 +458,7 @@ def _add(args: Any, console: Any) -> None: raise SystemExit(f"agent: {flag} applies to the room's workspace, and " f"#{args.room} has none on this machine yet; add " f"--repo ") - member = (cfg.get("api_key") or "").startswith("quorus_pk_") + member = _on_teammates_relay(cfg) mode = args.mode if (member and repo is not None and mode == "autonomous" and not getattr(args, "disclosed", False)): diff --git a/packages/tui/quorus_tui/hub.py b/packages/tui/quorus_tui/hub.py index e5e5a4f8..88622e5d 100644 --- a/packages/tui/quorus_tui/hub.py +++ b/packages/tui/quorus_tui/hub.py @@ -631,6 +631,14 @@ def _exchange_api_key_for_jwt(relay: str, api_key: str) -> str | None: return None +def _invited_elsewhere(api_key: str, relay_url: str) -> bool: + """A participant key on a relay this machine didn't start: an invitee. Since + H4 the relay's own owner holds a key too, so the key alone doesn't say.""" + from quorus_cli.agent_cmd import _on_teammates_relay + + return _on_teammates_relay({"api_key": api_key, "relay_url": relay_url}) + + def _ask_relay_address(console: Console, tries: int = 3) -> str: """Ask for the teammate's relay address until it looks like one (http(s)://host[:port]); empty after *tries* bad answers.""" @@ -4079,7 +4087,7 @@ def _run_session( "[dim]Is it running? Try: quorus relay[/]" ) # Any other failure → render the empty-state hint from the panel. - elif announce_ready and profile.get("room") and api_key_val.startswith("quorus_pk_"): + elif announce_ready and profile.get("room") and _invited_elsewhere(api_key_val, relay_url): # An invitee's first run: the teammate's relay already has rooms, so the # blank-slate offer never ran and their agent CLIs went unmentioned (bet 1's # invitee persona). Offer them for the room the invite was for. diff --git a/quorus/relay.py b/quorus/relay.py index c34cfff4..0a123191 100644 --- a/quorus/relay.py +++ b/quorus/relay.py @@ -948,7 +948,46 @@ def main() -> None: # SSE clients (wake daemons, MCP servers) never close on their own, so a # graceful shutdown with no cap waited forever and a launchd/systemd # restart of the relay hung (found 2026-10-08). Cap it. - uvicorn.run(app, host=host, port=port, timeout_graceful_shutdown=5) + server = uvicorn.Server(uvicorn.Config(app, host=host, port=port, + timeout_graceful_shutdown=5)) + + async def serve() -> None: + task = asyncio.ensure_future(server.serve()) + while not server.started and not task.done(): + await asyncio.sleep(0.05) + if server.started: + bound = [sock.getsockname()[:2] for srv in getattr(server, "servers", []) + for sock in (srv.sockets or [])] + await asyncio.to_thread(record_own_relay, bound) + await task + + asyncio.run(serve()) + + +def own_relay_urls(bound: list[tuple[str, int]]) -> set[str]: + """The loopback URLs that reach *only* the sockets this relay bound: an exact + 127.0.0.1 or ::1 bind. A wildcard bind proves nothing, because another process's + specific loopback bind on the same port wins its connections (BSD semantics), and + neither does a bind to another interface (codex, review of 380372e6). `localhost` + counts only when both loopbacks are bound, since it can resolve to either.""" + v4 = {p for h, p in bound if h == "127.0.0.1"} + v6 = {p for h, p in bound if h == "::1"} + urls = {f"http://127.0.0.1:{p}" for p in v4} | {f"http://[::1]:{p}" for p in v6} + return urls | {f"http://localhost:{p}" for p in v4 & v6} + + +def record_own_relay(bound: list[tuple[str, int]]) -> None: + """This machine's relay is now listening on *bound*: record the loopback URLs + those sockets serve, so the owner's own relay isn't mistaken for a teammate's + (quorus/runtime/own_relays.py). Only a relay this process started gets here; + finding someone else's listener, or joining a remote relay, never does (codex).""" + from quorus.runtime import own_relays + + for url in sorted(own_relay_urls(bound)): + try: + own_relays.add_own_relay(own_relays.own_relays_path(), url) + except Exception as exc: # never stop a relay over its trust note + logger.warning("couldn't record own relay", url=url, error=str(exc)) # --------------------------------------------------------------------------- diff --git a/quorus/runtime/own_relays.py b/quorus/runtime/own_relays.py new file mode 100644 index 00000000..53f320f5 --- /dev/null +++ b/quorus/runtime/own_relays.py @@ -0,0 +1,61 @@ +"""Relays this machine's owner runs, recorded where this machine starts one. + +The record says "this relay is mine": on it the owner's own name is trusted +without a signature (owner_sig), and an owner who signs in with a participant +key isn't shown a teammate's relay (agent_cmd). Split out of owner_sig so the +record needs nothing from the signing code. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from urllib.parse import urlsplit + + +def normalize_relay(url: str) -> str: + """One string per relay, on ``config.same_relay``'s rules: scheme, host, port + (the default one written out) and base path all count, so ``/alice`` and + ``/mallory`` on one host are different relays (codex, review of 86972324). An + IPv6 host keeps its brackets, so ``[::1]:8080`` can't read as ``[::1:8080]``. + localhost and 127.0.0.1 stay different: fail closed rather than guess. + Anything malformed, or carrying credentials, a query or a fragment, is "".""" + try: + parts = urlsplit((url or "").strip()) + port = parts.port or {"http": 80, "https": 443}.get(parts.scheme.lower()) + except ValueError: # a malformed port or IPv6 literal + return "" + scheme, host = parts.scheme.lower(), (parts.hostname or "").lower() + if (scheme not in ("http", "https") or not host or parts.username is not None + or parts.password is not None or parts.query or parts.fragment): + return "" + if ":" in host: + host = f"[{host}]" + return f"{scheme}://{host}:{port}{parts.path.rstrip('/')}" + + +def own_relays_path() -> Path: + return Path.home() / ".quorus" / "own_relays.json" + + +def own_relays(path: Path) -> set[str]: + """The relays where the owner's name alone is trusted, as recorded on this + machine. Missing or unreadable: none, so every relay needs a signature.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return set() + if not isinstance(data, list): + return set() + return {u for u in (normalize_relay(str(x)) for x in data) if u} + + +def add_own_relay(path: Path, url: str) -> None: + """Record *url* as a relay this machine runs. Called only where this machine + starts its own relay, never on joining someone else's (Codex).""" + from quorus.private_files import write_private_json + + norm = normalize_relay(url) + if not norm: + raise ValueError(f"not a relay URL: {url!r}") + write_private_json(path, sorted(own_relays(path) | {norm})) diff --git a/tests/test_agent_cmd.py b/tests/test_agent_cmd.py index 8ca09a48..2577f75f 100644 --- a/tests/test_agent_cmd.py +++ b/tests/test_agent_cmd.py @@ -236,7 +236,14 @@ def invitee(env: dict, tmp_path: Path, monkeypatch) -> dict[str, Any]: """bob on alice's relay: his own member key, no secret.""" env["cfg"].update(instance_name="bob", relay_secret="", api_key="quorus_pk_bob") monkeypatch.setattr(agent_cmd, "_claim_member_agent", lambda c, name: "agent-key") - monkeypatch.setattr(agent_cmd, "_pin_owner", lambda c, name: None) + pins: list[str] = [] + monkeypatch.setattr(agent_cmd, "_pin_owner", lambda c, name: pins.append(name)) + env["pins"] = pins + from quorus.runtime import own_relays + + own = tmp_path / "own_relays.json" # never the real HOME's record + monkeypatch.setattr(own_relays, "own_relays_path", lambda: own) + env["own_relays"] = own monkeypatch.setattr(agent_cmd, "_bearer", lambda c, cred, legacy: f"jwt:{cred}") repo = tmp_path / "app" repo.mkdir() @@ -331,3 +338,23 @@ def test_mode_with_no_workspace_says_to_add_repo(env: dict, tmp_path: Path) -> N agent_cmd.cmd_agent(_Args(tool="codex", mode="default"), _Console()) assert "--mode" in str(exc.value) and "--repo" in str(exc.value) assert env["joins"] == [] and env["daemons"] == [] + + +def test_the_relay_owner_with_a_key_isnt_treated_as_an_invitee(invitee, tmp_path, + monkeypatch) -> None: + """0.6.3 audit replay: since H4 the relay's own owner signs in with a participant + key, so the key alone made her own relay look like a teammate's: the note, an + extra question, and an invitee's owner pin. Her own relays are recorded.""" + invitee["own_relays"].write_text(json.dumps([invitee["cfg"]["relay_url"]])) + monkeypatch.setattr(agent_cmd.sys.stdin, "isatty", lambda: True) + console = _Console() + agent_cmd.cmd_agent(_Args(tool="codex", repo=str(invitee["repo"]), mode="autonomous"), + console) + assert not any(agent_cmd.TEAMMATE_RELAY_NOTE in ln for ln in console.lines) + assert invitee["pins"] == [] and _bound_mode(tmp_path) == "autonomous" + + +def test_an_invitee_on_a_teammates_relay_is_still_pinned(invitee, tmp_path) -> None: + agent_cmd.cmd_agent(_Args(tool="codex", repo=str(invitee["repo"]), mode="default"), + _Console()) + assert invitee["pins"] == ["bob-codex"] diff --git a/tests/test_own_relays.py b/tests/test_own_relays.py new file mode 100644 index 00000000..4e7ba2a6 --- /dev/null +++ b/tests/test_own_relays.py @@ -0,0 +1,42 @@ +"""The own-relay record stands alone: H4's "is this my relay?" check (agent_cmd) and +the relay's own record (relay.record_own_relay) need it without any signing code. +Ported for 0.6.5 from 9971eeb7 and test_owner_sig_daemon.py.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from quorus.runtime import own_relays + + +def test_own_relays_imports_no_signing_code() -> None: + tree = ast.parse(Path(own_relays.__file__).read_text(encoding="utf-8")) + names = {a.name for n in ast.walk(tree) if isinstance(n, ast.Import) for a in n.names} + names |= {n.module or "" for n in ast.walk(tree) if isinstance(n, ast.ImportFrom)} + assert not any("sig" in n for n in names), names + + +def test_a_recorded_relay_reads_back_normalized(tmp_path: Path) -> None: + path = tmp_path / "own_relays.json" + own_relays.add_own_relay(path, "http://127.0.0.1:8787/") + assert own_relays.own_relays(path) == {"http://127.0.0.1:8787"} + + +@pytest.mark.parametrize("bound,expected", [ + ([("127.0.0.1", 8787)], {"http://127.0.0.1:8787"}), + ([("127.0.0.1", 8787), ("::1", 8787)], + {"http://127.0.0.1:8787", "http://[::1]:8787", "http://localhost:8787"}), + ([("0.0.0.0", 8787)], set()), # wildcard: a specific loopback bind elsewhere wins + ([("::", 8787)], set()), + ([("192.168.1.5", 8787)], set()), # another interface only +]) +def test_a_relay_records_only_the_loopbacks_it_bound(monkeypatch, tmp_path, + bound, expected) -> None: + from quorus import relay as relay_mod + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + relay_mod.record_own_relay(bound) + assert own_relays.own_relays(own_relays.own_relays_path()) == expected From b605476dc56bb710d88cd039a0a92a124af8afa0 Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 19:13:56 -0400 Subject: [PATCH 25/26] chore: release quorus-ai 0.6.5 Bug fixes on released paths, per docs/internal/release-0.6.5-plan.md (Codex plan GO at 1a51e568). Version 0.6.5 in every package, the relay, server.json and the Claude plugin pin. CHANGELOG: one fresh 0.6.5 entry; the H4 lines the hold-revert re-added under 0.6.4 move here. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 53 ++++++++++++++++++++--- packages/cli/pyproject.toml | 2 +- packages/mcp/pyproject.toml | 2 +- packages/sdk/pyproject.toml | 2 +- packages/tui/pyproject.toml | 2 +- plugins/quorus/.claude-plugin/plugin.json | 4 +- pyproject.toml | 2 +- quorus/__init__.py | 2 +- quorus/relay.py | 2 +- server.json | 4 +- 10 files changed, 57 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17cafbb4..f0626caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,52 @@ All notable changes to Quorus are documented here. This project follows ## [Unreleased] +## [0.6.5] — 2026-10-20 + +Bug fixes on released paths, under the release freeze. Nothing new. + +### Security + +- Room posts now mask participant keys (`quorus_pk_…`), as they already masked `mct_` and + `quorus_sk_` keys. The owner's own key sits in `~/.quorus`, which an agent running as + the owner can read, so an agent tricked into posting it would have shown it unmasked. + +### Fixed + +- On the default local setup (Enter in the wizard), the human held only the relay secret. + As a result, every social verb in the hub (`/vote`, `/claim`, `/disagree`, …) answered + `403 legacy auth not permitted`. + - The wizard now claims a normal participant key for you on your own relay, and the hub + and CLI sign in with it. + - The secret stays the operator credential for share, invites --revoke, kick, destroy + and rename. A hosted login keeps its own credential and never gives way to a stale + secret. + - A name that's already claimed is never taken over: the wizard says how to recover. + `quorus whoami` says which credential signs in. + - Your own relay isn't mistaken for a teammate's. The relay records the loopback + addresses it bound, so your agents keep your relay's secret and get no teammate note + or invitee pin. +- `quorus relay` is a wrapper around `python -m quorus.relay_cli`. + - Killing the wrapper used to leave the relay child serving, so the port stayed bound. + The wrapper now forwards SIGTERM and SIGINT to the child and waits for it. + - Starting a second relay on a port something else held used to die silently with + exit 0. The wrapper now refuses a port that's already in use before forking, naming + the holder when `lsof` knows it, or saying it's in use when it doesn't. + - The wrapper now reports the child's exit code. +- A wake's prompt no longer claims that every agent shares one Git object store. Reviewers + from another owner are told how to fetch the commit instead. +- When several agents bid on one `@open`, each loser posted a `/defer` line. Losing + bidders now stay silent. +- A wake that crashed before committing left a held window behind. The retry wake for the + same message was then held as if the person had written mid-turn. A stale window no + longer holds a retry. +- `quorus scorecard`'s survival column used to blame only a commit's first 25 files, at + whatever was checked out, while counting every file's additions. It now blames every file + at one pinned end commit and follows renames. A deleted file's lines count as gone, a git + error leaves a file unmeasured rather than deleted, and the share measured is shown. +- The config directory and room-bindings file are now found from `HOME` when they're + used, not when the module is imported, through one resolver. + ## [0.6.4] — 2026-10-20 Bug fixes on released paths, found by the 0.6.3 audit and by running several people's @@ -29,13 +75,6 @@ agents in one room. ### Fixed -- On the default local setup (Enter in the wizard) the human held only the relay secret, - so every social verb in the hub (`/vote`, `/claim`, `/disagree`, …) answered `403 legacy - auth not permitted`. The wizard now claims a normal participant key for you on your own - relay; the hub and CLI sign in with it, and the secret stays the operator credential for - share, invites --revoke, kick, destroy and rename (a hosted login keeps its own). A name - already claimed is never taken over: the wizard says how to recover. `quorus whoami` says - which credential signs in. - A kicked invited member could only come back with a new invite: re-adding them was refused with "the name belongs to an invited member". The room's creator or an admin can now put an invitee back in the room their own invite was for, at that invite's role, unless the invite diff --git a/packages/cli/pyproject.toml b/packages/cli/pyproject.toml index 406e0287..e458179b 100644 --- a/packages/cli/pyproject.toml +++ b/packages/cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quorus-cli" -version = "0.6.4" +version = "0.6.5" description = "Quorus CLI — coordination layer for AI agent swarms" requires-python = ">=3.10" license = "MIT" diff --git a/packages/mcp/pyproject.toml b/packages/mcp/pyproject.toml index e935395a..be3093e4 100644 --- a/packages/mcp/pyproject.toml +++ b/packages/mcp/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quorus-mcp" -version = "0.6.4" +version = "0.6.5" description = "Quorus MCP server — exposes inter-agent coordination tools to MCP-compatible clients like Claude Code" requires-python = ">=3.10" license = "MIT" diff --git a/packages/sdk/pyproject.toml b/packages/sdk/pyproject.toml index d7e67388..bc4f7334 100644 --- a/packages/sdk/pyproject.toml +++ b/packages/sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quorus-sdk" -version = "0.6.4" +version = "0.6.5" description = "Quorus SDK — lightweight client for the Quorus relay (rooms, messaging, coordination)" requires-python = ">=3.10" license = "MIT" diff --git a/packages/tui/pyproject.toml b/packages/tui/pyproject.toml index e5042082..eafeffcb 100644 --- a/packages/tui/pyproject.toml +++ b/packages/tui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quorus-tui" -version = "0.6.4" +version = "0.6.5" description = "Quorus TUI — full-screen terminal hub for managing rooms, agents, and live chat" requires-python = ">=3.10" license = "MIT" diff --git a/plugins/quorus/.claude-plugin/plugin.json b/plugins/quorus/.claude-plugin/plugin.json index 461e3bfe..bc431a3b 100644 --- a/plugins/quorus/.claude-plugin/plugin.json +++ b/plugins/quorus/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "quorus", "displayName": "Quorus", - "version": "0.6.4", + "version": "0.6.5", "description": "Put this Claude Code session in a Quorus room: a shared group chat with other coding agents (Codex, Gemini CLI, opencode, more Claude sessions) and the humans running them, with file locks, shared state and approvals.", "author": { "name": "Quorus", @@ -43,7 +43,7 @@ "mcpServers": { "quorus": { "command": "uvx", - "args": ["--from", "quorus-ai==0.6.4", "quorus-mcp"], + "args": ["--from", "quorus-ai==0.6.5", "quorus-mcp"], "env": { "QUORUS_RELAY_URL": "${user_config.relay_url}", "QUORUS_INSTANCE_NAME": "${user_config.name}", diff --git a/pyproject.toml b/pyproject.toml index d0bfc15d..4654bd11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quorus-ai" -version = "0.6.4" +version = "0.6.5" description = "Real-time coordination layer for AI agent swarms — rooms, messaging, distributed locks" requires-python = ">=3.10" license = "MIT" diff --git a/quorus/__init__.py b/quorus/__init__.py index 91bb2de6..fecabe6c 100644 --- a/quorus/__init__.py +++ b/quorus/__init__.py @@ -9,7 +9,7 @@ messages = room.receive() """ -__version__ = "0.6.4" +__version__ = "0.6.5" from quorus.sdk import Room diff --git a/quorus/relay.py b/quorus/relay.py index 0a123191..74a2dc85 100644 --- a/quorus/relay.py +++ b/quorus/relay.py @@ -608,7 +608,7 @@ async def _periodic_save(): "Any agent that speaks HTTP can join rooms, send messages, " "and coordinate work through this API." ), - version="0.6.4", + version="0.6.5", lifespan=lifespan, ) diff --git a/server.json b/server.json index 7118d54b..92602ea1 100644 --- a/server.json +++ b/server.json @@ -8,12 +8,12 @@ "source": "github" }, "websiteUrl": "https://quorus.dev", - "version": "0.6.4", + "version": "0.6.5", "packages": [ { "registryType": "pypi", "identifier": "quorus-ai", - "version": "0.6.4", + "version": "0.6.5", "runtimeHint": "uvx", "transport": { "type": "stdio" From 8f2184389042ad16075f7792ef2fc3080f1c6cbc Mon Sep 17 00:00:00 2001 From: aravkek Date: Tue, 20 Oct 2026 19:21:41 -0400 Subject: [PATCH 26/26] test: relay main tests drive uvicorn.Server The own-relay port (c7c3a294) starts the relay through uvicorn.Server so it can record the sockets it bound; test_main_runs_uvicorn and the access-log CLI test still mocked uvicorn.run. Both take weekend's versions from 380372e6 (test hunks only). Co-Authored-By: Claude Opus 5.5 --- tests/test_relay.py | 17 ++++++++++++++--- tests/test_relay_access_secrets.py | 3 ++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_relay.py b/tests/test_relay.py index de8c4cfd..af2dca00 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -637,12 +637,23 @@ def test_main_runs_uvicorn(monkeypatch): calls = [] - def fake_run(app_arg, host: str, port: int, **kw): - calls.append((app_arg, host, port, kw)) + class FakeConfig: + def __init__(self, app_arg, host: str, port: int, **kw): + calls.append((app_arg, host, port, kw)) + + class FakeServer: # never starts listening, so nothing is recorded as ours + started = False + + def __init__(self, config): + self.config = config + + async def serve(self): + return None monkeypatch.setenv("PORT", "9090") monkeypatch.delenv("HOST", raising=False) - monkeypatch.setitem(sys.modules, "uvicorn", types.SimpleNamespace(run=fake_run)) + monkeypatch.setitem(sys.modules, "uvicorn", + types.SimpleNamespace(Config=FakeConfig, Server=FakeServer)) main() diff --git a/tests/test_relay_access_secrets.py b/tests/test_relay_access_secrets.py index 1f65f66d..5fd1398c 100644 --- a/tests/test_relay_access_secrets.py +++ b/tests/test_relay_access_secrets.py @@ -26,7 +26,8 @@ def run(*args, **kwargs): '/stream/alice?token=PRIVATE-stream-key&other=ok', '1.1', 200) """ if entry == "cli": - script += "\nwith patch.object(uvicorn, 'run', run):\n relay.main()\n" + script += ("\nasync def serve(self, sockets=None):\n run()\n" + "\nwith patch.object(uvicorn.Server, 'serve', serve):\n relay.main()\n") else: script += "\nrun()\n" # Other tests leave DATABASE_URL set; production mode then demands Redis.