@securecoms/agent-bridge 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/connectors/display-name-fixtures.json +605 -0
- package/connectors/hermes/__pycache__/securecoms_gate.cpython-310.pyc +0 -0
- package/connectors/hermes/__pycache__/securecoms_transport.cpython-310.pyc +0 -0
- package/connectors/hermes/adapter.py +417 -13
- package/connectors/hermes/securecoms_authz.py +112 -11
- package/connectors/hermes/securecoms_transport.py +109 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +683 -7
- package/connectors/hermes/tests/test_busy_session.py +1436 -0
- package/connectors/hermes/tests/test_securecoms_authz.py +194 -0
- package/connectors/hermes/tests/test_securecoms_transport.py +29 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +136 -4
- package/connectors/openclaw/openclaw-entry.js +151 -4
- package/connectors/openclaw/openclaw-entry.outbound.test.ts +217 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +65 -3
- package/dist/bridge-agent.d.ts +5 -0
- package/dist/http-server.d.ts +1 -0
- package/dist/index.js +17 -1
- package/dist/service/wire.d.ts +24 -0
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
# packages/connect-agent-bridge/connectors/hermes/tests/test_busy_session.py
|
|
2
|
+
#
|
|
3
|
+
# H0 (audit r1 #3, r2 #1, #2): a Hermes tool decision for a (chat_id, user_id)
|
|
4
|
+
# key can never be lifted while an ungranted turn for that key is still
|
|
5
|
+
# running, or still waiting to run, anywhere in its Hermes session.
|
|
6
|
+
#
|
|
7
|
+
# The fake below is COPIED, branch for branch, from Hermes v2026.9.24 as
|
|
8
|
+
# installed on the Mini (~/.hermes/hermes-agent-v2026.9.24, read 2026-11-11;
|
|
9
|
+
# md5 run_busy.py 9b9052966ef5605ac3d8b63f6f602248, platforms/base.py
|
|
10
|
+
# 6d3da1360844e3043361369f4b89a80f, run_inbound.py
|
|
11
|
+
# 4f490dcbfd4349abd649260573af2083, session.py
|
|
12
|
+
# 53677a3689bf100b9f4202fa46656cc8). Text-only subset: no media, no clarify,
|
|
13
|
+
# no topics, no profile routing, no busy-ack replies.
|
|
14
|
+
# - build_session_key: gateway/session.py:673-718, copied VERBATIM below
|
|
15
|
+
# (with _session_key_namespace 644-654 and _canonical_participant
|
|
16
|
+
# 664-670); only Platform.WHATSAPP / SLACK and the WhatsApp canonicaliser
|
|
17
|
+
# are shimmed. A DM is keyed by chat only, plus the thread when one is set.
|
|
18
|
+
# - The session store keeps one transcript per session key (session.py:885,
|
|
19
|
+
# :923, :1040-1143); Runner.transcripts models it.
|
|
20
|
+
# - HermesBase: gateway/platforms/base.py
|
|
21
|
+
# merge_pending_message_event 1739-1781 (text merge only)
|
|
22
|
+
# _event_session_key 2488 -> build_session_key (gateway/session.py
|
|
23
|
+
# 673-713: a DM is keyed by chat only; a group adds the sender when
|
|
24
|
+
# group_sessions_per_user, default True)
|
|
25
|
+
# debounce 3757-3832, guard/task helpers 3836-3888,
|
|
26
|
+
# cancel_session_processing 3890-3914, /stop /new /reset 3916-3948,
|
|
27
|
+
# handle_message 3950-3990, _handle_message_while_active 3992-4065,
|
|
28
|
+
# _finish_session_task 4414-4436, _process_message_background
|
|
29
|
+
# 4438-4557 (on_processing_complete fires ONCE per started task, for
|
|
30
|
+
# the starting event only, BEFORE the debounce flush and the pending
|
|
31
|
+
# pop), _spawn_drain_task 4560-4567, _cleanup_finished_session_task
|
|
32
|
+
# 4573-4590.
|
|
33
|
+
# - Runner.busy_session_handler: gateway/run_busy.py 767-860
|
|
34
|
+
# (_handle_active_session_busy_message): unauthorized drop 782-788,
|
|
35
|
+
# bot-loop drop 790-791, drain drop 795-797, queue-mode text to the
|
|
36
|
+
# base debounce 809-814, steer/redirect 816-819 via
|
|
37
|
+
# _resolve_busy_steer_or_redirect 559-610 (awaits the compression check
|
|
38
|
+
# at 575), FIFO queue 822-823 via _queue_or_replace_pending_event
|
|
39
|
+
# 358-406 (cap drop 398-404, _BUSY_QUEUE_MAX_PENDING run.py:4034) and
|
|
40
|
+
# _enqueue_fifo 88-97, interrupt 836-840.
|
|
41
|
+
# - Runner.__call__ (the gateway message handler): gateway/run_inbound.py
|
|
42
|
+
# 1295-1390: a session the RUNNER holds (turn.agent set) goes to
|
|
43
|
+
# _hm_handle_running_session_message 675-731 even when the base adapter
|
|
44
|
+
# started a task for this event (sentinel -> merged into the adapter's
|
|
45
|
+
# pending slot 690-697; queue / steer / redirect / interrupt); else the
|
|
46
|
+
# slot is claimed with the pending sentinel (1343), the turn runs, and
|
|
47
|
+
# the slot is released in the finally (1387) BEFORE the base hook runs.
|
|
48
|
+
# The durable marker token is set only for the turn's own event
|
|
49
|
+
# (run_turn.py:2090 -> run_inbound.py:1754-1767) and deleted at turn end
|
|
50
|
+
# (1793).
|
|
51
|
+
# - FIFO overflow promotion modelled at turn end (run_busy.py:100-116
|
|
52
|
+
# _promote_queued_event); /new clears the overflow
|
|
53
|
+
# (run_agent_cache.py:369 conversation.clear()) and the running slot
|
|
54
|
+
# (slash_commands_session.py:163).
|
|
55
|
+
|
|
56
|
+
import asyncio
|
|
57
|
+
import importlib.util
|
|
58
|
+
import logging
|
|
59
|
+
import os
|
|
60
|
+
import types
|
|
61
|
+
from collections import defaultdict
|
|
62
|
+
from enum import Enum
|
|
63
|
+
from typing import Optional
|
|
64
|
+
|
|
65
|
+
import pytest
|
|
66
|
+
|
|
67
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
68
|
+
_spec = importlib.util.spec_from_file_location(
|
|
69
|
+
"_securecoms_wiring_helpers", os.path.join(_HERE, "test_adapter_wiring.py")
|
|
70
|
+
)
|
|
71
|
+
_w = importlib.util.module_from_spec(_spec)
|
|
72
|
+
_spec.loader.exec_module(_w)
|
|
73
|
+
adapter = _w.adapter
|
|
74
|
+
authz = adapter.securecoms_authz
|
|
75
|
+
MessageType = adapter.MessageType
|
|
76
|
+
|
|
77
|
+
SENTINEL = object() # gateway.run._AGENT_PENDING_SENTINEL
|
|
78
|
+
|
|
79
|
+
# --- gateway/session.py, Hermes v2026.9.24 (shims: Platform members, WhatsApp) ---
|
|
80
|
+
Platform = types.SimpleNamespace(WHATSAPP=object(), SLACK=object())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def canonical_whatsapp_identifier(x):
|
|
84
|
+
return x
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _session_key_namespace(profile: Optional[str]) -> str:
|
|
88
|
+
if not profile or profile == "default":
|
|
89
|
+
return "agent:main"
|
|
90
|
+
return "agent:main~" if profile == "main" else f"agent:{profile}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _canonical_participant(source) -> Optional[str]:
|
|
94
|
+
"""Sender id for key isolation; WhatsApp JID/LID aliases are canonicalized so alias flips
|
|
95
|
+
cannot split one member into two sessions."""
|
|
96
|
+
participant_id = source.user_id_alt or source.user_id
|
|
97
|
+
if participant_id and source.platform == Platform.WHATSAPP:
|
|
98
|
+
participant_id = canonical_whatsapp_identifier(str(participant_id)) or participant_id
|
|
99
|
+
return participant_id
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_session_key(
|
|
103
|
+
source, group_sessions_per_user: bool = True,
|
|
104
|
+
thread_sessions_per_user: bool = False, profile: Optional[str] = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
is_dm = source.chat_type == "dm"
|
|
107
|
+
chat_id = source.chat_id
|
|
108
|
+
if is_dm and source.platform == Platform.WHATSAPP:
|
|
109
|
+
chat_id = canonical_whatsapp_identifier(chat_id)
|
|
110
|
+
# Discord auto-thread continuity: key a channel-initiating message on the thread it WILL be
|
|
111
|
+
# delivered into (prospective_thread_id), and normalize the chat_type slot to "thread" so
|
|
112
|
+
# in-thread follow-ups byte-match. A real thread_id always wins. DMs use thread_id only.
|
|
113
|
+
thread_id = source.thread_id or (None if is_dm else source.prospective_thread_id)
|
|
114
|
+
chat_type_slot = "thread" if thread_id and not source.thread_id else source.chat_type
|
|
115
|
+
if is_dm:
|
|
116
|
+
# No chat_id: fall back to the sender id before the bare per-platform sink, or every
|
|
117
|
+
# chat_id-less DM shares one agent.
|
|
118
|
+
isolate_user = not chat_id
|
|
119
|
+
else:
|
|
120
|
+
# Threads are shared by default; per-user isolation only via thread_sessions_per_user or
|
|
121
|
+
# outside a thread.
|
|
122
|
+
isolate_user = group_sessions_per_user and not (thread_id and not thread_sessions_per_user)
|
|
123
|
+
# Duck-typed sources may lack user_id_alt: read the participant only when it matters.
|
|
124
|
+
participant_id = _canonical_participant(source) if (isolate_user or not is_dm) else None
|
|
125
|
+
|
|
126
|
+
parts = [_session_key_namespace(profile), source.platform.value, chat_type_slot]
|
|
127
|
+
if source.platform == Platform.SLACK and source.scope_id:
|
|
128
|
+
parts.append(str(source.scope_id))
|
|
129
|
+
if chat_id:
|
|
130
|
+
parts.append(chat_id)
|
|
131
|
+
# DMs put the participant before the thread; groups/threads put it after.
|
|
132
|
+
user_part = [str(participant_id)] if isolate_user and participant_id else []
|
|
133
|
+
thread_part = [thread_id] if thread_id else []
|
|
134
|
+
parts += user_part + thread_part if is_dm else thread_part + user_part
|
|
135
|
+
return ":".join(str(part) for part in parts)
|
|
136
|
+
# --- end of the verbatim copy ---
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ProcessingOutcome(Enum):
|
|
140
|
+
SUCCESS = "success"
|
|
141
|
+
FAILURE = "failure"
|
|
142
|
+
CANCELLED = "cancelled"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def merge_pending_message_event(pending, key, event, *, merge_text=False):
|
|
146
|
+
"""base.py:1739-1781, text-only events."""
|
|
147
|
+
existing = pending.get(key)
|
|
148
|
+
if existing:
|
|
149
|
+
both_text = existing.message_type == MessageType.TEXT and event.message_type == MessageType.TEXT
|
|
150
|
+
if merge_text and both_text:
|
|
151
|
+
if event.text:
|
|
152
|
+
existing.text = existing.text + "\n" + event.text
|
|
153
|
+
return
|
|
154
|
+
pending[key] = event
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _TurnState:
|
|
158
|
+
def __init__(self):
|
|
159
|
+
self.agent = None
|
|
160
|
+
self.event = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _Conversation:
|
|
164
|
+
def __init__(self):
|
|
165
|
+
self.queued_events = []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class SessionState:
|
|
169
|
+
def __init__(self):
|
|
170
|
+
self.turn = _TurnState()
|
|
171
|
+
self.conversation = _Conversation()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Turn:
|
|
175
|
+
def __init__(self, event, key):
|
|
176
|
+
self.event = event
|
|
177
|
+
self.key = key
|
|
178
|
+
self.text = event.text
|
|
179
|
+
self.injected = [] # (verb, text) steered or redirected into this run
|
|
180
|
+
self.done = False
|
|
181
|
+
self.interrupted = False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Agent:
|
|
185
|
+
_supports_active_turn_redirect = True
|
|
186
|
+
|
|
187
|
+
def __init__(self, turn, runner):
|
|
188
|
+
self.turn = turn
|
|
189
|
+
self.runner = runner
|
|
190
|
+
|
|
191
|
+
def redirect(self, text):
|
|
192
|
+
self.turn.injected.append(("redirect", text))
|
|
193
|
+
self.runner.transcripts[self.turn.key].append(text)
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
def steer(self, text):
|
|
197
|
+
self.turn.injected.append(("steer", text))
|
|
198
|
+
self.runner.transcripts[self.turn.key].append(text)
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
def interrupt(self, text):
|
|
202
|
+
self.turn.interrupted = True
|
|
203
|
+
self.runner.release(self.turn.event.message_id)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _text_only(event):
|
|
207
|
+
return event.message_type == MessageType.TEXT and not event.media_urls
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Runner:
|
|
211
|
+
"""GatewayRunner: message handler, busy-session handler, session state."""
|
|
212
|
+
|
|
213
|
+
BUSY_QUEUE_MAX_PENDING = 32
|
|
214
|
+
|
|
215
|
+
def __init__(self, a):
|
|
216
|
+
self.a = a
|
|
217
|
+
self.sessions = {}
|
|
218
|
+
self.gates = {}
|
|
219
|
+
self.turns = {}
|
|
220
|
+
self.busy_input_mode = "interrupt"
|
|
221
|
+
self.draining = False
|
|
222
|
+
self.unauthorized = set()
|
|
223
|
+
self.dropped = []
|
|
224
|
+
self.compression_check = None # test hook at run_busy.py:575's await
|
|
225
|
+
self.transcripts = defaultdict(list) # session key -> user texts (session.py store)
|
|
226
|
+
self.skip_promotion = False # an early-exit turn that orphans the overflow (#28503)
|
|
227
|
+
self.allowed_users = set()
|
|
228
|
+
|
|
229
|
+
def admits(self, source):
|
|
230
|
+
"""authz_mixin.py:614-680: admission reads source.user_id (allowlist subset)."""
|
|
231
|
+
return source.user_id in self.allowed_users
|
|
232
|
+
|
|
233
|
+
# run.py:3455-3470
|
|
234
|
+
def _session_state(self, key):
|
|
235
|
+
return self.sessions.setdefault(key, SessionState())
|
|
236
|
+
|
|
237
|
+
def _peek_session_state(self, key):
|
|
238
|
+
return self.sessions.get(key)
|
|
239
|
+
|
|
240
|
+
def _session_key_for_source(self, source):
|
|
241
|
+
return self.a._source_session_key(source)
|
|
242
|
+
|
|
243
|
+
def gate(self, mid):
|
|
244
|
+
return self.gates.setdefault(mid, asyncio.Event())
|
|
245
|
+
|
|
246
|
+
def release(self, mid):
|
|
247
|
+
self.gate(mid).set()
|
|
248
|
+
|
|
249
|
+
def turn(self, mid):
|
|
250
|
+
return self.turns.get(mid)
|
|
251
|
+
|
|
252
|
+
async def __call__(self, event):
|
|
253
|
+
key = self._session_key_for_source(event.source)
|
|
254
|
+
st = self._peek_session_state(key)
|
|
255
|
+
if st is not None and st.turn.agent is not None: # run_inbound.py:1303-1305
|
|
256
|
+
return await self._running_session_message(event, key)
|
|
257
|
+
st = self._session_state(key)
|
|
258
|
+
st.turn.agent = SENTINEL # :1343
|
|
259
|
+
st.turn.event = event
|
|
260
|
+
try:
|
|
261
|
+
turn = Turn(event, key)
|
|
262
|
+
self.turns[event.message_id] = turn
|
|
263
|
+
self.transcripts[key].append(event.text)
|
|
264
|
+
st.turn.agent = Agent(turn, self)
|
|
265
|
+
event._gateway_active_turn_token = "tok-" + event.message_id # run_turn.py:2090
|
|
266
|
+
try:
|
|
267
|
+
await self.gate(event.message_id).wait()
|
|
268
|
+
finally:
|
|
269
|
+
del event._gateway_active_turn_token # run_inbound.py:1793
|
|
270
|
+
turn.done = True
|
|
271
|
+
# run_busy.py:100-116: the overflow head moves into the drained slot.
|
|
272
|
+
if not self.skip_promotion and st.conversation.queued_events and key not in self.a._pending_messages:
|
|
273
|
+
self.a._pending_messages[key] = st.conversation.queued_events.pop(0)
|
|
274
|
+
finally:
|
|
275
|
+
st.turn.agent = None # :1387 _release_running_agent_state
|
|
276
|
+
st.turn.event = None
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
async def _running_session_message(self, event, key):
|
|
280
|
+
"""run_inbound.py:675-731."""
|
|
281
|
+
running_agent = self._peek_session_state(key).turn.agent
|
|
282
|
+
if running_agent is SENTINEL: # 690-697
|
|
283
|
+
merge_pending_message_event(self.a._pending_messages, key, event, merge_text=True)
|
|
284
|
+
return None
|
|
285
|
+
if self.busy_input_mode == "queue":
|
|
286
|
+
self._queue_or_replace_pending_event(key, event)
|
|
287
|
+
return None
|
|
288
|
+
if self.busy_input_mode == "steer":
|
|
289
|
+
if _text_only(event) and event.text and running_agent.steer(event.text):
|
|
290
|
+
return None
|
|
291
|
+
self._queue_or_replace_pending_event(key, event)
|
|
292
|
+
return None
|
|
293
|
+
if _text_only(event) and running_agent.redirect(event.text): # _hm_busy_interrupt 650-660
|
|
294
|
+
return None
|
|
295
|
+
running_agent.interrupt(event.text)
|
|
296
|
+
merge_pending_message_event(self.a._pending_messages, key, event, merge_text=True)
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def _queue_depth(self, key):
|
|
300
|
+
"""run_busy.py:118-123."""
|
|
301
|
+
st = self._peek_session_state(key)
|
|
302
|
+
depth = len(st.conversation.queued_events) if st else 0
|
|
303
|
+
return depth + (1 if key in self.a._pending_messages else 0)
|
|
304
|
+
|
|
305
|
+
def _enqueue_fifo(self, key, event):
|
|
306
|
+
"""run_busy.py:88-97."""
|
|
307
|
+
if key in self.a._pending_messages:
|
|
308
|
+
self._session_state(key).conversation.queued_events.append(event)
|
|
309
|
+
else:
|
|
310
|
+
self.a._pending_messages[key] = event
|
|
311
|
+
event._gateway_accepted = True
|
|
312
|
+
|
|
313
|
+
def _queue_or_replace_pending_event(self, key, event):
|
|
314
|
+
"""run_busy.py:358-406 (no photo bursts here)."""
|
|
315
|
+
if self._queue_depth(key) >= self.BUSY_QUEUE_MAX_PENDING: # 398-404
|
|
316
|
+
self.dropped.append(event.message_id)
|
|
317
|
+
return
|
|
318
|
+
self._enqueue_fifo(key, event)
|
|
319
|
+
|
|
320
|
+
async def busy_session_handler(self, event, key):
|
|
321
|
+
"""run_busy.py:767-860 _handle_active_session_busy_message."""
|
|
322
|
+
if event.source.user_id in self.unauthorized: # 782-788
|
|
323
|
+
self.dropped.append(event.message_id)
|
|
324
|
+
return True
|
|
325
|
+
effective_mode = self.busy_input_mode # 794
|
|
326
|
+
if self.draining: # 795-797
|
|
327
|
+
self.dropped.append(event.message_id)
|
|
328
|
+
return True
|
|
329
|
+
if (
|
|
330
|
+
event.message_type == MessageType.TEXT
|
|
331
|
+
and getattr(self.a, "_busy_text_mode", "interrupt") == "queue"
|
|
332
|
+
and effective_mode != "steer"
|
|
333
|
+
): # 809-814
|
|
334
|
+
return False
|
|
335
|
+
st = self._peek_session_state(key)
|
|
336
|
+
running_agent = st.turn.agent if st else None
|
|
337
|
+
# _resolve_busy_steer_or_redirect 559-610
|
|
338
|
+
if self.compression_check is not None: # the await at 575
|
|
339
|
+
await self.compression_check()
|
|
340
|
+
else:
|
|
341
|
+
await asyncio.sleep(0)
|
|
342
|
+
steered = redirected = False
|
|
343
|
+
agent_live = running_agent is not None and running_agent is not SENTINEL
|
|
344
|
+
if effective_mode == "steer":
|
|
345
|
+
if event.text and _text_only(event) and agent_live:
|
|
346
|
+
steered = running_agent.steer(event.text)
|
|
347
|
+
if not steered:
|
|
348
|
+
effective_mode = "queue"
|
|
349
|
+
elif effective_mode == "interrupt" and _text_only(event) and agent_live:
|
|
350
|
+
redirected = running_agent.redirect(event.text)
|
|
351
|
+
if not steered and not redirected: # 822-823
|
|
352
|
+
self._queue_or_replace_pending_event(key, event)
|
|
353
|
+
if effective_mode == "interrupt" and not redirected and agent_live: # 836-840
|
|
354
|
+
running_agent.interrupt(event.text)
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
async def inline_command(self, event, cmd):
|
|
358
|
+
key = self._session_key_for_source(event.source)
|
|
359
|
+
if cmd in ("new", "reset"):
|
|
360
|
+
st = self._peek_session_state(key)
|
|
361
|
+
if st is not None:
|
|
362
|
+
st.conversation.queued_events.clear() # run_agent_cache.py:369
|
|
363
|
+
st.turn.agent = None # slash_commands_session.py:163
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class HermesBase:
|
|
367
|
+
"""gateway/platforms/base.py, the paths this adapter's sessions take."""
|
|
368
|
+
|
|
369
|
+
_busy_text_mode = "interrupt"
|
|
370
|
+
|
|
371
|
+
def _hermes_init(self):
|
|
372
|
+
self._active_sessions = {}
|
|
373
|
+
self._pending_messages = {}
|
|
374
|
+
self._session_tasks = {}
|
|
375
|
+
self._background_tasks = set()
|
|
376
|
+
self._expected_cancelled_tasks = set()
|
|
377
|
+
self._text_debounce = {}
|
|
378
|
+
self._busy_session_handler = None
|
|
379
|
+
self.inline_commands = []
|
|
380
|
+
|
|
381
|
+
def _source_session_key(self, source):
|
|
382
|
+
"""base.py:2492-2499."""
|
|
383
|
+
extra = self.config.extra
|
|
384
|
+
return build_session_key(
|
|
385
|
+
source, group_sessions_per_user=extra.get("group_sessions_per_user", True),
|
|
386
|
+
thread_sessions_per_user=extra.get("thread_sessions_per_user", False), profile=None)
|
|
387
|
+
|
|
388
|
+
def _event_session_key(self, event):
|
|
389
|
+
return self._source_session_key(event.source)
|
|
390
|
+
|
|
391
|
+
def _text_debounce_store(self):
|
|
392
|
+
return self._text_debounce
|
|
393
|
+
|
|
394
|
+
async def _run_processing_hook(self, hook_name, *args):
|
|
395
|
+
hook = getattr(self, hook_name, None)
|
|
396
|
+
if not callable(hook):
|
|
397
|
+
return
|
|
398
|
+
try:
|
|
399
|
+
await hook(*args)
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
async def on_processing_start(self, event):
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
# 3757-3832 (the timer is long: tests flush at completion, as 4520 does)
|
|
407
|
+
def _is_queue_text_debounce_candidate(self, event):
|
|
408
|
+
return self._busy_text_mode == "queue" and event.message_type == MessageType.TEXT
|
|
409
|
+
|
|
410
|
+
async def _queue_text_debounce(self, key, event):
|
|
411
|
+
state = self._text_debounce.get(key)
|
|
412
|
+
if state is None:
|
|
413
|
+
self._text_debounce[key] = types.SimpleNamespace(event=event)
|
|
414
|
+
else:
|
|
415
|
+
state.event.text = state.event.text + "\n" + event.text
|
|
416
|
+
|
|
417
|
+
async def _flush_text_debounce_now(self, key):
|
|
418
|
+
state = self._text_debounce.get(key)
|
|
419
|
+
if state is None:
|
|
420
|
+
return False
|
|
421
|
+
pending = self._pending_messages.get(key)
|
|
422
|
+
if pending is not None:
|
|
423
|
+
return False
|
|
424
|
+
self._text_debounce.pop(key, None)
|
|
425
|
+
merge_pending_message_event(self._pending_messages, key, state.event, merge_text=True)
|
|
426
|
+
return True
|
|
427
|
+
|
|
428
|
+
def _discard_text_debounce(self, key):
|
|
429
|
+
self._text_debounce.pop(key, None)
|
|
430
|
+
|
|
431
|
+
# 3836-3888
|
|
432
|
+
def _release_session_guard(self, key, *, guard=None):
|
|
433
|
+
current = self._active_sessions.get(key)
|
|
434
|
+
if current is None or (guard is not None and current is not guard):
|
|
435
|
+
return
|
|
436
|
+
del self._active_sessions[key]
|
|
437
|
+
|
|
438
|
+
def _session_task_is_stale(self, key):
|
|
439
|
+
done = getattr(self._session_tasks.get(key), "done", None)
|
|
440
|
+
return bool(done and done())
|
|
441
|
+
|
|
442
|
+
def _heal_stale_session_lock(self, key):
|
|
443
|
+
if key not in self._active_sessions or not self._session_task_is_stale(key):
|
|
444
|
+
return False
|
|
445
|
+
self._active_sessions.pop(key, None)
|
|
446
|
+
self._pending_messages.pop(key, None)
|
|
447
|
+
self._session_tasks.pop(key, None)
|
|
448
|
+
self._discard_text_debounce(key)
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
def _start_session_processing(self, event, key):
|
|
452
|
+
guard = asyncio.Event()
|
|
453
|
+
self._active_sessions[key] = guard
|
|
454
|
+
task = asyncio.create_task(self._process_message_background(event, key))
|
|
455
|
+
self._track_session_task(key, task)
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
def _track_session_task(self, key, task):
|
|
459
|
+
self._session_tasks[key] = task
|
|
460
|
+
self._background_tasks.add(task)
|
|
461
|
+
task.add_done_callback(self._background_tasks.discard)
|
|
462
|
+
task.add_done_callback(self._expected_cancelled_tasks.discard)
|
|
463
|
+
return True
|
|
464
|
+
|
|
465
|
+
# 3890-3914
|
|
466
|
+
async def cancel_session_processing(self, key, *, release_guard=True, discard_pending=True):
|
|
467
|
+
task = self._session_tasks.pop(key, None)
|
|
468
|
+
if task is not None and not task.done():
|
|
469
|
+
self._expected_cancelled_tasks.add(task)
|
|
470
|
+
task.cancel()
|
|
471
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
472
|
+
if discard_pending:
|
|
473
|
+
self._pending_messages.pop(key, None)
|
|
474
|
+
self._discard_text_debounce(key)
|
|
475
|
+
if release_guard:
|
|
476
|
+
self._release_session_guard(key)
|
|
477
|
+
|
|
478
|
+
# 3916-3948
|
|
479
|
+
async def _drain_pending_after_session_command(self, key, command_guard):
|
|
480
|
+
await self._flush_text_debounce_now(key)
|
|
481
|
+
pending_event = self._pending_messages.pop(key, None)
|
|
482
|
+
self._release_session_guard(key, guard=command_guard)
|
|
483
|
+
if pending_event is not None:
|
|
484
|
+
self._start_session_processing(pending_event, key)
|
|
485
|
+
|
|
486
|
+
async def _dispatch_active_session_command(self, event, key, cmd):
|
|
487
|
+
command_guard = asyncio.Event()
|
|
488
|
+
self._active_sessions[key] = command_guard
|
|
489
|
+
self.inline_commands.append(cmd)
|
|
490
|
+
await self.gateway_runner.inline_command(event, cmd)
|
|
491
|
+
await self.cancel_session_processing(key, release_guard=False, discard_pending=False)
|
|
492
|
+
await self._drain_pending_after_session_command(key, command_guard)
|
|
493
|
+
|
|
494
|
+
# 3950-3990
|
|
495
|
+
async def handle_message(self, event):
|
|
496
|
+
event._gateway_accepted = False
|
|
497
|
+
if not self._message_handler:
|
|
498
|
+
return
|
|
499
|
+
key = self._event_session_key(event)
|
|
500
|
+
if key in self._active_sessions:
|
|
501
|
+
self._heal_stale_session_lock(key)
|
|
502
|
+
if key in self._active_sessions:
|
|
503
|
+
await self._handle_message_while_active(event, key)
|
|
504
|
+
return
|
|
505
|
+
event._gateway_accepted = self._start_session_processing(event, key)
|
|
506
|
+
|
|
507
|
+
# 3992-4065
|
|
508
|
+
async def _handle_message_while_active(self, event, key):
|
|
509
|
+
text = event.text or ""
|
|
510
|
+
cmd = text[1:].split(" ")[0] if text.startswith("/") else None
|
|
511
|
+
if cmd in ("stop", "new", "reset"):
|
|
512
|
+
self._discard_text_debounce(key)
|
|
513
|
+
await self._dispatch_active_session_command(event, key, cmd)
|
|
514
|
+
return
|
|
515
|
+
if self._busy_session_handler is not None:
|
|
516
|
+
if await self._busy_session_handler(event, key):
|
|
517
|
+
return
|
|
518
|
+
if self._is_queue_text_debounce_candidate(event):
|
|
519
|
+
await self._queue_text_debounce(key, event)
|
|
520
|
+
return
|
|
521
|
+
merge_pending_message_event(
|
|
522
|
+
self._pending_messages, key, event, merge_text=event.message_type == MessageType.TEXT
|
|
523
|
+
)
|
|
524
|
+
event._gateway_accepted = True
|
|
525
|
+
|
|
526
|
+
# 4414-4436
|
|
527
|
+
def _finish_session_task(self, key, interrupt_event):
|
|
528
|
+
late_pending = self._pending_messages.pop(key, None)
|
|
529
|
+
current = asyncio.current_task()
|
|
530
|
+
if late_pending is not None:
|
|
531
|
+
existing = self._session_tasks.get(key)
|
|
532
|
+
if existing is not None and existing is not current:
|
|
533
|
+
self._pending_messages[key] = late_pending
|
|
534
|
+
else:
|
|
535
|
+
self._spawn_drain_task(late_pending, key)
|
|
536
|
+
elif current is not None and self._session_tasks.get(key) is current:
|
|
537
|
+
self._cleanup_finished_session_task(key, interrupt_event)
|
|
538
|
+
|
|
539
|
+
# 4438-4557
|
|
540
|
+
async def _process_message_background(self, event, key):
|
|
541
|
+
interrupt_event = self._active_sessions.get(key) or asyncio.Event()
|
|
542
|
+
self._active_sessions[key] = interrupt_event
|
|
543
|
+
try:
|
|
544
|
+
await self._run_processing_hook("on_processing_start", event)
|
|
545
|
+
await self._message_handler(event)
|
|
546
|
+
await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.SUCCESS)
|
|
547
|
+
await self._flush_text_debounce_now(key)
|
|
548
|
+
if key in self._pending_messages:
|
|
549
|
+
pending_event = self._pending_messages.pop(key)
|
|
550
|
+
self._clear_session_guard(key)
|
|
551
|
+
self._spawn_drain_task(pending_event, key)
|
|
552
|
+
return
|
|
553
|
+
except asyncio.CancelledError:
|
|
554
|
+
expected = asyncio.current_task() in self._expected_cancelled_tasks
|
|
555
|
+
await self._run_processing_hook(
|
|
556
|
+
"on_processing_complete", event,
|
|
557
|
+
ProcessingOutcome.CANCELLED if expected else ProcessingOutcome.FAILURE)
|
|
558
|
+
raise
|
|
559
|
+
except BaseException:
|
|
560
|
+
await self._run_processing_hook("on_processing_complete", event, ProcessingOutcome.FAILURE)
|
|
561
|
+
finally:
|
|
562
|
+
await self._flush_text_debounce_now(key)
|
|
563
|
+
self._finish_session_task(key, interrupt_event)
|
|
564
|
+
|
|
565
|
+
def _spawn_drain_task(self, pending_event, key):
|
|
566
|
+
self._clear_session_guard(key)
|
|
567
|
+
self._track_session_task(key, asyncio.create_task(self._process_message_background(pending_event, key)))
|
|
568
|
+
|
|
569
|
+
def _clear_session_guard(self, key):
|
|
570
|
+
guard = self._active_sessions.get(key)
|
|
571
|
+
if guard is not None:
|
|
572
|
+
guard.clear()
|
|
573
|
+
|
|
574
|
+
def _cleanup_finished_session_task(self, key, interrupt_event):
|
|
575
|
+
self._release_session_guard(key, guard=interrupt_event)
|
|
576
|
+
if key not in self._active_sessions:
|
|
577
|
+
self._session_tasks.pop(key, None)
|
|
578
|
+
|
|
579
|
+
async def cancel_background_tasks(self):
|
|
580
|
+
for _ in range(5):
|
|
581
|
+
tasks = [t for t in self._background_tasks if not t.done()]
|
|
582
|
+
if not tasks:
|
|
583
|
+
return
|
|
584
|
+
for t in tasks:
|
|
585
|
+
t.cancel()
|
|
586
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class _RecWS:
|
|
590
|
+
def __init__(self):
|
|
591
|
+
self.sent = []
|
|
592
|
+
self.closed = False
|
|
593
|
+
|
|
594
|
+
async def send_json(self, payload):
|
|
595
|
+
self.sent.append(payload)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
# Driver
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
GRANTS = [{"tool": "current_time", "mode": "read"}]
|
|
603
|
+
NON_OWNER = authz.NON_OWNER_THREAD
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _auth(audience, grants, **kw):
|
|
607
|
+
block = {"version": 1, "audience": audience, "sender_membership": "active", "tool_grants": list(grants)}
|
|
608
|
+
block.update(kw)
|
|
609
|
+
return block
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def owner_relay(mid, text, chan="dm"):
|
|
613
|
+
aud = "other_human_dm" if chan == "dm" else "team_room"
|
|
614
|
+
return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": "relay", "text": text,
|
|
615
|
+
"authorization": _auth(aud, GRANTS, relay_speaker="owner")}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def guest_relay(mid, text, chan="dm"):
|
|
619
|
+
aud = "other_human_dm" if chan == "dm" else "team_room"
|
|
620
|
+
return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": "relay", "text": text,
|
|
621
|
+
"authorization": _auth(aud, [])}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def owner_direct(mid, text, chan="dm", grants=GRANTS):
|
|
625
|
+
return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": "owner", "text": text,
|
|
626
|
+
"authorization": _auth("owner_dm", grants)}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def no_block(mid, text, chan="dm", sender="stranger"):
|
|
630
|
+
return {"type": "message", "message_id": mid, "channel_id": chan, "sender_id": sender, "text": text}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
async def settle(n=40):
|
|
634
|
+
for _ in range(n):
|
|
635
|
+
await asyncio.sleep(0)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@pytest.fixture
|
|
639
|
+
def gate_state(monkeypatch):
|
|
640
|
+
monkeypatch.setattr(adapter, "_TURN_AUTHZ", authz.TurnAuthzLedger())
|
|
641
|
+
monkeypatch.setattr(adapter, "_ATTACHED", {})
|
|
642
|
+
monkeypatch.setattr(adapter, "_IDLE_INPUTS_WARNED", False)
|
|
643
|
+
monkeypatch.setattr(adapter, "_SHARED_ROOMS_WARNED", False)
|
|
644
|
+
monkeypatch.setattr(adapter, "_TOOL_VETO_ACTIVE", True)
|
|
645
|
+
monkeypatch.setattr(adapter, "_TURN_TRACKING_WARNED", False, raising=False)
|
|
646
|
+
monkeypatch.setattr(adapter, "_ACCEPT_WAIT_S", 30.0)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@pytest.fixture(params=["shared", "threaded"])
|
|
650
|
+
def threaded(request, monkeypatch):
|
|
651
|
+
"""shared: H0.2c's non-owner thread switched off (every DM sender shares one
|
|
652
|
+
key and one session, the HEAD condition); threaded: H0.2c on."""
|
|
653
|
+
if request.param == "shared":
|
|
654
|
+
monkeypatch.setattr(authz, "is_owner_turn", lambda frame: True)
|
|
655
|
+
return request.param == "threaded"
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def make(extra=None, mode="interrupt"):
|
|
659
|
+
class Faithful(HermesBase, adapter.SecureComsAdapter):
|
|
660
|
+
pass
|
|
661
|
+
|
|
662
|
+
a = Faithful(types.SimpleNamespace(extra=dict(extra or {})))
|
|
663
|
+
a._hermes_init()
|
|
664
|
+
a._bound_identity = "A"
|
|
665
|
+
a._channels = {"room": {"name": "room", "is_dm": False}, "dm": {"name": "dm", "is_dm": True}}
|
|
666
|
+
a._ws = _RecWS()
|
|
667
|
+
r = Runner(a)
|
|
668
|
+
r.busy_input_mode = mode
|
|
669
|
+
a.set_message_handler(r)
|
|
670
|
+
a.gateway_runner = r
|
|
671
|
+
a._busy_session_handler = r.busy_session_handler
|
|
672
|
+
return a, r
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
async def teardown(a):
|
|
676
|
+
for t in list(a._ack_waiters):
|
|
677
|
+
t.cancel()
|
|
678
|
+
await asyncio.gather(*list(a._ack_waiters), return_exceptions=True)
|
|
679
|
+
await a.cancel_background_tasks()
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
async def deliver(a, frame):
|
|
683
|
+
await a._receive_message(frame)
|
|
684
|
+
await settle()
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def tools(key):
|
|
688
|
+
return adapter._TURN_AUTHZ.tools_allowed(*key)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def inflight(key):
|
|
692
|
+
return adapter._TURN_AUTHZ.ungranted_in_flight(*key)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
OWNER_SESSION = "agent:main:securecoms:dm:dm"
|
|
696
|
+
GUEST_SESSION = "agent:main:securecoms:dm:dm:sc-non-owner"
|
|
697
|
+
UNCLASSIFIED_SESSION = "agent:main:securecoms:dm:dm:sc-unclassified"
|
|
698
|
+
UNCLASSIFIED = getattr(authz, "UNCLASSIFIED_THREAD", "sc-unclassified")
|
|
699
|
+
OWNER_RELAY = ("dm", "relay", None)
|
|
700
|
+
ROOM = ("room", "relay", None) # a room guest relay key
|
|
701
|
+
SHARED = ("dm", "relay", None) # a DM with the H0.2c split off: owner and guest relay share key and session
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def guest_key(threaded_on):
|
|
705
|
+
return ("dm", "relay", NON_OWNER if threaded_on else None)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def guest_session(threaded_on):
|
|
709
|
+
return GUEST_SESSION if threaded_on else OWNER_SESSION
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# ---------------------------------------------------------------------------
|
|
713
|
+
# H0.1c: redirect, steer, merge (each with and without the non-owner thread)
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class TestRedirect:
|
|
718
|
+
@pytest.mark.asyncio
|
|
719
|
+
async def test_guest_text_redirected_into_a_running_owner_turn(self, gate_state, threaded):
|
|
720
|
+
a, r = make()
|
|
721
|
+
guest = guest_key(threaded)
|
|
722
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
723
|
+
t_owner = r.turn("o1")
|
|
724
|
+
assert t_owner is not None and tools(OWNER_RELAY)
|
|
725
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
726
|
+
if not threaded:
|
|
727
|
+
# One key, one session: the guest's text is inside the owner's run.
|
|
728
|
+
assert ("redirect", authz.COMMAND_ESCAPE + "guest speaks") in t_owner.injected
|
|
729
|
+
assert not tools(OWNER_RELAY)
|
|
730
|
+
await deliver(a, owner_relay("o2", "owner again")) # a later grant for the same key
|
|
731
|
+
assert not tools(OWNER_RELAY)
|
|
732
|
+
r.release("o1")
|
|
733
|
+
await settle()
|
|
734
|
+
assert t_owner.done
|
|
735
|
+
assert tools(OWNER_RELAY) and inflight(guest) == 0
|
|
736
|
+
else:
|
|
737
|
+
assert t_owner.injected == []
|
|
738
|
+
t_guest = r.turn("g1")
|
|
739
|
+
assert t_guest is not None and t_guest.key == GUEST_SESSION != t_owner.key
|
|
740
|
+
assert tools(OWNER_RELAY) and not tools(guest)
|
|
741
|
+
r.release("g1")
|
|
742
|
+
await settle()
|
|
743
|
+
assert inflight(guest) == 0 and not tools(guest)
|
|
744
|
+
await teardown(a)
|
|
745
|
+
|
|
746
|
+
@pytest.mark.asyncio
|
|
747
|
+
async def test_owner_text_redirected_into_a_running_guest_turn(self, gate_state, threaded):
|
|
748
|
+
a, r = make()
|
|
749
|
+
guest = guest_key(threaded)
|
|
750
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
751
|
+
t_guest = r.turn("g1")
|
|
752
|
+
assert t_guest is not None and not tools(guest)
|
|
753
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
754
|
+
if not threaded:
|
|
755
|
+
assert ("redirect", "owner speaks") in t_guest.injected
|
|
756
|
+
# The latest decision for the key is the owner's grant; the turn
|
|
757
|
+
# still carries the guest's text, so it has no tools.
|
|
758
|
+
assert not tools(OWNER_RELAY)
|
|
759
|
+
r.release("g1")
|
|
760
|
+
await settle()
|
|
761
|
+
assert tools(OWNER_RELAY) and inflight(guest) == 0
|
|
762
|
+
else:
|
|
763
|
+
assert t_guest.injected == []
|
|
764
|
+
assert r.turn("o1").key == OWNER_SESSION
|
|
765
|
+
assert tools(OWNER_RELAY) and not tools(guest)
|
|
766
|
+
r.release("g1")
|
|
767
|
+
await settle()
|
|
768
|
+
assert inflight(guest) == 0
|
|
769
|
+
await teardown(a)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
class TestSteer:
|
|
773
|
+
@pytest.mark.asyncio
|
|
774
|
+
async def test_guest_text_steered_into_a_running_owner_turn(self, gate_state, threaded):
|
|
775
|
+
a, r = make(mode="steer")
|
|
776
|
+
guest = guest_key(threaded)
|
|
777
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
778
|
+
t_owner = r.turn("o1")
|
|
779
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
780
|
+
if not threaded:
|
|
781
|
+
assert ("steer", authz.COMMAND_ESCAPE + "guest speaks") in t_owner.injected
|
|
782
|
+
assert not tools(OWNER_RELAY)
|
|
783
|
+
r.release("o1")
|
|
784
|
+
await settle()
|
|
785
|
+
assert inflight(guest) == 0
|
|
786
|
+
else:
|
|
787
|
+
assert t_owner.injected == []
|
|
788
|
+
assert tools(OWNER_RELAY) and not tools(guest)
|
|
789
|
+
r.release("g1")
|
|
790
|
+
await settle()
|
|
791
|
+
assert inflight(guest) == 0
|
|
792
|
+
await teardown(a)
|
|
793
|
+
|
|
794
|
+
@pytest.mark.asyncio
|
|
795
|
+
async def test_owner_text_steered_into_a_running_guest_turn(self, gate_state, threaded):
|
|
796
|
+
a, r = make(mode="steer")
|
|
797
|
+
guest = guest_key(threaded)
|
|
798
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
799
|
+
t_guest = r.turn("g1")
|
|
800
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
801
|
+
if not threaded:
|
|
802
|
+
assert ("steer", "owner speaks") in t_guest.injected
|
|
803
|
+
assert not tools(OWNER_RELAY)
|
|
804
|
+
else:
|
|
805
|
+
assert t_guest.injected == []
|
|
806
|
+
assert tools(OWNER_RELAY)
|
|
807
|
+
r.release("g1")
|
|
808
|
+
await settle()
|
|
809
|
+
assert inflight(guest) == 0
|
|
810
|
+
await teardown(a)
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
class TestPendingSentinelMerge:
|
|
814
|
+
@pytest.mark.asyncio
|
|
815
|
+
async def test_a_guest_event_parked_behind_the_sentinel_is_held_until_its_turn_ends_and_idle(
|
|
816
|
+
self, gate_state, threaded
|
|
817
|
+
):
|
|
818
|
+
a, r = make()
|
|
819
|
+
guest = guest_key(threaded)
|
|
820
|
+
session = guest_session(threaded)
|
|
821
|
+
# The runner holds the guest session's slot with the pending sentinel
|
|
822
|
+
# (a resumed session, run_startup.py:607) while the base adapter is
|
|
823
|
+
# idle for it: the guest event starts its own base task, the runner
|
|
824
|
+
# parks it in the pending slot (run_inbound.py:697), and that base
|
|
825
|
+
# task completes at once.
|
|
826
|
+
r._session_state(session).turn.agent = SENTINEL
|
|
827
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
828
|
+
assert r.turn("g1") is None
|
|
829
|
+
assert inflight(guest) == 1 and not tools(guest)
|
|
830
|
+
r._session_state(session).turn.agent = None # the resumed turn ends
|
|
831
|
+
await settle()
|
|
832
|
+
t_guest = r.turn("g1")
|
|
833
|
+
assert t_guest is not None and not t_guest.done # the parked text runs now
|
|
834
|
+
assert inflight(guest) == 1
|
|
835
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
836
|
+
if not threaded:
|
|
837
|
+
assert ("redirect", "owner speaks") in t_guest.injected
|
|
838
|
+
assert not tools(OWNER_RELAY) # owner's grant recorded, guest's text still running
|
|
839
|
+
else:
|
|
840
|
+
assert tools(OWNER_RELAY)
|
|
841
|
+
r.release("g1")
|
|
842
|
+
await settle()
|
|
843
|
+
assert inflight(guest) == 0
|
|
844
|
+
if not threaded:
|
|
845
|
+
assert tools(OWNER_RELAY)
|
|
846
|
+
await teardown(a)
|
|
847
|
+
|
|
848
|
+
@pytest.mark.asyncio
|
|
849
|
+
async def test_a_guest_event_queued_behind_a_sentinel_turn_waits_for_the_pending_turn(self, gate_state):
|
|
850
|
+
# Base-level variant: the running turn holds the sentinel when the
|
|
851
|
+
# guest arrives, so the guest is queued in the pending slot; it is
|
|
852
|
+
# released only after the pending turn that carries it completes,
|
|
853
|
+
# not at the completion of the turn that was running when it arrived.
|
|
854
|
+
a, r = make()
|
|
855
|
+
session = "agent:main:securecoms:group:room:relay"
|
|
856
|
+
await deliver(a, guest_relay("g0", "first guest", chan="room"))
|
|
857
|
+
r.sessions[session].turn.agent = SENTINEL # agent still building
|
|
858
|
+
await deliver(a, guest_relay("g1", "second guest", chan="room"))
|
|
859
|
+
r.sessions[session].turn.agent = Agent(r.turn("g0"), r)
|
|
860
|
+
assert inflight(ROOM) == 2
|
|
861
|
+
r.release("g0")
|
|
862
|
+
await settle()
|
|
863
|
+
assert r.turn("g0").done
|
|
864
|
+
t1 = r.turn("g1")
|
|
865
|
+
assert t1 is not None and not t1.done
|
|
866
|
+
assert inflight(ROOM) == 2 # g0's completion found g1 pending: not idle
|
|
867
|
+
r.release("g1")
|
|
868
|
+
await settle()
|
|
869
|
+
assert inflight(ROOM) == 0
|
|
870
|
+
await teardown(a)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# ---------------------------------------------------------------------------
|
|
874
|
+
# H0.1c: drops, discards, later grants. A DM with the H0.2c split switched
|
|
875
|
+
# off: owner and guest relay share one key and one session (the HEAD
|
|
876
|
+
# condition), the case the count alone must hold. (The daemon marks
|
|
877
|
+
# relay_speaker only in DMs, so no room has a granted relay key.)
|
|
878
|
+
# ---------------------------------------------------------------------------
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
@pytest.fixture
|
|
882
|
+
def shared_dm(monkeypatch):
|
|
883
|
+
monkeypatch.setattr(authz, "dm_thread", lambda frame: None)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
class TestDropsAndDiscards:
|
|
887
|
+
@pytest.mark.asyncio
|
|
888
|
+
async def test_queue_cap_drop_while_the_session_goes_idle_releases_at_once(self, gate_state, shared_dm):
|
|
889
|
+
a, r = make(mode="queue")
|
|
890
|
+
r.BUSY_QUEUE_MAX_PENDING = 0 # the cap drop needs no backlog
|
|
891
|
+
|
|
892
|
+
async def owner_turn_finishes():
|
|
893
|
+
r.release("o1")
|
|
894
|
+
await settle()
|
|
895
|
+
|
|
896
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
897
|
+
r.compression_check = owner_turn_finishes
|
|
898
|
+
await a._receive_message(guest_relay("g1", "guest speaks"))
|
|
899
|
+
assert r.dropped == ["g1"]
|
|
900
|
+
assert inflight(SHARED) == 0 # not accepted, session idle: released at once
|
|
901
|
+
await teardown(a)
|
|
902
|
+
|
|
903
|
+
@pytest.mark.asyncio
|
|
904
|
+
async def test_queue_cap_drop_while_busy_waits_for_idle(self, gate_state, shared_dm):
|
|
905
|
+
a, r = make(mode="queue")
|
|
906
|
+
r.BUSY_QUEUE_MAX_PENDING = 0
|
|
907
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
908
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
909
|
+
assert r.dropped == ["g1"]
|
|
910
|
+
assert inflight(SHARED) == 1
|
|
911
|
+
r.release("o1")
|
|
912
|
+
await settle()
|
|
913
|
+
assert inflight(SHARED) == 0
|
|
914
|
+
await teardown(a)
|
|
915
|
+
|
|
916
|
+
@pytest.mark.asyncio
|
|
917
|
+
async def test_unauthorized_drop_while_busy_waits_for_idle(self, gate_state, shared_dm):
|
|
918
|
+
a, r = make()
|
|
919
|
+
await deliver(a, owner_relay("o1", "owner speaks")) # admitted on the cold path (not modelled)
|
|
920
|
+
r.unauthorized.add("relay")
|
|
921
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
922
|
+
assert r.dropped == ["g1"]
|
|
923
|
+
assert inflight(SHARED) == 1
|
|
924
|
+
r.release("o1")
|
|
925
|
+
await settle()
|
|
926
|
+
assert inflight(SHARED) == 0
|
|
927
|
+
await teardown(a)
|
|
928
|
+
|
|
929
|
+
@pytest.mark.asyncio
|
|
930
|
+
async def test_stop_then_the_pending_guest_turn_runs_and_releases_at_idle(self, gate_state, shared_dm):
|
|
931
|
+
a, r = make(mode="queue")
|
|
932
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
933
|
+
await deliver(a, guest_relay("g1", "guest speaks")) # queued in the pending slot
|
|
934
|
+
assert inflight(SHARED) == 1
|
|
935
|
+
await deliver(a, owner_relay("o2", "/stop"))
|
|
936
|
+
assert a.inline_commands == ["stop"]
|
|
937
|
+
assert r.turn("o1").done
|
|
938
|
+
t_guest = r.turn("g1")
|
|
939
|
+
assert t_guest is not None and not t_guest.done
|
|
940
|
+
assert inflight(SHARED) == 1 and not tools(SHARED)
|
|
941
|
+
r.release("g1")
|
|
942
|
+
await settle()
|
|
943
|
+
assert inflight(SHARED) == 0
|
|
944
|
+
await teardown(a)
|
|
945
|
+
|
|
946
|
+
@pytest.mark.asyncio
|
|
947
|
+
async def test_new_discards_a_queued_guest_event_released_at_the_next_idle_completion(self, gate_state, shared_dm):
|
|
948
|
+
a, r = make(mode="queue")
|
|
949
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
950
|
+
await deliver(a, owner_relay("o2", "owner follow-up")) # pending slot head
|
|
951
|
+
await deliver(a, guest_relay("g1", "guest speaks")) # FIFO overflow
|
|
952
|
+
assert inflight(SHARED) == 1
|
|
953
|
+
await deliver(a, owner_relay("o3", "/new"))
|
|
954
|
+
assert r.turn("o1").done
|
|
955
|
+
assert r.turn("g1") is None # discarded by /new
|
|
956
|
+
assert inflight(SHARED) == 1 # the cancelled turn's completion found o2 pending
|
|
957
|
+
r.release("o2")
|
|
958
|
+
await settle()
|
|
959
|
+
assert inflight(SHARED) == 0
|
|
960
|
+
await teardown(a)
|
|
961
|
+
|
|
962
|
+
@pytest.mark.asyncio
|
|
963
|
+
async def test_a_later_granted_owner_turn_never_releases_an_attached_count(self, gate_state, shared_dm):
|
|
964
|
+
a, r = make(mode="queue")
|
|
965
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
966
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
967
|
+
await deliver(a, owner_relay("o2", "owner again")) # queued behind g1, grant recorded
|
|
968
|
+
assert adapter._TURN_AUTHZ._entries[("dm", "relay", "")] is True
|
|
969
|
+
assert inflight(SHARED) == 1 and not tools(SHARED)
|
|
970
|
+
r.release("o1")
|
|
971
|
+
await settle()
|
|
972
|
+
assert inflight(SHARED) == 1 and not tools(SHARED) # g1 runs now
|
|
973
|
+
r.release("g1")
|
|
974
|
+
await settle()
|
|
975
|
+
assert r.turn("o2") is not None and not r.turn("o2").done
|
|
976
|
+
assert inflight(SHARED) == 1 and not tools(SHARED) # o2 still to finish: not idle
|
|
977
|
+
r.release("o2")
|
|
978
|
+
await settle()
|
|
979
|
+
assert inflight(SHARED) == 0 and tools(SHARED)
|
|
980
|
+
await teardown(a)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# ---------------------------------------------------------------------------
|
|
984
|
+
# Audit F3: one test per idle-predicate clause
|
|
985
|
+
# ---------------------------------------------------------------------------
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
class TestIdleClauses:
|
|
989
|
+
@pytest.mark.asyncio
|
|
990
|
+
async def test_a_runner_held_live_turn_with_no_adapter_task_keeps_the_count(self, gate_state):
|
|
991
|
+
# turn.agent clause: a resumed runner turn is live in the session but
|
|
992
|
+
# the adapter has no task for it. The guest event starts its own base
|
|
993
|
+
# task, the runner redirects its text into the live turn, and the base
|
|
994
|
+
# task completes at once.
|
|
995
|
+
a, r = make()
|
|
996
|
+
session = "agent:main:securecoms:group:room:relay"
|
|
997
|
+
resumed = Turn(types.SimpleNamespace(message_id="resumed", text="resumed turn"), session)
|
|
998
|
+
r._session_state(session).turn.agent = Agent(resumed, r)
|
|
999
|
+
await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
|
|
1000
|
+
assert ("redirect", authz.COMMAND_ESCAPE + "guest speaks") in resumed.injected
|
|
1001
|
+
assert a._session_tasks.get(session) is None # the guest's own base task is done
|
|
1002
|
+
assert inflight(ROOM) == 1 # still inside the resumed turn
|
|
1003
|
+
r._session_state(session).turn.agent = None
|
|
1004
|
+
await deliver(a, guest_relay("g2", "guest again", chan="room"))
|
|
1005
|
+
r.release("g2")
|
|
1006
|
+
await settle()
|
|
1007
|
+
assert inflight(ROOM) == 0 # the next idle completion clears it
|
|
1008
|
+
await teardown(a)
|
|
1009
|
+
|
|
1010
|
+
@pytest.mark.asyncio
|
|
1011
|
+
async def test_an_orphaned_runner_fifo_keeps_the_count(self, gate_state):
|
|
1012
|
+
# queued_events clause: an early-exit turn leaves the overflow behind
|
|
1013
|
+
# with the pending slot empty (#28503).
|
|
1014
|
+
a, r = make(mode="queue")
|
|
1015
|
+
await deliver(a, guest_relay("g0", "zero", chan="room"))
|
|
1016
|
+
await deliver(a, guest_relay("g1", "one", chan="room")) # pending slot
|
|
1017
|
+
await deliver(a, guest_relay("g2", "two", chan="room")) # overflow
|
|
1018
|
+
session = "agent:main:securecoms:group:room:relay"
|
|
1019
|
+
assert [e.message_id for e in r.sessions[session].conversation.queued_events] == ["g2"]
|
|
1020
|
+
r.release("g0")
|
|
1021
|
+
await settle()
|
|
1022
|
+
r.skip_promotion = True
|
|
1023
|
+
r.release("g1")
|
|
1024
|
+
await settle()
|
|
1025
|
+
assert a._pending_messages == {} and r.sessions[session].conversation.queued_events
|
|
1026
|
+
assert inflight(ROOM) == 3 # g2 still waits in the runner FIFO
|
|
1027
|
+
await teardown(a)
|
|
1028
|
+
|
|
1029
|
+
@pytest.mark.asyncio
|
|
1030
|
+
async def test_debounced_text_keeps_the_count(self, gate_state):
|
|
1031
|
+
# debounce clause: busy_text_mode=queue buffers the guest text; the
|
|
1032
|
+
# running turn's completion fires BEFORE the debounce flush.
|
|
1033
|
+
a, r = make()
|
|
1034
|
+
a._busy_text_mode = "queue"
|
|
1035
|
+
await deliver(a, guest_relay("g0", "zero", chan="room"))
|
|
1036
|
+
await deliver(a, guest_relay("g1", "one", chan="room"))
|
|
1037
|
+
session = "agent:main:securecoms:group:room:relay"
|
|
1038
|
+
assert session in a._text_debounce
|
|
1039
|
+
r.release("g0")
|
|
1040
|
+
await settle()
|
|
1041
|
+
t1 = r.turn("g1")
|
|
1042
|
+
assert t1 is not None and not t1.done
|
|
1043
|
+
assert inflight(ROOM) == 2
|
|
1044
|
+
r.release("g1")
|
|
1045
|
+
await settle()
|
|
1046
|
+
assert inflight(ROOM) == 0
|
|
1047
|
+
await teardown(a)
|
|
1048
|
+
|
|
1049
|
+
@pytest.mark.asyncio
|
|
1050
|
+
async def test_a_just_started_task_keeps_the_count(self, gate_state):
|
|
1051
|
+
# live-task clause: right after handle_message returns, the guest's
|
|
1052
|
+
# own task exists but has not run (no runner turn yet).
|
|
1053
|
+
a, r = make()
|
|
1054
|
+
await a._receive_message(guest_relay("g1", "guest speaks", chan="room"))
|
|
1055
|
+
assert inflight(ROOM) == 1
|
|
1056
|
+
await settle()
|
|
1057
|
+
assert inflight(ROOM) == 1
|
|
1058
|
+
r.release("g1")
|
|
1059
|
+
await settle()
|
|
1060
|
+
assert inflight(ROOM) == 0
|
|
1061
|
+
await teardown(a)
|
|
1062
|
+
|
|
1063
|
+
@pytest.mark.asyncio
|
|
1064
|
+
async def test_a_stop_command_guard_keeps_the_count_until_a_later_completion(self, gate_state, shared_dm):
|
|
1065
|
+
# command-guard clause: /stop installs its guard, then cancels the
|
|
1066
|
+
# running turn; that turn's completion sees a guard with no owner task.
|
|
1067
|
+
a, r = make()
|
|
1068
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
1069
|
+
await deliver(a, guest_relay("g1", "guest speaks")) # redirected into o1
|
|
1070
|
+
assert inflight(SHARED) == 1
|
|
1071
|
+
await deliver(a, owner_relay("o2", "/stop"))
|
|
1072
|
+
assert r.turn("o1").done and a._pending_messages == {}
|
|
1073
|
+
assert inflight(SHARED) == 1 # next-turn-clears case (stated in the PR)
|
|
1074
|
+
await deliver(a, owner_relay("o3", "owner again"))
|
|
1075
|
+
r.release("o3")
|
|
1076
|
+
await settle()
|
|
1077
|
+
assert inflight(SHARED) == 0
|
|
1078
|
+
await teardown(a)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
# ---------------------------------------------------------------------------
|
|
1082
|
+
# H0.2d: non-owner text never enters the owner session
|
|
1083
|
+
# ---------------------------------------------------------------------------
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
class TestOwnerSessionIsolation:
|
|
1087
|
+
@pytest.mark.asyncio
|
|
1088
|
+
@pytest.mark.parametrize("order", ["owner_first", "guest_first"])
|
|
1089
|
+
async def test_classification_of_dm_turns(self, gate_state, order):
|
|
1090
|
+
a, r = make()
|
|
1091
|
+
frames = [owner_direct("o1", "typed"), owner_relay("o2", "voice"), guest_relay("g1", "guest"),
|
|
1092
|
+
no_block("x1", "nobody")] # x1: unclassified (audit r2)
|
|
1093
|
+
if order == "guest_first":
|
|
1094
|
+
frames.reverse()
|
|
1095
|
+
for f in frames: # each turn finishes before the next arrives
|
|
1096
|
+
await deliver(a, f)
|
|
1097
|
+
r.release(f["message_id"])
|
|
1098
|
+
await settle()
|
|
1099
|
+
ev = {mid: r.turn(mid).event for mid in ("o1", "o2", "g1", "x1")}
|
|
1100
|
+
assert ev["o1"].source.thread_id is None and ev["o2"].source.thread_id is None
|
|
1101
|
+
assert r.turn("o1").key == r.turn("o2").key == OWNER_SESSION
|
|
1102
|
+
assert ev["g1"].source.thread_id == NON_OWNER and ev["x1"].source.thread_id == UNCLASSIFIED
|
|
1103
|
+
assert r.turn("g1").key == GUEST_SESSION and r.turn("x1").key == UNCLASSIFIED_SESSION
|
|
1104
|
+
await teardown(a)
|
|
1105
|
+
|
|
1106
|
+
@pytest.mark.asyncio
|
|
1107
|
+
async def test_an_owners_ungranted_turn_stays_in_the_owner_session(self, gate_state):
|
|
1108
|
+
a, r = make()
|
|
1109
|
+
await deliver(a, owner_direct("o1", "typed", grants=[])) # grants kill switch
|
|
1110
|
+
assert r.turn("o1").key == OWNER_SESSION
|
|
1111
|
+
assert inflight(("dm", "owner", None)) == 1
|
|
1112
|
+
await teardown(a)
|
|
1113
|
+
|
|
1114
|
+
@pytest.mark.asyncio
|
|
1115
|
+
@pytest.mark.parametrize("order", ["owner_first", "guest_first"])
|
|
1116
|
+
async def test_a_guest_canary_never_reaches_the_owner_transcript(self, gate_state, order):
|
|
1117
|
+
a, r = make()
|
|
1118
|
+
canary = "CANARY-guest-7f3a"
|
|
1119
|
+
first, second = (owner_direct("o1", "what time is it"), guest_relay("g1", canary))
|
|
1120
|
+
if order == "guest_first":
|
|
1121
|
+
first, second = second, first
|
|
1122
|
+
await deliver(a, first)
|
|
1123
|
+
await deliver(a, second)
|
|
1124
|
+
assert r.turn("o1") is not None and r.turn("g1") is not None
|
|
1125
|
+
assert OWNER_SESSION not in a._pending_messages and OWNER_SESSION not in a._text_debounce
|
|
1126
|
+
st = r._peek_session_state(OWNER_SESSION)
|
|
1127
|
+
assert st is None or st.conversation.queued_events == []
|
|
1128
|
+
r.release("o1")
|
|
1129
|
+
r.release("g1")
|
|
1130
|
+
await settle()
|
|
1131
|
+
assert not any(canary in t for t in r.transcripts[OWNER_SESSION])
|
|
1132
|
+
assert any(canary in t for t in r.transcripts[GUEST_SESSION])
|
|
1133
|
+
await teardown(a)
|
|
1134
|
+
|
|
1135
|
+
@pytest.mark.asyncio
|
|
1136
|
+
async def test_owner_tools_stay_available_while_a_guest_turn_is_in_flight(self, gate_state):
|
|
1137
|
+
a, r = make()
|
|
1138
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
1139
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
1140
|
+
assert inflight(("dm", "relay", NON_OWNER)) == 1
|
|
1141
|
+
env = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": "dm",
|
|
1142
|
+
"HERMES_SESSION_USER_ID": "relay", "HERMES_SESSION_THREAD_ID": ""}
|
|
1143
|
+
assert authz.pre_tool_call_decision(lambda n, d="": env[n], adapter._TURN_AUTHZ) is None
|
|
1144
|
+
assert a.toolsets_for_source(r.turn("o1").event.source) is None
|
|
1145
|
+
await teardown(a)
|
|
1146
|
+
|
|
1147
|
+
@pytest.mark.asyncio
|
|
1148
|
+
async def test_a_tool_call_bound_to_the_guest_session_is_blocked(self, gate_state):
|
|
1149
|
+
a, r = make()
|
|
1150
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
1151
|
+
r.release("g1")
|
|
1152
|
+
await settle() # the guest's count is released: the key itself must refuse
|
|
1153
|
+
await deliver(a, owner_relay("o1", "owner speaks")) # the LATEST record: granted (dm, relay, "")
|
|
1154
|
+
assert tools(OWNER_RELAY)
|
|
1155
|
+
env = {"HERMES_SESSION_PLATFORM": "securecoms", "HERMES_SESSION_CHAT_ID": "dm",
|
|
1156
|
+
"HERMES_SESSION_USER_ID": "relay", "HERMES_SESSION_THREAD_ID": NON_OWNER}
|
|
1157
|
+
assert authz.pre_tool_call_decision(lambda n, d="": env[n], adapter._TURN_AUTHZ)["action"] == "block"
|
|
1158
|
+
assert a.toolsets_for_source(r.turn("g1").event.source) == list(authz.NO_TOOLS_TOOLSETS)
|
|
1159
|
+
await teardown(a)
|
|
1160
|
+
|
|
1161
|
+
@pytest.mark.asyncio
|
|
1162
|
+
async def test_a_granted_frame_recorded_under_the_non_owner_thread_is_forced_off(self, gate_state):
|
|
1163
|
+
adapter._TURN_AUTHZ.record("dm", "relay", True, NON_OWNER)
|
|
1164
|
+
assert not tools(("dm", "relay", NON_OWNER))
|
|
1165
|
+
|
|
1166
|
+
@pytest.mark.asyncio
|
|
1167
|
+
async def test_a_no_block_dm_frame_is_an_unclassified_turn(self, gate_state):
|
|
1168
|
+
a, r = make()
|
|
1169
|
+
await deliver(a, no_block("x1", "hello", sender="owner")) # even the owner's id, without a block
|
|
1170
|
+
assert r.turn("x1").event.source.thread_id == UNCLASSIFIED
|
|
1171
|
+
assert r.turn("x1").key == UNCLASSIFIED_SESSION
|
|
1172
|
+
await teardown(a)
|
|
1173
|
+
|
|
1174
|
+
@pytest.mark.asyncio
|
|
1175
|
+
@pytest.mark.parametrize("order", ["owner_first", "stale_first"])
|
|
1176
|
+
async def test_a_stale_roster_owner_frame_runs_in_the_unclassified_session(self, gate_state, order):
|
|
1177
|
+
# Audit r2: the owner's text during a stale roster (sender_membership
|
|
1178
|
+
# "unknown") lands in neither the guest transcript nor the owner's.
|
|
1179
|
+
a, r = make()
|
|
1180
|
+
stale = owner_direct("s1", "STALE-owner-91c")
|
|
1181
|
+
stale["authorization"]["sender_membership"] = "unknown"
|
|
1182
|
+
frames = [stale, guest_relay("g1", "CANARY-guest-4d2"), owner_direct("o1", "fresh owner")]
|
|
1183
|
+
if order == "stale_first":
|
|
1184
|
+
frames = [frames[0], frames[2], frames[1]]
|
|
1185
|
+
for f in frames:
|
|
1186
|
+
await deliver(a, f)
|
|
1187
|
+
r.release(f["message_id"])
|
|
1188
|
+
await settle()
|
|
1189
|
+
assert r.turn("s1").event.source.thread_id == UNCLASSIFIED
|
|
1190
|
+
assert r.turn("g1").event.source.thread_id == NON_OWNER
|
|
1191
|
+
assert r.turn("o1").event.source.thread_id is None
|
|
1192
|
+
assert any("STALE-owner-91c" in t for t in r.transcripts[UNCLASSIFIED_SESSION])
|
|
1193
|
+
assert not any("STALE-owner-91c" in t for t in r.transcripts[GUEST_SESSION] + r.transcripts[OWNER_SESSION])
|
|
1194
|
+
assert not any("CANARY-guest-4d2" in t for t in r.transcripts[UNCLASSIFIED_SESSION] + r.transcripts[OWNER_SESSION])
|
|
1195
|
+
assert not tools(("dm", "owner", UNCLASSIFIED))
|
|
1196
|
+
await teardown(a)
|
|
1197
|
+
|
|
1198
|
+
@pytest.mark.asyncio
|
|
1199
|
+
@pytest.mark.parametrize("cache", ["empty", "refresh_failed", "stale_says_room"])
|
|
1200
|
+
async def test_a_dm_block_splits_even_when_the_channel_cache_misses(self, gate_state, cache):
|
|
1201
|
+
# Audit r2: the block says DM (owner_dm / other_human_dm / relay
|
|
1202
|
+
# mark), so the split applies whatever the channel cache says.
|
|
1203
|
+
a, r = make()
|
|
1204
|
+
if cache == "empty":
|
|
1205
|
+
a._channels = {}
|
|
1206
|
+
elif cache == "refresh_failed":
|
|
1207
|
+
a._channels = {}
|
|
1208
|
+
a._session = object()
|
|
1209
|
+
|
|
1210
|
+
async def failing_refresh():
|
|
1211
|
+
return None
|
|
1212
|
+
|
|
1213
|
+
a._refresh_channels = failing_refresh
|
|
1214
|
+
else:
|
|
1215
|
+
a._channels = {"dm": {"name": "dm", "is_dm": False}}
|
|
1216
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
1217
|
+
await deliver(a, guest_relay("g1", "CANARY-guest-cache"))
|
|
1218
|
+
assert r.turn("o1").event.source.chat_type == "dm"
|
|
1219
|
+
assert r.turn("g1").event.source.thread_id == NON_OWNER
|
|
1220
|
+
assert r.turn("g1").key == GUEST_SESSION != r.turn("o1").key
|
|
1221
|
+
r.release("o1")
|
|
1222
|
+
r.release("g1")
|
|
1223
|
+
await settle()
|
|
1224
|
+
assert not any("CANARY-guest-cache" in t for t in r.transcripts[OWNER_SESSION])
|
|
1225
|
+
await teardown(a)
|
|
1226
|
+
|
|
1227
|
+
@pytest.mark.asyncio
|
|
1228
|
+
async def test_send_with_thread_metadata_posts_to_the_channel_only(self, gate_state):
|
|
1229
|
+
a, r = make()
|
|
1230
|
+
sess = _w.FakeSession(post_response=(200, {"message_id": "m9"}))
|
|
1231
|
+
a._session = sess
|
|
1232
|
+
a.bridge_url = "http://127.0.0.1:9"
|
|
1233
|
+
res = await a.send("dm", "reply", metadata={"thread_id": NON_OWNER})
|
|
1234
|
+
assert res.success
|
|
1235
|
+
assert sess.post_calls[0][2] == {"channel_id": "dm", "text": "reply"}
|
|
1236
|
+
|
|
1237
|
+
@pytest.mark.asyncio
|
|
1238
|
+
async def test_admission_is_by_user_id_so_both_relay_turns_are_admitted(self, gate_state):
|
|
1239
|
+
a, r = make()
|
|
1240
|
+
r.allowed_users = {"relay"} # SECURECOMS_ALLOWED_USERS lists only the relay
|
|
1241
|
+
await deliver(a, owner_relay("o1", "owner speaks"))
|
|
1242
|
+
await deliver(a, guest_relay("g1", "guest speaks"))
|
|
1243
|
+
assert r.admits(r.turn("o1").event.source) and r.admits(r.turn("g1").event.source)
|
|
1244
|
+
await teardown(a)
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
# ---------------------------------------------------------------------------
|
|
1248
|
+
# H0.3, audit F2, F8
|
|
1249
|
+
# ---------------------------------------------------------------------------
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
class TestReleaseBookkeeping:
|
|
1253
|
+
@pytest.mark.asyncio
|
|
1254
|
+
async def test_a_second_completion_for_one_started_event_releases_once(self, gate_state):
|
|
1255
|
+
a, r = make()
|
|
1256
|
+
await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
|
|
1257
|
+
adapter._TURN_AUTHZ.begin_ungranted(*ROOM) # some other in-flight count
|
|
1258
|
+
assert inflight(ROOM) == 2
|
|
1259
|
+
ev = r.turn("g1").event
|
|
1260
|
+
r.release("g1")
|
|
1261
|
+
await settle()
|
|
1262
|
+
assert inflight(ROOM) == 1
|
|
1263
|
+
await a.on_processing_complete(ev, ProcessingOutcome.SUCCESS)
|
|
1264
|
+
assert inflight(ROOM) == 1
|
|
1265
|
+
await teardown(a)
|
|
1266
|
+
|
|
1267
|
+
@pytest.mark.asyncio
|
|
1268
|
+
async def test_a_dispatch_exception_before_handle_message_releases(self, gate_state):
|
|
1269
|
+
a, r = make()
|
|
1270
|
+
|
|
1271
|
+
def boom(event):
|
|
1272
|
+
raise RuntimeError("session lookup failed")
|
|
1273
|
+
|
|
1274
|
+
a._session_was_busy = boom
|
|
1275
|
+
with pytest.raises(RuntimeError):
|
|
1276
|
+
await a._receive_message(guest_relay("g1", "guest speaks", chan="room"))
|
|
1277
|
+
assert inflight(ROOM) == 0
|
|
1278
|
+
assert adapter._ATTACHED == {}
|
|
1279
|
+
await teardown(a)
|
|
1280
|
+
|
|
1281
|
+
@pytest.mark.asyncio
|
|
1282
|
+
async def test_a_handle_message_exception_keeps_the_count_until_idle(self, gate_state):
|
|
1283
|
+
a, r = make()
|
|
1284
|
+
|
|
1285
|
+
async def broken(event):
|
|
1286
|
+
raise RuntimeError("hermes failed mid-intake")
|
|
1287
|
+
|
|
1288
|
+
await deliver(a, guest_relay("g0", "running", chan="room"))
|
|
1289
|
+
a.handle_message = broken
|
|
1290
|
+
with pytest.raises(RuntimeError):
|
|
1291
|
+
await a._receive_message(guest_relay("g1", "guest speaks", chan="room"))
|
|
1292
|
+
assert inflight(ROOM) == 2 # session busy (g0 runs): attached, not released
|
|
1293
|
+
r.release("g0")
|
|
1294
|
+
await settle()
|
|
1295
|
+
assert inflight(ROOM) == 0
|
|
1296
|
+
await teardown(a)
|
|
1297
|
+
|
|
1298
|
+
@pytest.mark.asyncio
|
|
1299
|
+
async def test_a_granted_turn_alone_keeps_todays_behaviour(self, gate_state):
|
|
1300
|
+
a, r = make()
|
|
1301
|
+
await deliver(a, owner_direct("o1", "hi"))
|
|
1302
|
+
assert tools(("dm", "owner", None))
|
|
1303
|
+
assert inflight(("dm", "owner", None)) == 0
|
|
1304
|
+
assert a.toolsets_for_source(r.turn("o1").event.source) is None
|
|
1305
|
+
r.release("o1")
|
|
1306
|
+
await settle()
|
|
1307
|
+
assert tools(("dm", "owner", None))
|
|
1308
|
+
assert adapter._ATTACHED == {}
|
|
1309
|
+
await teardown(a)
|
|
1310
|
+
|
|
1311
|
+
@pytest.mark.asyncio
|
|
1312
|
+
async def test_two_concurrent_ungranted_turns_need_two_releases(self, gate_state):
|
|
1313
|
+
a, r = make(mode="queue")
|
|
1314
|
+
await deliver(a, guest_relay("g1", "one", chan="room"))
|
|
1315
|
+
await deliver(a, guest_relay("g2", "two", chan="room"))
|
|
1316
|
+
assert inflight(ROOM) == 2
|
|
1317
|
+
r.release("g1")
|
|
1318
|
+
await settle()
|
|
1319
|
+
assert r.turn("g2") is not None
|
|
1320
|
+
assert inflight(ROOM) == 2
|
|
1321
|
+
r.release("g2")
|
|
1322
|
+
await settle()
|
|
1323
|
+
assert inflight(ROOM) == 0
|
|
1324
|
+
await teardown(a)
|
|
1325
|
+
|
|
1326
|
+
@pytest.mark.asyncio
|
|
1327
|
+
async def test_an_owner_dm_key_is_unaffected_by_a_room_key(self, gate_state):
|
|
1328
|
+
a, r = make()
|
|
1329
|
+
await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
|
|
1330
|
+
await deliver(a, owner_direct("o1", "hi"))
|
|
1331
|
+
assert inflight(ROOM) == 1
|
|
1332
|
+
assert tools(("dm", "owner", None))
|
|
1333
|
+
await teardown(a)
|
|
1334
|
+
|
|
1335
|
+
@pytest.mark.asyncio
|
|
1336
|
+
async def test_an_unreadable_idle_input_counts_as_busy(self, gate_state):
|
|
1337
|
+
a, r = make()
|
|
1338
|
+
await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
|
|
1339
|
+
|
|
1340
|
+
def broken(key):
|
|
1341
|
+
raise RuntimeError("state unreadable")
|
|
1342
|
+
|
|
1343
|
+
r._peek_session_state = broken
|
|
1344
|
+
r.release("g1")
|
|
1345
|
+
await settle()
|
|
1346
|
+
assert inflight(ROOM) == 1 # fail closed: never released on an unreadable input
|
|
1347
|
+
await teardown(a)
|
|
1348
|
+
|
|
1349
|
+
@pytest.mark.asyncio
|
|
1350
|
+
async def test_an_acceptance_timeout_releases_nothing(self, gate_state, monkeypatch):
|
|
1351
|
+
monkeypatch.setattr(adapter, "_ACCEPT_WAIT_S", 0.05)
|
|
1352
|
+
a, r = make()
|
|
1353
|
+
a.gateway_runner = None # no runner state: idle cannot be proven
|
|
1354
|
+
await deliver(a, guest_relay("g1", "guest speaks", chan="room"))
|
|
1355
|
+
await asyncio.sleep(0.1)
|
|
1356
|
+
assert inflight(ROOM) == 1
|
|
1357
|
+
await teardown(a)
|
|
1358
|
+
|
|
1359
|
+
@pytest.mark.asyncio
|
|
1360
|
+
async def test_a_replaced_adapter_releases_a_count_whose_task_never_ran(self, gate_state):
|
|
1361
|
+
# F2: the guest's task is cancelled before its first step (no hook
|
|
1362
|
+
# ever fires for it) and the adapter instance is replaced; the next
|
|
1363
|
+
# idle completion in the same session, on the new instance, releases it.
|
|
1364
|
+
a1, r1 = make()
|
|
1365
|
+
session = "agent:main:securecoms:group:room:relay"
|
|
1366
|
+
await a1._receive_message(guest_relay("g1", "guest speaks", chan="room"))
|
|
1367
|
+
task = a1._session_tasks[session]
|
|
1368
|
+
task.cancel()
|
|
1369
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
1370
|
+
assert r1.turn("g1") is None and inflight(ROOM) == 1
|
|
1371
|
+
await teardown(a1)
|
|
1372
|
+
a2, r2 = make()
|
|
1373
|
+
await deliver(a2, guest_relay("g2", "guest again", chan="room"))
|
|
1374
|
+
assert inflight(ROOM) == 2
|
|
1375
|
+
r2.release("g2")
|
|
1376
|
+
await settle()
|
|
1377
|
+
assert inflight(ROOM) == 0
|
|
1378
|
+
await teardown(a2)
|
|
1379
|
+
|
|
1380
|
+
@pytest.mark.asyncio
|
|
1381
|
+
async def test_a_missing_idle_input_warns_once(self, gate_state, caplog):
|
|
1382
|
+
a, r = make()
|
|
1383
|
+
a.gateway_runner = None
|
|
1384
|
+
with caplog.at_level(logging.WARNING, logger=adapter.logger.name):
|
|
1385
|
+
await deliver(a, guest_relay("g1", "one", chan="room"))
|
|
1386
|
+
await deliver(a, guest_relay("g2", "two", chan="dm"))
|
|
1387
|
+
lines = [rec for rec in caplog.records if "idle state" in rec.getMessage()]
|
|
1388
|
+
assert len(lines) == 1 and lines[0].args == ()
|
|
1389
|
+
await teardown(a)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
# ---------------------------------------------------------------------------
|
|
1393
|
+
# H0.2b: group_sessions_per_user off -> room turns ungranted
|
|
1394
|
+
# ---------------------------------------------------------------------------
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
class TestSessionSeparationGuard:
|
|
1398
|
+
async def _connect(self, a, monkeypatch):
|
|
1399
|
+
monkeypatch.setattr(adapter, "resolve_bound_identity", lambda *args: None)
|
|
1400
|
+
assert await a.connect() is False # stops at the (absent) binding
|
|
1401
|
+
|
|
1402
|
+
@pytest.mark.asyncio
|
|
1403
|
+
async def test_off_makes_every_room_turn_ungranted_and_logs_once(self, gate_state, monkeypatch, caplog):
|
|
1404
|
+
# Defence in depth: today the daemon marks relay_speaker only in DMs,
|
|
1405
|
+
# so no room frame carries a grant the connector honours. Simulate a
|
|
1406
|
+
# granted room relay frame by not reading the block's DM mark.
|
|
1407
|
+
monkeypatch.setattr(authz, "block_says_dm", lambda frame: False)
|
|
1408
|
+
a, r = make(extra={"group_sessions_per_user": False})
|
|
1409
|
+
with caplog.at_level(logging.WARNING, logger=adapter.logger.name):
|
|
1410
|
+
await self._connect(a, monkeypatch)
|
|
1411
|
+
await self._connect(a, monkeypatch)
|
|
1412
|
+
a2, _ = make(extra={"group_sessions_per_user": False})
|
|
1413
|
+
await self._connect(a2, monkeypatch)
|
|
1414
|
+
assert len([rec for rec in caplog.records if "group_sessions_per_user" in rec.getMessage()]) == 1
|
|
1415
|
+
await deliver(a, owner_relay("o1", "owner speaks", chan="room"))
|
|
1416
|
+
ev = r.turn("o1").event
|
|
1417
|
+
assert ev.metadata["securecoms"]["tools_allowed"] is False
|
|
1418
|
+
assert not tools(ROOM)
|
|
1419
|
+
assert a.toolsets_for_source(ev.source) == list(authz.NO_TOOLS_TOOLSETS)
|
|
1420
|
+
# A DM is unaffected.
|
|
1421
|
+
await deliver(a, owner_direct("d1", "hi"))
|
|
1422
|
+
assert tools(("dm", "owner", None))
|
|
1423
|
+
await teardown(a)
|
|
1424
|
+
|
|
1425
|
+
@pytest.mark.asyncio
|
|
1426
|
+
@pytest.mark.parametrize("extra", [{}, {"group_sessions_per_user": True}])
|
|
1427
|
+
async def test_absent_or_true_is_unchanged(self, gate_state, monkeypatch, caplog, extra):
|
|
1428
|
+
monkeypatch.setattr(authz, "block_says_dm", lambda frame: False) # as in the test above
|
|
1429
|
+
a, r = make(extra=extra)
|
|
1430
|
+
with caplog.at_level(logging.WARNING, logger=adapter.logger.name):
|
|
1431
|
+
await self._connect(a, monkeypatch)
|
|
1432
|
+
assert not any("group_sessions_per_user" in rec.getMessage() for rec in caplog.records)
|
|
1433
|
+
await deliver(a, owner_relay("o1", "owner speaks", chan="room"))
|
|
1434
|
+
assert tools(ROOM)
|
|
1435
|
+
assert a.toolsets_for_source(r.turn("o1").event.source) is None
|
|
1436
|
+
await teardown(a)
|