Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
08a0b16
fix: config dir re-reads Path.home at call time
aravkek Oct 20, 2026
e7a88ed
test: relay wrapper test stubs Popen, not run
aravkek Oct 20, 2026
291ffb9
test: room bindings never read the real HOME
aravkek Oct 20, 2026
a5c4677
fix: one resolver for room-bindings, isolated in tests
aravkek Oct 20, 2026
e52ddb1
test: move HOME off the real one for the session
aravkek Oct 20, 2026
f654660
test: scrub legacy config-dir alias before imports
aravkek Oct 20, 2026
e838d47
test: named reviewer stub waits for the fix
aravkek Oct 20, 2026
1a39cd6
test: invitee helper closes sessions on failure
aravkek Oct 20, 2026
0d5cbd0
test: invitee setup closes sessions on failure
aravkek Oct 20, 2026
642d838
fix: quorus relay forwards signals, refuses a bound port
aravkek Oct 20, 2026
e7a30c9
fix: a taken port with no known holder is refused
aravkek Oct 20, 2026
89efc25
style: sort imports in the relay wrapper test
aravkek Oct 20, 2026
b7f5fed
fix: wake text stops claiming a shared object store
aravkek Oct 20, 2026
614d1d1
fix: room-post redaction masks participant keys
aravkek Oct 20, 2026
0f8f0ed
fix: a lost @open bid posts no /defer line
aravkek Oct 20, 2026
32f809a
test: lost auctions post no defer, chatter or @open
aravkek Oct 20, 2026
fab2f56
fix: stale held window no longer holds a retry
aravkek Oct 20, 2026
988c742
fix: scorecard survival counts every file
aravkek Oct 20, 2026
292563c
fix: survival parses renames, errors uncomputed
aravkek Oct 20, 2026
326f7a7
Reapply "fix: the local owner gets a participant identity"
aravkek Oct 20, 2026
dd7ede8
Reapply "fix: wizard config path resolves at call time"
aravkek Oct 20, 2026
8472fe1
Reapply "fix: hosted login never yields to a stale secret"
aravkek Oct 20, 2026
54d37a6
Revert "chore: hold H4 out of 0.6.4"
aravkek Oct 20, 2026
c7c3a29
fix: own relay isn't a teammate's (H4 port)
aravkek Oct 20, 2026
b605476
chore: release quorus-ai 0.6.5
aravkek Oct 20, 2026
8f21843
test: relay main tests drive uvicorn.Server
aravkek Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
39 changes: 33 additions & 6 deletions packages/cli/quorus_cli/agent_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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 <path>")
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)):
Expand Down Expand Up @@ -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))
Expand Down
109 changes: 102 additions & 7 deletions packages/cli/quorus_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]")
Expand All @@ -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]")
Expand All @@ -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]")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <wrapper pid>` 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__
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/quorus_cli/invites_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/quorus_cli/replay_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/quorus_cli/room_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/quorus_cli/scorecard_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading