connectonion 0.6.4__py3-none-any.whl → 0.6.5__py3-none-any.whl
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.
- connectonion/__init__.py +1 -1
- connectonion/cli/co_ai/main.py +2 -2
- connectonion/cli/co_ai/prompts/connectonion/concepts/trust.md +166 -208
- connectonion/cli/commands/copy_commands.py +21 -0
- connectonion/cli/commands/trust_commands.py +152 -0
- connectonion/cli/main.py +82 -0
- connectonion/core/llm.py +2 -2
- connectonion/docs/concepts/fast_rules.md +237 -0
- connectonion/docs/concepts/onboarding.md +465 -0
- connectonion/docs/concepts/trust.md +933 -192
- connectonion/docs/design-decisions/023-trust-policy-system-design.md +323 -0
- connectonion/docs/network/README.md +23 -1
- connectonion/docs/network/connect.md +135 -0
- connectonion/docs/network/host.md +73 -4
- connectonion/network/__init__.py +7 -6
- connectonion/network/asgi/__init__.py +3 -0
- connectonion/network/asgi/http.py +125 -19
- connectonion/network/asgi/websocket.py +276 -15
- connectonion/network/connect.py +145 -29
- connectonion/network/host/auth.py +70 -67
- connectonion/network/host/routes.py +88 -3
- connectonion/network/host/server.py +100 -17
- connectonion/network/trust/__init__.py +27 -19
- connectonion/network/trust/factory.py +51 -24
- connectonion/network/trust/fast_rules.py +100 -0
- connectonion/network/trust/tools.py +316 -32
- connectonion/network/trust/trust_agent.py +403 -0
- connectonion/transcribe.py +1 -1
- {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/METADATA +1 -1
- {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/RECORD +32 -27
- connectonion/network/trust/prompts.py +0 -71
- {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/WHEEL +0 -0
- {connectonion-0.6.4.dist-info → connectonion-0.6.5.dist-info}/entry_points.txt +0 -0
|
@@ -107,6 +107,7 @@ async def handle_http(
|
|
|
107
107
|
route_handlers: dict,
|
|
108
108
|
storage,
|
|
109
109
|
trust: str,
|
|
110
|
+
trust_config: dict | None = None,
|
|
110
111
|
start_time: float,
|
|
111
112
|
blacklist: list | None = None,
|
|
112
113
|
whitelist: list | None = None,
|
|
@@ -120,6 +121,7 @@ async def handle_http(
|
|
|
120
121
|
route_handlers: Dict of route handler functions (input, session, sessions, health, info, auth)
|
|
121
122
|
storage: SessionStorage instance
|
|
122
123
|
trust: Trust level (open/careful/strict)
|
|
124
|
+
trust_config: Parsed YAML config from trust policy (for /info onboard)
|
|
123
125
|
start_time: Server start time
|
|
124
126
|
blacklist: Blocked identities
|
|
125
127
|
whitelist: Allowed identities
|
|
@@ -133,26 +135,130 @@ async def handle_http(
|
|
|
133
135
|
await send({"type": "http.response.body", "body": b""})
|
|
134
136
|
return
|
|
135
137
|
|
|
136
|
-
# Admin endpoints
|
|
138
|
+
# Admin endpoints
|
|
137
139
|
if path.startswith("/admin"):
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if "
|
|
148
|
-
|
|
140
|
+
# Legacy admin endpoints (Bearer token auth)
|
|
141
|
+
if path in ["/admin/logs", "/admin/sessions"]:
|
|
142
|
+
headers_dict = dict(scope.get("headers", []))
|
|
143
|
+
auth = headers_dict.get(b"authorization", b"").decode()
|
|
144
|
+
expected = os.environ.get("OPENONION_API_KEY", "")
|
|
145
|
+
if not expected or not auth.startswith("Bearer ") or not hmac.compare_digest(auth[7:], expected):
|
|
146
|
+
await send_json(send, {"error": "unauthorized"}, 401)
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
if method == "GET" and path == "/admin/logs":
|
|
150
|
+
result = route_handlers["admin_logs"]()
|
|
151
|
+
if "error" in result:
|
|
152
|
+
await send_json(send, result, 404)
|
|
153
|
+
else:
|
|
154
|
+
await send_text(send, result["content"])
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if method == "GET" and path == "/admin/sessions":
|
|
158
|
+
await send_json(send, route_handlers["admin_sessions"]())
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Admin trust routes (signed request + admin check)
|
|
162
|
+
if path.startswith("/admin/trust/") or path.startswith("/superadmin/"):
|
|
163
|
+
trust_agent = route_handlers["trust_agent"]
|
|
164
|
+
|
|
165
|
+
# For GET requests, allow auth via headers (bodies often stripped by proxies)
|
|
166
|
+
headers_dict = dict(scope.get("headers", []))
|
|
167
|
+
header_from = headers_dict.get(b"x-from", b"").decode()
|
|
168
|
+
header_sig = headers_dict.get(b"x-signature", b"").decode()
|
|
169
|
+
header_ts = headers_dict.get(b"x-timestamp", b"").decode()
|
|
170
|
+
|
|
171
|
+
if method == "GET" and header_from and header_sig and header_ts:
|
|
172
|
+
# Build signed request from headers
|
|
173
|
+
try:
|
|
174
|
+
data = {
|
|
175
|
+
"payload": {"timestamp": float(header_ts)},
|
|
176
|
+
"from": header_from,
|
|
177
|
+
"signature": header_sig
|
|
178
|
+
}
|
|
179
|
+
except ValueError:
|
|
180
|
+
await send_json(send, {"error": "Invalid X-Timestamp header"}, 400)
|
|
181
|
+
return
|
|
149
182
|
else:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
183
|
+
# Read from body (POST or GET without headers)
|
|
184
|
+
body = await read_body(receive)
|
|
185
|
+
try:
|
|
186
|
+
data = json.loads(body) if body else {}
|
|
187
|
+
except json.JSONDecodeError:
|
|
188
|
+
await send_json(send, {"error": "Invalid JSON"}, 400)
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
# Authenticate signed request (reuse same auth as /input, but with "open" trust)
|
|
192
|
+
_, identity, sig_valid, err = route_handlers["auth"](data, "open")
|
|
193
|
+
if err or not sig_valid:
|
|
194
|
+
await send_json(send, {"error": err or "unauthorized: invalid signature"}, 401)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# Check admin permission
|
|
198
|
+
if path.startswith("/superadmin/"):
|
|
199
|
+
# Super admin only (self address)
|
|
200
|
+
if not trust_agent.is_super_admin(identity):
|
|
201
|
+
await send_json(send, {"error": "forbidden: super admin only"}, 403)
|
|
202
|
+
return
|
|
203
|
+
else:
|
|
204
|
+
# Any admin
|
|
205
|
+
if not trust_agent.is_admin(identity):
|
|
206
|
+
await send_json(send, {"error": "forbidden: admin only"}, 403)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
payload = data.get("payload", {})
|
|
210
|
+
client_id = payload.get("client_id")
|
|
211
|
+
|
|
212
|
+
# Route to handlers
|
|
213
|
+
if method == "POST" and path == "/admin/trust/promote":
|
|
214
|
+
if not client_id:
|
|
215
|
+
await send_json(send, {"error": "client_id required"}, 400)
|
|
216
|
+
return
|
|
217
|
+
await send_json(send, route_handlers["admin_trust_promote"](client_id))
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
if method == "POST" and path == "/admin/trust/demote":
|
|
221
|
+
if not client_id:
|
|
222
|
+
await send_json(send, {"error": "client_id required"}, 400)
|
|
223
|
+
return
|
|
224
|
+
await send_json(send, route_handlers["admin_trust_demote"](client_id))
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if method == "POST" and path == "/admin/trust/block":
|
|
228
|
+
if not client_id:
|
|
229
|
+
await send_json(send, {"error": "client_id required"}, 400)
|
|
230
|
+
return
|
|
231
|
+
reason = payload.get("reason", "")
|
|
232
|
+
await send_json(send, route_handlers["admin_trust_block"](client_id, reason))
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
if method == "POST" and path == "/admin/trust/unblock":
|
|
236
|
+
if not client_id:
|
|
237
|
+
await send_json(send, {"error": "client_id required"}, 400)
|
|
238
|
+
return
|
|
239
|
+
await send_json(send, route_handlers["admin_trust_unblock"](client_id))
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
if method == "GET" and path.startswith("/admin/trust/level/"):
|
|
243
|
+
client_id = path[len("/admin/trust/level/"):]
|
|
244
|
+
await send_json(send, route_handlers["admin_trust_level"](client_id))
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
if method == "POST" and path == "/superadmin/add":
|
|
248
|
+
admin_id = payload.get("admin_id")
|
|
249
|
+
if not admin_id:
|
|
250
|
+
await send_json(send, {"error": "admin_id required"}, 400)
|
|
251
|
+
return
|
|
252
|
+
await send_json(send, route_handlers["admin_admins_add"](admin_id))
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
if method == "POST" and path == "/superadmin/remove":
|
|
256
|
+
admin_id = payload.get("admin_id")
|
|
257
|
+
if not admin_id:
|
|
258
|
+
await send_json(send, {"error": "admin_id required"}, 400)
|
|
259
|
+
return
|
|
260
|
+
await send_json(send, route_handlers["admin_admins_remove"](admin_id))
|
|
261
|
+
return
|
|
156
262
|
|
|
157
263
|
await send_json(send, {"error": "not found"}, 404)
|
|
158
264
|
return
|
|
@@ -189,7 +295,7 @@ async def handle_http(
|
|
|
189
295
|
await send_json(send, route_handlers["health"](start_time))
|
|
190
296
|
|
|
191
297
|
elif method == "GET" and path == "/info":
|
|
192
|
-
await send_json(send, route_handlers["info"](trust))
|
|
298
|
+
await send_json(send, route_handlers["info"](trust, trust_config))
|
|
193
299
|
|
|
194
300
|
elif method == "GET" and path == "/docs":
|
|
195
301
|
# Serve static docs page
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Purpose: WebSocket bidirectional communication for ASGI server with real-time agent I/O
|
|
2
|
+
Purpose: WebSocket bidirectional communication for ASGI server with real-time agent I/O and keep-alive
|
|
3
3
|
LLM-Note:
|
|
4
4
|
Dependencies: imports from [network/io/websocket.py, network/asgi/http.py pydantic_json_encoder, asyncio, json, queue, threading] | imported by [network/asgi/__init__.py] | tested by [tests/network/test_asgi_websocket.py]
|
|
5
|
-
Data flow: handle_websocket() accepts connection → receives INPUT message with prompt+session → authenticates via route_handlers["auth"] → starts agent in background thread with WebSocketIO → agent sends events via io.log()/send() → forwards to client via websocket.send → client sends ASK_USER_RESPONSE for approvals → io receives via queue → agent resumes → returns OUTPUT with result+session_id
|
|
6
|
-
State/Effects: maintains WebSocket connection during agent execution | runs agent in daemon thread (non-blocking) | uses queue.Queue for thread-safe I/O between agent and WebSocket | no persistent state (connection-scoped)
|
|
7
|
-
Integration: exposes handle_websocket(scope, receive, send, route_handlers, storage, trust, blacklist, whitelist) | uses WebSocketIO for bidirectional I/O | supports session continuation (same as HTTP) | message types: INPUT (client), OUTPUT/ERROR (server), ASK_USER_RESPONSE (client), trace events (server)
|
|
8
|
-
Performance: async WebSocket handling | agent runs in separate thread to avoid blocking | queue-based message passing | streams events in real-time (thinking, tool_result, approval_needed)
|
|
9
|
-
Errors: sends ERROR message for invalid JSON, auth failures, missing prompt | closes connection with code 4004 for wrong path | catches exceptions in agent thread and sends ERROR
|
|
10
|
-
WebSocket handling for ASGI.
|
|
5
|
+
Data flow: handle_websocket() accepts connection → receives INPUT message with prompt+session → authenticates via route_handlers["auth"] → starts agent in background thread with WebSocketIO → sends PING every 30s for keep-alive → agent sends events via io.log()/send() → forwards to client via websocket.send → client responds with PONG to PING → client sends ASK_USER_RESPONSE for approvals → io receives via queue → agent resumes → returns OUTPUT with result+session_id → result saved to SessionStorage even if client disconnects
|
|
6
|
+
State/Effects: maintains WebSocket connection during agent execution | runs agent in daemon thread (non-blocking) | uses queue.Queue for thread-safe I/O between agent and WebSocket | no persistent state (connection-scoped) | results persisted to .co/session_results.jsonl for 24h TTL
|
|
7
|
+
Integration: exposes handle_websocket(scope, receive, send, route_handlers, storage, trust, blacklist, whitelist) | uses WebSocketIO for bidirectional I/O | supports session continuation (same as HTTP) | message types: INPUT (client), OUTPUT/ERROR (server), PING (server), PONG (client), ASK_USER_RESPONSE (client), trace events (server) | clients can poll GET /sessions/{id} to recover results after disconnect
|
|
8
|
+
Performance: async WebSocket handling | agent runs in separate thread to avoid blocking | queue-based message passing | streams events in real-time (thinking, tool_result, approval_needed) | PING task keeps connection alive through proxies/firewalls
|
|
9
|
+
Errors: sends ERROR message for invalid JSON, auth failures, missing prompt | closes connection with code 4004 for wrong path | catches exceptions in agent thread and sends ERROR | saves result to storage even on disconnect (enables polling recovery)
|
|
10
|
+
WebSocket handling for ASGI with keep-alive and session recovery.
|
|
11
11
|
|
|
12
12
|
ASGI Protocol Types (not our custom types - this is the ASGI spec):
|
|
13
13
|
- websocket.connect : ASGI sends when client wants to connect
|
|
@@ -18,10 +18,21 @@ ASGI Protocol Types (not our custom types - this is the ASGI spec):
|
|
|
18
18
|
- websocket.close : We send to close the connection
|
|
19
19
|
|
|
20
20
|
Our Application Types (sent inside websocket.send text payload):
|
|
21
|
-
- INPUT : Client sends prompt
|
|
22
|
-
- OUTPUT : Server sends final result
|
|
21
|
+
- INPUT : Client sends prompt with session_id
|
|
22
|
+
- OUTPUT : Server sends final result with session_id
|
|
23
23
|
- ERROR : Server sends error message
|
|
24
|
+
- PING : Server keep-alive (every 30s)
|
|
25
|
+
- PONG : Client acknowledges keep-alive
|
|
24
26
|
- ASK_USER_RESPONSE : Client responds to approval request
|
|
27
|
+
- ONBOARD_REQUIRED : Server sends when stranger needs to onboard
|
|
28
|
+
- ONBOARD_SUBMIT : Client sends invite_code or payment
|
|
29
|
+
- ONBOARD_SUCCESS : Server confirms onboard complete
|
|
30
|
+
- ADMIN_PROMOTE : Client (admin) requests promote
|
|
31
|
+
- ADMIN_DEMOTE : Client (admin) requests demote
|
|
32
|
+
- ADMIN_BLOCK : Client (admin) requests block
|
|
33
|
+
- ADMIN_UNBLOCK : Client (admin) requests unblock
|
|
34
|
+
- ADMIN_GET_LEVEL : Client (admin) requests client level
|
|
35
|
+
- ADMIN_RESULT : Server sends admin action result
|
|
25
36
|
- (trace events) : thinking, tool_result, approval_needed, etc.
|
|
26
37
|
"""
|
|
27
38
|
|
|
@@ -30,7 +41,10 @@ import json
|
|
|
30
41
|
import queue
|
|
31
42
|
import threading
|
|
32
43
|
|
|
44
|
+
from rich.console import Console
|
|
33
45
|
from ..io import WebSocketIO
|
|
46
|
+
|
|
47
|
+
console = Console()
|
|
34
48
|
# Import pydantic_json_encoder for serializing Pydantic models (e.g., TokenUsage) in WebSocket responses
|
|
35
49
|
from .http import pydantic_json_encoder
|
|
36
50
|
|
|
@@ -78,10 +92,37 @@ async def handle_websocket(
|
|
|
78
92
|
"text": json.dumps({"type": "ERROR", "message": "Invalid JSON"})})
|
|
79
93
|
continue
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
msg_type = data.get("type")
|
|
96
|
+
|
|
97
|
+
# Handle ONBOARD_SUBMIT (stranger submitting invite code or payment)
|
|
98
|
+
if msg_type == "ONBOARD_SUBMIT":
|
|
99
|
+
await _handle_onboard_submit(data, send, route_handlers)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Handle ADMIN messages (promote, demote, block, unblock, get_level)
|
|
103
|
+
if msg_type and msg_type.startswith("ADMIN_"):
|
|
104
|
+
await _handle_admin_message(data, send, route_handlers)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if msg_type == "INPUT": # Our app type: client wants to run agent
|
|
82
108
|
prompt, identity, sig_valid, err = route_handlers["auth"](
|
|
83
109
|
data, trust, blacklist=blacklist, whitelist=whitelist
|
|
84
110
|
)
|
|
111
|
+
|
|
112
|
+
# Check if stranger needs onboarding
|
|
113
|
+
if err and "forbidden" in err.lower():
|
|
114
|
+
# Get onboard requirements from trust config
|
|
115
|
+
trust_agent = route_handlers["trust_agent"]
|
|
116
|
+
onboard_info = _get_onboard_requirements(trust_agent)
|
|
117
|
+
if onboard_info:
|
|
118
|
+
await send({"type": "websocket.send",
|
|
119
|
+
"text": json.dumps({
|
|
120
|
+
"type": "ONBOARD_REQUIRED",
|
|
121
|
+
"identity": identity,
|
|
122
|
+
**onboard_info
|
|
123
|
+
})})
|
|
124
|
+
continue
|
|
125
|
+
|
|
85
126
|
if err:
|
|
86
127
|
await send({"type": "websocket.send",
|
|
87
128
|
"text": json.dumps({"type": "ERROR", "message": err})})
|
|
@@ -139,16 +180,23 @@ async def handle_websocket(
|
|
|
139
180
|
|
|
140
181
|
|
|
141
182
|
async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threading.Event) -> bool:
|
|
142
|
-
"""Pump messages between WebSocket and IO queues.
|
|
183
|
+
"""Pump messages between WebSocket and IO queues with keep-alive.
|
|
143
184
|
|
|
144
185
|
Runs until agent completes. Handles:
|
|
145
|
-
- Outgoing: io._outgoing queue -> WebSocket
|
|
146
|
-
- Incoming: WebSocket -> io._incoming queue (
|
|
186
|
+
- Outgoing: io._outgoing queue -> WebSocket (agent events)
|
|
187
|
+
- Incoming: WebSocket -> io._incoming queue (approval responses, PONG)
|
|
188
|
+
- Keep-alive: PING messages every 30s to detect dead connections
|
|
147
189
|
|
|
148
190
|
Returns:
|
|
149
191
|
True if client disconnected before agent completed, False otherwise.
|
|
150
192
|
When True, caller should skip sending final OUTPUT (client is gone,
|
|
151
|
-
but result is already saved to SessionStorage).
|
|
193
|
+
but result is already saved to SessionStorage for polling recovery).
|
|
194
|
+
|
|
195
|
+
Keep-alive mechanism:
|
|
196
|
+
- send_ping() task sends PING every 30s
|
|
197
|
+
- Client responds with PONG (handled in receive_incoming)
|
|
198
|
+
- Keeps connection alive through proxies/firewalls
|
|
199
|
+
- Client can detect dead connection if no PING for 60s
|
|
152
200
|
|
|
153
201
|
Implementation note:
|
|
154
202
|
Uses asyncio.Event for signaling disconnect between nested async functions.
|
|
@@ -191,7 +239,11 @@ async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threa
|
|
|
191
239
|
if msg["type"] == "websocket.receive":
|
|
192
240
|
try:
|
|
193
241
|
data = json.loads(msg.get("text", "{}"))
|
|
194
|
-
|
|
242
|
+
# Handle PONG responses (client responding to PING)
|
|
243
|
+
if data.get("type") == "PONG":
|
|
244
|
+
pass # Just acknowledge, no action needed
|
|
245
|
+
else:
|
|
246
|
+
io._incoming.put(data)
|
|
195
247
|
except json.JSONDecodeError:
|
|
196
248
|
pass
|
|
197
249
|
elif msg["type"] == "websocket.disconnect":
|
|
@@ -201,17 +253,226 @@ async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threa
|
|
|
201
253
|
except asyncio.TimeoutError:
|
|
202
254
|
continue
|
|
203
255
|
|
|
256
|
+
async def send_ping():
|
|
257
|
+
"""Send PING messages every 30 seconds to keep connection alive."""
|
|
258
|
+
while not agent_done.is_set() and not disconnected.is_set():
|
|
259
|
+
await asyncio.sleep(30) # Send ping every 30 seconds
|
|
260
|
+
if not agent_done.is_set() and not disconnected.is_set():
|
|
261
|
+
try:
|
|
262
|
+
await ws_send({"type": "websocket.send", "text": json.dumps({"type": "PING"})})
|
|
263
|
+
except Exception:
|
|
264
|
+
# Connection likely closed, stop pinging
|
|
265
|
+
break
|
|
266
|
+
|
|
204
267
|
send_task = asyncio.create_task(send_outgoing())
|
|
205
268
|
recv_task = asyncio.create_task(receive_incoming())
|
|
269
|
+
ping_task = asyncio.create_task(send_ping())
|
|
206
270
|
|
|
207
271
|
while not agent_done.is_set():
|
|
208
272
|
await asyncio.sleep(0.05)
|
|
209
273
|
|
|
274
|
+
# Cancel all tasks
|
|
210
275
|
recv_task.cancel()
|
|
276
|
+
ping_task.cancel()
|
|
211
277
|
try:
|
|
212
278
|
await recv_task
|
|
213
279
|
except asyncio.CancelledError:
|
|
214
280
|
pass
|
|
281
|
+
try:
|
|
282
|
+
await ping_task
|
|
283
|
+
except asyncio.CancelledError:
|
|
284
|
+
pass
|
|
215
285
|
await send_task
|
|
216
286
|
|
|
217
287
|
return disconnected.is_set()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _get_onboard_requirements(trust_agent) -> dict | None:
|
|
291
|
+
"""Get onboard requirements from trust config."""
|
|
292
|
+
config = trust_agent.config
|
|
293
|
+
onboard = config.get("onboard", {})
|
|
294
|
+
if not onboard:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
result = {"methods": []}
|
|
298
|
+
if "invite_code" in onboard:
|
|
299
|
+
result["methods"].append("invite_code")
|
|
300
|
+
if "payment" in onboard:
|
|
301
|
+
result["methods"].append("payment")
|
|
302
|
+
result["payment_amount"] = onboard["payment"]
|
|
303
|
+
# Include agent's address for payment transfer
|
|
304
|
+
payment_address = trust_agent.get_self_address()
|
|
305
|
+
if payment_address:
|
|
306
|
+
result["payment_address"] = payment_address
|
|
307
|
+
|
|
308
|
+
return result if result["methods"] else None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def _handle_onboard_submit(data: dict, send, route_handlers: dict):
|
|
312
|
+
"""Handle ONBOARD_SUBMIT message from client."""
|
|
313
|
+
trust_agent = route_handlers["trust_agent"]
|
|
314
|
+
|
|
315
|
+
# Authenticate the request (signature check only, open trust)
|
|
316
|
+
_, identity, sig_valid, err = route_handlers["auth"](data, "open")
|
|
317
|
+
if err or not sig_valid:
|
|
318
|
+
await send({"type": "websocket.send",
|
|
319
|
+
"text": json.dumps({"type": "ERROR", "message": err or "invalid signature"})})
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
# Check if blocked BEFORE onboarding (don't waste invite codes/payments)
|
|
323
|
+
if trust_agent.is_blocked(identity):
|
|
324
|
+
await send({"type": "websocket.send",
|
|
325
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: blocked"})})
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
payload = data.get("payload", {})
|
|
329
|
+
invite_code = payload.get("invite_code")
|
|
330
|
+
payment = payload.get("payment", 0)
|
|
331
|
+
|
|
332
|
+
# Try invite code
|
|
333
|
+
if invite_code:
|
|
334
|
+
if trust_agent.verify_invite(identity, invite_code):
|
|
335
|
+
# Get actual level after promotion (may differ if already promoted)
|
|
336
|
+
actual_level = trust_agent.get_level(identity)
|
|
337
|
+
# Log to server console for auditing
|
|
338
|
+
console.print(f"[green]✓[/green] Verified [bold]{identity[:16]}...[/bold] with invite code [cyan]{invite_code}[/cyan] → {actual_level}")
|
|
339
|
+
await send({"type": "websocket.send",
|
|
340
|
+
"text": json.dumps({
|
|
341
|
+
"type": "ONBOARD_SUCCESS",
|
|
342
|
+
"identity": identity,
|
|
343
|
+
"level": actual_level,
|
|
344
|
+
"message": f"Invite code verified. You are now a {actual_level}."
|
|
345
|
+
})})
|
|
346
|
+
return
|
|
347
|
+
else:
|
|
348
|
+
console.print(f"[red]✗[/red] Invalid invite code [cyan]{invite_code}[/cyan] from {identity[:16]}...")
|
|
349
|
+
await send({"type": "websocket.send",
|
|
350
|
+
"text": json.dumps({"type": "ERROR", "message": "Invalid invite code"})})
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# Try payment
|
|
354
|
+
if payment > 0:
|
|
355
|
+
if trust_agent.verify_payment(identity, payment):
|
|
356
|
+
# Get actual level after promotion
|
|
357
|
+
actual_level = trust_agent.get_level(identity)
|
|
358
|
+
# Log to server console for auditing
|
|
359
|
+
console.print(f"[green]✓[/green] Verified [bold]{identity[:16]}...[/bold] with payment [cyan]${payment}[/cyan] → {actual_level}")
|
|
360
|
+
await send({"type": "websocket.send",
|
|
361
|
+
"text": json.dumps({
|
|
362
|
+
"type": "ONBOARD_SUCCESS",
|
|
363
|
+
"identity": identity,
|
|
364
|
+
"level": actual_level,
|
|
365
|
+
"message": f"Payment verified. You are now a {actual_level}."
|
|
366
|
+
})})
|
|
367
|
+
return
|
|
368
|
+
else:
|
|
369
|
+
console.print(f"[red]✗[/red] Insufficient payment [cyan]${payment}[/cyan] from {identity[:16]}...")
|
|
370
|
+
await send({"type": "websocket.send",
|
|
371
|
+
"text": json.dumps({"type": "ERROR", "message": "Insufficient payment"})})
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
await send({"type": "websocket.send",
|
|
375
|
+
"text": json.dumps({"type": "ERROR", "message": "invite_code or payment required"})})
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def _handle_admin_message(data: dict, send, route_handlers: dict):
|
|
379
|
+
"""Handle ADMIN_* messages from client."""
|
|
380
|
+
trust_agent = route_handlers["trust_agent"]
|
|
381
|
+
msg_type = data.get("type")
|
|
382
|
+
|
|
383
|
+
# Authenticate the request
|
|
384
|
+
_, identity, sig_valid, err = route_handlers["auth"](data, "open")
|
|
385
|
+
if err or not sig_valid:
|
|
386
|
+
await send({"type": "websocket.send",
|
|
387
|
+
"text": json.dumps({"type": "ERROR", "message": err or "invalid signature"})})
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# Check admin permission
|
|
391
|
+
if not trust_agent.is_admin(identity):
|
|
392
|
+
await send({"type": "websocket.send",
|
|
393
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: admin only"})})
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
payload = data.get("payload", {})
|
|
397
|
+
client_id = payload.get("client_id")
|
|
398
|
+
|
|
399
|
+
# Handle each admin action
|
|
400
|
+
if msg_type == "ADMIN_PROMOTE":
|
|
401
|
+
if not client_id:
|
|
402
|
+
await send({"type": "websocket.send",
|
|
403
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
404
|
+
return
|
|
405
|
+
result = route_handlers["admin_trust_promote"](client_id)
|
|
406
|
+
await send({"type": "websocket.send",
|
|
407
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "promote", **result})})
|
|
408
|
+
|
|
409
|
+
elif msg_type == "ADMIN_DEMOTE":
|
|
410
|
+
if not client_id:
|
|
411
|
+
await send({"type": "websocket.send",
|
|
412
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
413
|
+
return
|
|
414
|
+
result = route_handlers["admin_trust_demote"](client_id)
|
|
415
|
+
await send({"type": "websocket.send",
|
|
416
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "demote", **result})})
|
|
417
|
+
|
|
418
|
+
elif msg_type == "ADMIN_BLOCK":
|
|
419
|
+
if not client_id:
|
|
420
|
+
await send({"type": "websocket.send",
|
|
421
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
422
|
+
return
|
|
423
|
+
reason = payload.get("reason", "")
|
|
424
|
+
result = route_handlers["admin_trust_block"](client_id, reason)
|
|
425
|
+
await send({"type": "websocket.send",
|
|
426
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "block", **result})})
|
|
427
|
+
|
|
428
|
+
elif msg_type == "ADMIN_UNBLOCK":
|
|
429
|
+
if not client_id:
|
|
430
|
+
await send({"type": "websocket.send",
|
|
431
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
432
|
+
return
|
|
433
|
+
result = route_handlers["admin_trust_unblock"](client_id)
|
|
434
|
+
await send({"type": "websocket.send",
|
|
435
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "unblock", **result})})
|
|
436
|
+
|
|
437
|
+
elif msg_type == "ADMIN_GET_LEVEL":
|
|
438
|
+
if not client_id:
|
|
439
|
+
await send({"type": "websocket.send",
|
|
440
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
441
|
+
return
|
|
442
|
+
result = route_handlers["admin_trust_level"](client_id)
|
|
443
|
+
await send({"type": "websocket.send",
|
|
444
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "get_level", **result})})
|
|
445
|
+
|
|
446
|
+
elif msg_type == "ADMIN_ADD":
|
|
447
|
+
# Super admin only
|
|
448
|
+
if not trust_agent.is_super_admin(identity):
|
|
449
|
+
await send({"type": "websocket.send",
|
|
450
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: super admin only"})})
|
|
451
|
+
return
|
|
452
|
+
admin_id = payload.get("admin_id")
|
|
453
|
+
if not admin_id:
|
|
454
|
+
await send({"type": "websocket.send",
|
|
455
|
+
"text": json.dumps({"type": "ERROR", "message": "admin_id required"})})
|
|
456
|
+
return
|
|
457
|
+
result = route_handlers["admin_admins_add"](admin_id)
|
|
458
|
+
await send({"type": "websocket.send",
|
|
459
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "add_admin", **result})})
|
|
460
|
+
|
|
461
|
+
elif msg_type == "ADMIN_REMOVE":
|
|
462
|
+
# Super admin only
|
|
463
|
+
if not trust_agent.is_super_admin(identity):
|
|
464
|
+
await send({"type": "websocket.send",
|
|
465
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: super admin only"})})
|
|
466
|
+
return
|
|
467
|
+
admin_id = payload.get("admin_id")
|
|
468
|
+
if not admin_id:
|
|
469
|
+
await send({"type": "websocket.send",
|
|
470
|
+
"text": json.dumps({"type": "ERROR", "message": "admin_id required"})})
|
|
471
|
+
return
|
|
472
|
+
result = route_handlers["admin_admins_remove"](admin_id)
|
|
473
|
+
await send({"type": "websocket.send",
|
|
474
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "remove_admin", **result})})
|
|
475
|
+
|
|
476
|
+
else:
|
|
477
|
+
await send({"type": "websocket.send",
|
|
478
|
+
"text": json.dumps({"type": "ERROR", "message": f"Unknown admin action: {msg_type}"})})
|