Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
5576 lines (5217 loc) · 257 KB
/
Copy pathserver.py
File metadata and controls
5576 lines (5217 loc) · 257 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""AI Agent Infra v5.0.5 - Community Edition - Web Visualization Server
Lightweight HTTP server providing session-based auth, page routing,
and JSON API endpoints for knowledge, memory, agents, tasks, workspaces,
specs, collaboration groups, and graph visualization.
"""
import hashlib
import base64
import json
import queue
import threading
import logging
import os
import re
import signal
import socket
import sys
import time
import urllib.parse
import urllib.request
import urllib.error
from decimal import Decimal
from http.cookies import SimpleCookie
from contextvars import ContextVar
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from lib import connection, memory_api, memory_lifecycle, knowledge_api, agent_api
from lib import content_security, knowledge_grounding, portal_grounding
from lib import task_plan_api, workspace_api, harness_api, graph_api
from lib import spec_api, collab_api, branch_api, sdd_api, scm_adapter_api
from lib import security, config, user_api
from lib import loop_api
from lib import message_api, event_bus, trace_api, monitor_api, tool_registry
from lib import edition_features
from lib import agent_registration
from lib import identity_api
from lib import governed_contracts, security_lifecycle
from lib import graph_production_profile
from lib import platform_agent_pool
from lib import native_runtime
if getattr(edition_features, 'GRAPH_ENGINEERING_ENABLED', False):
from lib import graph_definition_api, graph_compiler, graph_runtime, graph_worker, graph_event_api, graph_adapter, graph_compat, graph_executor
from lib import graph_assurance, graph_dynamic, a2a_gateway, graph_telemetry
else:
graph_definition_api = graph_compiler = graph_runtime = graph_worker = graph_event_api = graph_adapter = graph_compat = graph_executor = None
graph_assurance = graph_dynamic = a2a_gateway = graph_telemetry = None
if edition_features.has_feature('orchestrator'):
from lib import orchestrator
else:
orchestrator = None
if edition_features.has_feature('governance'):
from lib import governance_api
else:
governance_api = None
VERSION = "5.0.5"
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), 'templates')
STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static')
logger = logging.getLogger(__name__)
sessions = {}
def _session_timeout(scope='PORTAL'):
"""Return the configured web session timeout in seconds."""
try:
from lib import admin_management
return int(admin_management.session_policy(str(scope or 'PORTAL').upper())['idle_timeout_seconds'])
except Exception:
return max(1, int(getattr(_load_server_config(), 'session_timeout', 300)))
def _session_absolute_timeout(scope='PORTAL'):
try:
from lib import admin_management
return int(admin_management.session_policy(str(scope or 'PORTAL').upper())['absolute_timeout_seconds'])
except Exception:
return 28800
def _portal_absolute_session_timeout():
return _session_absolute_timeout('PORTAL')
def _session_cookie(session_id, scope='PORTAL'):
secure = '; Secure' if os.environ.get('CX_WEB_SECURE_COOKIE', '').lower() in {'1', 'true', 'yes'} else ''
return '{}={}; Path=/; HttpOnly; Max-Age={}; SameSite=Lax{}'.format(
_get_cookie_name(scope), session_id, _session_absolute_timeout(scope), secure
)
PAGE_ROUTES = {
'/knowledge': 'knowledge.html',
'/memory': 'memory.html',
'/agents': 'agents.html',
'/tasks': 'tasks.html',
'/workspaces': 'workspaces.html',
'/graph': 'graph.html',
'/specs': 'specs.html',
'/skills': 'skills.html',
'/branches': 'branches.html',
'/loops': 'loops.html',
'/monitor': 'monitor.html',
}
if edition_features.has_feature('audit'):
PAGE_ROUTES['/audit'] = 'audit.html'
if edition_features.has_feature('approvals'):
PAGE_ROUTES['/approvals'] = 'approvals.html'
PUBLIC_API = {
'/api/health',
'/api/login',
'/portal/api/register',
'/portal/api/login',
}
# These routes authenticate with an Admin Token inside their handlers so the
# bootstrap CLI can operate before it has a browser session.
TOKEN_AUTH_API = {
'/api/admin/agent/register',
'/api/admin/agent/recover',
'/api/agents/register',
'/api/agents/heartbeat',
}
ADMIN_SESSION_API_PREFIXES = (
'/api/admin/token/',
'/api/admin/crypto/',
'/api/admin/skill/',
'/api/graph-executors',
)
FEATURE_ROUTE_PREFIXES = {
'approvals': ('/api/approvals', '/approvals'),
'audit': ('/api/audit', '/audit'),
'ldap': ('/api/ldap',),
'skill_token': ('/api/skill/token',),
'orchestrator': ('/api/orchestrator',),
'governance': ('/api/governance', '/api/agents/registry'),
'graph_engineering': (
'/api/graphs', '/api/graph-types', '/api/graph-versions', '/api/graph-runs',
'/api/graph-triggers',
'/api/graph-worker', '/api/graph/workers', '/api/graph/events',
'/api/graph/capabilities', '/api/graph-artifacts',
'/api/graph-executors',
'/api/graph-dynamic', '/api/graph-assurance', '/api/a2a', '/api/telemetry',
),
}
def _route_feature_available(path):
for feature, prefixes in FEATURE_ROUTE_PREFIXES.items():
if any(path == prefix or path.startswith(prefix + '/') for prefix in prefixes):
return edition_features.has_feature(feature)
return True
def _graph_operation_capability(path):
"""Apply the database-authoritative Graph profile to legacy HTTP routes."""
from lib import effective_capabilities
normalized = "/" + str(path or "").lstrip("/")
if normalized.startswith("/api/graphs/") and normalized.endswith("/import"):
return "graph_manifest_draft_import"
if normalized.endswith("/fork"):
return "graph_checkpoint_fork"
if normalized.endswith("/replay"):
return "graph_replay"
if normalized.endswith("/migrate"):
return "graph_dynamic_migration"
return effective_capabilities.graph_operation(path)
def _enforce_graph_profile(path, handler=None):
capability = _graph_operation_capability(path)
if not capability:
return True
try:
current = graph_production_profile.state(capability)
if current == 'CONTROLLED':
if handler is None or handler._require_admin() is None:
return False
graph_production_profile.require(capability, controlled=True)
else:
graph_production_profile.require(capability)
return True
except graph_production_profile.ProfileConflict as exc:
# This helper is called from the request handler, which owns the
# response writer; callers translate the exception into a JSON error.
raise PermissionError('GRAPH_CAPABILITY_BLOCKED: ' + str(exc)) from exc
except graph_production_profile.ProfileUnavailable as exc:
raise RuntimeError('GRAPH_CAPABILITY_UNAVAILABLE: ' + str(exc)) from exc
def _is_public_api(path):
return path in PUBLIC_API
def _uses_token_auth(path):
return path in TOKEN_AUTH_API
def _is_admin_role(role):
return str(role or '').strip().upper() in {'ADMIN', 'ADMINISTRATOR'}
_request_cookie_port = ContextVar('legacy_request_cookie_port', default=None)
def _get_cookie_name(scope='PORTAL'):
"""Return entry-scoped, port-specific cookie name for Web sessions."""
# FastAPI is the network entrypoint from v4.3.0 onward. Legacy page/API
# handlers run in-process behind it, so they must use its runtime port
# rather than the packaged config.json default (normally 8000).
port = _request_cookie_port.get() or os.environ.get('MEMORY_SERVER_PORT', '').strip()
if not port:
port = str(_load_server_config().port)
normalized = str(scope or '').upper()
if normalized not in {'DASHBOARD', 'PORTAL'}:
raise ValueError('invalid session scope')
prefix = 'portal_session_id' if normalized == 'PORTAL' else 'dashboard_session_id'
return f"{prefix}_{port}"
def _load_server_config():
cfg = config.get_config()
return cfg.server
def _product_database_display():
dialect = str(getattr(connection, 'DATABASE_DIALECT', '') or '').lower()
return {
'oracle': 'Oracle',
'postgresql': 'PostgreSQL',
'pg': 'PostgreSQL',
'yashandb': 'YashanDB',
}.get(dialect, dialect or 'Database')
def _product_tier():
return str(getattr(edition_features, 'EDITION', 'Enterprise') or 'Enterprise')
def _product_display_name():
from lib.product_identity import identity
return identity()['name_en']
def _parse_bounded_limit(value, *, default=500, maximum=1000):
"""Parse an export limit without allowing an unbounded snapshot query."""
if value in (None, ''):
return default
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError('limit must be an integer') from exc
if parsed < 1 or parsed > maximum:
raise ValueError(f'limit must be between 1 and {maximum}')
return parsed
def _portal_node_id():
"""Return a stable identifier used to isolate Portal Agent ownership."""
cfg = _load_server_config()
configured = getattr(cfg, 'node_id', '') or os.environ.get('MEMORY_SERVER_NODE_ID', '')
node_id = str(configured).strip() or '{}:{}'.format(socket.gethostname(), cfg.port)
return node_id[:128]
def _create_session(username, user_id, role, mfa_level='NONE', require_identity=False, session_scope='PORTAL'):
created_session = None
try:
principal = identity_api._row(identity_api.connection.execute_query_one(
"SELECT PRINCIPAL_ID FROM CX_HUMAN_IDENTITIES WHERE IDENTITY_TYPE = 'LOCAL' AND SUBJECT_KEY = :username",
{'username': username},
))
principal_id = str((principal or {}).get('principal_id') or user_id)
created_session = identity_api.create_session(
principal_id, str(user_id), _portal_node_id(), auth_method='LOCAL',
mfa_level=mfa_level, ttl_seconds=_session_timeout(session_scope), session_scope=session_scope,
)
session_id = created_session['session_id']
except Exception:
if require_identity:
raise
raw = "{}:{}:{}:{}".format(username, user_id, role, time.time())
session_id = hashlib.sha256(raw.encode()).hexdigest()
sessions[session_id] = {
'username': username,
'user_id': str(user_id),
'principal_id': principal_id if 'principal_id' in locals() else str(user_id),
'role': role,
'created_at': time.time(),
'last_access': time.time(),
'mfa_level': str(mfa_level or 'NONE').upper(),
'session_digest': hashlib.sha256(session_id.encode('utf-8')).hexdigest(),
'csrf_token': (created_session or {}).get('csrf_token', ''),
'expires_at': (created_session or {}).get('expires_at', ''),
}
return session_id
def _set_portal_agent_context(sess):
agent_id = sess.get('agent_id', '')
if agent_id:
connection.set_agent_context(agent_id)
def _clear_portal_agent_context():
connection.set_agent_context(None)
def _resolve_session_as_schema_owner(raw_session_id, session_scope='PORTAL'):
"""Resolve human Web Sessions outside the Business Agent context."""
previous_agent_id = connection.get_current_agent_id()
if previous_agent_id:
connection.set_agent_context(None)
try:
return identity_api.resolve_session(
raw_session_id, ttl_seconds=_session_timeout(session_scope),
absolute_ttl_seconds=_session_absolute_timeout(session_scope), expected_scope=session_scope,
)
finally:
connection.set_agent_context(previous_agent_id)
def _resolve_session_role_as_schema_owner(principal_id):
previous_agent_id = connection.get_current_agent_id()
if previous_agent_id:
connection.set_agent_context(None)
try:
role_row = identity_api.connection.execute_query_one(
"SELECT ROLE_CODE FROM CX_USER_ROLES WHERE PRINCIPAL_ID = :principal_id "
"AND STATUS = 'ACTIVE' ORDER BY VALID_FROM DESC FETCH FIRST 1 ROWS ONLY",
{'principal_id': principal_id},
)
return str((role_row or {}).get('role_code') or '').upper()
finally:
connection.set_agent_context(previous_agent_id)
def _get_or_assign_portal_agent(user_id):
"""Reuse or claim an Agent owned by this Portal node."""
if not user_id:
return None
node_id = _portal_node_id()
attempted = set()
assigned = connection.execute_query_one(
"SELECT a.AGENT_ID FROM AGENT_REGISTRY a "
"JOIN CX_PRINCIPALS p ON p.PRINCIPAL_ID = a.AGENT_ID "
"WHERE a.STATUS = 'ACTIVE' AND p.PRINCIPAL_TYPE = 'AGENT' "
"AND p.STATUS = 'ACTIVE' AND a.CURRENT_USER_ID = :v_uid "
"AND a.PORTAL_NODE_ID = :v_node_id "
"ORDER BY a.UPDATED_AT DESC",
{"v_uid": str(user_id), "v_node_id": node_id},
)
if assigned:
candidate = agent_api.get_agent(assigned['agent_id']) or assigned
else:
candidate = None
while True:
if not candidate:
candidate = agent_api.assign_random_pool_agent(str(user_id), node_id, attempted)
if not candidate:
return None
agent_id = str(candidate.get('agent_id') or '')
if not agent_id or agent_id in attempted:
candidate = None
continue
attempted.add(agent_id)
# Existing pool records are bound only when they are actually selected
# by the local Portal node; this avoids silently claiming stale agents.
try:
registration = agent_registration.get_registration(agent_id)
if registration and str(registration.get('status') or '').upper() != 'ACTIVE':
agent_api.hibernate_agent(agent_id, node_id)
candidate = None
continue
# This is idempotent for an already registered Agent and also
# repairs legacy pool records missing adapter-specific credentials.
agent_api.register_agent(
agent_id,
candidate.get('agent_name') or agent_id,
agent_type=candidate.get('agent_type'),
description=candidate.get('description'),
capabilities=candidate.get('capabilities'),
config=candidate.get('config'),
)
registration = agent_registration.get_registration(agent_id)
if not registration:
# Legacy records remain explicitly adopted only after the
# adapter has had a chance to provision its runtime identity.
registration = agent_registration.adopt_legacy_agent(
agent_id, created_by='portal-confirmation'
)
if not registration or str(registration.get('status') or '').upper() != 'ACTIVE':
agent_api.hibernate_agent(agent_id, node_id)
candidate = None
continue
if identity_api.effective_access(agent_id, 'knowledge.read').get('decision') != 'ALLOW':
agent_api.hibernate_agent(agent_id, node_id)
candidate = None
continue
# Never report an Agent as available when the database-specific
# End User connection cannot actually be opened.
with connection.get_connection_for_agent(agent_id):
pass
return candidate
except Exception:
logger.warning('Unable to bind Portal Agent %s to registration inventory', agent_id)
try:
agent_api.hibernate_agent(agent_id, node_id)
except Exception:
pass
candidate = agent_api.assign_random_pool_agent(str(user_id), node_id, attempted)
return None
def _get_session(request_handler):
cached = getattr(request_handler, '_cx_session_cache', None)
if cached is not None:
return cached
cookie = SimpleCookie(request_handler.headers.get('Cookie', ''))
session_id = None
session_scope = 'PORTAL' if request_handler.path.startswith('/portal') or request_handler.path == '/logout' else 'DASHBOARD'
_cookie_name = _get_cookie_name(session_scope)
if _cookie_name in cookie:
session_id = cookie[_cookie_name].value
if not session_id:
request_handler._cx_session_cache = None
return None
sess = sessions.get(session_id)
if sess:
try:
persisted = _resolve_session_as_schema_owner(session_id, session_scope)
if persisted is None:
sessions.pop(session_id, None)
request_handler._cx_session_cache = None
return None
sess['principal_id'] = persisted.get('principal_id') or sess.get('principal_id')
sess['mfa_level'] = persisted.get('mfa_level') or 'NONE'
except Exception:
pass
# The cached session may predate a server restart or an earlier
# workspace recovery fix. Restore its conversation lazily as well so
# a valid Portal user never receives an empty history by accident.
if session_scope == 'PORTAL' and sess.get('user_id') and not sess.get('workspace_id'):
previous_agent_id = connection.get_current_agent_id()
try:
connection.set_agent_context(None)
recent_workspace = connection.execute_query_one(
"SELECT WORKSPACE_ID FROM WORKSPACES "
"WHERE OWNER_USER_ID = :v_uid AND WORKSPACE_TYPE = 'CONVERSATION' "
"AND STATUS = 'ACTIVE' ORDER BY UPDATED_AT DESC",
{'v_uid': sess['user_id']},
)
if recent_workspace:
sess['workspace_id'] = str(recent_workspace.get('workspace_id') or recent_workspace.get('WORKSPACE_ID') or '')
except Exception:
logger.warning('Unable to restore cached Portal conversation workspace', exc_info=True)
finally:
connection.set_agent_context(previous_agent_id)
else:
try:
persisted = _resolve_session_as_schema_owner(session_id, session_scope)
except Exception:
persisted = None
if not persisted:
request_handler._cx_session_cache = None
return None
sess = {
'username': persisted.get('user_id') or persisted.get('principal_id'),
'user_id': str(persisted.get('user_id') or ''),
'principal_id': persisted.get('principal_id'),
'role': 'USER',
'created_at': time.time(),
'last_access': time.time(),
}
try:
role_code = _resolve_session_role_as_schema_owner(persisted.get('principal_id'))
if role_code in {'SYSTEM_ADMIN', 'SECURITY_ADMIN'}:
sess['role'] = 'ADMIN'
elif role_code:
sess['role'] = role_code
except Exception:
pass
# A persisted Portal session survives a web-service restart, while
# this in-memory cache does not. Restore the user's latest active
# conversation so history remains available before any new message is
# sent or an Agent is reassigned.
if session_scope == 'PORTAL' and sess.get('user_id'):
previous_agent_id = connection.get_current_agent_id()
try:
connection.set_agent_context(None)
recent_workspace = connection.execute_query_one(
"SELECT WORKSPACE_ID FROM WORKSPACES "
"WHERE OWNER_USER_ID = :v_uid AND WORKSPACE_TYPE = 'CONVERSATION' "
"AND STATUS = 'ACTIVE' ORDER BY UPDATED_AT DESC",
{'v_uid': sess['user_id']},
)
if recent_workspace:
sess['workspace_id'] = str(recent_workspace.get('workspace_id') or recent_workspace.get('WORKSPACE_ID') or '')
except Exception:
logger.warning('Unable to restore Portal conversation workspace', exc_info=True)
finally:
connection.set_agent_context(previous_agent_id)
sessions[session_id] = sess
# Preserve the server-authenticated credential identity after a restart as
# well as on cached reads. Controlled model dispatch rechecks this exact
# Session; a Principal ID alone is not a replacement credential.
for credential_key in ('session_digest', 'auth_method', 'permission_version', 'mfa_level'):
if credential_key in persisted:
sess[credential_key] = persisted[credential_key]
if session_scope == 'PORTAL' and sess.get('principal_id'):
# A copied Portal cookie may be valid while another page owns the
# operation lease. Keep the session visible for read-only inspection,
# but fence every mutation through the page lease boundary.
client_instance = request_handler.headers.get('X-CX-Client-Instance', '').strip()
page_instance = request_handler.headers.get('X-CX-Page-Instance', '').strip() or client_instance
try:
identity_api.heartbeat_portal_connection(session_id, _session_timeout('PORTAL'))
if page_instance:
page_lease = identity_api.acquire_portal_page_lease(session_id, page_instance)
sess['portal_page_lease_id'] = page_lease.get('lease_id')
sess['portal_read_only'] = False
except identity_api.IdentityError:
sess['portal_read_only'] = True
except Exception:
# A missing lease schema or temporary database failure must never
# turn a valid authenticated read into an operational bypass.
sess['portal_read_only'] = True
timeout = _session_timeout()
if time.time() - sess.get('last_access', sess['created_at']) > timeout:
sessions.pop(session_id, None)
request_handler._cx_session_cache = None
return None
sess['last_access'] = time.time()
request_handler._cx_session_cache = (session_id, sess)
return request_handler._cx_session_cache
def _authenticate_local(username, password):
try:
modern = identity_api.authenticate_local(username, password)
if modern:
return modern
except Exception:
logger.debug('v4.3 identity login unavailable; using legacy verifier', exc_info=True)
try:
row = connection.execute_query_one(
"SELECT user_id, username, password_hash, role, status, auth_source FROM system_users WHERE username = :uname",
{"uname": username}
)
if row:
try:
salt_row = connection.execute_query_one(
"SELECT salt FROM system_users WHERE username = :uname",
{"uname": username}
)
if salt_row:
row['salt'] = salt_row.get('salt', '') or ''
except Exception:
row['salt'] = ''
except Exception:
return None
if not row or row.get('status') != 'ACTIVE':
return None
if row.get('auth_source', 'LOCAL') != 'LOCAL':
return None
stored_hash = row.get('password_hash', '')
if stored_hash and stored_hash.startswith('SHA256:'):
expected = stored_hash[7:]
salt = row.get('salt', '') or ''
if salt:
actual = hashlib.sha256((password + salt).encode()).hexdigest()
else:
actual = hashlib.sha256(password.encode()).hexdigest()
if actual.upper() == expected.upper():
return row
return None
def _serialize_datetime(val):
if val is None:
return None
if hasattr(val, 'isoformat'):
return val.isoformat()
return str(val)
def _clean_value(value):
if isinstance(value, dict):
return {key: _clean_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_clean_value(item) for item in value]
if hasattr(value, 'isoformat'):
return value.isoformat()
if isinstance(value, Decimal):
return int(value) if value % 1 == 0 else float(value)
if isinstance(value, bytes):
try:
return value.decode('utf-8')
except Exception:
return value.hex()
return value
def _clean_row(row):
return _clean_value(row)
def _graph_all():
all_edges = connection.execute_query(
"SELECT SOURCE_ID, TARGET_ID, EDGE_TYPE, STRENGTH FROM ENTITY_EDGES "
"ORDER BY STRENGTH DESC FETCH FIRST 300 ROWS ONLY"
)
endpoint_ids = []
for edge in all_edges:
for key in ('source_id', 'target_id'):
value = edge.get(key)
if value is not None and value not in endpoint_ids:
endpoint_ids.append(value)
all_items = []
if endpoint_ids:
is_pg = str(getattr(connection, 'DATABASE_DIALECT', '')).lower() in ('pg', 'postgresql')
params = {
'node_%d' % index: str(value) if is_pg else value
for index, value in enumerate(endpoint_ids)
}
placeholders = ','.join(':%s' % key for key in params)
entity_id_expression = 'CAST(ENTITY_ID AS TEXT)' if is_pg else 'ENTITY_ID'
all_items.extend(connection.execute_query(
"SELECT ENTITY_ID, TITLE, ENTITY_TYPE, CATEGORY, IMPORTANCE, STATUS "
"FROM ENTITIES WHERE %s IN (%s)" % (entity_id_expression, placeholders),
params,
))
# Include representative nodes for every persisted type, even when that
# type has no edge among the strongest relationships returned above.
entity_types = connection.execute_query(
"SELECT DISTINCT ENTITY_TYPE FROM ENTITIES ORDER BY ENTITY_TYPE"
)
for type_row in entity_types:
entity_type = type_row.get('entity_type')
if not entity_type:
continue
all_items.extend(connection.execute_query(
"SELECT ENTITY_ID, TITLE, ENTITY_TYPE, CATEGORY, IMPORTANCE, STATUS "
"FROM ENTITIES WHERE ENTITY_TYPE = :entity_type "
"ORDER BY IMPORTANCE DESC FETCH FIRST 12 ROWS ONLY",
{'entity_type': entity_type},
))
type_colors = {
'KNOWLEDGE': '#4a90d9', 'MEMORY': '#4fc3f7', 'TASK_OUTPUT': '#ffb74d',
'EXPERIENCE': '#e57373', 'HARNESS_TEMPLATE': '#ba68c8', 'SPEC': '#66bb6a',
}
nodes = []
known_nodes = set()
for item in all_items:
node_id = str(item['entity_id'])
if node_id in known_nodes:
continue
known_nodes.add(node_id)
tc = type_colors.get(item.get('entity_type', ''), '#666')
imp = item.get('importance', 5) or 5
try:
imp = float(imp)
except (TypeError, ValueError):
imp = 5
nodes.append({
'id': node_id,
'label': (item.get('title') or '')[:40],
'title': item.get('title') or '',
'entity_type': item.get('entity_type', ''),
'importance': imp,
'color': {'background': tc, 'border': tc},
'size': 5 + (imp / 10) * 15,
'category': item.get('category') or '',
})
edges = []
for e in all_edges:
src = e.get('source_id')
tgt = e.get('target_id')
if not src or not tgt or str(src) not in known_nodes or str(tgt) not in known_nodes:
continue
edges.append({
'from': str(src),
'to': str(tgt),
'label': e.get('edge_type', ''),
'edge_type': e.get('edge_type', ''),
'strength': e.get('strength'),
})
return {'nodes': nodes, 'edges': edges}
def _get_tags_for_entities(entity_ids):
if not entity_ids:
return {}
tags_map = {}
params = {f'entity{i}': value for i, value in enumerate(dict.fromkeys(entity_ids))}
rows = connection.execute_query(
"SELECT et.entity_id, t.tag_name FROM entity_tags et JOIN tags t ON et.tag_id = t.tag_id "
"WHERE et.entity_id IN ({})".format(','.join(':' + name for name in params)), params,
)
for r in rows:
eid = str(r['entity_id'])
if eid not in tags_map:
tags_map[eid] = []
tags_map[eid].append(r['tag_name'])
return tags_map
def _knowledge_to_vis(principal_id=None, limit=500):
limit = _parse_bounded_limit(limit)
items = knowledge_api.search_knowledge(limit=limit, principal_id=principal_id)
edge_params = {f'knowledge{i}': item['entity_id'] for i, item in enumerate(items)}
placeholders = ','.join(':' + name for name in edge_params)
edges = connection.execute_query(
"SELECT source_id, source_type, target_id, edge_type, strength, confidence FROM entity_edges "
"WHERE source_type = 'KNOWLEDGE' "
f"AND source_id IN ({placeholders}) AND target_id IN ({placeholders})",
edge_params,
) if items else []
eids = [i['entity_id'] for i in items]
tags_map = _get_tags_for_entities(eids)
nodes = []
for item in items:
nodes.append({
'id': item['entity_id'],
'label': (item.get('title') or '')[:60],
'group': item.get('domain') or item.get('category') or 'general',
'title': item.get('summary') or item.get('title') or '',
'entity_type': 'KNOWLEDGE',
'importance': item.get('importance', 5),
'content': item.get('content') or '',
'summary': item.get('summary') or '',
'domain': item.get('domain') or '',
'topic': item.get('topic') or '',
'difficulty': item.get('difficulty') or '',
'review_count': item.get('review_count', 0),
'tags': tags_map.get(str(item['entity_id']), []),
})
vis_edges = []
visible_ids = {str(item['entity_id']) for item in items}
for e in edges:
if str(e['source_id']) not in visible_ids or str(e['target_id']) not in visible_ids:
continue
vis_edges.append({
'from': e['source_id'],
'to': e['target_id'],
'label': e.get('edge_type', ''),
'value': float(e.get('strength', 1.0)),
})
return {'nodes': nodes, 'edges': vis_edges,
'window': {'limit': limit, 'returned': len(items), 'complete': False}}
def _memory_to_vis(principal_id=None, limit=500):
limit = _parse_bounded_limit(limit)
items = memory_api.search_memories(limit=limit, principal_id=principal_id)
version_ids = [str(i.get('version_id')) for i in items if i.get('version_id')]
# Versioned families need not have a legacy ENTITIES row. Their family ID
# is a display compatibility ID, never an entity_tags foreign key.
def legacy_id(item):
return item.get('legacy_entity_id') if item.get('family_id') else item.get('entity_id')
eids = [legacy_id(item) for item in items if legacy_id(item) is not None]
tags_map = _get_tags_for_entities(eids)
try:
# Load relationships adjacent to this window, including their exact
# authorized endpoints outside it; unrelated relations are another view.
relation_params = {f'memory_v{i}': value for i, value in enumerate(version_ids)}
placeholders = ','.join(':'+name for name in relation_params)
mem_edges = connection.execute_query(
"SELECT r.SOURCE_VERSION_ID AS source_id, r.TARGET_VERSION_ID AS target_id, r.RELATION_TYPE AS edge_type, "
"COALESCE(r.CONFIDENCE, 1) AS strength FROM CX_MEMORY_RELATIONS r "
"WHERE r.RELATION_STATE = 'ACTIVE' AND ("
f"r.SOURCE_VERSION_ID IN ({placeholders}) OR r.TARGET_VERSION_ID IN ({placeholders})) "
"ORDER BY r.SOURCE_VERSION_ID,r.TARGET_VERSION_ID FETCH FIRST 1000 ROWS ONLY",
relation_params,
) if version_ids else []
except Exception:
mem_edges = connection.execute_query(
"SELECT source_id, target_id, edge_type, strength FROM entity_edges "
"WHERE source_type = 'MEMORY' OR target_id IN (%s)" % ','.join(["'%s'" % eid for eid in eids]) if eids else "FALSE",
) if eids else []
endpoint_ids = {
str(value)
for edge in mem_edges
for value in (edge.get('source_id'), edge.get('target_id'))
if value and str(value) not in set(version_ids)
}
related_versions = []
if endpoint_ids:
params = {f"v{i}": value for i, value in enumerate(sorted(endpoint_ids)[:250])}
related_versions = connection.execute_query(
"SELECT v.VERSION_ID,v.TITLE,v.VERSION_NUMBER,v.MEMORY_TYPE,v.MEMORY_SCOPE,v.LIFECYCLE_STATE,v.CLASSIFICATION,"
"v.OWNER_PRINCIPAL_ID,v.OWNER_AGENT_ID,v.SECURITY_DOMAIN_ID,v.VALID_UNTIL,"
"f.FAMILY_ID,CASE WHEN f.CURRENT_VERSION_ID=v.VERSION_ID THEN 1 ELSE 0 END AS IS_CURRENT "
"FROM CX_MEMORY_VERSIONS v LEFT JOIN CX_MEMORY_FAMILIES f ON f.FAMILY_ID=v.FAMILY_ID WHERE v.VERSION_ID IN ({})".format(
','.join(f":v{i}" for i in range(len(params)))
),
params,
)
# Relation rows are only locators. Before exposing a historical endpoint
# or its edge, recheck the current reader and the candidate-derived source
# chain. A graph query must not disclose hidden version IDs by adjacency.
allowed_ids = set(version_ids)
authorized_related = []
if principal_id:
from lib.continuity_promotions import require_memory_original_sources
from lib.continuity_state import ContinuityConflict
for row in related_versions:
try:
memory_lifecycle._require_memory_access(principal_id, row, purpose="RUNTIME_CONTEXT")
connection.execute_transaction_callback(
lambda tx, item=row: require_memory_original_sources(
tx, principal_id, str(item.get("family_id") or ""),
str(item.get("version_id") or ""), item,
)
)
except (PermissionError, memory_lifecycle.MemoryLifecycleError, ContinuityConflict):
continue
authorized_related.append(row)
allowed_ids.add(str(row.get("version_id")))
related_versions = authorized_related
mem_edges = [edge for edge in mem_edges
if str(edge.get("source_id")) in allowed_ids
and str(edge.get("target_id")) in allowed_ids]
nodes = []
for item in items:
nodes.append({
'id': item.get('version_id') or item['entity_id'],
'family_id': item.get('family_id'),
'version_id': item.get('version_id'),
'version_number': item.get('version_number'),
'label': (item.get('title') or '')[:60],
'group': item.get('category') or 'general',
'title': item.get('summary') or item.get('title') or '',
'entity_type': 'MEMORY',
'importance': item.get('importance', 5),
'content': item.get('content') or '',
'summary': item.get('summary') or '',
'category': item.get('memory_type') or item.get('category') or '',
'memory_scope': item.get('memory_scope') or '',
'lifecycle_state': item.get('lifecycle_state') or item.get('status') or '',
'visibility': item.get('visibility') or '',
'owned_by_agent': item.get('owned_by_agent') or '',
'tags': tags_map.get(str(legacy_id(item)), []),
})
# A current-memory view needs the linked historical endpoints; otherwise
# valid lineage edges are silently dropped by the client graph renderer.
for item in related_versions:
is_current = str(item.get('is_current') or '0').upper() in {'1', 'Y', 'TRUE'}
nodes.append({
'id': item['version_id'],
'family_id': item.get('family_id'),
'version_id': item['version_id'],
'version_number': item.get('version_number'),
'label': '{} · V{}'.format(item.get('title') or item['version_id'], item.get('version_number') or '?')[:80],
'group': 'MEMORY' if is_current else 'MEMORY_LINEAGE',
'entity_type': 'MEMORY' if is_current else 'MEMORY_LINEAGE',
'title': 'Current Memory metadata.' if is_current else 'Historical lineage metadata only; this version is not returned to runtime context.',
'category': item.get('memory_type') or '',
'memory_scope': item.get('memory_scope') or '',
'lifecycle_state': item.get('lifecycle_state') or '',
'classification': item.get('classification') or '',
'history_node': not is_current,
'color': None if is_current else {'background': '#80909a', 'border': '#52636d'},
})
vis_edges = []
for e in mem_edges:
vis_edges.append({
'from': e['source_id'],
'to': e['target_id'],
'label': e.get('edge_type', ''),
'value': float(e.get('strength', 1.0)),
})
return {'nodes': nodes, 'edges': vis_edges,
'window': {'limit': limit, 'returned': len(items), 'complete': False,
'related_nodes': len(related_versions), 'relation_limit': 1000,
'related_endpoint_limit': 250, 'relation_scope': 'ADJACENT_TO_WINDOW'}}
class VisHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
allow_reuse_address = True
def log_message(self, fmt, *args):
pass
def _send_json(self, data, status=200):
body = json.dumps(data, default=_serialize_datetime).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_html(self, html, status=200):
body = html.encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'no-store, max-age=0')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_redirect(self, url):
self.send_response(302)
self.send_header('Location', url)
self.end_headers()
def _send_error(self, code, message=''):
self._send_json({'error': message or self.responses.get(code, ('',))[1]}, code)
def _require_auth(self):
result = _get_session(self)
if result is None:
self._send_error(401, 'Authentication required')
return None
entry = 'PORTAL' if self.path.startswith('/portal') else 'APP'
previous_agent_id = connection.get_current_agent_id()
connection.set_agent_context(None)
try:
if not identity_api.entry_allowed(str(result[1].get('principal_id') or ''), entry):
self._send_error(403, '{} access is disabled'.format(entry.title()))
return None
if entry == 'PORTAL' and result[1].get('portal_read_only') and self.command.upper() not in {'GET', 'HEAD'}:
if self.path not in {'/portal/api/agent/release', '/portal/api/auth/logout'}:
self._send_error(423, 'Portal page is read-only because another page is active')
return None
except Exception:
self._send_error(503, 'Entry-access policy is unavailable')
return None
finally:
connection.set_agent_context(previous_agent_id)
return result
def _require_admin(self):
result = self._require_auth()
if result is None:
return None
if not _is_admin_role(result[1].get('role')):
self._send_error(403, 'Administrator role required')
return None
return result
def _is_admin_session(self):
result = _get_session(self)
return bool(result and _is_admin_role(result[1].get('role')))
def _authenticated_actor(self):
"""Derive an actor from the authenticated session or Agent headers."""
agent_id = self.headers.get('X-Agent-Id', '').strip()
if agent_id and agent_registration.authenticate_agent(
agent_id, self.headers.get('X-Agent-Token', '').strip()):
return agent_id
result = _get_session(self)
if not result:
raise PermissionError('authenticated actor required')
sess = result[1]
return str(sess.get('username') or sess.get('user_id') or '')
def _authenticated_principal(self):
"""Return server-derived actor, role, and approval groups.
Request bodies may describe an operation, but they never define who
performed it. The current schema stores a single system-user role, so
group claims are derived conservatively from that role until a separate
directory/group provider is configured.
"""
agent_id = self.headers.get('X-Agent-Id', '').strip()
agent_token = self.headers.get('X-Agent-Token', '').strip()
if agent_id and agent_registration.authenticate_agent(agent_id, agent_token):
registration = agent_registration.get_registration(agent_id) or {}
return {
'actor_id': agent_id,
'actor_type': 'AGENT',
'role': 'AGENT',
'groups': [],
'registration': registration,
}
result = _get_session(self)
if not result:
raise PermissionError('authenticated actor required')
sess = result[1]
role = str(sess.get('role') or 'USER').upper()
groups = {'ADMIN', 'SECURITY', 'AUDIT', 'APPROVAL'} if _is_admin_role(role) else set()
return {
'actor_id': str(sess.get('principal_id') or sess.get('user_id') or sess.get('username') or ''),
'actor_type': 'HUMAN',
'role': role,
'groups': sorted(groups),
'user_id': str(sess.get('user_id') or ''),
}