mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
fix(pairing): keep approvals across transient store read failures
_load() treated any OSError like corruption and returned an empty store. When pairing.json was transiently unreadable, an unapproved DM could deny the sender, generate a pairing code from the empty view, and overwrite the store without its approved senders. Keep the existing JSONDecodeError reset behavior, but propagate OSError so mutations cannot persist unreadable state. Read-only checks fail closed without writing; mutating /pairing subcommands report temporary unavailability; and the DM pairing path skips one reply instead of crashing the handler. This mirrors the refuse-to-overwrite strategy used by the cron and trigger stores.
This commit is contained in:
parent
e633f867e8
commit
52680dbe19
@ -248,7 +248,15 @@ class BaseChannel(ABC):
|
||||
permission_id = authorization_id if authorization_id is not None else sender_id
|
||||
if not self.is_allowed(permission_id):
|
||||
if is_dm:
|
||||
code = generate_code(self.name, str(sender_id))
|
||||
try:
|
||||
code = generate_code(self.name, str(sender_id))
|
||||
except OSError:
|
||||
# Transient pairing-store I/O failure: skip the pairing
|
||||
# reply for this message rather than crash the handler.
|
||||
self.logger.warning(
|
||||
"Pairing store unavailable; dropping DM from {}", sender_id
|
||||
)
|
||||
return
|
||||
await self.send(
|
||||
OutboundMessage(
|
||||
channel=self.name,
|
||||
|
||||
@ -40,9 +40,15 @@ def _load() -> dict[str, Any]:
|
||||
data = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {"approved": {}, "pending": {}}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Corrupted pairing store, resetting")
|
||||
return {"approved": {}, "pending": {}}
|
||||
except OSError:
|
||||
# A transiently locked or busy file is not corruption. Propagate so
|
||||
# mutating callers fail loudly instead of persisting an empty view
|
||||
# that would erase every approved sender.
|
||||
logger.warning("Pairing store temporarily unreadable: {}", path)
|
||||
raise
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Corrupted pairing store, resetting")
|
||||
return {"approved": {}, "pending": {}}
|
||||
@ -171,7 +177,11 @@ def deny_code(code: str) -> bool:
|
||||
def is_approved(channel: str, sender_id: str) -> bool:
|
||||
"""Check whether *sender_id* has been approved on *channel*."""
|
||||
with _LOCK:
|
||||
data = _load()
|
||||
try:
|
||||
data = _load()
|
||||
except OSError:
|
||||
# Fail closed for this check; the store itself stays untouched.
|
||||
return False
|
||||
approved: dict[str, set[str]] = data.get("approved", {})
|
||||
return str(sender_id) in approved.get(channel, set())
|
||||
|
||||
@ -179,7 +189,10 @@ def is_approved(channel: str, sender_id: str) -> bool:
|
||||
def list_pending() -> list[dict[str, Any]]:
|
||||
"""Return all non-expired pending pairing requests."""
|
||||
with _LOCK:
|
||||
data = _load()
|
||||
try:
|
||||
data = _load()
|
||||
except OSError:
|
||||
return []
|
||||
_gc_pending(data)
|
||||
return [
|
||||
{"code": code, **info}
|
||||
@ -257,7 +270,10 @@ def clear_channel(channel: str) -> dict[str, int]:
|
||||
def get_approved(channel: str) -> list[str]:
|
||||
"""Return all approved sender IDs for *channel*."""
|
||||
with _LOCK:
|
||||
data = _load()
|
||||
try:
|
||||
data = _load()
|
||||
except OSError:
|
||||
return []
|
||||
return sorted(data.get("approved", {}).get(channel, set()))
|
||||
|
||||
|
||||
@ -283,6 +299,15 @@ def handle_pairing_command(channel: str, subcommand_text: str) -> str:
|
||||
This is a pure function (no side effects other than store mutations)
|
||||
so it can be used from both the CLI and the agent CommandRouter.
|
||||
"""
|
||||
try:
|
||||
return _handle_pairing_subcommand(channel, subcommand_text)
|
||||
except OSError:
|
||||
# Mutations fail loudly on a transient I/O error instead of lying
|
||||
# ("invalid code") or silently rewriting the store from an empty view.
|
||||
return "The pairing store is temporarily unavailable. Please try again."
|
||||
|
||||
|
||||
def _handle_pairing_subcommand(channel: str, subcommand_text: str) -> str:
|
||||
parts = subcommand_text.split()
|
||||
sub = parts[0] if parts else "list"
|
||||
arg = parts[1] if len(parts) > 1 else None
|
||||
|
||||
@ -83,6 +83,49 @@ async def test_handle_message_dm_sends_pairing_code(monkeypatch) -> None:
|
||||
assert msg.metadata.get("_pairing_code") == "ABCD-EFGH"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_during_transient_store_failure_keeps_approvals(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""An unapproved DM while pairing.json is unreadable must not wipe approvals.
|
||||
|
||||
The pairing store treated a transient OSError like corruption and returned
|
||||
an empty store; the DM pairing path then persisted that empty view,
|
||||
erasing every approved sender.
|
||||
"""
|
||||
import builtins
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.pairing import store
|
||||
|
||||
path = tmp_path / "pairing.json"
|
||||
monkeypatch.setattr(store, "_store_path", lambda: path)
|
||||
code = store.generate_code("dummy", "friend")
|
||||
store.approve_code(code)
|
||||
|
||||
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
||||
|
||||
real_open = builtins.open
|
||||
|
||||
def flaky_open(file, mode="r", *args, **kwargs):
|
||||
try:
|
||||
same = Path(file) == path
|
||||
except TypeError:
|
||||
same = False
|
||||
if same and "r" in mode and "+" not in mode:
|
||||
raise PermissionError(13, "temporarily locked", str(path))
|
||||
return real_open(file, mode, *args, **kwargs)
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(builtins, "open", flaky_open)
|
||||
await channel._handle_message(
|
||||
sender_id="stranger", chat_id="chat1", content="hello", is_dm=True
|
||||
)
|
||||
|
||||
assert channel._sent == []
|
||||
assert store.is_approved("dummy", "friend") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_message_group_ignores_unknown() -> None:
|
||||
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
||||
|
||||
@ -323,3 +323,66 @@ def test_pending_gc_drops_malformed_entries(tmp_path, monkeypatch):
|
||||
)
|
||||
monkeypatch.setattr(store, "_store_path", lambda: path)
|
||||
assert store.list_pending() == []
|
||||
|
||||
|
||||
def _fail_reads_of(monkeypatch, path):
|
||||
"""Make reads of *path* raise like a transiently locked/busy file."""
|
||||
import builtins
|
||||
from pathlib import Path
|
||||
|
||||
real_open = builtins.open
|
||||
|
||||
def flaky_open(file, mode="r", *args, **kwargs):
|
||||
try:
|
||||
same = Path(file) == path
|
||||
except TypeError:
|
||||
same = False
|
||||
if same and "r" in mode and "+" not in mode:
|
||||
raise PermissionError(13, "temporarily locked", str(path))
|
||||
return real_open(file, mode, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", flaky_open)
|
||||
|
||||
|
||||
class TestTransientReadFailure:
|
||||
"""A transient I/O failure is not corruption and must never wipe the store."""
|
||||
|
||||
def test_generate_code_does_not_wipe_approvals(self, tmp_path, monkeypatch):
|
||||
"""An unapproved DM during a read blip previously erased every approval.
|
||||
|
||||
_load treated OSError like corruption and returned an empty store;
|
||||
generate_code then unconditionally saved it, overwriting pairing.json
|
||||
with no approved senders.
|
||||
"""
|
||||
code = store.generate_code("telegram", "123")
|
||||
store.approve_code(code)
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
_fail_reads_of(m, store._store_path())
|
||||
with pytest.raises(OSError):
|
||||
store.generate_code("telegram", "stranger")
|
||||
|
||||
assert store.is_approved("telegram", "123") is True
|
||||
|
||||
def test_reads_fail_closed_without_crashing(self, tmp_path, monkeypatch):
|
||||
code = store.generate_code("telegram", "123")
|
||||
store.approve_code(code)
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
_fail_reads_of(m, store._store_path())
|
||||
assert store.is_approved("telegram", "123") is False
|
||||
assert store.list_pending() == []
|
||||
assert store.get_approved("telegram") == []
|
||||
|
||||
assert store.is_approved("telegram", "123") is True
|
||||
|
||||
def test_approve_command_reports_store_unavailable(self, tmp_path, monkeypatch):
|
||||
"""/pairing approve must fail loudly instead of claiming the code is invalid."""
|
||||
code = store.generate_code("telegram", "123")
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
_fail_reads_of(m, store._store_path())
|
||||
reply = store.handle_pairing_command("telegram", f"approve {code}")
|
||||
|
||||
assert "unavailable" in reply.lower()
|
||||
assert store.approve_code(code) == ("telegram", "123")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user