diff --git a/CHANGELOG.md b/CHANGELOG.md index ab298a32..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 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/cli/quorus_cli/agent_cmd.py b/packages/cli/quorus_cli/agent_cmd.py index 9aacb92c..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 @@ -274,11 +294,19 @@ def _drop_lead_everywhere(name: str) -> None: _set_lead(room, None) +def bindings_path() -> Path: + """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: """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 +317,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): @@ -430,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)): @@ -483,8 +511,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..b533b8fe 100644 --- a/packages/cli/quorus_cli/cli.py +++ b/packages/cli/quorus_cli/cli.py @@ -343,6 +343,20 @@ 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. 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() + + def _get_sse_token(recipient: str) -> str: """Get a short-lived SSE stream token.""" headers = _auth_headers() @@ -1197,7 +1211,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( @@ -3568,7 +3584,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]") @@ -3594,7 +3610,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]") @@ -3619,7 +3635,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]") @@ -5885,7 +5901,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, @@ -7555,15 +7571,84 @@ 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 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) 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: + """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 + 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__ @@ -8956,6 +9041,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/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/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/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/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/packages/tui/quorus_tui/hub.py b/packages/tui/quorus_tui/hub.py index ea11903a..88622e5d 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, @@ -505,6 +510,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 +552,57 @@ 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() + 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) + + +# 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) @@ -569,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.""" @@ -896,7 +966,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 +1005,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 +4006,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 @@ -4021,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/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/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/ledger.py b/quorus/ledger.py index d57ebb99..d03c6b5d 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]]: @@ -119,22 +130,108 @@ 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 -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 _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" (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 + 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: + status[path] = "uncomputed" + continue + target = renames.get(path, "") + if not target: + status[path] = "deleted" + computed += added + continue + 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 + " ")) + computed += added + status[path] = "counted" + return alive, computed, status def _reverted(repo: Path, rev_range: str) -> set[str]: @@ -228,8 +325,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 +351,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 +375,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 +389,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 +399,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/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/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/quorus/relay.py b/quorus/relay.py index c34cfff4..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, ) @@ -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/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/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/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/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/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/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/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" diff --git a/tests/conftest.py b/tests/conftest.py index b0b44d50..ee5db01e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,36 @@ """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 +# 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 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) +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") @@ -20,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) @@ -180,6 +208,37 @@ 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.runtime import bindings + + 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 shared session HOME is diverted to a per-test file. + home = _Path.home() + if home != session_home: + return home / ".quorus" / "room-bindings.json" + return target + + with pytest.MonkeyPatch.context() as mp: + # 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 + + _OPT_IN_MARKERS = {"real_harness", "cross_harness"} @@ -230,3 +289,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/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") diff --git a/tests/firstrun/test_first_result.py b/tests/firstrun/test_first_result.py index e8502071..d4c83bee 100644 --- a/tests/firstrun/test_first_result.py +++ b/tests/firstrun/test_first_result.py @@ -128,32 +128,46 @@ 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 + # (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) + 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 None: + continue + try: + s.close() + except Exception: + pass # the original failure is the one to report; still close the other + raise def test_an_invitee_adds_their_agent_from_the_wizard(tmp_path_factory): @@ -219,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 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_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"] 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" diff --git a/tests/test_h4_owner_identity.py b/tests/test_h4_owner_identity.py new file mode 100644 index 00000000..65331eef --- /dev/null +++ b/tests/test_h4_owner_identity.py @@ -0,0 +1,222 @@ +"""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 + +import json +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 + + # 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") + 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) + 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 + + +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 + # 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", "") + + +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"} + # 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 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 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 diff --git a/tests/test_ledger.py b/tests/test_ledger.py index e812e54e..8a27c0c6 100644 --- a/tests/test_ledger.py +++ b/tests/test_ledger.py @@ -202,3 +202,96 @@ 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 + + +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 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 == [] 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 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-----", diff --git a/tests/test_reflexd_wake_contract.py b/tests/test_reflexd_wake_contract.py index 1592b845..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: @@ -1155,3 +1157,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 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. diff --git a/tests/test_relay_wrapper.py b/tests/test_relay_wrapper.py new file mode 100644 index 00000000..288f23a9 --- /dev/null +++ b/tests/test_relay_wrapper.py @@ -0,0 +1,166 @@ +"""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 + + +@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_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) + 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 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 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: diff --git a/tests/test_room_bindings_isolated.py b/tests/test_room_bindings_isolated.py new file mode 100644 index 00000000..ac3bde9c --- /dev/null +++ b/tests/test_room_bindings_isolated.py @@ -0,0 +1,118 @@ +"""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), 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, 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 = 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 / ".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}" + + +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: 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 + + 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 + + +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 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")