polyrob 0.5.0__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.
Files changed (745) hide show
  1. agents/README.md +453 -0
  2. agents/__init__.py +164 -0
  3. agents/base_agent.py +743 -0
  4. agents/personality/character.py +119 -0
  5. agents/personality/character_manager.py +309 -0
  6. agents/personality/characters/rob.character.json +56 -0
  7. agents/personality/characters/trump.character.json +63 -0
  8. agents/personality/persona_render.py +78 -0
  9. agents/personality/persona_resolver.py +115 -0
  10. agents/prompt/__init__.py +58 -0
  11. agents/prompt/base_prompt.py +92 -0
  12. agents/prompt/system.py +417 -0
  13. agents/task/README_PATH_MANAGEMENT.md +141 -0
  14. agents/task/__init__.py +162 -0
  15. agents/task/agent/__init__.py +70 -0
  16. agents/task/agent/agent_state.py +408 -0
  17. agents/task/agent/async_delegation.py +274 -0
  18. agents/task/agent/autonomy_state.py +275 -0
  19. agents/task/agent/conversation.py +75 -0
  20. agents/task/agent/core/__init__.py +6 -0
  21. agents/task/agent/core/aux_metering.py +61 -0
  22. agents/task/agent/core/background_review.py +157 -0
  23. agents/task/agent/core/construction.py +1179 -0
  24. agents/task/agent/core/conversational_exit.py +49 -0
  25. agents/task/agent/core/correspondent_gate.py +227 -0
  26. agents/task/agent/core/curator.py +246 -0
  27. agents/task/agent/core/episodic_digest.py +103 -0
  28. agents/task/agent/core/error_recovery.py +480 -0
  29. agents/task/agent/core/history_io.py +257 -0
  30. agents/task/agent/core/llm_provisioning.py +287 -0
  31. agents/task/agent/core/llm_runner.py +547 -0
  32. agents/task/agent/core/logging_io.py +276 -0
  33. agents/task/agent/core/loop_detection.py +262 -0
  34. agents/task/agent/core/memory_prefetch.py +325 -0
  35. agents/task/agent/core/memory_writer.py +667 -0
  36. agents/task/agent/core/model_introspection.py +82 -0
  37. agents/task/agent/core/model_swap.py +177 -0
  38. agents/task/agent/core/next_action_internal.py +1331 -0
  39. agents/task/agent/core/output_validation.py +303 -0
  40. agents/task/agent/core/project_context.py +272 -0
  41. agents/task/agent/core/resources.py +93 -0
  42. agents/task/agent/core/result_processing.py +389 -0
  43. agents/task/agent/core/run_loop.py +661 -0
  44. agents/task/agent/core/safety_lifecycle.py +228 -0
  45. agents/task/agent/core/secret_guard.py +293 -0
  46. agents/task/agent/core/self_wake.py +280 -0
  47. agents/task/agent/core/session_metadata.py +104 -0
  48. agents/task/agent/core/step.py +781 -0
  49. agents/task/agent/core/step_execution.py +398 -0
  50. agents/task/agent/core/step_telemetry.py +222 -0
  51. agents/task/agent/core/test_aux_metering.py +50 -0
  52. agents/task/agent/core/test_billing_failover.py +68 -0
  53. agents/task/agent/core/test_fatal_error_classifier.py +38 -0
  54. agents/task/agent/core/test_judge_validation.py +275 -0
  55. agents/task/agent/core/test_model_swap.py +190 -0
  56. agents/task/agent/core/test_reflection_offload.py +54 -0
  57. agents/task/agent/core/turn_input.py +8 -0
  58. agents/task/agent/core/untrusted_wrap.py +103 -0
  59. agents/task/agent/core/user_ingress.py +228 -0
  60. agents/task/agent/hitl_manager.py +396 -0
  61. agents/task/agent/log_sanitize.py +36 -0
  62. agents/task/agent/message_manager/config.py +97 -0
  63. agents/task/agent/message_manager/service.py +758 -0
  64. agents/task/agent/message_manager/tests.py +305 -0
  65. agents/task/agent/message_manager/tool_call_builder.py +539 -0
  66. agents/task/agent/message_manager/tool_message_repair.py +364 -0
  67. agents/task/agent/message_manager/views.py +143 -0
  68. agents/task/agent/messages/__init__.py +8 -0
  69. agents/task/agent/messages/builders.py +309 -0
  70. agents/task/agent/messages/compactor.py +611 -0
  71. agents/task/agent/messages/context_references.py +271 -0
  72. agents/task/agent/messages/filters.py +333 -0
  73. agents/task/agent/messages/guidance.py +328 -0
  74. agents/task/agent/messages/persistence.py +448 -0
  75. agents/task/agent/messages/retrieval.py +506 -0
  76. agents/task/agent/messages/sqlite_persistence.py +85 -0
  77. agents/task/agent/messages/test_compaction_upgrade.py +349 -0
  78. agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
  79. agents/task/agent/messages/test_sqlite_persistence.py +54 -0
  80. agents/task/agent/messages/token_counter.py +532 -0
  81. agents/task/agent/orchestrator.py +776 -0
  82. agents/task/agent/profile_manager.py +105 -0
  83. agents/task/agent/profile_registry.py +173 -0
  84. agents/task/agent/prompts.py +1090 -0
  85. agents/task/agent/scenario_registry.py +226 -0
  86. agents/task/agent/service.py +509 -0
  87. agents/task/agent/session.py +984 -0
  88. agents/task/agent/skill_discovery.py +110 -0
  89. agents/task/agent/skill_frontmatter.py +94 -0
  90. agents/task/agent/skill_manager.py +1201 -0
  91. agents/task/agent/skill_store.py +627 -0
  92. agents/task/agent/skill_validation.py +56 -0
  93. agents/task/agent/skill_writer.py +492 -0
  94. agents/task/agent/sub_agent_manager.py +1137 -0
  95. agents/task/agent/test_session_disk_recovery.py +34 -0
  96. agents/task/agent/tests.py +513 -0
  97. agents/task/agent/tool_call_tracker.py +511 -0
  98. agents/task/agent/views.py +469 -0
  99. agents/task/config.py +432 -0
  100. agents/task/constants.py +1384 -0
  101. agents/task/flag_defaults.py +63 -0
  102. agents/task/goals/__init__.py +1 -0
  103. agents/task/goals/autonomy_marker.py +28 -0
  104. agents/task/goals/board.py +690 -0
  105. agents/task/goals/completion_judge.py +247 -0
  106. agents/task/goals/context.py +62 -0
  107. agents/task/goals/dispatcher.py +848 -0
  108. agents/task/goals/escalation.py +102 -0
  109. agents/task/goals/planner.py +114 -0
  110. agents/task/logging_config.py +155 -0
  111. agents/task/path.py +991 -0
  112. agents/task/robust_parse_config.py +400 -0
  113. agents/task/runtime/__init__.py +0 -0
  114. agents/task/runtime/run_as_session.py +130 -0
  115. agents/task/runtime_safety.py +308 -0
  116. agents/task/session/__init__.py +5 -0
  117. agents/task/session/browser_pool.py +130 -0
  118. agents/task/session/cleanup.py +612 -0
  119. agents/task/session/execution.py +397 -0
  120. agents/task/session/feed.py +200 -0
  121. agents/task/session/hitl_ingress.py +201 -0
  122. agents/task/session/hooks.py +83 -0
  123. agents/task/session/multi_agent.py +243 -0
  124. agents/task/session/workspace.py +91 -0
  125. agents/task/session_registry.py +91 -0
  126. agents/task/session_route.py +35 -0
  127. agents/task/sqlite_session_registry.py +261 -0
  128. agents/task/surface_config.py +185 -0
  129. agents/task/telemetry/__init__.py +58 -0
  130. agents/task/telemetry/event_log.py +180 -0
  131. agents/task/telemetry/formatters.py +642 -0
  132. agents/task/telemetry/manager.py +571 -0
  133. agents/task/telemetry/memory_events.py +66 -0
  134. agents/task/telemetry/self_events.py +53 -0
  135. agents/task/telemetry/sequence.py +160 -0
  136. agents/task/telemetry/service.py +1265 -0
  137. agents/task/telemetry/views.py +882 -0
  138. agents/task/templates.py +98 -0
  139. agents/task/test_aux_chain.py +151 -0
  140. agents/task/test_aux_router.py +41 -0
  141. agents/task/tests/test_parse_robustness.py +170 -0
  142. agents/task/tool_defaults.py +110 -0
  143. agents/task/utils.py +762 -0
  144. agents/task/utils_json.py +768 -0
  145. agents/task/utils_webview.py +86 -0
  146. agents/task/workspace_context.py +229 -0
  147. agents/task_agent_lite.py +2398 -0
  148. api/README.md +683 -0
  149. api/TASK_API_DOCS.md +151 -0
  150. api/__init__.py +0 -0
  151. api/a2a/__init__.py +37 -0
  152. api/a2a/agent_card.py +337 -0
  153. api/a2a/client.py +657 -0
  154. api/a2a/endpoints.py +375 -0
  155. api/a2a/models.py +268 -0
  156. api/a2a/streaming.py +419 -0
  157. api/a2a/task_handler.py +929 -0
  158. api/admin_endpoints.py +1584 -0
  159. api/app.py +923 -0
  160. api/auth_constants.py +28 -0
  161. api/auth_endpoints.py +402 -0
  162. api/auth_state.py +36 -0
  163. api/chat_via_task.py +42 -0
  164. api/conversation_manager.py +231 -0
  165. api/dependencies.py +186 -0
  166. api/eip8004_endpoints.py +452 -0
  167. api/hyperliquid_models.py +174 -0
  168. api/hyperliquid_routes.py +450 -0
  169. api/interfaces.py +207 -0
  170. api/jwt_middleware.py +83 -0
  171. api/kb/__init__.py +1 -0
  172. api/kb/endpoints.py +314 -0
  173. api/mcp_models.py +279 -0
  174. api/mcp_routes.py +438 -0
  175. api/middleware.py +526 -0
  176. api/models.py +176 -0
  177. api/openai_compat/__init__.py +0 -0
  178. api/openai_compat/model_map.py +25 -0
  179. api/openai_compat/models.py +59 -0
  180. api/openai_compat/router.py +140 -0
  181. api/payment_endpoints.py +444 -0
  182. api/payment_verification.py +138 -0
  183. api/polymarket_models.py +155 -0
  184. api/polymarket_routes.py +501 -0
  185. api/pricing_endpoints.py +89 -0
  186. api/session_routing.py +54 -0
  187. api/skill_endpoints.py +453 -0
  188. api/task_http_api.py +1856 -0
  189. api/webhooks.py +52 -0
  190. api/x402_endpoints.py +280 -0
  191. avatar/README.md +44 -0
  192. avatar/__init__.py +1 -0
  193. avatar/config/rob.json +7 -0
  194. avatar/mindprint.js +395 -0
  195. avatar/renders/rob.meta.json +20 -0
  196. avatar/renders/rob.png +0 -0
  197. avatar/studio.html +373 -0
  198. avatar/webview/avatar-live.js +44 -0
  199. cli/README.md +89 -0
  200. cli/__init__.py +0 -0
  201. cli/cli_surface.py +67 -0
  202. cli/commands/__init__.py +0 -0
  203. cli/commands/_bootstrap.py +49 -0
  204. cli/commands/_errors.py +53 -0
  205. cli/commands/chat.py +973 -0
  206. cli/commands/config.py +100 -0
  207. cli/commands/dashboard.py +103 -0
  208. cli/commands/doctor.py +222 -0
  209. cli/commands/email.py +174 -0
  210. cli/commands/gateway.py +346 -0
  211. cli/commands/goals.py +509 -0
  212. cli/commands/init.py +276 -0
  213. cli/commands/journey.py +30 -0
  214. cli/commands/kb.py +280 -0
  215. cli/commands/model.py +107 -0
  216. cli/commands/owner.py +431 -0
  217. cli/commands/pfp.py +232 -0
  218. cli/commands/run.py +414 -0
  219. cli/commands/serve.py +41 -0
  220. cli/commands/session.py +684 -0
  221. cli/commands/skill_install.py +786 -0
  222. cli/commands/skills.py +128 -0
  223. cli/commands/subagents.py +100 -0
  224. cli/commands/surface.py +81 -0
  225. cli/commands/telegram.py +185 -0
  226. cli/commands/todos.py +209 -0
  227. cli/commands/tools.py +102 -0
  228. cli/commands/update.py +316 -0
  229. cli/commands/whatsapp.py +175 -0
  230. cli/config_store.py +276 -0
  231. cli/gitignore.py +43 -0
  232. cli/inventory.py +46 -0
  233. cli/keys.py +101 -0
  234. cli/persona.py +17 -0
  235. cli/polyrob.py +137 -0
  236. cli/ui/__init__.py +64 -0
  237. cli/ui/activity.py +141 -0
  238. cli/ui/app.py +538 -0
  239. cli/ui/banner.py +233 -0
  240. cli/ui/blocks.py +534 -0
  241. cli/ui/bootstrap_notice.py +47 -0
  242. cli/ui/commands/__init__.py +41 -0
  243. cli/ui/commands/h_cron.py +64 -0
  244. cli/ui/commands/h_journey.py +164 -0
  245. cli/ui/commands/h_kb.py +121 -0
  246. cli/ui/commands/h_learn.py +100 -0
  247. cli/ui/commands/h_mcp.py +215 -0
  248. cli/ui/commands/h_self.py +113 -0
  249. cli/ui/commands/h_skills.py +314 -0
  250. cli/ui/commands/handlers.py +1672 -0
  251. cli/ui/commands/registry.py +426 -0
  252. cli/ui/dialog.py +425 -0
  253. cli/ui/event_registry.py +110 -0
  254. cli/ui/event_specs.py +45 -0
  255. cli/ui/events.py +433 -0
  256. cli/ui/identity.py +15 -0
  257. cli/ui/lifecycle.py +159 -0
  258. cli/ui/live_hooks.py +43 -0
  259. cli/ui/model_selector.py +532 -0
  260. cli/ui/persistent_loop.py +181 -0
  261. cli/ui/pick.py +113 -0
  262. cli/ui/plain_renderer.py +351 -0
  263. cli/ui/renderer.py +384 -0
  264. cli/ui/rich_renderer.py +533 -0
  265. cli/ui/secrets.py +115 -0
  266. cli/ui/state.py +376 -0
  267. cli/ui/statusbar.py +231 -0
  268. cli/ui/streaming.py +254 -0
  269. cli/ui/terminal_render.py +105 -0
  270. cli/ui/theme.py +132 -0
  271. cli/update/__init__.py +6 -0
  272. cli/update/context.py +121 -0
  273. cli/update/detect.py +176 -0
  274. cli/update/engine.py +95 -0
  275. cli/update/migrate_guarded.py +55 -0
  276. cli/update/process_guard.py +215 -0
  277. cli/update/runners.py +78 -0
  278. cli/update/snapshot.py +270 -0
  279. cli/update/versions.py +205 -0
  280. core/README.md +377 -0
  281. core/__init__.py +174 -0
  282. core/assets.py +87 -0
  283. core/async_bridge.py +76 -0
  284. core/autonomy_runtime.py +259 -0
  285. core/base_component.py +302 -0
  286. core/bootstrap.py +681 -0
  287. core/bot.py +310 -0
  288. core/config.py +833 -0
  289. core/constants.py +126 -0
  290. core/container.py +462 -0
  291. core/db_manifest.py +101 -0
  292. core/embedding.py +57 -0
  293. core/env.py +39 -0
  294. core/exceptions.py +287 -0
  295. core/flags.py +144 -0
  296. core/flags_catalog.py +345 -0
  297. core/home_migration.py +62 -0
  298. core/identity.py +118 -0
  299. core/initialization.py +1004 -0
  300. core/instance.py +537 -0
  301. core/interactive_gate.py +96 -0
  302. core/logging.py +436 -0
  303. core/owner_doc_writer.py +176 -0
  304. core/pairing.py +193 -0
  305. core/path_safety.py +20 -0
  306. core/paths.py +27 -0
  307. core/payment_config.py +21 -0
  308. core/permissions.py +322 -0
  309. core/runtime_config.py +96 -0
  310. core/runtime_paths.py +135 -0
  311. core/seams.py +35 -0
  312. core/secret_scan.py +25 -0
  313. core/secret_scrub.py +71 -0
  314. core/secrets.py +35 -0
  315. core/security_logging_filter.py +148 -0
  316. core/self_context_writer.py +333 -0
  317. core/self_evolution.py +294 -0
  318. core/session_context.py +47 -0
  319. core/sqlite_util.py +59 -0
  320. core/surfaces/__init__.py +16 -0
  321. core/surfaces/access.py +118 -0
  322. core/surfaces/binding.py +88 -0
  323. core/surfaces/bootstrap.py +78 -0
  324. core/surfaces/circuit.py +168 -0
  325. core/surfaces/continuity.py +77 -0
  326. core/surfaces/correspondents.py +228 -0
  327. core/surfaces/dispatcher.py +216 -0
  328. core/surfaces/envelopes.py +75 -0
  329. core/surfaces/gc.py +30 -0
  330. core/surfaces/idempotency.py +57 -0
  331. core/surfaces/inbound_webhook.py +124 -0
  332. core/surfaces/media.py +32 -0
  333. core/surfaces/message_router.py +91 -0
  334. core/surfaces/outbound_allowlist.py +80 -0
  335. core/surfaces/outbound_dispatcher.py +110 -0
  336. core/surfaces/outbound_mirror.py +37 -0
  337. core/surfaces/outbound_queue.py +107 -0
  338. core/surfaces/outbound_target.py +12 -0
  339. core/surfaces/owner_admin.py +29 -0
  340. core/surfaces/proactive.py +78 -0
  341. core/surfaces/progress.py +108 -0
  342. core/surfaces/rate_bucket.py +21 -0
  343. core/surfaces/registry.py +51 -0
  344. core/surfaces/rendering.py +53 -0
  345. core/surfaces/send_policy.py +14 -0
  346. core/surfaces/serialize.py +24 -0
  347. core/surfaces/session_chat_registry.py +112 -0
  348. core/surfaces/session_policy.py +60 -0
  349. core/surfaces/surface.py +288 -0
  350. core/surfaces/transcription.py +88 -0
  351. core/surfaces/voice_echo.py +26 -0
  352. core/surfaces/voice_guard.py +23 -0
  353. core/tickers.py +174 -0
  354. core/tool_catalog.py +192 -0
  355. core/version.py +75 -0
  356. core/wallet/__init__.py +5 -0
  357. core/wallet/agent_wallet.py +59 -0
  358. core/wallet/audit_sink.py +61 -0
  359. core/wallet/config.py +70 -0
  360. core/wallet/factory.py +87 -0
  361. core/wallet/policy.py +104 -0
  362. core/wallet/signer.py +51 -0
  363. cron/README.md +65 -0
  364. cron/__init__.py +0 -0
  365. cron/delivery.py +277 -0
  366. cron/digest.py +124 -0
  367. cron/jobs.py +207 -0
  368. cron/runner.py +297 -0
  369. cron/schedule.py +194 -0
  370. cron/scheduler.py +201 -0
  371. cron/service.py +57 -0
  372. cron/wake_gate.py +223 -0
  373. data/__init__.py +1 -0
  374. data/prompts/autov2_prompts.json +87 -0
  375. data/prompts/skills/browser-automation/SKILL.md +43 -0
  376. data/prompts/skills/coding-workflow/SKILL.md +36 -0
  377. data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
  378. data/prompts/skills/document-writing/SKILL.md +33 -0
  379. data/prompts/skills/email-comms/SKILL.md +33 -0
  380. data/prompts/skills/file-data-ops/SKILL.md +35 -0
  381. data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
  382. data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
  383. data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
  384. data/prompts/skills/lead-research/SKILL.md +86 -0
  385. data/prompts/skills/market-research-brief/SKILL.md +104 -0
  386. data/prompts/skills/person-analyzer/SKILL.md +124 -0
  387. data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
  388. data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
  389. data/prompts/skills/polymarket-trading/SKILL.md +55 -0
  390. data/prompts/skills/presentation-creator/SKILL.md +36 -0
  391. data/prompts/skills/project-analyzer/SKILL.md +169 -0
  392. data/prompts/skills/rules.json +268 -0
  393. data/prompts/skills/secret-handling/SKILL.md +20 -0
  394. data/prompts/skills/skill-authoring/SKILL.md +31 -0
  395. data/prompts/skills/skill-security-review/SKILL.md +27 -0
  396. data/prompts/skills/social-discovery/SKILL.md +57 -0
  397. data/prompts/skills/task-planning/SKILL.md +33 -0
  398. data/prompts/skills/web-research/SKILL.md +39 -0
  399. data/prompts/skills/web-scraping/SKILL.md +73 -0
  400. data/prompts/skills/x-engagement/SKILL.md +69 -0
  401. data/prompts/system_prompts.json +4 -0
  402. data/subscription_channels.json +5 -0
  403. modules/README.md +503 -0
  404. modules/__init__.py +124 -0
  405. modules/auth/__init__.py +13 -0
  406. modules/auth/api_key_manager.py +157 -0
  407. modules/auth/identity_mapper.py +380 -0
  408. modules/auth/siwe_auth.py +273 -0
  409. modules/auth/tier_manager.py +173 -0
  410. modules/base_module.py +190 -0
  411. modules/credits/__init__.py +29 -0
  412. modules/credits/balance_manager.py +237 -0
  413. modules/credits/cost_utils.py +129 -0
  414. modules/credits/pricing.py +139 -0
  415. modules/credits/unified_ledger.py +148 -0
  416. modules/credits/usage_meter.py +310 -0
  417. modules/credits/usage_tracker.py +674 -0
  418. modules/database/__init__.py +33 -0
  419. modules/database/audit_log.py +322 -0
  420. modules/database/auth_tables.py +276 -0
  421. modules/database/connection.py +644 -0
  422. modules/database/connection_pool.py +305 -0
  423. modules/database/conversation_contexts.py +446 -0
  424. modules/database/database_manager.py +237 -0
  425. modules/database/hyperliquid.py +375 -0
  426. modules/database/polymarket.py +574 -0
  427. modules/database/user_mcp_servers.py +728 -0
  428. modules/database/user_profiles.py +562 -0
  429. modules/database/utils.py +107 -0
  430. modules/database/x402_tables.py +92 -0
  431. modules/eip8004/README.md +1069 -0
  432. modules/eip8004/__init__.py +65 -0
  433. modules/eip8004/contracts.py +498 -0
  434. modules/eip8004/models.py +271 -0
  435. modules/eip8004/registration.py +148 -0
  436. modules/eip8004/reputation.py +367 -0
  437. modules/eip8004/validation.py +277 -0
  438. modules/llm/__init__.py +97 -0
  439. modules/llm/adapters.py +963 -0
  440. modules/llm/anthropic_client.py +831 -0
  441. modules/llm/available_models.py +106 -0
  442. modules/llm/brain_scrubber.py +137 -0
  443. modules/llm/cache_hints.py +162 -0
  444. modules/llm/deepseek_client.py +562 -0
  445. modules/llm/gemini_client.py +1367 -0
  446. modules/llm/llm_client.py +1139 -0
  447. modules/llm/llm_client_registry.py +188 -0
  448. modules/llm/llm_factory.py +237 -0
  449. modules/llm/llm_manager.py +1036 -0
  450. modules/llm/messages.py +374 -0
  451. modules/llm/model_registry.py +1935 -0
  452. modules/llm/nvidia_client.py +125 -0
  453. modules/llm/openai_client.py +800 -0
  454. modules/llm/openrouter_client.py +969 -0
  455. modules/llm/profiles.py +178 -0
  456. modules/llm/test_anthropic_cache_capture.py +35 -0
  457. modules/llm/test_cached_pricing_derivation.py +24 -0
  458. modules/llm/think_scrubber.py +444 -0
  459. modules/llm/token_counter.py +268 -0
  460. modules/memory/__init__.py +35 -0
  461. modules/memory/backend_factory.py +83 -0
  462. modules/memory/cache_manager.py +186 -0
  463. modules/memory/episodic.py +108 -0
  464. modules/memory/local_vector_memory_provider.py +552 -0
  465. modules/memory/memory_manager.py +225 -0
  466. modules/memory/models.py +312 -0
  467. modules/memory/provider.py +210 -0
  468. modules/memory/registry.py +205 -0
  469. modules/memory/sqlite_memory_provider.py +767 -0
  470. modules/memory/task/__init__.py +52 -0
  471. modules/memory/task/compaction_manager.py +157 -0
  472. modules/memory/task/context_retriever.py +809 -0
  473. modules/memory/task/hierarchical_memory.py +1002 -0
  474. modules/memory/task/lexical_retriever.py +71 -0
  475. modules/memory/task/null_context_manager.py +213 -0
  476. modules/memory/task/phase_manager.py +625 -0
  477. modules/memory/task/reflection_service.py +157 -0
  478. modules/memory/task/semantic_retriever.py +218 -0
  479. modules/memory/task/task_context_manager.py +1165 -0
  480. modules/memory/task/test_forgetting_engages.py +24 -0
  481. modules/memory/task/test_reflection_llm.py +39 -0
  482. modules/memory/task/test_threat_scan.py +53 -0
  483. modules/memory/task/threat_scan.py +90 -0
  484. modules/memory/test_sqlite_memory_provider.py +106 -0
  485. modules/memory/user_profile_manager.py +527 -0
  486. modules/payments/__init__.py +11 -0
  487. modules/payments/deposit_monitor.py +478 -0
  488. modules/payments/price_oracle.py +70 -0
  489. modules/payments/treasury_sweeper.py +401 -0
  490. modules/payments/wallet_generator.py +145 -0
  491. modules/pfp/__init__.py +1 -0
  492. modules/pfp/config.py +92 -0
  493. modules/pfp/mesh.py +565 -0
  494. modules/pfp/push.py +69 -0
  495. modules/pfp/renderer.py +85 -0
  496. modules/pfp/store.py +82 -0
  497. modules/skills/__init__.py +1 -0
  498. modules/skills/skill_usage.py +248 -0
  499. modules/transcription/__init__.py +24 -0
  500. modules/transcription/base.py +20 -0
  501. modules/transcription/faster_whisper_transcriber.py +55 -0
  502. modules/x402/README.md +249 -0
  503. modules/x402/__init__.py +22 -0
  504. modules/x402/invoicing.py +416 -0
  505. modules/x402/middleware.py +364 -0
  506. modules/x402/settlement_watcher.py +145 -0
  507. modules/x402/x402_integration.py +344 -0
  508. polyrob-0.5.0.dist-info/METADATA +482 -0
  509. polyrob-0.5.0.dist-info/RECORD +745 -0
  510. polyrob-0.5.0.dist-info/WHEEL +5 -0
  511. polyrob-0.5.0.dist-info/entry_points.txt +2 -0
  512. polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
  513. polyrob-0.5.0.dist-info/top_level.txt +12 -0
  514. surfaces/README.md +70 -0
  515. surfaces/__init__.py +0 -0
  516. surfaces/email/__init__.py +0 -0
  517. surfaces/email/dedup.py +44 -0
  518. surfaces/email/harness.py +187 -0
  519. surfaces/email/inbound.py +159 -0
  520. surfaces/email/seed.py +60 -0
  521. surfaces/email/surface.py +105 -0
  522. surfaces/telegram/__init__.py +0 -0
  523. surfaces/telegram/dedup.py +83 -0
  524. surfaces/telegram/harness.py +886 -0
  525. surfaces/telegram/inbound.py +145 -0
  526. surfaces/telegram/interactive_tools.py +49 -0
  527. surfaces/telegram/markdown.py +347 -0
  528. surfaces/telegram/rate_limit.py +167 -0
  529. surfaces/telegram/surface.py +187 -0
  530. surfaces/telegram/voice.py +52 -0
  531. surfaces/whatsapp/__init__.py +0 -0
  532. surfaces/whatsapp/client.py +51 -0
  533. surfaces/whatsapp/harness.py +62 -0
  534. surfaces/whatsapp/inbound.py +132 -0
  535. surfaces/whatsapp/surface.py +64 -0
  536. surfaces/whatsapp/window.py +34 -0
  537. tools/README.md +578 -0
  538. tools/__init__.py +424 -0
  539. tools/alchemy/__init__.py +5 -0
  540. tools/alchemy/alchemy_tool.py +389 -0
  541. tools/anysite/__init__.py +17 -0
  542. tools/anysite/client.py +88 -0
  543. tools/anysite/tool.py +89 -0
  544. tools/base_tool.py +468 -0
  545. tools/browser/__init__.py +22 -0
  546. tools/browser/actions.py +118 -0
  547. tools/browser/browser.py +1457 -0
  548. tools/browser/browser_manager.py +690 -0
  549. tools/browser/context.py +1748 -0
  550. tools/browser/playwright_utils.py +137 -0
  551. tools/browser/views.py +56 -0
  552. tools/code_exec/SANDBOX_SECURITY.md +144 -0
  553. tools/code_exec/__init__.py +178 -0
  554. tools/code_exec/backend.py +73 -0
  555. tools/code_exec/backends/__init__.py +1 -0
  556. tools/code_exec/backends/docker.py +779 -0
  557. tools/code_exec/backends/local_subprocess.py +154 -0
  558. tools/code_exec/env_policy.py +45 -0
  559. tools/code_exec/result.py +42 -0
  560. tools/code_exec/sandbox_guard.py +59 -0
  561. tools/code_exec/tool.py +227 -0
  562. tools/coding/__init__.py +50 -0
  563. tools/coding/edit.py +107 -0
  564. tools/coding/search.py +113 -0
  565. tools/coding/tool.py +353 -0
  566. tools/collabland/__init__.py +5 -0
  567. tools/collabland/collabland_tool.py +471 -0
  568. tools/controller/__init__.py +0 -0
  569. tools/controller/_helpers.py +165 -0
  570. tools/controller/action_registration.py +1765 -0
  571. tools/controller/approval.py +244 -0
  572. tools/controller/approval_interactive.py +90 -0
  573. tools/controller/delegation.py +195 -0
  574. tools/controller/execution.py +797 -0
  575. tools/controller/execution_context.py +141 -0
  576. tools/controller/hooks.py +135 -0
  577. tools/controller/introspection.py +344 -0
  578. tools/controller/mcp_registrar.py +190 -0
  579. tools/controller/message_send.py +31 -0
  580. tools/controller/registry/__init__.py +4 -0
  581. tools/controller/registry/schema_generators.py +395 -0
  582. tools/controller/registry/schema_sanitizer.py +406 -0
  583. tools/controller/registry/service.py +1266 -0
  584. tools/controller/registry/views.py +151 -0
  585. tools/controller/service.py +409 -0
  586. tools/controller/tool_management.py +334 -0
  587. tools/controller/types.py +28 -0
  588. tools/controller/views.py +450 -0
  589. tools/cronjob_tools.py +166 -0
  590. tools/crypto_trade_gate.py +59 -0
  591. tools/descriptors.py +452 -0
  592. tools/dom/__init__.py +0 -0
  593. tools/dom/history_tree_processor/service.py +244 -0
  594. tools/dom/history_tree_processor/view.py +99 -0
  595. tools/dom/service.py +471 -0
  596. tools/dom/views.py +346 -0
  597. tools/email_tool.py +403 -0
  598. tools/exceptions.py +169 -0
  599. tools/filesystem.py +1038 -0
  600. tools/filesystem_docproc.py +331 -0
  601. tools/filesystem_pdf.py +755 -0
  602. tools/git/__init__.py +42 -0
  603. tools/git/tool.py +258 -0
  604. tools/github/__init__.py +33 -0
  605. tools/github/client.py +94 -0
  606. tools/github/tool.py +237 -0
  607. tools/goal_tools.py +309 -0
  608. tools/hyperliquid/__init__.py +56 -0
  609. tools/hyperliquid/models.py +233 -0
  610. tools/hyperliquid/service.py +1680 -0
  611. tools/knowledge_ingest.py +950 -0
  612. tools/mcp/README.md +535 -0
  613. tools/mcp/__init__.py +52 -0
  614. tools/mcp/catalog.py +115 -0
  615. tools/mcp/config.py +337 -0
  616. tools/mcp/mcp_tool.py +1415 -0
  617. tools/mcp/param_coercion.py +289 -0
  618. tools/mcp/protocol.py +1520 -0
  619. tools/mcp/rate_limit.py +50 -0
  620. tools/mcp/security.py +424 -0
  621. tools/mcp/self_install.py +111 -0
  622. tools/mcp/server_manager.py +829 -0
  623. tools/mcp/subscriptions.py +72 -0
  624. tools/mcp/user_mcp_service.py +899 -0
  625. tools/mcp/validation_tracker.py +89 -0
  626. tools/mcp/views.py +346 -0
  627. tools/oauth/__init__.py +17 -0
  628. tools/oauth/manager.py +75 -0
  629. tools/oauth/provider.py +69 -0
  630. tools/oauth/providers/__init__.py +1 -0
  631. tools/oauth/providers/generic_oauth2.py +96 -0
  632. tools/perplexity_tool.py +280 -0
  633. tools/polymarket/__init__.py +41 -0
  634. tools/polymarket/clob_adapter.py +76 -0
  635. tools/polymarket/models.py +182 -0
  636. tools/polymarket/service.py +2082 -0
  637. tools/self_env/__init__.py +61 -0
  638. tools/self_env/tool.py +274 -0
  639. tools/shell/__init__.py +80 -0
  640. tools/shell/backend_pool.py +118 -0
  641. tools/shell/discipline.py +62 -0
  642. tools/shell/executor.py +157 -0
  643. tools/shell/loopback_allow.py +87 -0
  644. tools/shell/process_registry.py +113 -0
  645. tools/shell/process_tool.py +131 -0
  646. tools/shell/state.py +129 -0
  647. tools/shell/tool.py +151 -0
  648. tools/task_tool.py +637 -0
  649. tools/twitter_tool.py +2086 -0
  650. tools/user_directory.py +179 -0
  651. tools/web_fetch/__init__.py +3 -0
  652. tools/web_fetch/fetcher.py +144 -0
  653. tools/web_fetch/render.py +44 -0
  654. tools/web_fetch/tool.py +60 -0
  655. tools/x402/__init__.py +60 -0
  656. tools/x402/client.py +47 -0
  657. tools/x402/invoice_tool.py +143 -0
  658. tools/x402/real_client.py +250 -0
  659. tools/x402/service.py +131 -0
  660. utils/README.md +498 -0
  661. utils/__init__.py +46 -0
  662. utils/auth_utils.py +114 -0
  663. utils/bounded_collections.py +162 -0
  664. utils/circuit_breaker.py +389 -0
  665. utils/gif_utils.py +435 -0
  666. utils/markdown_utils.py +291 -0
  667. utils/message_utils.py +147 -0
  668. utils/metrics.py +36 -0
  669. utils/path_validator.py +227 -0
  670. utils/rate_limit_manager.py +399 -0
  671. utils/result_size.py +296 -0
  672. utils/time_utils.py +206 -0
  673. utils/user_utils.py +188 -0
  674. webview/README.md +111 -0
  675. webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
  676. webview/__init__.py +8 -0
  677. webview/activity.py +636 -0
  678. webview/owner_auth.py +108 -0
  679. webview/pages.py +448 -0
  680. webview/repair_sessions.py +399 -0
  681. webview/server.py +4323 -0
  682. webview/server_launcher.py +112 -0
  683. webview/static/css/activity.css +89 -0
  684. webview/static/css/chat.css +1772 -0
  685. webview/static/css/components.css +1190 -0
  686. webview/static/css/config-panel.css +651 -0
  687. webview/static/css/pages/admin.css +766 -0
  688. webview/static/css/pages/profile.css +231 -0
  689. webview/static/css/pages/settings.css +297 -0
  690. webview/static/css/pages/signin.css +316 -0
  691. webview/static/css/style.css +4250 -0
  692. webview/static/css/variables.css +147 -0
  693. webview/static/css/workspace-fullwidth-fix.css +109 -0
  694. webview/static/img/favicon.ico +0 -0
  695. webview/static/js/activity.js +394 -0
  696. webview/static/js/admin/activity.js +186 -0
  697. webview/static/js/admin/dashboard.js +163 -0
  698. webview/static/js/admin/user_detail.js +400 -0
  699. webview/static/js/admin/users.js +174 -0
  700. webview/static/js/admin/utils.js +214 -0
  701. webview/static/js/chat.js +3178 -0
  702. webview/static/js/config-panel.js +522 -0
  703. webview/static/js/constants.js +20 -0
  704. webview/static/js/error-handler.js +226 -0
  705. webview/static/js/ethers.min.js +1 -0
  706. webview/static/js/event-filter.js +137 -0
  707. webview/static/js/event-store.js +351 -0
  708. webview/static/js/file-attachments.js +374 -0
  709. webview/static/js/file-loader.js +163 -0
  710. webview/static/js/index.js +103 -0
  711. webview/static/js/performance-utils.js +196 -0
  712. webview/static/js/profile.js +396 -0
  713. webview/static/js/screenshot.js +386 -0
  714. webview/static/js/session.js +1527 -0
  715. webview/static/js/settings.js +1288 -0
  716. webview/static/js/sidebar-data.js +766 -0
  717. webview/static/js/sidebar-toggle.js +296 -0
  718. webview/static/js/socket.io.min.js +7 -0
  719. webview/static/js/stats.js +355 -0
  720. webview/static/js/ui-utils.js +420 -0
  721. webview/static/js/workspace.js +966 -0
  722. webview/stats_service.py +613 -0
  723. webview/templates/__init__.py +1 -0
  724. webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
  725. webview/templates/activity.html +36 -0
  726. webview/templates/admin/activity.html +122 -0
  727. webview/templates/admin/dashboard.html +112 -0
  728. webview/templates/admin/user_detail.html +228 -0
  729. webview/templates/admin/users.html +105 -0
  730. webview/templates/autonomy.html +60 -0
  731. webview/templates/error.html +133 -0
  732. webview/templates/finance.html +90 -0
  733. webview/templates/identity.html +73 -0
  734. webview/templates/index.html +42 -0
  735. webview/templates/layout.html +125 -0
  736. webview/templates/memory.html +59 -0
  737. webview/templates/owner_login.html +15 -0
  738. webview/templates/profile.html +121 -0
  739. webview/templates/session.html +312 -0
  740. webview/templates/settings.html +534 -0
  741. webview/templates/sidebar.html +112 -0
  742. webview/templates/signin.html +213 -0
  743. webview/templates/status.html +14 -0
  744. webview/templates/system.html +45 -0
  745. webview/webgate.py +196 -0
webview/server.py ADDED
@@ -0,0 +1,4323 @@
1
+ from __future__ import annotations
2
+
3
+ """WebView ASGI server for POLYROB sessions.
4
+
5
+ This tiny server fulfils three roles:
6
+
7
+ 1. Serve the static front-end assets and Jinja2 templates that make up the
8
+ Web-UI shipped in the *webview* package.
9
+ 2. Expose a very small HTTP JSON API that the UI can call to get the initial
10
+ state of a session (feed entries, workspace tree…).
11
+ 3. Provide a **WebSocket / Socket.IO** real-time stream so browsers can receive
12
+ live updates as soon as the POLYROB agents append new telemetry to the *feed*
13
+ directory of the session.
14
+
15
+ The implementation purposefully avoids any heavy frameworks – *FastAPI* +
16
+ *python-socketio* + *watchfiles* give us everything we need while keeping the
17
+ runtime footprint minimal.
18
+ """
19
+
20
+ from pathlib import Path
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ from typing import Any, Dict, List, Optional
25
+ import os
26
+ from datetime import datetime
27
+ import time
28
+ import hashlib
29
+ import hmac
30
+ from collections import defaultdict
31
+
32
+ import socketio
33
+ from fastapi import FastAPI, HTTPException, Request, Response, Depends
34
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
35
+ from fastapi.staticfiles import StaticFiles
36
+ from fastapi.templating import Jinja2Templates
37
+ from watchfiles import awatch, Change
38
+
39
+ # Local utility that aggregates session statistics from feed files
40
+ from webview.stats_service import compute_session_stats
41
+
42
+ # Webgate config object — single source of truth for single-user (default) vs
43
+ # multitenant mode. Consulted at the four seam points below (middleware, ownership,
44
+ # page/router mount-gate). Read at IMPORT time for the mount-gate; at REQUEST time
45
+ # for the middleware/ownership short-circuits.
46
+ from webview import webgate
47
+
48
+ # Bounded-collection utility (LRU eviction) — reused for _event_emissions (E5-Minor)
49
+ # so the rate-limiter's per-session key space can't grow without bound.
50
+ from utils.bounded_collections import BoundedDict
51
+
52
+ # No demo mode - WebView must use the real PathManager
53
+ import os
54
+ import sys
55
+
56
+ # NOTE: a legacy `sys.path.insert(0, '/opt/rob')` used to live here (pre-rename
57
+ # install path). On any box where the stale /opt/rob tree still exists it
58
+ # HIJACKED every `agents`/`modules`/`core` import away from the live tree —
59
+ # removed 2026-07-06. The webview imports from the tree it is deployed in
60
+ # (WorkingDirectory/PYTHONPATH), never a hardcoded absolute path.
61
+
62
+ # Import PathManager for session paths
63
+ from agents.task.path import pm
64
+
65
+ from core.version import get_version
66
+
67
+ logger = logging.getLogger("webview.server")
68
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
69
+
70
+ FEED_DEFAULT_LIMIT = max(1, int(os.environ.get('WEBVIEW_FEED_DEFAULT_LIMIT', '500')))
71
+ FEED_MAX_LIMIT = max(1, int(os.environ.get('WEBVIEW_FEED_MAX_LIMIT', '2000')))
72
+
73
+ logger.info(f"Feed event limits: default={FEED_DEFAULT_LIMIT}, max={FEED_MAX_LIMIT}")
74
+
75
+ # SECURITY FIX: Get allowed origins from environment
76
+ # Include the deploy's domain by default to prevent Socket.IO connection failures
77
+ # One domain SSOT with api/auth_endpoints.py's SIWE `domain` (which already reads
78
+ # WEBVIEW_DOMAIN) — an operator overrides one env var, not two independent hardcodes.
79
+ _webview_domain = os.environ.get("WEBVIEW_DOMAIN", "localhost:3000").strip()
80
+
81
+
82
+ def _compute_cors_origins() -> List[str]:
83
+ """Explicit CORS_ALLOW_ORIGINS wins verbatim; otherwise the default list:
84
+ legacy localhost:3000 entries + WEBVIEW_DOMAIN + the console's own serving
85
+ origins (bind port on localhost/127.0.0.1) — the webview serves its own UI,
86
+ so the serving origin must be allowed or the browser's same-origin
87
+ Socket.IO handshake is rejected with a 400 (P0-1, 2026-07-06)."""
88
+ raw = os.environ.get("CORS_ALLOW_ORIGINS", "").strip()
89
+ if raw:
90
+ return [origin.strip() for origin in raw.split(",") if origin.strip()]
91
+ origins = [
92
+ "http://localhost:3000",
93
+ "https://localhost:3000",
94
+ f"https://{_webview_domain}",
95
+ f"http://{_webview_domain}",
96
+ ]
97
+ port = webgate.bind_port()
98
+ for scheme in ("http", "https"):
99
+ for host in ("localhost", "127.0.0.1"):
100
+ origins.append(f"{scheme}://{host}:{port}")
101
+ return list(dict.fromkeys(origins))
102
+
103
+
104
+ _cors_origins = _compute_cors_origins()
105
+ logger.info(f"Socket.IO CORS allowed origins: {_cors_origins}")
106
+
107
+
108
+ def _cors_origin_allowed(origin: Optional[str], environ: Optional[dict] = None) -> bool:
109
+ """engineio ``cors_allowed_origins`` callable: allow the explicit list OR a
110
+ TRUE same-origin request (Origin == scheme://Host).
111
+
112
+ Host is a browser-forbidden request header, so a cross-origin attacker page
113
+ cannot make Origin match it. X-Forwarded-Host/-Proto ARE settable from
114
+ cross-origin JS — they must never feed this comparison (only the scheme may
115
+ vary, which concedes nothing a network MITM doesn't already have).
116
+ """
117
+ if origin in _cors_origins:
118
+ return True
119
+ host = (environ or {}).get("HTTP_HOST")
120
+ if not origin or not host:
121
+ return False
122
+ return origin in (f"http://{host}", f"https://{host}")
123
+
124
+
125
+ # Socket.IO instance that will be used for live updates
126
+ _sio = socketio.AsyncServer(
127
+ async_mode="asgi",
128
+ cors_allowed_origins=_cors_origin_allowed, # SECURITY: allowlist + true same-origin only
129
+ max_http_buffer_size=2_000_000, # 2 MB limit to prevent memory spikes
130
+ ping_timeout=60, # Ping timeout to detect disconnected clients
131
+ ping_interval=25, # Ping interval for keep-alive
132
+ engineio_logger=False, # Disable verbose logging
133
+ logger=False # Disable Socket.IO debug logging
134
+ )
135
+
136
+ # Initialize FastAPI with a specific title
137
+ _fastapi = FastAPI(title=f"{webgate.console_display_name()} API")
138
+
139
+
140
+ def _posture_route(method: str, path: str, postures: tuple, **kwargs):
141
+ """`@_fastapi.<method>`-equivalent decorator that ONLY registers the route
142
+ when the CURRENT posture (``webgate.posture()``) is one of ``postures``.
143
+
144
+ Generalizes the old binary ``_multitenant_get`` to the full 3-posture
145
+ model (B4). The handler functions stay in this file unchanged (00 §6.5:
146
+ demote behind the flag, never delete) — only their *registration* is
147
+ gated. Read at import time (the route table is built once).
148
+ """
149
+ def deco(fn):
150
+ if webgate.posture() in postures:
151
+ return getattr(_fastapi, method)(path, **kwargs)(fn)
152
+ return fn
153
+ return deco
154
+
155
+
156
+ def _posture_get(path: str, postures: tuple = ("multitenant",), **kwargs):
157
+ """`@_fastapi.get`-equivalent decorator, gated by posture (default:
158
+ multitenant-only, matching the old `_multitenant_get` default)."""
159
+ return _posture_route("get", path, postures, **kwargs)
160
+
161
+
162
+ def _posture_post(path: str, postures: tuple = ("multitenant",), **kwargs):
163
+ """`@_fastapi.post`-equivalent decorator, gated by posture."""
164
+ return _posture_route("post", path, postures, **kwargs)
165
+
166
+
167
+ def _multitenant_get(path: str, **kwargs):
168
+ """Back-compat alias — multitenant-only routes (signin/logout/profile/admin).
169
+
170
+ The signin/logout/profile/admin pages are the multitenant surface — in
171
+ other postures they are simply not registered (a request → 404; that
172
+ surface does not exist there).
173
+ """
174
+ return _posture_get(path, postures=("multitenant",), **kwargs)
175
+
176
+
177
+ # Global references for services
178
+ _container = None
179
+ _wallet_generator: Optional[Any] = None
180
+
181
+ # Process start time, for the public /api/status uptime figure (B2).
182
+ _START_TIME = time.monotonic()
183
+
184
+
185
+ @_fastapi.get("/api/status")
186
+ async def api_status(request: Request) -> Response:
187
+ """Public, always-reachable status JSON — no posture gate, no auth.
188
+
189
+ Mirrors api/app.py's /health shape in spirit (this is the webview process,
190
+ a separate service in a typical deployment). Consumed client-side by
191
+ status.html.
192
+ """
193
+ from core.instance import resolve_instance_id
194
+
195
+ return JSONResponse({
196
+ "status": "live",
197
+ "instance": resolve_instance_id(),
198
+ "version": os.environ.get("WEBVIEW_VERSION", get_version()),
199
+ "uptime_seconds": int(time.monotonic() - _START_TIME),
200
+ })
201
+
202
+
203
+ @_fastapi.on_event("startup")
204
+ async def startup_event():
205
+ """Initialize all services before accepting requests."""
206
+ global _container, _wallet_generator
207
+
208
+ logger.info("🚀 Initializing webview services...")
209
+
210
+ # 0. Install the process-global session data root BEFORE anything touches
211
+ # pm() (RC-1, 2026-07-07): the agent process derives its tree from
212
+ # POLYROB_DATA_DIR via build_cli_container; without this the webview's
213
+ # pm() falls back to env DATA_ROOT (./data/task) and silently browses a
214
+ # DIFFERENT (stale) tree. Same SSOT pattern the CLI uses. Fail-open with a
215
+ # loud error — a path issue must not take the console down, but it must
216
+ # never be silent again either.
217
+ try:
218
+ from core.runtime_paths import resolve_session_data_root
219
+ from agents.task.path import get_path_manager, set_path_manager
220
+ _session_root = resolve_session_data_root()
221
+ set_path_manager(get_path_manager(data_root=str(_session_root)))
222
+ logger.info(f"✅ Session data root: {_session_root}")
223
+ except Exception as e:
224
+ logger.error(f"❌ Failed to install session data root — pm() will use "
225
+ f"its legacy default and may browse the WRONG tree: {e}",
226
+ exc_info=True)
227
+
228
+ # 1. Validate critical environment variables.
229
+ # JWT is only used by the multitenant auth layer; the single-user webgate
230
+ # (WEBGATE_MULTITENANT=OFF, the default) has no auth at all, so requiring a
231
+ # JWT secret it never uses would needlessly block the loopback primitive.
232
+ if webgate.is_multitenant():
233
+ jwt_secret = os.environ.get("JWT_SECRET_KEY")
234
+ if not jwt_secret:
235
+ raise RuntimeError("❌ JWT_SECRET_KEY not configured - cannot start service")
236
+ logger.info("✅ JWT authentication configured")
237
+ else:
238
+ logger.info("✅ webgate single-user mode: JWT not required (no auth)")
239
+
240
+ # 2. Initialize DependencyContainer and core services
241
+ try:
242
+ from core.container import DependencyContainer
243
+ from core.config import BotConfig
244
+ from core.initialization import initialize_core
245
+
246
+ # Create config first (required for container initialization)
247
+ config = BotConfig()
248
+ _container = DependencyContainer.get_instance(config=config)
249
+ logger.info("✅ DependencyContainer initialized")
250
+
251
+ # Initialize core (database, memory, cache)
252
+ # This is all webview needs - auth services are in polyrob-api.service!
253
+ await initialize_core(_container)
254
+ logger.info("✅ Core services initialized (database, memory, cache)")
255
+
256
+ # ✅ ARCHITECTURE FIX:
257
+ # Webview does NOT initialize auth services (balance_manager, tier_manager, etc.)
258
+ # Those are in polyrob-api.service (port 9000) - frontend calls /api/payments/* which nginx routes there
259
+ # Webview only needs: database (read sessions), memory (session context), cache (performance)
260
+
261
+ logger.info("🎉 Webview services initialized - UI server ready!")
262
+ logger.info("💡 Payment/Auth APIs handled by polyrob-api.service (port 9000) via nginx proxy")
263
+
264
+ except Exception as e:
265
+ logger.error(f"❌ Failed to initialize services: {e}", exc_info=True)
266
+ raise RuntimeError(f"Cannot start webview without services: {e}")
267
+
268
+ # Auth/task/wallet half (was a second, competing startup handler).
269
+ await _startup_late_services()
270
+
271
+ # Security headers middleware
272
+ @_fastapi.middleware("http")
273
+ async def add_security_headers(request: Request, call_next):
274
+ """Add security headers to all responses."""
275
+ response = await call_next(request)
276
+
277
+ # Check if this is a /serve/ endpoint (allows iframe embedding for presentations)
278
+ is_serve_endpoint = "/workspace/serve/" in str(request.url.path)
279
+
280
+ if is_serve_endpoint:
281
+ # Relaxed CSP for iframe-embedded content (presentations, HTML apps)
282
+ response.headers["Content-Security-Policy"] = (
283
+ "default-src 'self' https:; "
284
+ "script-src 'self' 'unsafe-inline' https:; "
285
+ "style-src 'self' 'unsafe-inline' https:; "
286
+ "font-src 'self' https: data:; "
287
+ "img-src 'self' data: https: blob:; "
288
+ "connect-src 'self' https:; "
289
+ "frame-src 'self' blob: data: https:; "
290
+ "frame-ancestors 'self'; " # Allow embedding by same origin
291
+ "base-uri 'self'; "
292
+ "form-action 'self'"
293
+ )
294
+ # No X-Frame-Options for serve endpoint (allows iframe embedding)
295
+ else:
296
+ # Content Security Policy - strict but allows needed resources
297
+ response.headers["Content-Security-Policy"] = (
298
+ "default-src 'self'; "
299
+ "script-src 'self' 'unsafe-inline' https://cdn.socket.io https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net; "
300
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com; "
301
+ "font-src 'self' https://fonts.gstatic.com; "
302
+ "img-src 'self' data: https: blob:; "
303
+ "connect-src 'self' ws: wss: https://cdn.socket.io https://cdnjs.cloudflare.com; "
304
+ "frame-src 'self' blob: data:; "
305
+ "frame-ancestors 'none'; "
306
+ "base-uri 'self'; "
307
+ "form-action 'self'"
308
+ )
309
+ response.headers["X-Frame-Options"] = "DENY"
310
+
311
+ # Security headers (apply to all)
312
+ response.headers["X-Content-Type-Options"] = "nosniff"
313
+ response.headers["X-XSS-Protection"] = "1; mode=block"
314
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
315
+
316
+ # Cache control for sensitive pages
317
+ if "/session/" in str(request.url):
318
+ response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
319
+
320
+ return response
321
+
322
+ # Global exception handler to prevent information leakage
323
+ @_fastapi.exception_handler(Exception)
324
+ async def global_exception_handler(request: Request, exc: Exception):
325
+ """Handle unexpected exceptions without leaking sensitive information."""
326
+ # Log the full error server-side
327
+ logger.error(f"Unhandled exception: {exc}", exc_info=True)
328
+
329
+ # Return generic error to client
330
+ return JSONResponse(
331
+ status_code=500,
332
+ content={"error": "An internal error occurred. Please try again later."}
333
+ )
334
+
335
+ # Add static files - need to add this BEFORE creating ASGI app
336
+ # Resolve the webgate asset base via core.assets (packaged web_dist bundle when
337
+ # built, else the repo webview/ checkout — byte-identical in a dev tree).
338
+ try:
339
+ from core.assets import webgate_asset_dir as _webgate_asset_dir
340
+ STATIC_DIR = _webgate_asset_dir() / "static"
341
+ except Exception: # fail-open to the legacy repo-relative path
342
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
343
+
344
+ # Middleware to add cache-control headers for JS files
345
+ @_fastapi.middleware("http")
346
+ async def add_cache_control_headers(request: Request, call_next):
347
+ """Add no-cache headers for JS files to prevent stale cache issues."""
348
+ response = await call_next(request)
349
+ if request.url.path.startswith("/static/js/") and request.url.path.endswith(".js"):
350
+ # Force revalidation for JS files
351
+ response.headers["Cache-Control"] = "no-cache, must-revalidate"
352
+ return response
353
+
354
+ _fastapi.mount(
355
+ "/static",
356
+ StaticFiles(directory=str(STATIC_DIR)),
357
+ name="static"
358
+ )
359
+
360
+ # Final app for Uvicorn to run - order is important here
361
+ app = socketio.ASGIApp(_sio, other_asgi_app=_fastapi)
362
+
363
+ # For tracking watchers and client sessions
364
+ _watch_tasks: dict[str, asyncio.Task] = {}
365
+ _client_session: dict[str, str] = {}
366
+ _session_clients: dict[str, int] = {}
367
+
368
+ from collections import defaultdict
369
+
370
+ # Rate limiting configuration (hardcoded for webview - not business logic)
371
+ RATE_LIMIT_WINDOW = 60 # seconds
372
+ RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
373
+ RATE_LIMIT_MAX_EVENTS = 100 # max events per session per window
374
+ # Cap on distinct sessions tracked by the event-rate limiter (E5-Minor): without
375
+ # this, _event_emissions grew one key per session forever (a slow leak) since
376
+ # nothing ever popped a finished session's entry. LRU eviction bounds the key
377
+ # space without changing rate-limit semantics for any session still active.
378
+ EVENT_EMISSIONS_MAX_SESSIONS = 5000
379
+
380
+ # Track connection attempts by IP
381
+ _connection_attempts: dict[str, list[float]] = defaultdict(list)
382
+ # Track event emissions by session (bounded — LRU-evicted past EVENT_EMISSIONS_MAX_SESSIONS)
383
+ _event_emissions: "BoundedDict[str, list[float]]" = BoundedDict(max_size=EVENT_EMISSIONS_MAX_SESSIONS)
384
+
385
+ def check_rate_limit(ip: str) -> bool:
386
+ """Check if IP has exceeded connection rate limit.
387
+
388
+ Args:
389
+ ip: IP address to check
390
+
391
+ Returns:
392
+ True if within limit, False if exceeded
393
+ """
394
+ now = time.time()
395
+
396
+ # Clean old attempts
397
+ _connection_attempts[ip] = [
398
+ t for t in _connection_attempts[ip]
399
+ if now - t < RATE_LIMIT_WINDOW
400
+ ]
401
+
402
+ # Check limit
403
+ if len(_connection_attempts[ip]) >= RATE_LIMIT_MAX_CONNECTIONS:
404
+ logger.warning(f"Rate limit exceeded for IP {ip}: {len(_connection_attempts[ip])} connections in {RATE_LIMIT_WINDOW}s")
405
+ return False
406
+
407
+ # Record this attempt
408
+ _connection_attempts[ip].append(now)
409
+ return True
410
+
411
+ def check_event_rate_limit(session_id: str) -> bool:
412
+ """Check if session has exceeded event emission rate limit.
413
+
414
+ Args:
415
+ session_id: Session ID to check
416
+
417
+ Returns:
418
+ True if within limit, False if exceeded
419
+ """
420
+ now = time.time()
421
+
422
+ # Clean old events. _event_emissions is a bounded (LRU-evicted) dict — read
423
+ # via .get(default=[]) rather than defaultdict auto-vivification, then write
424
+ # back explicitly so the write also refreshes the key's LRU position.
425
+ events = [
426
+ t for t in _event_emissions.get(session_id, [])
427
+ if now - t < RATE_LIMIT_WINDOW
428
+ ]
429
+ _event_emissions[session_id] = events
430
+
431
+ # Check limit
432
+ if len(events) >= RATE_LIMIT_MAX_EVENTS:
433
+ logger.warning(f"Event rate limit exceeded for session {session_id}: {len(events)} events in {RATE_LIMIT_WINDOW}s")
434
+ return False
435
+
436
+ # Record this event
437
+ events.append(now)
438
+ return True
439
+
440
+
441
+ async def _emit_feed_event(entry: dict, room: str) -> bool:
442
+ """Emit one feed_update event to `room`, honoring the per-session event rate
443
+ limit (E5). Returns False (and drops the event) if the session has exceeded
444
+ RATE_LIMIT_MAX_EVENTS within RATE_LIMIT_WINDOW — closes the gap where
445
+ check_event_rate_limit was defined but never called.
446
+
447
+ Fail-safe: if the rate-limit check itself raises, we log and fall through to
448
+ emitting the event rather than crashing the feed watcher's loop.
449
+ """
450
+ try:
451
+ allowed = check_event_rate_limit(room)
452
+ except Exception as exc:
453
+ logger.error("check_event_rate_limit raised for room %s: %s", room, exc, exc_info=True)
454
+ allowed = True
455
+
456
+ if not allowed:
457
+ logger.warning("Dropping feed_update for %s: event rate limit exceeded", room)
458
+ return False
459
+
460
+ await _sio.emit("feed_update", entry, room=room)
461
+ return True
462
+
463
+
464
+ def _enrich_llm_event_with_cost(entry: dict) -> dict:
465
+ """Enrich LLM request event with cost estimate if missing.
466
+
467
+ Uses the centralized cost calculation from stats_service which relies on
468
+ the model registry for consistent, up-to-date pricing across the application.
469
+
470
+ Args:
471
+ entry: Feed event dictionary (must have type='llm_request')
472
+
473
+ Returns:
474
+ The same entry dict (modified in place), with cost_estimate added if missing
475
+ """
476
+ if entry.get("type") != "llm_request" or "data" not in entry:
477
+ return entry
478
+
479
+ data = entry["data"]
480
+
481
+ # Check if cost_estimate is missing or zero
482
+ cost_estimate = data.get("cost_estimate")
483
+ if cost_estimate is None or cost_estimate == 0:
484
+ # Import centralized cost calculation from stats_service
485
+ try:
486
+ from webview.stats_service import _calculate_cost_from_registry
487
+
488
+ # Extract token counts
489
+ prompt_tokens = data.get("prompt_tokens")
490
+ completion_tokens = data.get("completion_tokens")
491
+ total_tokens = data.get("token_count") or data.get("total_tokens")
492
+ model_name = data.get("model_name")
493
+
494
+ # Calculate using centralized registry (handles all models)
495
+ estimated_cost = _calculate_cost_from_registry(
496
+ model_name=model_name,
497
+ prompt_tokens=prompt_tokens,
498
+ completion_tokens=completion_tokens,
499
+ total_tokens=total_tokens
500
+ )
501
+
502
+ if estimated_cost > 0:
503
+ data["cost_estimate"] = estimated_cost
504
+
505
+ except Exception as e:
506
+ # If cost calculation fails, log but don't crash
507
+ logger.debug(f"Cost calculation failed: {e}")
508
+ data["cost_estimate"] = 0.0
509
+
510
+ return entry
511
+
512
+ async def get_clean_session_id(session_id: str) -> str:
513
+ """Clean a session ID and return it. Use as a FastAPI dependency."""
514
+ return pm().clean_session_id(session_id)
515
+
516
+
517
+
518
+ def _manual_auth_check(request: Request) -> None:
519
+ """Manually validate JWT token and populate request.state for public endpoints.
520
+
521
+ Args:
522
+ request: FastAPI request object
523
+
524
+ Side Effects:
525
+ Populates request.state with user info if valid token found
526
+
527
+ Note:
528
+ This is needed for endpoints in public_paths where auth middleware doesn't run.
529
+ For protected endpoints, use the normal auth middleware instead.
530
+ """
531
+ # Posture "local": loopback operator IS the owner, there is no JWT/SIWE/owner
532
+ # login, ever — nothing to decode. own_ops/multitenant both mint tokens
533
+ # (owner-login cookie or wallet/SIWE JWT respectively) that must be
534
+ # decode-able here too — this is what makes the own_ops "owner logs in →
535
+ # dashboard" round-trip work (B4).
536
+ if not webgate.requires_owner_login():
537
+ return
538
+
539
+ auth_token = None
540
+ auth_header = request.headers.get("Authorization")
541
+
542
+ if auth_header and auth_header.startswith("Bearer "):
543
+ auth_token = auth_header[7:]
544
+ else:
545
+ auth_token = request.cookies.get("auth_token")
546
+
547
+ # Validate and populate request.state if token exists
548
+ if auth_token:
549
+ try:
550
+ import jwt as pyjwt
551
+ jwt_secret = os.environ.get("JWT_SECRET_KEY")
552
+
553
+ if jwt_secret:
554
+ decoded = pyjwt.decode(auth_token, jwt_secret, algorithms=["HS256"])
555
+ from api.auth_state import set_auth_state
556
+ request.state.wallet_address = decoded.get("sub")
557
+ set_auth_state(
558
+ request.state,
559
+ user_id=decoded.get("user_id"),
560
+ tier=decoded.get("tier", "free"),
561
+ role=decoded.get("role", "user"),
562
+ payment_method=decoded.get("payment_method"),
563
+ authenticated=True,
564
+ )
565
+ logger.debug(f"Manual auth successful: {request.state.user_id}")
566
+ except Exception as e:
567
+ logger.debug(f"Manual auth failed: {e}")
568
+ # Don't populate request.state - user treated as unauthenticated
569
+
570
+
571
+ def _check_session_ownership(request: Request, session_id: str) -> tuple[bool, Optional[str], Optional[str]]:
572
+ """Check if the current user owns a session.
573
+
574
+ Args:
575
+ request: FastAPI request with auth state
576
+ session_id: The session ID to check
577
+
578
+ Returns:
579
+ Tuple of (is_owner, current_user_id, session_owner_id)
580
+
581
+ Note:
582
+ This is the centralized ownership check used across all endpoints.
583
+ Returns (False, None, None) if user is not authenticated.
584
+ Returns (False, user_id, owner_id) if authenticated but not owner.
585
+ Returns (True, user_id, owner_id) if authenticated and is owner.
586
+ """
587
+ # Single-user webgate (posture "local"): every session is owned by the local
588
+ # owner. No 401/403 on interaction, no JWT — the loopback operator IS the
589
+ # owner. Gated on requires_owner_login() (False only for "local"), NOT
590
+ # is_multitenant() (B4-M) — own_ops has real auth (owner-login cookie) too,
591
+ # so it must run the actual ownership check below rather than this bypass.
592
+ if not webgate.requires_owner_login():
593
+ local_owner = webgate.local_owner_id()
594
+ return (True, local_owner, local_owner)
595
+
596
+ from utils.auth_utils import get_authenticated_user_id, is_authenticated
597
+
598
+ # Check authentication
599
+ if not is_authenticated(request):
600
+ owner_id = pm().get_session_user(session_id)
601
+ return (False, None, owner_id)
602
+
603
+ # Get current user
604
+ current_user_id = get_authenticated_user_id(request)
605
+
606
+ # Get session owner
607
+ session_owner_id = pm().get_session_user(session_id)
608
+
609
+ if webgate.is_own_ops():
610
+ # own_ops has exactly ONE owner: the authenticated owner-login identity
611
+ # (upstream route auth already ensures only the owner reaches a
612
+ # protected route in the first place). That owner owns EVERY session
613
+ # in this instance, regardless of which surface/identity path tagged
614
+ # it — e.g. CLI-created sessions are hardcoded to user_id="local"
615
+ # (core/identity.py), which never equals the own_ops owner-login id
616
+ # (webgate.local_owner_id(), default "rob"). A strict per-session
617
+ # string match here false-denies the owner on their own CLI sessions.
618
+ # So: authenticated-as-owner -> allow unconditionally; anything else
619
+ # (authenticated as someone/something else) -> deny. This keeps H2b's
620
+ # real security value (a non-owner identity is still denied) without
621
+ # the CLI-session false-deny.
622
+ is_owner = bool(current_user_id) and current_user_id == webgate.local_owner_id()
623
+ if is_owner:
624
+ return (True, current_user_id, session_owner_id or current_user_id)
625
+ return (False, current_user_id, session_owner_id)
626
+
627
+ # multitenant: multiple tenants share this instance, so per-session
628
+ # ownership must be checked strictly.
629
+ is_owner = (current_user_id == session_owner_id) if session_owner_id else False
630
+
631
+ return (is_owner, current_user_id, session_owner_id)
632
+
633
+ ROOT_DIR = Path(__file__).resolve().parent
634
+ # Templates resolve through the same webgate asset base as STATIC_DIR above
635
+ # (packaged web_dist bundle when built, else the repo webview/ checkout).
636
+ try:
637
+ from core.assets import webgate_asset_dir as _webgate_asset_dir
638
+ TEMPLATES_DIR = _webgate_asset_dir() / "templates"
639
+ except Exception: # fail-open to the legacy repo-relative path
640
+ TEMPLATES_DIR = ROOT_DIR / "templates"
641
+
642
+ _templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
643
+
644
+ # UI branding (Workstream D): registered as Jinja globals so every template
645
+ # can render the current console name / footer links without every route
646
+ # threading them through its own context dict.
647
+ _templates.env.globals["console_display_name"] = webgate.console_display_name
648
+ _templates.env.globals["branding"] = webgate.branding_config
649
+ _templates.env.globals["get_version"] = get_version
650
+ # Posture default for the layout's tenant-nav block (P0-3): pages that don't
651
+ # pass `is_multitenant` fall back to the posture SSOT instead of "shown".
652
+ _templates.env.globals["is_multitenant_posture"] = webgate.is_multitenant
653
+
654
+ # Multitenant-only: the wallet auth router is the JWT/SIWE surface. In single-user
655
+ # mode it is simply not mounted (no /api/auth/* — single-user has no auth).
656
+ if webgate.is_multitenant():
657
+ try:
658
+ from api.auth_endpoints import router as auth_router
659
+ _fastapi.include_router(auth_router, prefix="/api/auth", tags=["authentication"])
660
+ AUTH_ROUTER_MOUNTED = True
661
+ logger.info("✅ Auth endpoints mounted at /api/auth (nonce, verify, me)")
662
+ except ImportError as e:
663
+ AUTH_ROUTER_MOUNTED = False
664
+ logger.error(f"❌ Failed to mount auth router: {e}")
665
+ logger.warning("⚠️ Wallet authentication will not work!")
666
+ else:
667
+ AUTH_ROUTER_MOUNTED = False
668
+ logger.info("webgate single-user mode: auth router not mounted (no JWT/SIWE)")
669
+
670
+ async def _task_router_read_only_guard(request: Request) -> None:
671
+ """WS-3.2 (2026-07-07): the task router is mounted DIRECTLY in this app,
672
+ so its mutating routes (POST /api/task/sessions, …/messages, …/cancel)
673
+ would bypass the wrapper endpoints' webgate.read_only() checks. Refuse
674
+ mutations at the router seam in read-only consoles; reads stay allowed."""
675
+ if request.method not in ("GET", "HEAD", "OPTIONS") and webgate.read_only():
676
+ raise HTTPException(
677
+ status_code=403,
678
+ detail="Console is read-only (WEBVIEW_READ_ONLY)",
679
+ )
680
+
681
+
682
+ try:
683
+ from api.task_http_api import router as task_router
684
+ _fastapi.include_router(
685
+ task_router, prefix="/api", tags=["task"],
686
+ dependencies=[Depends(_task_router_read_only_guard)],
687
+ )
688
+ TASK_ROUTER_MOUNTED = True
689
+ logger.info("✅ Task endpoints mounted at /api/task")
690
+ except ImportError as e:
691
+ TASK_ROUTER_MOUNTED = False
692
+ logger.error(f"❌ Failed to mount task router: {e}")
693
+ logger.warning("⚠️ Task session creation from webview will not work!")
694
+
695
+ PAYMENT_ROUTER_MOUNTED = False
696
+
697
+ # Webgate v1 read-only pages (Memory/Autonomy/Identity/System) — core single-user
698
+ # surfaces. Mounted in ALL postures; per-tenant scoping under multitenant is
699
+ # handled INSIDE each handler via webview.pages._effective_user_id (B7 —
700
+ # closes assessment gap 5, was previously hardcoded to the instance owner in
701
+ # every posture). Each endpoint REUSES the underlying service (memory
702
+ # provider / GoalBoard / CronService / core.instance / doctor_report); see
703
+ # webview/pages.py. Fail-open: a mount failure must never break the webview boot.
704
+ try:
705
+ from webview.pages import router as webgate_pages_router
706
+ _fastapi.include_router(webgate_pages_router)
707
+ PAGES_ROUTER_MOUNTED = True
708
+ logger.info("✅ Webgate v1 pages mounted (memory/autonomy/identity/system)")
709
+ except Exception as e:
710
+ PAGES_ROUTER_MOUNTED = False
711
+ logger.error(f"❌ Failed to mount webgate pages router: {e}")
712
+
713
+ # Global activity stream (/activity page + /api/activity/*). Mounted in ALL
714
+ # postures; access is enforced at request time inside webview/activity.py
715
+ # (_require_activity_access): local open, own_ops behind the owner cookie
716
+ # (auth middleware — /activity is deliberately NOT a public path), multitenant
717
+ # admin/instance-owner only. Fail-open mount: a failure never breaks boot.
718
+ try:
719
+ from webview.activity import router as activity_router
720
+ _fastapi.include_router(activity_router)
721
+ ACTIVITY_ROUTER_MOUNTED = True
722
+ logger.info("✅ Activity stream mounted (/activity)")
723
+ except Exception as e:
724
+ ACTIVITY_ROUTER_MOUNTED = False
725
+ logger.error(f"❌ Failed to mount activity router: {e}")
726
+
727
+ def _login_redirect_path() -> str:
728
+ """Where an unauthenticated browser request should be sent to authenticate.
729
+
730
+ own_ops has no ``/signin`` (that surface is multitenant-only, gated by
731
+ ``_multitenant_get``) — send it to ``/owner-login`` instead. Multitenant
732
+ keeps its existing ``/signin`` target unchanged.
733
+ """
734
+ return "/owner-login" if webgate.is_own_ops() else "/signin"
735
+
736
+
737
+ @_fastapi.middleware("http")
738
+ async def auth_middleware(request: Request, call_next):
739
+ """Require authentication (owner login or wallet/SIWE) for all routes except
740
+ signin/owner-login and static files. Runs for BOTH own_ops and multitenant
741
+ postures (B4) — only "local" (the loopback operator IS the owner) skips it.
742
+ """
743
+
744
+ # Posture "local" (WEBGATE_MULTITENANT=OFF and no POLYROB_POSTURE override,
745
+ # the default): no JWT/SIWE/owner-login, ever — fold into the auth-disabled
746
+ # short-circuit. own_ops/multitenant both require SOME authenticated
747
+ # identity (webgate.requires_owner_login()) and run the checks below.
748
+ if not webgate.requires_owner_login():
749
+ # The loopback operator IS the owner (the same statement
750
+ # _check_session_ownership's local bypass makes). Stamp the canonical
751
+ # owner auth state so downstream gates that read request.state — the
752
+ # task router's payment admin-bypass, catalog scope, template
753
+ # is_authenticated branches — see the owner instead of an anonymous
754
+ # user; without this the local console 402s on POST /api/task/sessions
755
+ # (WS-3 E2E finding, 2026-07-07).
756
+ try:
757
+ from api.auth_state import set_auth_state
758
+ set_auth_state(
759
+ request.state,
760
+ user_id=webgate.local_owner_id(),
761
+ tier="admin",
762
+ role="owner",
763
+ payment_method=None,
764
+ authenticated=True,
765
+ )
766
+ except Exception:
767
+ pass
768
+ return await call_next(request)
769
+
770
+ # Check if auth is enabled (default: enabled in production, disabled in dev)
771
+ env = os.environ.get("ENV", "production")
772
+ auth_enabled_default = "false" if env == "development" else "true"
773
+ auth_enabled = os.environ.get("WEBVIEW_AUTH_ENABLED", auth_enabled_default).lower() == "true"
774
+
775
+ # If auth is disabled, skip all checks
776
+ if not auth_enabled:
777
+ logger.debug("Auth disabled - allowing request")
778
+ return await call_next(request)
779
+
780
+ path = request.url.path
781
+
782
+ # Public paths (no auth required)
783
+ # Note: /api/auth/me requires auth, so use specific paths for public auth endpoints
784
+ public_paths = [
785
+ "/signin",
786
+ "/owner-login",
787
+ "/logout",
788
+ "/static",
789
+ "/api/status",
790
+ "/api/auth/nonce",
791
+ "/api/auth/verify",
792
+ "/api/payments/pricing", # Only pricing info is public (no user data)
793
+ "/api/webview/sessions/", # Internal streaming from agent (localhost only, verified in endpoint)
794
+ ]
795
+ # Shareable-link viewing (/session/, /api/session/ — which also covers the
796
+ # workspace-file/screenshot sub-routes) stays public ONLY in the
797
+ # "multitenant" posture, matching the original shareable-link product
798
+ # intent (design predates the posture model, when "multitenant" was the
799
+ # only public posture that existed). own_ops has NO public session-viewing
800
+ # surface at all — there is only one owner, so "shareable" (implying
801
+ # sharing with someone who ISN'T the owner) has no identity model to hang
802
+ # off of — closing gaps 2/3 from the alignment assessment. "local" never
803
+ # reaches this middleware body (short-circuited above), so this branch is
804
+ # unreachable there and Posture 0 is unaffected.
805
+ if webgate.posture() == "multitenant":
806
+ public_paths += ["/session/", "/api/session/"]
807
+ # SECURITY ARCHITECTURE:
808
+ # - Session viewing endpoints are public ONLY in multitenant (shareable links)
809
+ # - Session interaction (POST messages) is protected by ownership check in endpoint
810
+ # - Ownership verification happens in send_message_to_session (401/403 responses)
811
+ # - User data isolation maintained via user_id in _get_user_sessions
812
+ #
813
+ # `/` (exact match, NOT a prefix — a prefix would exempt every path) is the
814
+ # B2 posture-aware root: it must always be reachable and does its OWN
815
+ # is_authenticated()-branch (status page vs dashboard) in the handler, so
816
+ # it is let through here too (after populating request.state via
817
+ # _manual_auth_check, same as the other public paths) rather than
818
+ # redirected to a login page by this middleware.
819
+ if path == "/" or any(path.startswith(p) for p in public_paths):
820
+ # Attempt to authenticate if token present (for better UX)
821
+ # This allows detecting session owners vs viewers
822
+ _manual_auth_check(request)
823
+ return await call_next(request)
824
+
825
+ # Check for auth token in header or cookie
826
+ auth_header = request.headers.get("Authorization")
827
+ auth_token = None
828
+
829
+ # Get all cookies for auth check (debug logging removed for security)
830
+
831
+ if auth_header and auth_header.startswith("Bearer "):
832
+ auth_token = auth_header[7:]
833
+ logger.info(f"🔐 Found token in Authorization header for {path}")
834
+ else:
835
+ # Check cookie
836
+ auth_token = request.cookies.get("auth_token")
837
+ if auth_token:
838
+ logger.info(f"🔐 Found token in cookie for {path} (token length: {len(auth_token)})")
839
+ else:
840
+ logger.info(f"🔐 No token found in cookie or header for {path}")
841
+ logger.info(f" Cookies received: {list(request.cookies.keys())}")
842
+
843
+ if not auth_token:
844
+ # No token - redirect to the posture-appropriate login page with a
845
+ # return_to parameter. A real HTTP redirect (not a 200 JS-hack page)
846
+ # so this is provably a denial, not a served response — safe-by-default.
847
+ login_path = _login_redirect_path()
848
+ logger.info(f"❌ No auth token found - redirecting to {login_path} (path={path})")
849
+ if path.startswith("/api/"):
850
+ return JSONResponse(status_code=401, content={"error": "Authentication required"})
851
+ from urllib.parse import quote
852
+ return_to = quote(path)
853
+ return RedirectResponse(url=f"{login_path}?return_to={return_to}", status_code=302)
854
+
855
+ # Verify JWT token properly
856
+ jwt_secret = os.environ.get("JWT_SECRET_KEY")
857
+ if not jwt_secret:
858
+ logger.error("JWT_SECRET_KEY not configured")
859
+ if path.startswith("/api/"):
860
+ return JSONResponse(status_code=500, content={"error": "Auth not configured"})
861
+ response = RedirectResponse(url=_login_redirect_path(), status_code=302)
862
+ response.delete_cookie("auth_token")
863
+ return response
864
+
865
+ # SECURITY FIX: Never log JWT secret, even partially - removed debug logging
866
+
867
+ try:
868
+ import jwt as pyjwt
869
+ decoded = pyjwt.decode(auth_token, jwt_secret, algorithms=["HS256"])
870
+
871
+ # Token is valid - extract user info and role
872
+ # NEW JWT STRUCTURE: "sub" = wallet_address, "user_id" = internal ID
873
+ # request.state is populated via the canonical C4 contract
874
+ # (api/auth_state.py::set_auth_state) so an owner-login token
875
+ # (webview/owner_auth.py, role="owner") converges on the exact same
876
+ # request.state shape a wallet/SIWE token does.
877
+ from api.auth_state import set_auth_state
878
+ request.state.wallet_address = decoded.get("sub") # Wallet is now primary!
879
+ request.state.chain = decoded.get("chain", "ethereum")
880
+ set_auth_state(
881
+ request.state,
882
+ user_id=decoded.get("user_id"), # Internal DB ID
883
+ tier=decoded.get("tier", "free"),
884
+ role=decoded.get("role", "user"),
885
+ payment_method=decoded.get("payment_method"),
886
+ authenticated=True,
887
+ )
888
+
889
+ # Admin check using centralized auth_constants (single source of truth)
890
+ from api.auth_constants import is_admin as check_admin
891
+ request.state.is_admin = check_admin(
892
+ role=request.state.role,
893
+ wallet_address=request.state.wallet_address
894
+ )
895
+
896
+ if request.state.is_admin:
897
+ logger.info(f"🔐 Webview admin access granted: {request.state.wallet_address} (role: {request.state.role})")
898
+
899
+ logger.info(f"✅ Webview auth successful: user={request.state.user_id}, tier={request.state.tier}, admin={request.state.is_admin}")
900
+
901
+ except pyjwt.ExpiredSignatureError:
902
+ logger.warning(f"❌ Token expired for path={path}")
903
+ if path.startswith("/api/"):
904
+ return JSONResponse(status_code=401, content={"error": "Token expired"})
905
+ from urllib.parse import quote
906
+ return_to = quote(path)
907
+ response = RedirectResponse(url=f"{_login_redirect_path()}?return_to={return_to}", status_code=302)
908
+ response.delete_cookie("auth_token") # Clear invalid token
909
+ return response
910
+
911
+ except pyjwt.InvalidTokenError as e:
912
+ logger.error(f"❌ Invalid token for path={path}: {e}")
913
+ logger.error(f" Token (first 50 chars): {auth_token[:50] if auth_token else 'None'}")
914
+ logger.error(f" JWT_SECRET_KEY present: {bool(jwt_secret)}")
915
+ if path.startswith("/api/"):
916
+ return JSONResponse(status_code=401, content={"error": "Invalid token"})
917
+ from urllib.parse import quote
918
+ return_to = quote(path)
919
+ response = RedirectResponse(url=f"{_login_redirect_path()}?return_to={return_to}", status_code=302)
920
+ response.delete_cookie("auth_token") # Clear invalid token
921
+ return response
922
+
923
+ response = await call_next(request)
924
+ return response
925
+
926
+
927
+ def _collect_sessions_in_dir(user_path, user_label: str) -> List[Dict[str, Any]]:
928
+ """Collect session rows from ONE user directory.
929
+
930
+ Rows keep the raw ``created_timestamp`` so callers can sort ACROSS user
931
+ dirs before stripping it; each row carries ``user`` (the directory name)
932
+ so the catalog can label who a session belongs to.
933
+ """
934
+ sessions: List[Dict[str, Any]] = []
935
+ if not user_path.exists():
936
+ logger.debug(f"User path does not exist: {user_path}")
937
+ return sessions
938
+
939
+ # Sessions are stored at: {data_root}/{user_id}/{session_id}/
940
+ for session_path in user_path.iterdir():
941
+ if not session_path.is_dir():
942
+ continue
943
+
944
+ feed_dir = session_path / "feed"
945
+ if not feed_dir.exists():
946
+ continue
947
+
948
+ # Read task and metadata
949
+ task_text = "No task description"
950
+ model = None
951
+ provider = None
952
+ status = "completed"
953
+
954
+ # Read task.json
955
+ task_file = session_path / "task.json"
956
+ if task_file.exists():
957
+ try:
958
+ with task_file.open('r') as f:
959
+ task_data = json.load(f)
960
+ task_text = task_data.get('task', task_text)
961
+ model = task_data.get('model')
962
+ provider = task_data.get('provider')
963
+ except Exception as e:
964
+ logger.debug(f"Failed to read task.json: {e}")
965
+
966
+ # Read status.json for current status
967
+ status_file = session_path / "status.json"
968
+ if status_file.exists():
969
+ try:
970
+ with status_file.open('r') as f:
971
+ status_data = json.load(f)
972
+ status = status_data.get('status', 'completed')
973
+ except Exception as e:
974
+ logger.debug(f"Failed to read status.json: {e}")
975
+
976
+ # Fallback to metadata.json
977
+ if task_text == "No task description":
978
+ metadata_file = session_path / "metadata.json"
979
+ if metadata_file.exists():
980
+ try:
981
+ with metadata_file.open('r') as f:
982
+ metadata = json.load(f)
983
+ task_text = metadata.get('task', task_text)
984
+ if not model:
985
+ model = metadata.get('model')
986
+ if not provider:
987
+ provider = metadata.get('provider')
988
+ except Exception as e:
989
+ logger.debug(f"Failed to read metadata.json: {e}")
990
+
991
+ # Get creation time
992
+ created_timestamp = session_path.stat().st_ctime
993
+ created = datetime.fromtimestamp(created_timestamp)
994
+
995
+ # Count steps
996
+ step_files = list(feed_dir.glob('step_*.json')) + list(feed_dir.glob('agent_step_*.json'))
997
+
998
+ sessions.append({
999
+ 'id': session_path.name,
1000
+ 'user': user_label,
1001
+ 'task': task_text[:100],
1002
+ 'created': created.strftime('%Y-%m-%d %H:%M'),
1003
+ 'created_timestamp': created_timestamp,
1004
+ 'steps': len(step_files),
1005
+ 'status': status,
1006
+ 'model': model or 'unknown',
1007
+ 'provider': provider or 'unknown'
1008
+ })
1009
+
1010
+ return sessions
1011
+
1012
+
1013
+ def _finalize_session_rows(sessions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1014
+ """Sort newest-first across whatever dirs the rows came from, then strip
1015
+ the temporary sort key."""
1016
+ sessions.sort(key=lambda s: s['created_timestamp'], reverse=True)
1017
+ for session in sessions:
1018
+ session.pop('created_timestamp', None)
1019
+ return sessions
1020
+
1021
+
1022
+ def _get_user_sessions(user_id: Optional[str] = None) -> List[Dict[str, Any]]:
1023
+ """Load sessions with rich metadata for a specific user.
1024
+
1025
+ Args:
1026
+ user_id: User ID to fetch sessions for. If None, uses DEFAULT_USER_ID.
1027
+
1028
+ Returns:
1029
+ List of session metadata dicts
1030
+
1031
+ Security:
1032
+ Only returns sessions for the specified user - multi-user isolation enforced.
1033
+ """
1034
+ sessions = []
1035
+ try:
1036
+ # Get data directory from PathManager
1037
+ data_root = pm().data_root
1038
+
1039
+ logger.info(f"Loading sessions from data_root: {data_root}")
1040
+
1041
+ # Default to DEFAULT_USER_ID if no user_id provided
1042
+ if not user_id:
1043
+ from agents.task.constants import DEFAULT_USER_ID
1044
+ user_id = DEFAULT_USER_ID
1045
+
1046
+ # BACKWARD COMPATIBILITY: Check both old (cleaned) and new (proper) user directories
1047
+ # This handles sessions created before the clean_user_id() fix
1048
+ potential_user_dirs = [user_id]
1049
+
1050
+ # For _anonymous_ user, also check the old "anonymous" directory (cleaned version)
1051
+ if user_id == "_anonymous_":
1052
+ potential_user_dirs.append("anonymous") # Old PathManager cleaned this
1053
+ logger.info("Checking backward compatibility path for _anonymous_ → anonymous")
1054
+
1055
+ # Collect sessions from all potential user directories
1056
+ for check_user_id in potential_user_dirs:
1057
+ sessions.extend(_collect_sessions_in_dir(data_root / check_user_id, check_user_id))
1058
+
1059
+ sessions = _finalize_session_rows(sessions)
1060
+
1061
+ logger.info(f"Found {len(sessions)} sessions for user {user_id}")
1062
+
1063
+ except Exception as exc:
1064
+ logger.error(f"Failed to list sessions: {exc}", exc_info=True)
1065
+
1066
+ return sessions
1067
+
1068
+
1069
+ def _get_all_sessions() -> List[Dict[str, Any]]:
1070
+ """Load sessions across ALL user directories under the data root.
1071
+
1072
+ RC-2 (2026-07-07): own_ops/local ONLY — the single owner of this instance
1073
+ owns every session regardless of which surface/identity path tagged it
1074
+ (CLI sessions are user_id="local", telegram principals "u_<hash>", goal/
1075
+ cron runs the owner principal). Callers MUST gate on _catalog_scope();
1076
+ multitenant keeps strict per-tenant listing via _get_user_sessions.
1077
+ """
1078
+ sessions: List[Dict[str, Any]] = []
1079
+ try:
1080
+ data_root = pm().data_root
1081
+ logger.info(f"Loading ALL sessions from data_root: {data_root}")
1082
+ for user_path in data_root.iterdir():
1083
+ if not user_path.is_dir():
1084
+ continue
1085
+ sessions.extend(_collect_sessions_in_dir(user_path, user_path.name))
1086
+ sessions = _finalize_session_rows(sessions)
1087
+ logger.info(f"Found {len(sessions)} sessions across all users")
1088
+ except Exception as exc:
1089
+ logger.error(f"Failed to list all sessions: {exc}", exc_info=True)
1090
+ return sessions
1091
+
1092
+
1093
+ def _catalog_scope(request: Request) -> tuple[str, Optional[str]]:
1094
+ """Who may list WHAT in the session catalog: ('all'|'user'|'none', user_id).
1095
+
1096
+ Mirrors _check_session_ownership's posture logic exactly:
1097
+ - local: the loopback operator IS the owner → 'all'.
1098
+ - own_ops: the authenticated owner-login identity
1099
+ (webgate.local_owner_id()) → 'all'; any other identity or no auth →
1100
+ 'none' (a non-owner in own_ops has no sessions of their own).
1101
+ - multitenant: authenticated → 'user' (strict per-tenant, unchanged);
1102
+ unauthenticated → 'none'.
1103
+ """
1104
+ if not webgate.requires_owner_login():
1105
+ return ("all", webgate.local_owner_id())
1106
+
1107
+ from utils.auth_utils import is_authenticated
1108
+ if not is_authenticated(request):
1109
+ return ("none", None)
1110
+ current_user_id = getattr(request.state, 'user_id', None)
1111
+
1112
+ if webgate.is_own_ops():
1113
+ if current_user_id and current_user_id == webgate.local_owner_id():
1114
+ return ("all", current_user_id)
1115
+ return ("none", current_user_id)
1116
+
1117
+ return ("user", current_user_id) if current_user_id else ("none", None)
1118
+
1119
+
1120
+ def _annotate_runtime(sessions: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
1121
+ """WS-4 minimum (2026-07-07): say WHERE an active-looking session lives.
1122
+
1123
+ Uses the P6 routing seam (TaskAgent.route_session → SessionRoute): with
1124
+ SESSION_REGISTRY_BACKEND=sqlite the registry mirrors liveness across
1125
+ processes, so the console can distinguish
1126
+ - 'agent': live in ANOTHER process (the agent service) — watch via feed,
1127
+ steering needs that process (honest 409 on send);
1128
+ - 'here': resident in THIS process (console-created / resumed here);
1129
+ - 'idle': no live orchestrator anywhere (resumable from disk).
1130
+ Only rows whose on-disk status looks active are queried (one registry
1131
+ lookup each); with the default in-process registry this degrades to
1132
+ here/idle, never lies. Fail-open: no agent → no annotation.
1133
+ """
1134
+ agent = _in_process_task_agent()
1135
+ route_fn = getattr(agent, "route_session", None) if agent else None
1136
+ if route_fn is None:
1137
+ return sessions
1138
+ for row in sessions:
1139
+ if row.get('status') not in ("running", "created", "resumed"):
1140
+ continue
1141
+ try:
1142
+ route = route_fn(row['id'])
1143
+ except Exception:
1144
+ continue
1145
+ if route is None or getattr(route, "is_missing", False):
1146
+ row['runtime'] = "idle"
1147
+ elif getattr(route, "is_remote", False):
1148
+ row['runtime'] = "agent"
1149
+ row['owner_pid'] = route.owner_pid
1150
+ else:
1151
+ row['runtime'] = "here"
1152
+ return sessions
1153
+
1154
+
1155
+ def _sessions_for_request(request: Request) -> List[Dict[str, Any]]:
1156
+ """Catalog rows for this request, per _catalog_scope."""
1157
+ scope, user_id = _catalog_scope(request)
1158
+ if scope == "all":
1159
+ return _annotate_runtime(_get_all_sessions())
1160
+ if scope == "user":
1161
+ return _annotate_runtime(_get_user_sessions(user_id=user_id))
1162
+ return []
1163
+
1164
+
1165
+ @_multitenant_get("/signin", response_class=HTMLResponse)
1166
+ async def signin_page(request: Request) -> Response:
1167
+ """Show wallet sign in page."""
1168
+ from utils.auth_utils import is_authenticated
1169
+ return _templates.TemplateResponse("signin.html", {
1170
+ "request": request,
1171
+ "is_authenticated": is_authenticated(request),
1172
+ "is_admin": getattr(request.state, 'is_admin', False)
1173
+ })
1174
+
1175
+
1176
+ @_multitenant_get("/logout", response_class=HTMLResponse)
1177
+ async def logout(request: Request) -> Response:
1178
+ """Logout user and redirect to signin."""
1179
+ response = HTMLResponse(content="""
1180
+ <html>
1181
+ <head>
1182
+ <script>
1183
+ localStorage.clear();
1184
+ window.location.href = '/signin';
1185
+ </script>
1186
+ </head>
1187
+ </html>
1188
+ """)
1189
+ response.delete_cookie("auth_token")
1190
+ return response
1191
+
1192
+
1193
+ # --- owner-login hardening: throttle + CSRF + return_to sanitizing ---------- #
1194
+ # In-memory per-IP sliding window. Argon2 cost alone is not brute-force
1195
+ # throttling; 5 attempts / 5 min per IP is generous for one owner.
1196
+ _LOGIN_ATTEMPT_WINDOW_SEC = 300
1197
+ _LOGIN_ATTEMPT_MAX = 5
1198
+ _login_attempts: dict = {}
1199
+
1200
+
1201
+ def _login_throttled(ip: str) -> bool:
1202
+ now = time.time()
1203
+ attempts = [t for t in _login_attempts.get(ip, []) if now - t < _LOGIN_ATTEMPT_WINDOW_SEC]
1204
+ _login_attempts[ip] = attempts
1205
+ return len(attempts) >= _LOGIN_ATTEMPT_MAX
1206
+
1207
+
1208
+ def _record_login_attempt(ip: str) -> None:
1209
+ _login_attempts.setdefault(ip, []).append(time.time())
1210
+ if len(_login_attempts) > 10000: # bound memory under address churn
1211
+ _login_attempts.clear()
1212
+
1213
+
1214
+ def _csrf_token_for(nonce: Optional[str]) -> Optional[str]:
1215
+ """Stateless double-submit token: HMAC(JWT_SECRET_KEY, nonce).
1216
+
1217
+ None when no JWT secret is configured (local/dev without auth) — CSRF
1218
+ enforcement is skipped there, matching the no-auth posture.
1219
+ """
1220
+ secret = os.environ.get("JWT_SECRET_KEY")
1221
+ if not secret or not nonce:
1222
+ return None
1223
+ import hashlib
1224
+ import hmac as _hmac
1225
+ return _hmac.new(secret.encode(), f"owner-login:{nonce}".encode(), hashlib.sha256).hexdigest()
1226
+
1227
+
1228
+ def _safe_return_to(raw) -> str:
1229
+ """Only same-origin relative paths — kills open redirects via return_to."""
1230
+ to = str(raw or "/")
1231
+ if not to.startswith("/") or to.startswith("//") or "\\" in to:
1232
+ return "/"
1233
+ return to
1234
+
1235
+
1236
+ @_posture_get("/owner-login", postures=("own_ops", "multitenant"), response_class=HTMLResponse)
1237
+ async def owner_login_page(request: Request) -> Response:
1238
+ """Owner username/password login page (Posture 1, own_ops).
1239
+
1240
+ Registered for own_ops AND multitenant (NOT `_multitenant_get`, which
1241
+ would be multitenant-only) — own_ops posture is NOT multitenant, so this
1242
+ must stay reachable outside that gate. Wallet sign-in (`/signin`) stays
1243
+ available too, as an additional method in multitenant (design doc §1:
1244
+ owner-login is "optionally also selectable" there). Posture 0 (local)
1245
+ has no auth at all — no login surface needed or wanted, so it is NOT
1246
+ registered there (a request → 404).
1247
+ """
1248
+ import secrets as _secrets
1249
+ return_to = _safe_return_to(request.query_params.get("return_to", "/"))
1250
+ nonce = _secrets.token_hex(16)
1251
+ response = _templates.TemplateResponse("owner_login.html", {
1252
+ "request": request, "return_to": return_to, "error": None,
1253
+ "csrf_token": _csrf_token_for(nonce),
1254
+ })
1255
+ response.set_cookie(
1256
+ "csrf_nonce", nonce, max_age=600, httponly=True, samesite="lax",
1257
+ secure=(os.environ.get("ENVIRONMENT", "production") == "production"), path="/owner-login",
1258
+ )
1259
+ return response
1260
+
1261
+
1262
+ @_posture_post("/owner-login", postures=("own_ops", "multitenant"))
1263
+ async def owner_login_submit(request: Request) -> Response:
1264
+ """Verify owner credentials and, on success, issue the owner session cookie.
1265
+
1266
+ Same error for a bad username or a bad password (no user-enumeration).
1267
+ Hardened: per-IP attempt throttle (429), stateless double-submit CSRF
1268
+ (403 on mismatch when a JWT secret is configured), sanitized return_to.
1269
+ """
1270
+ from webview.owner_auth import verify_owner_password, issue_owner_session_cookie
1271
+
1272
+ client_ip = request.client.host if request.client else "unknown"
1273
+ if _login_throttled(client_ip):
1274
+ return _templates.TemplateResponse(
1275
+ "owner_login.html",
1276
+ {"request": request, "return_to": "/",
1277
+ "error": "Too many attempts. Try again in a few minutes.", "csrf_token": None},
1278
+ status_code=429,
1279
+ )
1280
+ _record_login_attempt(client_ip)
1281
+
1282
+ form = await request.form()
1283
+ username = form.get("username", "")
1284
+ password = form.get("password", "")
1285
+ return_to = _safe_return_to(form.get("return_to", "/"))
1286
+
1287
+ expected_csrf = _csrf_token_for(request.cookies.get("csrf_nonce"))
1288
+ if os.environ.get("JWT_SECRET_KEY"):
1289
+ supplied = str(form.get("csrf_token", ""))
1290
+ if not expected_csrf or not hmac.compare_digest(supplied, expected_csrf):
1291
+ return _templates.TemplateResponse(
1292
+ "owner_login.html",
1293
+ {"request": request, "return_to": return_to,
1294
+ "error": "Invalid or expired form. Please try again.", "csrf_token": None},
1295
+ status_code=403,
1296
+ )
1297
+
1298
+ if not verify_owner_password(username, password):
1299
+ return _templates.TemplateResponse(
1300
+ "owner_login.html",
1301
+ {"request": request, "return_to": return_to,
1302
+ "error": "Invalid username or password.", "csrf_token": None},
1303
+ status_code=401,
1304
+ )
1305
+
1306
+ response = RedirectResponse(url=return_to, status_code=303)
1307
+ issue_owner_session_cookie(response)
1308
+ return response
1309
+
1310
+
1311
+ @_multitenant_get("/profile", response_class=HTMLResponse)
1312
+ async def profile_page(request: Request) -> Response:
1313
+ """Show user profile page with credits and deposit information."""
1314
+ from utils.auth_utils import is_authenticated
1315
+ return _templates.TemplateResponse("profile.html", {
1316
+ "request": request,
1317
+ "is_authenticated": is_authenticated(request),
1318
+ "is_admin": getattr(request.state, 'is_admin', False)
1319
+ })
1320
+
1321
+
1322
+ @_fastapi.get("/settings", response_class=HTMLResponse)
1323
+ async def settings_page(request: Request) -> Response:
1324
+ """Show user settings page (MCP servers, preferences, API keys)."""
1325
+ from utils.auth_utils import is_authenticated
1326
+ return _templates.TemplateResponse("settings.html", {
1327
+ "request": request,
1328
+ "is_authenticated": is_authenticated(request),
1329
+ "is_admin": getattr(request.state, 'is_admin', False)
1330
+ })
1331
+
1332
+
1333
+ # ============================================================================
1334
+ # Admin Dashboard Routes
1335
+ # ============================================================================
1336
+
1337
+ @_multitenant_get("/admin", response_class=HTMLResponse)
1338
+ async def admin_dashboard(request: Request) -> Response:
1339
+ """Show admin dashboard - requires admin access."""
1340
+ from utils.auth_utils import is_authenticated
1341
+
1342
+ # Check if user is admin
1343
+ is_admin = getattr(request.state, 'is_admin', False)
1344
+ if not is_admin:
1345
+ logger.warning(f"Non-admin attempted to access /admin: user_id={getattr(request.state, 'user_id', 'unknown')}")
1346
+ return HTMLResponse(content="""
1347
+ <html>
1348
+ <head>
1349
+ <script>
1350
+ alert('Admin access required');
1351
+ window.location.href = '/';
1352
+ </script>
1353
+ </head>
1354
+ </html>
1355
+ """, status_code=403)
1356
+
1357
+ return _templates.TemplateResponse("admin/dashboard.html", {
1358
+ "request": request,
1359
+ "is_authenticated": is_authenticated(request),
1360
+ "is_admin": True
1361
+ })
1362
+
1363
+
1364
+ @_multitenant_get("/admin/users", response_class=HTMLResponse)
1365
+ async def admin_users_page(request: Request) -> Response:
1366
+ """Show admin user management page."""
1367
+ from utils.auth_utils import is_authenticated
1368
+
1369
+ is_admin = getattr(request.state, 'is_admin', False)
1370
+ if not is_admin:
1371
+ return RedirectResponse(url="/", status_code=303)
1372
+
1373
+ return _templates.TemplateResponse("admin/users.html", {
1374
+ "request": request,
1375
+ "is_authenticated": is_authenticated(request),
1376
+ "is_admin": True
1377
+ })
1378
+
1379
+
1380
+ @_multitenant_get("/admin/users/{user_id}", response_class=HTMLResponse)
1381
+ async def admin_user_detail_page(request: Request, user_id: str) -> Response:
1382
+ """Show admin user detail page."""
1383
+ from utils.auth_utils import is_authenticated
1384
+
1385
+ is_admin = getattr(request.state, 'is_admin', False)
1386
+ if not is_admin:
1387
+ return RedirectResponse(url="/", status_code=303)
1388
+
1389
+ return _templates.TemplateResponse("admin/user_detail.html", {
1390
+ "request": request,
1391
+ "is_authenticated": is_authenticated(request),
1392
+ "is_admin": True,
1393
+ "target_user_id": user_id
1394
+ })
1395
+
1396
+
1397
+ @_multitenant_get("/admin/activity", response_class=HTMLResponse)
1398
+ async def admin_activity_page(request: Request) -> Response:
1399
+ """Show admin activity/audit log page."""
1400
+ from utils.auth_utils import is_authenticated
1401
+
1402
+ is_admin = getattr(request.state, 'is_admin', False)
1403
+ if not is_admin:
1404
+ return RedirectResponse(url="/", status_code=303)
1405
+
1406
+ return _templates.TemplateResponse("admin/activity.html", {
1407
+ "request": request,
1408
+ "is_authenticated": is_authenticated(request),
1409
+ "is_admin": True
1410
+ })
1411
+
1412
+
1413
+ @_fastapi.get("/", response_class=HTMLResponse)
1414
+ async def index(request: Request) -> Response:
1415
+ """Posture-aware root.
1416
+
1417
+ - local: full dashboard (unchanged).
1418
+ - own_ops/multitenant, unauthenticated: minimal public status page ONLY
1419
+ (safe-by-default invariant — no console/SaaS UI exposed by default).
1420
+ - own_ops/multitenant, authenticated (owner or, Posture 2, tenant session):
1421
+ full dashboard.
1422
+
1423
+ Security:
1424
+ - `/` itself is intentionally NOT in `public_paths`/exempt from
1425
+ `auth_middleware`; enforcement here is the in-handler
1426
+ `is_authenticated(request)` check above, which branches by
1427
+ posture: local always gets the dashboard, own_ops/multitenant
1428
+ get the dashboard only once authenticated (owner or, Posture 2,
1429
+ a tenant session) and otherwise fall through to the public
1430
+ status page.
1431
+ - New sessions are always owned by the current user
1432
+ """
1433
+ from utils.auth_utils import is_authenticated
1434
+
1435
+ if webgate.posture() != "local" and not is_authenticated(request):
1436
+ from core.instance import resolve_instance_id
1437
+
1438
+ return _templates.TemplateResponse("status.html", {
1439
+ "request": request,
1440
+ "instance_id": resolve_instance_id(),
1441
+ "version": os.environ.get("WEBVIEW_VERSION", get_version()),
1442
+ })
1443
+
1444
+ ws_url = os.environ.get("WEBVIEW_WS_URL", "")
1445
+ version = os.environ.get("WEBVIEW_VERSION", get_version())
1446
+
1447
+ # For new sessions, the current user is always the owner
1448
+ is_owner = True
1449
+ user_is_authenticated = is_authenticated(request)
1450
+
1451
+ return _templates.TemplateResponse(
1452
+ "session.html",
1453
+ {
1454
+ "request": request,
1455
+ "session_id": "new", # Special value for empty state
1456
+ "ws_url": ws_url,
1457
+ "version": version,
1458
+ "is_owner": is_owner,
1459
+ "is_authenticated": user_is_authenticated,
1460
+ "is_admin": getattr(request.state, 'is_admin', False),
1461
+ "read_only": webgate.read_only(),
1462
+ },
1463
+ )
1464
+
1465
+
1466
+ @_fastapi.get("/sessions", response_class=HTMLResponse)
1467
+ async def sessions_list(request: Request) -> Response:
1468
+ """Show a list of sessions with metadata.
1469
+
1470
+ Security:
1471
+ - own_ops/local: the instance owner sees ALL sessions (RC-2)
1472
+ - multitenant: only the authenticated tenant's own sessions
1473
+ - anyone else sees an empty list (no shared DEFAULT_USER_ID sessions)
1474
+ """
1475
+ from utils.auth_utils import is_authenticated
1476
+
1477
+ # SECURITY: same scope rules as /api/sessions (_catalog_scope) — own_ops/
1478
+ # local owner sees ALL user dirs (RC-2), multitenant stays per-tenant,
1479
+ # everyone else gets an empty list.
1480
+ scope, user_id = _catalog_scope(request)
1481
+ sessions = _sessions_for_request(request)
1482
+ logger.info(f"Sessions list: scope={scope} user={user_id}, count={len(sessions)}")
1483
+
1484
+ # Get WebSocket URL from environment variable
1485
+ ws_url = os.environ.get("WEBVIEW_WS_URL", "")
1486
+
1487
+ # Get version from environment variable or use a default
1488
+ version = os.environ.get("WEBVIEW_VERSION", get_version())
1489
+
1490
+ return _templates.TemplateResponse(
1491
+ "index.html",
1492
+ {
1493
+ "request": request,
1494
+ "sessions": sessions,
1495
+ "ws_url": ws_url,
1496
+ "version": version,
1497
+ "is_authenticated": is_authenticated(request),
1498
+ "is_admin": getattr(request.state, 'is_admin', False)
1499
+ },
1500
+ )
1501
+
1502
+
1503
+ @_fastapi.get("/new", response_class=HTMLResponse)
1504
+ async def new_session(request: Request) -> Response:
1505
+ """Alias for main chat page (backwards compatibility)."""
1506
+ return RedirectResponse(url="/", status_code=302)
1507
+
1508
+
1509
+ @_fastapi.get("/session/{session_id}", response_class=HTMLResponse)
1510
+ async def session_page(request: Request, session_id: str) -> Response:
1511
+ """Render the main session view.
1512
+
1513
+ Security:
1514
+ - Viewing is allowed for anyone (no auth required)
1515
+ - Interaction (sending messages) requires ownership
1516
+ - Ownership is determined by matching user_id in session metadata
1517
+
1518
+ Note:
1519
+ This endpoint is in public_paths, so auth middleware doesn't run.
1520
+ We manually check for authentication token to populate request.state.
1521
+ """
1522
+ from utils.auth_utils import is_authenticated, get_authenticated_user_id
1523
+
1524
+ # Get WebSocket URL from environment variable
1525
+ ws_url = os.environ.get("WEBVIEW_WS_URL", "")
1526
+
1527
+ # Get version from environment variable or use a default
1528
+ version = os.environ.get("WEBVIEW_VERSION", get_version())
1529
+
1530
+ # Clean session ID
1531
+ clean_id = pm().clean_session_id(session_id)
1532
+
1533
+ # Note: _manual_auth_check() is now called by auth middleware for all public paths
1534
+ # This populates request.state if a valid token is present
1535
+
1536
+ # Check authentication and ownership using centralized helper
1537
+ is_owner, current_user_id, session_owner_id = _check_session_ownership(request, clean_id)
1538
+ user_is_authenticated = is_authenticated(request)
1539
+
1540
+ # Log ownership status
1541
+ if user_is_authenticated and current_user_id:
1542
+ logger.info(f"Session {clean_id}: current_user={current_user_id}, owner={session_owner_id}, is_owner={is_owner}")
1543
+ else:
1544
+ logger.info(f"Session {clean_id}: viewing without authentication")
1545
+
1546
+ return _templates.TemplateResponse(
1547
+ "session.html",
1548
+ {
1549
+ "request": request,
1550
+ "session_id": session_id,
1551
+ "ws_url": ws_url,
1552
+ "version": version,
1553
+ "is_owner": is_owner,
1554
+ "is_authenticated": user_is_authenticated,
1555
+ "is_admin": getattr(request.state, 'is_admin', False),
1556
+ "read_only": webgate.read_only(),
1557
+ },
1558
+ )
1559
+
1560
+
1561
+ @_fastapi.get("/api/session/{session_id}/feed", response_class=JSONResponse)
1562
+ async def api_feed(clean_id: str = Depends(get_clean_session_id)) -> Response:
1563
+ """DEPRECATED: Use /api/session/{id}/feed/events instead.
1564
+
1565
+ This endpoint redirects to the newer, more flexible endpoint.
1566
+ Will be removed in a future version.
1567
+ """
1568
+ logger.warning(f"DEPRECATED endpoint /api/session/{clean_id}/feed called - use /feed/events instead")
1569
+
1570
+ # Redirect to new endpoint with sensible defaults
1571
+ return RedirectResponse(
1572
+ url=f"/api/session/{clean_id}/feed/events?limit=500",
1573
+ status_code=307 # Temporary redirect, preserves method
1574
+ )
1575
+
1576
+
1577
+ @_fastapi.get("/api/session/{session_id}/workspace/tree", response_class=JSONResponse)
1578
+ async def api_workspace_tree(request: Request, clean_id: str = Depends(get_clean_session_id)) -> Response:
1579
+ """Return a very small file-tree of the session's workspace.
1580
+
1581
+ Note: Uses session owner's user_id to allow public viewing of shared sessions.
1582
+ """
1583
+ logger.debug(f"API workspace tree request for cleaned ID: {clean_id}")
1584
+
1585
+ try:
1586
+ # Use session owner's user_id (not requesting user's) to allow public viewing
1587
+ from utils.auth_utils import get_authenticated_user_id
1588
+ current_user_id = get_authenticated_user_id(request)
1589
+ session_owner_id = pm().get_session_user(clean_id)
1590
+
1591
+ # Use session owner's ID for data access (allows public viewing)
1592
+ user_id = session_owner_id if session_owner_id else current_user_id
1593
+
1594
+ ws_dir = pm().get_workspace_dir(clean_id, user_id=user_id)
1595
+ logger.debug(f"Getting workspace tree for {clean_id}, owner: {user_id}")
1596
+
1597
+ if not ws_dir.exists():
1598
+ # Try to create workspace directory for just this session
1599
+ try:
1600
+ ws_dir.mkdir(parents=True, exist_ok=True)
1601
+ logger.info(f"Created missing workspace directory: {ws_dir}")
1602
+ except Exception as e:
1603
+ logger.error(f"Failed to create workspace directory: {e}")
1604
+ # Instead of an HTTP error, return empty tree with a message
1605
+ return JSONResponse({
1606
+ "name": ws_dir.name,
1607
+ "type": "dir",
1608
+ "children": [],
1609
+ "message": "Workspace directory does not exist or is not accessible"
1610
+ })
1611
+
1612
+ def _walk(dir_path: Path) -> Dict[str, Any]:
1613
+ try:
1614
+ children = []
1615
+ # Skip if the directory doesn't exist or is not accessible
1616
+ if not dir_path.exists():
1617
+ logger.warning(f"Directory does not exist: {dir_path}")
1618
+ return children
1619
+
1620
+ # Check if we can actually read the directory
1621
+ if not os.access(str(dir_path), os.R_OK):
1622
+ logger.warning(f"Cannot read directory: {dir_path} (permission denied)")
1623
+ return children
1624
+
1625
+ for child in sorted(dir_path.iterdir()):
1626
+ # Skip hidden files/dirs at all directory levels (not just top level)
1627
+ if child.name.startswith("."):
1628
+ continue
1629
+
1630
+ item = {"name": child.name, "type": "dir" if child.is_dir() else "file"}
1631
+ if child.is_dir():
1632
+ item["children"] = _walk(child)
1633
+ children.append(item)
1634
+ return children
1635
+ except PermissionError:
1636
+ logger.warning(f"Permission denied when reading directory {dir_path}")
1637
+ return []
1638
+ except Exception as e:
1639
+ logger.error(f"Error walking directory {dir_path}: {e}")
1640
+ return []
1641
+
1642
+ logger.debug(f"Building workspace tree for: {ws_dir}")
1643
+ children = _walk(ws_dir)
1644
+
1645
+ # Log the result summary
1646
+ file_count = sum(1 for item in children if item["type"] == "file")
1647
+ dir_count = sum(1 for item in children if item["type"] == "dir")
1648
+ logger.info(f"Workspace tree for {clean_id}: {file_count} files, {dir_count} directories at top level")
1649
+
1650
+ # Include empty message if no files
1651
+ if not children:
1652
+ return JSONResponse({
1653
+ "name": ws_dir.name,
1654
+ "type": "dir",
1655
+ "children": [],
1656
+ "empty": True,
1657
+ "message": "No files found in workspace"
1658
+ })
1659
+
1660
+ return JSONResponse({
1661
+ "name": ws_dir.name,
1662
+ "type": "dir",
1663
+ "children": children
1664
+ })
1665
+ except Exception as e:
1666
+ logger.error(f"Error getting workspace tree for {clean_id}: {e}", exc_info=True)
1667
+ return JSONResponse({
1668
+ "name": "workspace",
1669
+ "type": "dir",
1670
+ "children": [],
1671
+ "error": str(e),
1672
+ "message": "Error accessing workspace"
1673
+ }, status_code=500)
1674
+
1675
+
1676
+ @_fastapi.get("/api/session/{session_id}/workspace/status", response_class=JSONResponse)
1677
+ async def api_workspace_status(request: Request, clean_id: str = Depends(get_clean_session_id)) -> Response:
1678
+ """Diagnostic endpoint to check workspace directory status.
1679
+
1680
+ Note: Uses session owner's user_id to allow public viewing of shared sessions.
1681
+ """
1682
+ try:
1683
+ # Use session owner's user_id (not requesting user's) to allow public viewing
1684
+ from utils.auth_utils import get_authenticated_user_id
1685
+ current_user_id = get_authenticated_user_id(request)
1686
+ session_owner_id = pm().get_session_user(clean_id)
1687
+ user_id = session_owner_id if session_owner_id else current_user_id
1688
+
1689
+ # Get workspace directory
1690
+ ws_dir = pm().get_workspace_dir(clean_id, user_id=user_id)
1691
+
1692
+ # Check if directory exists
1693
+ exists = ws_dir.exists()
1694
+
1695
+ # Get directory stats if it exists
1696
+ stats = {}
1697
+ files = []
1698
+ if exists:
1699
+ try:
1700
+ # List top-level files/dirs
1701
+ top_items = list(ws_dir.iterdir())
1702
+ stats["item_count"] = len(top_items)
1703
+ stats["dirs"] = sum(1 for item in top_items if item.is_dir())
1704
+ stats["files"] = sum(1 for item in top_items if item.is_file())
1705
+
1706
+ # List top 10 files for debugging
1707
+ for item in sorted(top_items)[:10]:
1708
+ if item.is_file():
1709
+ files.append({
1710
+ "name": item.name,
1711
+ "size": item.stat().st_size,
1712
+ "modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat()
1713
+ })
1714
+ elif item.is_dir():
1715
+ # Count items in subdirectory
1716
+ try:
1717
+ subdir_items = list(item.iterdir())
1718
+ files.append({
1719
+ "name": f"{item.name}/",
1720
+ "type": "directory",
1721
+ "item_count": len(subdir_items)
1722
+ })
1723
+ except Exception as e:
1724
+ files.append({
1725
+ "name": f"{item.name}/",
1726
+ "type": "directory",
1727
+ "error": str(e)
1728
+ })
1729
+ except Exception as e:
1730
+ stats["error"] = str(e)
1731
+
1732
+ # Get full path
1733
+ full_path = str(ws_dir.resolve())
1734
+
1735
+ # Check permissions
1736
+ permissions = {}
1737
+ try:
1738
+ permissions["readable"] = os.access(str(ws_dir), os.R_OK)
1739
+ permissions["writable"] = os.access(str(ws_dir), os.W_OK)
1740
+ permissions["executable"] = os.access(str(ws_dir), os.X_OK)
1741
+ except Exception as e:
1742
+ permissions["error"] = str(e)
1743
+
1744
+ # Return detailed status
1745
+ return JSONResponse({
1746
+ "session_id": clean_id,
1747
+ "workspace_dir": str(ws_dir),
1748
+ "full_path": full_path,
1749
+ "exists": exists,
1750
+ "stats": stats,
1751
+ "permissions": permissions,
1752
+ "files": files
1753
+ })
1754
+ except Exception as e:
1755
+ logger.error(f"Error checking workspace status for {clean_id}: {e}", exc_info=True)
1756
+ return JSONResponse({
1757
+ "session_id": clean_id,
1758
+ "error": str(e)
1759
+ }, status_code=500)
1760
+
1761
+
1762
+ @_fastapi.get("/api/session/{session_id}/workspace/file")
1763
+ async def api_workspace_file(request: Request, path: str, clean_id: str = Depends(get_clean_session_id)) -> Response: # noqa: WPS110 – param name dictated by API
1764
+ """Return the *text* content of a workspace file.
1765
+
1766
+ Note: Uses session owner's user_id to allow public viewing of shared sessions.
1767
+ """
1768
+ logger.debug(f"API workspace file request for cleaned ID: {clean_id}, path: {path}")
1769
+
1770
+ # Use session owner's user_id (not requesting user's) to allow public viewing
1771
+ from utils.auth_utils import get_authenticated_user_id
1772
+ current_user_id = get_authenticated_user_id(request)
1773
+ session_owner_id = pm().get_session_user(clean_id)
1774
+
1775
+ # Use session owner's ID for data access (allows public viewing)
1776
+ user_id = session_owner_id if session_owner_id else current_user_id
1777
+
1778
+ # FIXED: Enhanced security validation against directory traversal attacks
1779
+ import urllib.parse
1780
+
1781
+ # First decode any URL encoding (including double encoding)
1782
+ decoded_path = path
1783
+ for _ in range(3): # Decode up to 3 levels to catch double/triple encoding
1784
+ try:
1785
+ new_decoded = urllib.parse.unquote(decoded_path)
1786
+ if new_decoded == decoded_path:
1787
+ break # No more decoding needed
1788
+ decoded_path = new_decoded
1789
+ except Exception:
1790
+ break
1791
+
1792
+ # FIXED: Comprehensive path traversal prevention
1793
+ if any(dangerous in decoded_path.lower() for dangerous in [
1794
+ '..', './', '.\\.', '/.', '\\.',
1795
+ '%2e%2e', '%2f', '%5c', # URL encoded variants
1796
+ 'c:', 'd:', 'windows', 'system32', # Windows system paths
1797
+ '/etc/', '/proc/', '/sys/', '/root/', '/home/' # Unix system paths
1798
+ ]):
1799
+ logger.warning(f"Rejected dangerous path: {path} (decoded: {decoded_path})")
1800
+ raise HTTPException(403, "Forbidden: path contains dangerous sequences")
1801
+
1802
+ # Additional check: path cannot start with / or \ (absolute paths)
1803
+ if decoded_path.startswith(('//', '\\\\', '/', '\\')):
1804
+ logger.warning(f"Rejected absolute path: {path} (decoded: {decoded_path})")
1805
+ raise HTTPException(403, "Forbidden: absolute paths not allowed")
1806
+
1807
+ # Normalize the path to remove any remaining relative components
1808
+ import os.path
1809
+ normalized_path = os.path.normpath(decoded_path)
1810
+
1811
+ # Final check: normalized path should not start with .. or contain ..
1812
+ if normalized_path.startswith('..') or '/..' in normalized_path or '\\..' in normalized_path:
1813
+ logger.warning(f"Rejected path after normalization: {normalized_path}")
1814
+ raise HTTPException(403, "Forbidden: path resolves outside workspace")
1815
+
1816
+ workspace_dir = pm().get_workspace_dir(clean_id, user_id=user_id)
1817
+ file_path = workspace_dir / normalized_path
1818
+
1819
+ # FIXED: Enhanced path resolution with security checks
1820
+ try:
1821
+ file_path = file_path.resolve()
1822
+
1823
+ # Security: ensure the resolved path is still inside the workspace dir
1824
+ workspace_dir_resolved = workspace_dir.resolve()
1825
+ try:
1826
+ # Use relative_to to check if file_path is under workspace_dir_resolved
1827
+ file_path.relative_to(workspace_dir_resolved)
1828
+ except ValueError:
1829
+ logger.warning(f"Path resolves outside workspace: {file_path} not under {workspace_dir_resolved}")
1830
+ raise HTTPException(403, "Forbidden: path resolves outside workspace")
1831
+
1832
+ except FileNotFoundError:
1833
+ raise HTTPException(404, "File not found") from None
1834
+ except OSError as e:
1835
+ logger.error(f"Error resolving file path: {e}")
1836
+ raise HTTPException(500, "Error processing file path") from None
1837
+
1838
+ if not file_path.is_file():
1839
+ raise HTTPException(400, "Not a file")
1840
+
1841
+ # FIXED: Enhanced file size and type checking for security
1842
+ try:
1843
+ file_size = file_path.stat().st_size
1844
+ except OSError:
1845
+ raise HTTPException(404, "Cannot access file")
1846
+
1847
+ # Security: reject extremely large files to prevent DoS
1848
+ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB limit
1849
+ if file_size > MAX_FILE_SIZE:
1850
+ logger.warning(f"Rejected oversized file: {file_path} ({file_size} bytes)")
1851
+ raise HTTPException(413, f"File too large (max {MAX_FILE_SIZE // 1024 // 1024}MB)")
1852
+
1853
+ # Determine MIME type based on file extension with security filtering
1854
+ import mimetypes
1855
+ content_type, _ = mimetypes.guess_type(str(file_path))
1856
+
1857
+ # FIXED: Security filter for content types
1858
+ ALLOWED_TEXT_TYPES = {
1859
+ 'text/plain', 'text/html', 'text/css', 'text/javascript',
1860
+ 'application/json', 'application/xml', 'text/xml',
1861
+ 'text/markdown', 'text/csv', 'application/csv'
1862
+ }
1863
+
1864
+ DANGEROUS_EXTENSIONS = {
1865
+ '.exe', '.bat', '.cmd', '.com', '.scr', '.pif',
1866
+ '.vbs', '.js', '.jar', '.ps1', '.sh'
1867
+ }
1868
+
1869
+ file_extension = file_path.suffix.lower()
1870
+
1871
+ # Block dangerous file types
1872
+ if file_extension in DANGEROUS_EXTENSIONS:
1873
+ logger.warning(f"Rejected dangerous file type: {file_path}")
1874
+ raise HTTPException(403, "Forbidden: dangerous file type")
1875
+
1876
+ # Default to text/plain for unknown types with size limit for safety
1877
+ if not content_type:
1878
+ content_type = "text/plain"
1879
+
1880
+ # For binary files, large files, or non-text types, return FileResponse with proper headers
1881
+ max_inline_size = 1024 * 1024 # 1MB limit for inline display
1882
+
1883
+ if (content_type and not content_type.startswith("text/") and
1884
+ content_type not in ALLOWED_TEXT_TYPES) or file_size > max_inline_size:
1885
+
1886
+ # FIXED: Secure headers for file downloads
1887
+ headers = {
1888
+ "Content-Disposition": f"attachment; filename*=UTF-8''{urllib.parse.quote(file_path.name)}",
1889
+ "X-Content-Type-Options": "nosniff",
1890
+ "X-Frame-Options": "DENY",
1891
+ "Cache-Control": "no-store"
1892
+ }
1893
+ return FileResponse(file_path, media_type=content_type or "application/octet-stream", headers=headers)
1894
+
1895
+ # For normal text files, return the content with security headers
1896
+ headers = {
1897
+ "X-Content-Type-Options": "nosniff",
1898
+ "X-Frame-Options": "DENY",
1899
+ "Cache-Control": "no-store, no-cache, must-revalidate"
1900
+ }
1901
+
1902
+ return FileResponse(file_path, media_type=content_type, headers=headers)
1903
+
1904
+
1905
+ @_fastapi.get("/api/session/{session_id}/workspace/serve/{path:path}")
1906
+ async def api_workspace_serve(request: Request, path: str, clean_id: str = Depends(get_clean_session_id)) -> Response:
1907
+ """Serve workspace files for iframe embedding with relative path support.
1908
+
1909
+ Unlike /workspace/file, this endpoint:
1910
+ - Allows iframe embedding (no X-Frame-Options DENY)
1911
+ - Supports relative paths for presentations/HTML apps
1912
+ - Returns files with proper content types
1913
+ """
1914
+ logger.debug(f"API workspace serve request for cleaned ID: {clean_id}, path: {path}")
1915
+
1916
+ from utils.auth_utils import get_authenticated_user_id
1917
+ current_user_id = get_authenticated_user_id(request)
1918
+ session_owner_id = pm().get_session_user(clean_id)
1919
+ user_id = session_owner_id if session_owner_id else current_user_id
1920
+
1921
+ import urllib.parse
1922
+ import os.path
1923
+ import mimetypes
1924
+
1925
+ # Decode URL encoding
1926
+ decoded_path = path
1927
+ for _ in range(3):
1928
+ try:
1929
+ new_decoded = urllib.parse.unquote(decoded_path)
1930
+ if new_decoded == decoded_path:
1931
+ break
1932
+ decoded_path = new_decoded
1933
+ except Exception:
1934
+ break
1935
+
1936
+ # Security: reject dangerous patterns
1937
+ if any(dangerous in decoded_path.lower() for dangerous in [
1938
+ '..', './', '.\\.', '/.', '\\.',
1939
+ '%2e%2e', '%2f', '%5c',
1940
+ 'c:', 'd:', 'windows', 'system32',
1941
+ '/etc/', '/proc/', '/sys/', '/root/', '/home/'
1942
+ ]):
1943
+ logger.warning(f"Rejected dangerous path: {path}")
1944
+ raise HTTPException(403, "Forbidden: path contains dangerous sequences")
1945
+
1946
+ if decoded_path.startswith(('//', '\\\\', '/', '\\')):
1947
+ raise HTTPException(403, "Forbidden: absolute paths not allowed")
1948
+
1949
+ normalized_path = os.path.normpath(decoded_path)
1950
+ if normalized_path.startswith('..') or '/..' in normalized_path:
1951
+ raise HTTPException(403, "Forbidden: path resolves outside workspace")
1952
+
1953
+ workspace_dir = pm().get_workspace_dir(clean_id, user_id=user_id)
1954
+ file_path = workspace_dir / normalized_path
1955
+
1956
+ try:
1957
+ file_path = file_path.resolve()
1958
+ workspace_dir_resolved = workspace_dir.resolve()
1959
+ file_path.relative_to(workspace_dir_resolved)
1960
+ except (ValueError, FileNotFoundError):
1961
+ raise HTTPException(404, "File not found")
1962
+ except OSError as e:
1963
+ logger.error(f"Error resolving file path: {e}")
1964
+ raise HTTPException(500, "Error processing file path")
1965
+
1966
+ if not file_path.is_file():
1967
+ raise HTTPException(404, "File not found")
1968
+
1969
+ # Get content type
1970
+ content_type, _ = mimetypes.guess_type(str(file_path))
1971
+ if not content_type:
1972
+ content_type = "application/octet-stream"
1973
+
1974
+ # Security headers that allow iframe embedding
1975
+ headers = {
1976
+ "X-Content-Type-Options": "nosniff",
1977
+ "Cache-Control": "no-store, no-cache, must-revalidate"
1978
+ # Note: No X-Frame-Options to allow iframe embedding
1979
+ }
1980
+
1981
+ return FileResponse(file_path, media_type=content_type, headers=headers)
1982
+
1983
+
1984
+ @_fastapi.get("/api/sessions", response_class=JSONResponse)
1985
+ async def api_sessions(request: Request) -> Response:
1986
+ """Return a list of sessions with rich metadata for the authenticated user.
1987
+
1988
+ SECURITY: Only returns sessions for the authenticated user from JWT token.
1989
+ Unauthenticated users get empty list (no shared DEFAULT_USER_ID sessions).
1990
+ """
1991
+ # SECURITY: scope comes from the posture + authenticated identity ONLY
1992
+ # (_catalog_scope): own_ops/local owner → ALL user dirs (RC-2); multitenant
1993
+ # stays strictly per-tenant; anyone else → empty list.
1994
+ scope, user_id = _catalog_scope(request)
1995
+ sessions = _sessions_for_request(request)
1996
+ logger.info(f"📊 Catalog scope={scope} user={user_id}: {len(sessions)} sessions")
1997
+ return JSONResponse({"sessions": sessions})
1998
+
1999
+
2000
+ @_fastapi.get("/api/refresh", response_class=JSONResponse)
2001
+ async def api_refresh() -> Response:
2002
+ """Force a refresh of all session data."""
2003
+ try:
2004
+ # Just return success - the UI will reload the page
2005
+ return JSONResponse({"status": "ok", "message": "Sessions refreshed"})
2006
+ except Exception as exc:
2007
+ logger.error("Failed to refresh sessions: %s", exc, exc_info=True)
2008
+ return JSONResponse({"status": "error", "message": str(exc)}, status_code=500)
2009
+
2010
+
2011
+ @_fastapi.get("/api/repair/{session_id}", response_class=JSONResponse)
2012
+ async def api_repair(clean_id: str = Depends(get_clean_session_id)) -> Response:
2013
+ """Repair a session's telemetry (dedup + token estimation + validation).
2014
+
2015
+ Wired to the REAL ``webview.repair_sessions.repair_session_telemetry``
2016
+ (this endpoint used to return fake success without doing anything).
2017
+ Mutates feed/llm_usage files, so it is refused in read-only mode.
2018
+ """
2019
+ if webgate.read_only():
2020
+ return JSONResponse(
2021
+ {"status": "error", "message": "Console is read-only (WEBVIEW_READ_ONLY)"},
2022
+ status_code=403,
2023
+ )
2024
+ try:
2025
+ from webview.repair_sessions import repair_session_telemetry
2026
+ session_dir = pm().get_feed_dir(clean_id).parent
2027
+ if not session_dir.exists():
2028
+ return JSONResponse({"status": "error", "message": "Session not found"}, status_code=404)
2029
+ results = await asyncio.to_thread(repair_session_telemetry, session_dir)
2030
+ return JSONResponse({"status": "ok", "repair": results})
2031
+ except Exception as exc:
2032
+ logger.error("Failed to repair session %s: %s", clean_id, exc, exc_info=True)
2033
+ return JSONResponse({"status": "error", "message": str(exc)}, status_code=500)
2034
+
2035
+
2036
+ @_fastapi.get("/api/session/{session_id}/screenshot", response_class=JSONResponse)
2037
+ async def api_screenshot(request: Request, clean_id: str = Depends(get_clean_session_id)) -> Response:
2038
+ """Return the latest screenshot URL or data for a session.
2039
+
2040
+ Note: Uses session owner's user_id to allow public viewing of shared sessions.
2041
+ """
2042
+ logger.debug(f"API screenshot request for cleaned ID: {clean_id}")
2043
+
2044
+ try:
2045
+ # Use session owner's user_id (not requesting user's) to allow public viewing
2046
+ from utils.auth_utils import get_authenticated_user_id
2047
+ current_user_id = get_authenticated_user_id(request)
2048
+ session_owner_id = pm().get_session_user(clean_id)
2049
+ user_id = session_owner_id if session_owner_id else current_user_id
2050
+
2051
+ session_dir = pm().get_feed_dir(clean_id, user_id=user_id).parent
2052
+ screenshot_dir = session_dir / "screenshots"
2053
+
2054
+ # Check if the screenshots directory exists
2055
+ if not screenshot_dir.exists():
2056
+ # Try to create it, but no error if it already exists
2057
+ screenshot_dir.mkdir(exist_ok=True, parents=True)
2058
+ logger.info(f"Created screenshots directory for session {clean_id}")
2059
+
2060
+ # Find the latest screenshot
2061
+ screenshots = []
2062
+ if screenshot_dir.exists():
2063
+ screenshots = sorted(
2064
+ [p for p in screenshot_dir.glob("*.png") if p.is_file()],
2065
+ key=lambda p: p.stat().st_mtime,
2066
+ reverse=True
2067
+ )
2068
+
2069
+ if screenshots:
2070
+ # Get the most recent screenshot
2071
+ latest_screenshot = screenshots[0]
2072
+ # Return the path relative to static files or a direct file response
2073
+ screenshot_url = f"/api/session/{clean_id}/screenshot/file?ts={int(latest_screenshot.stat().st_mtime)}"
2074
+
2075
+ # Try to get page URL from screenshot filename
2076
+ page_url = None
2077
+ try:
2078
+ # Extract URL from filename (handles both new and old formats)
2079
+ filename = latest_screenshot.stem
2080
+
2081
+ # New format: screenshot_TIMESTAMP_URLENCODED.png
2082
+ # First check if there's at least one underscore
2083
+ if '_' in filename:
2084
+ # Split at first underscore to separate timestamp and URL parts
2085
+ parts = filename.split('_', 1)
2086
+
2087
+ # If we have more parts, the second part might contain an encoded URL
2088
+ if len(parts) > 1 and parts[1]:
2089
+ # Check if there's another underscore - new format includes an underscore before the URL
2090
+ if '_' in parts[1]:
2091
+ # Get the URL part (everything after the second underscore)
2092
+ url_part = parts[1].split('_', 1)[1]
2093
+ if url_part:
2094
+ import urllib.parse
2095
+ # Try to decode URL - will be empty if parsing fails
2096
+ page_url = url_part
2097
+ # If URL wasn't encoded, try to reconstruct a usable URL
2098
+ if not page_url.startswith('http'):
2099
+ # Check if it's a domain
2100
+ if '.' in page_url and 'www' in page_url or 'com' in page_url:
2101
+ page_url = f"https://{page_url}"
2102
+ except Exception as e:
2103
+ logger.debug(f"Error extracting URL from screenshot filename: {e}")
2104
+
2105
+ # If we couldn't get URL from filename, try to get from recent step events
2106
+ if not page_url:
2107
+ try:
2108
+ # Get the feed directory
2109
+ feed_dir = pm().get_feed_dir(clean_id)
2110
+ # Find the most recent step event that contains a URL
2111
+ step_files = sorted(
2112
+ feed_dir.glob("step_*.json"),
2113
+ key=lambda p: p.stat().st_mtime,
2114
+ reverse=True
2115
+ )[:5] # Check the 5 most recent step files
2116
+
2117
+ for step_file in step_files:
2118
+ try:
2119
+ with step_file.open("r") as f:
2120
+ step_data = json.load(f)
2121
+ # Check different places where URL might be stored
2122
+ if "data" in step_data:
2123
+ data = step_data["data"]
2124
+ # Check different possible URL fields
2125
+ for field in ["page_url", "current_url", "url"]:
2126
+ if field in data and data[field]:
2127
+ page_url = data[field]
2128
+ break
2129
+ if page_url:
2130
+ break
2131
+ except Exception:
2132
+ continue
2133
+ except Exception as e:
2134
+ logger.debug(f"Error checking step events for URL: {e}")
2135
+
2136
+ return JSONResponse({
2137
+ "status": "ok",
2138
+ "url": screenshot_url,
2139
+ "timestamp": latest_screenshot.stat().st_mtime,
2140
+ "filename": latest_screenshot.name,
2141
+ "page_url": page_url
2142
+ })
2143
+ else:
2144
+ # No screenshots found, return a placeholder
2145
+ logger.warning(f"No screenshots found for session {clean_id} in {screenshot_dir}")
2146
+ # Use an existing asset that we know exists to avoid 404s
2147
+ return JSONResponse({
2148
+ "status": "ok",
2149
+ "url": "/static/img/favicon.ico",
2150
+ "is_placeholder": True,
2151
+ "message": "No screenshots available"
2152
+ })
2153
+ except Exception as exc:
2154
+ logger.error("Failed to get screenshot for %s: %s", clean_id, exc, exc_info=True)
2155
+ return JSONResponse({
2156
+ "status": "error",
2157
+ "message": str(exc),
2158
+ "url": "/static/img/favicon.ico",
2159
+ "is_placeholder": True
2160
+ }, status_code=500)
2161
+
2162
+
2163
+ @_fastapi.get("/api/session/{session_id}/screenshot/file")
2164
+ async def api_screenshot_file(request: Request, clean_id: str = Depends(get_clean_session_id), ts: int = None) -> Response:
2165
+ """Return the actual screenshot file.
2166
+
2167
+ Note: Uses session owner's user_id to allow public viewing of shared sessions.
2168
+ """
2169
+
2170
+ try:
2171
+ # Use session owner's user_id (not requesting user's) to allow public viewing
2172
+ from utils.auth_utils import get_authenticated_user_id
2173
+ current_user_id = get_authenticated_user_id(request)
2174
+ session_owner_id = pm().get_session_user(clean_id)
2175
+ user_id = session_owner_id if session_owner_id else current_user_id
2176
+
2177
+ screenshot_dir = pm().get_feed_dir(clean_id, user_id=user_id).parent / "screenshots"
2178
+
2179
+ if not screenshot_dir.exists():
2180
+ logger.warning(f"Screenshots directory not found for {clean_id}")
2181
+ raise HTTPException(404, "No screenshots directory found")
2182
+
2183
+ screenshots = sorted(
2184
+ [p for p in screenshot_dir.glob("*.png") if p.is_file()],
2185
+ key=lambda p: p.stat().st_mtime,
2186
+ reverse=True
2187
+ )
2188
+
2189
+ if not screenshots:
2190
+ logger.warning(f"No screenshots found for {clean_id}")
2191
+ raise HTTPException(404, "No screenshots found")
2192
+
2193
+ # Include cache busting header to prevent browser caching
2194
+ headers = {
2195
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
2196
+ "Pragma": "no-cache"
2197
+ }
2198
+
2199
+ return FileResponse(
2200
+ screenshots[0],
2201
+ media_type="image/png",
2202
+ headers=headers
2203
+ )
2204
+ except HTTPException:
2205
+ raise
2206
+ except Exception as exc:
2207
+ logger.error("Failed to serve screenshot for %s: %s", clean_id, exc, exc_info=True)
2208
+ raise HTTPException(500, f"Error serving screenshot: {str(exc)}")
2209
+
2210
+
2211
+ @_fastapi.get("/api/session/{session_id}/agents", response_class=JSONResponse)
2212
+ async def api_agents(clean_id: str = Depends(get_clean_session_id)) -> Response:
2213
+ """Return the agents information for a session."""
2214
+ logger.debug(f"API agents request for cleaned ID: {clean_id}")
2215
+
2216
+ try:
2217
+ # Look for an agents.json file in the session directory
2218
+ feed_dir = pm().get_feed_dir(clean_id)
2219
+ session_dir = feed_dir.parent
2220
+ agents_file = session_dir / "agents.json"
2221
+
2222
+ # If agents.json exists, read it directly
2223
+ if agents_file.exists():
2224
+ try:
2225
+ with agents_file.open("r") as f:
2226
+ cached_data = f.read()
2227
+ if cached_data and len(cached_data) >= 10:
2228
+ cached_agents = json.loads(cached_data)
2229
+
2230
+ if cached_agents and isinstance(cached_agents, list) and len(cached_agents) > 0:
2231
+ # Normalize the cached data
2232
+ normalized_agents = []
2233
+ for agent in cached_agents:
2234
+ normalized_agent = {
2235
+ 'id': agent.get('id', agent.get('agent_id', 'Unknown')),
2236
+ 'name': agent.get('name', agent.get('agent_name', 'Unknown')),
2237
+ 'type': agent.get('type', agent.get('agent_type', 'Unknown')),
2238
+ 'model': agent.get('model', agent.get('model_name', ''))
2239
+ }
2240
+ normalized_agents.append(normalized_agent)
2241
+
2242
+ logger.debug(f"Using cached agents data with {len(normalized_agents)} agents")
2243
+
2244
+ return JSONResponse(
2245
+ {"status": "ok", "agents": normalized_agents},
2246
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2247
+ )
2248
+ except json.JSONDecodeError:
2249
+ logger.warning(f"Invalid JSON in agents file for session {clean_id}")
2250
+ except Exception as e:
2251
+ logger.warning(f"Error reading agents file: {e}")
2252
+
2253
+ # If no agents.json or it's invalid, extract from feed files (READ-ONLY)
2254
+ agents = []
2255
+ agent_ids = set()
2256
+ agent_models = {}
2257
+ execution_sequence = []
2258
+
2259
+ logger.debug(f"Extracting agents data from feed for session {clean_id}")
2260
+
2261
+ if feed_dir.exists():
2262
+ # Check for multi_agent_relationship entries - they have the most complete agent info
2263
+ relationship_files = sorted(feed_dir.glob("*multi_agent_relationship*.json"), reverse=True)
2264
+
2265
+ # Limit to latest 5 relationship files for efficiency
2266
+ for file in relationship_files[:5]:
2267
+ try:
2268
+ with file.open("r") as f:
2269
+ entry = json.load(f)
2270
+ if entry.get("type") in ["multi_agent_relationship", "multi_agent_relationship_detailed"] and "data" in entry:
2271
+ data = entry["data"]
2272
+ # Look for agent_models first - this is the most reliable source
2273
+ if "agent_models" in data and isinstance(data["agent_models"], dict):
2274
+ for agent_id, model in data["agent_models"].items():
2275
+ if model:
2276
+ agent_models[agent_id] = model
2277
+ logger.debug(f"Found model info from relationship: {agent_id} -> {model}")
2278
+
2279
+ # Store execution sequence if available
2280
+ if "execution_sequence" in data and isinstance(data["execution_sequence"], list) and data["execution_sequence"]:
2281
+ execution_sequence = data["execution_sequence"]
2282
+ logger.debug(f"Found execution sequence with {len(execution_sequence)} agents")
2283
+ elif "agent_ids" in data and isinstance(data["agent_ids"], list) and data["agent_ids"]:
2284
+ execution_sequence = data["agent_ids"]
2285
+ logger.debug(f"Using agent_ids as execution sequence with {len(execution_sequence)} agents")
2286
+
2287
+ # Process agent details
2288
+ if "agent_details" in data and isinstance(data["agent_details"], list):
2289
+ for agent_detail in data["agent_details"]:
2290
+ agent_id = agent_detail.get("id") or agent_detail.get("agent_id")
2291
+ if not agent_id:
2292
+ continue
2293
+
2294
+ # Extract model info directly from agent_detail if available
2295
+ model_from_detail = agent_detail.get("model")
2296
+ if model_from_detail:
2297
+ agent_models[agent_id] = model_from_detail
2298
+ logger.debug(f"Found agent model in detail: {agent_id} -> {model_from_detail}")
2299
+
2300
+ # Use the best available model info for this agent
2301
+ best_model = model_from_detail or agent_models.get(agent_id, '')
2302
+
2303
+ if agent_id not in agent_ids:
2304
+ agent = {
2305
+ 'id': agent_id,
2306
+ 'name': agent_detail.get("name") or agent_detail.get("agent_name") or agent_id,
2307
+ 'type': agent_detail.get("type") or agent_detail.get("agent_type") or "Unknown",
2308
+ 'model': best_model
2309
+ }
2310
+ agents.append(agent)
2311
+ agent_ids.add(agent_id)
2312
+ logger.debug(f"Added agent from relationship detail: {agent_id} with model {best_model}")
2313
+ # Update model for existing agents
2314
+ else:
2315
+ for agent in agents:
2316
+ if agent.get("id") == agent_id:
2317
+ if not agent.get("model") and best_model:
2318
+ agent["model"] = best_model
2319
+ logger.debug(f"Updated agent model: {agent_id} -> {best_model}")
2320
+ break
2321
+ except Exception as e:
2322
+ logger.debug(f"Error processing relationship file {file}: {e}")
2323
+ continue
2324
+
2325
+ # Look for agent_registration entries to find more agents
2326
+ registration_files = sorted(feed_dir.glob("agent_registration_*.json"), reverse=True)
2327
+
2328
+ # Limit to 20 most recent registration files
2329
+ for file in registration_files[:20]:
2330
+ try:
2331
+ with file.open("r") as f:
2332
+ entry = json.load(f)
2333
+ if entry.get("type") == "agent_registration" and "data" in entry:
2334
+ agent_data = entry["data"]
2335
+ agent_id = agent_data.get("id") or agent_data.get("agent_id")
2336
+
2337
+ if not agent_id:
2338
+ # Try to extract agent_id from filename if not in data
2339
+ filename = file.name
2340
+ if "_" in filename:
2341
+ parts = filename.split("_")
2342
+ if len(parts) >= 3: # agent_registration_AGENT_ID_TIMESTAMP.json
2343
+ possible_id = "_".join(parts[2:-1])
2344
+ if possible_id:
2345
+ agent_id = possible_id
2346
+ logger.debug(f"Extracted agent_id from filename: {agent_id}")
2347
+
2348
+ if not agent_id:
2349
+ continue
2350
+
2351
+ # Extract model information from registration
2352
+ model = None
2353
+ if "model_name" in agent_data and agent_data["model_name"]:
2354
+ model = agent_data["model_name"]
2355
+ elif "model" in agent_data and agent_data["model"]:
2356
+ model = agent_data["model"]
2357
+ elif "llm_model" in agent_data and agent_data["llm_model"]:
2358
+ model = agent_data["llm_model"]
2359
+
2360
+ # Handle "Unknown" or "None" values
2361
+ if model and (model == "Unknown" or model == "None"):
2362
+ model = None
2363
+
2364
+ # Store the model in our models dictionary for later use
2365
+ if model:
2366
+ agent_models[agent_id] = model
2367
+ logger.debug(f"Stored model for agent {agent_id}: {model}")
2368
+
2369
+ # Use the best available model
2370
+ best_model = model or agent_models.get(agent_id, '')
2371
+
2372
+ if agent_id not in agent_ids:
2373
+ agent = {
2374
+ 'id': agent_id,
2375
+ 'name': agent_data.get("name") or agent_data.get("agent_name") or agent_id,
2376
+ 'type': agent_data.get("type") or agent_data.get("agent_type") or "Unknown",
2377
+ 'model': best_model
2378
+ }
2379
+ agents.append(agent)
2380
+ agent_ids.add(agent_id)
2381
+ logger.debug(f"Added agent from registration: {agent_id} with model {best_model}")
2382
+ # Update existing agents with better model info if needed
2383
+ else:
2384
+ for agent in agents:
2385
+ if agent.get("id") == agent_id:
2386
+ if not agent.get("model") or (best_model and len(best_model) > len(agent.get("model", ""))):
2387
+ agent["model"] = best_model
2388
+ logger.debug(f"Updated existing agent model: {agent_id} -> {best_model}")
2389
+ if agent.get("name") in [agent_id, "Unknown", "Agent"] and agent_data.get("name"):
2390
+ agent["name"] = agent_data.get("name") or agent_data.get("agent_name")
2391
+ if agent.get("type") == "Unknown" and agent_data.get("type"):
2392
+ agent["type"] = agent_data.get("type") or agent_data.get("agent_type")
2393
+ break
2394
+ except Exception as e:
2395
+ logger.debug(f"Error processing agent registration file {file}: {e}")
2396
+ continue
2397
+
2398
+ # Look for LLM request data to capture model information
2399
+ llm_request_files = sorted(feed_dir.glob("llm_request_*.json"), reverse=True)
2400
+
2401
+ # Limit to 30 most recent LLM requests
2402
+ for file in llm_request_files[:30]:
2403
+ try:
2404
+ with file.open("r") as f:
2405
+ entry = json.load(f)
2406
+ if entry.get("type") == "llm_request" and "data" in entry:
2407
+ data = entry["data"]
2408
+ agent_id = data.get("agent_id")
2409
+
2410
+ if not agent_id:
2411
+ continue
2412
+
2413
+ # Extract model information
2414
+ model_name = None
2415
+ if "model_name" in data and data["model_name"]:
2416
+ model_name = data["model_name"]
2417
+ elif "model" in data and data["model"]:
2418
+ model_name = data["model"]
2419
+
2420
+ # Update agent models if we found a model name
2421
+ if agent_id and model_name:
2422
+ agent_models[agent_id] = model_name
2423
+ logger.debug(f"Updated agent model: {agent_id} -> {model_name}")
2424
+
2425
+ # Update existing agents' model info if needed
2426
+ for agent in agents:
2427
+ if agent.get("id") == agent_id and (not agent.get("model") or agent.get("model") == ''):
2428
+ agent["model"] = model_name
2429
+ logger.debug(f"Applied model to existing agent: {agent_id} -> {model_name}")
2430
+ except Exception as e:
2431
+ logger.debug(f"Error processing LLM request file {file}: {e}")
2432
+ continue
2433
+
2434
+ # If we still don't have agent data, search in step files
2435
+ if not agents:
2436
+ step_files = sorted(feed_dir.glob("step_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:20]
2437
+ for file in step_files:
2438
+ try:
2439
+ with file.open("r") as f:
2440
+ entry = json.load(f)
2441
+ if entry.get("type") == "step" and "data" in entry:
2442
+ data = entry["data"]
2443
+ agent_id_val = (
2444
+ data.get("agent_id")
2445
+ or data.get("id")
2446
+ or data.get("agent_name")
2447
+ )
2448
+ agent_name_val = data.get("agent_name") or data.get("name")
2449
+ agent_type_val = data.get("agent_type") or data.get("type") or "Unknown"
2450
+
2451
+ if agent_id_val and agent_id_val not in agent_ids:
2452
+ agents.append({
2453
+ 'id': agent_id_val,
2454
+ 'name': agent_name_val or agent_id_val,
2455
+ 'type': agent_type_val,
2456
+ 'model': agent_models.get(agent_id_val, '')
2457
+ })
2458
+ agent_ids.add(agent_id_val)
2459
+ except Exception as e:
2460
+ logger.debug(f"Error processing step file {file}: {e}")
2461
+ continue
2462
+
2463
+ # Check metadata.json as a fallback for all agents
2464
+ try:
2465
+ metadata_file = session_dir / "metadata.json"
2466
+ if metadata_file.exists():
2467
+ with metadata_file.open("r") as f:
2468
+ metadata_payload = json.load(f)
2469
+ meta_agents = metadata_payload.get("agents", []) if isinstance(metadata_payload, dict) else []
2470
+ for meta_agent in meta_agents:
2471
+ meta_id = meta_agent.get("id") or meta_agent.get("agent_id")
2472
+ if not meta_id:
2473
+ continue
2474
+ if meta_id not in agent_ids:
2475
+ agents.append({
2476
+ 'id': meta_id,
2477
+ 'name': meta_agent.get('name') or meta_agent.get('agent_name') or meta_id,
2478
+ 'type': meta_agent.get('type') or meta_agent.get('agent_type') or 'Unknown',
2479
+ 'model': meta_agent.get('model') or meta_agent.get('model_name') or ''
2480
+ })
2481
+ agent_ids.add(meta_id)
2482
+ else:
2483
+ # Update existing agent entry with any missing details
2484
+ for ag in agents:
2485
+ if ag.get('id') == meta_id:
2486
+ if (not ag.get('name') or ag['name'] == 'Unknown') and (meta_agent.get('name') or meta_agent.get('agent_name')):
2487
+ ag['name'] = meta_agent.get('name') or meta_agent.get('agent_name')
2488
+ if (not ag.get('type') or ag['type'] == 'Unknown') and (meta_agent.get('type') or meta_agent.get('agent_type')):
2489
+ ag['type'] = meta_agent.get('type') or meta_agent.get('agent_type')
2490
+ if not ag.get('model') and (meta_agent.get('model') or meta_agent.get('model_name')):
2491
+ ag['model'] = meta_agent.get('model') or meta_agent.get('model_name')
2492
+ break
2493
+ except Exception as e:
2494
+ logger.debug(f"Error merging agents from metadata.json: {e}")
2495
+
2496
+ # Ensure we're getting all unique agents with complete information
2497
+ unique_agents = {}
2498
+ for agent in agents:
2499
+ agent_id = agent.get('id')
2500
+ if agent_id:
2501
+ # If this agent ID already exists, merge information (favor non-empty values)
2502
+ if agent_id in unique_agents:
2503
+ existing = unique_agents[agent_id]
2504
+ # For each field, use the new value only if the existing one is empty
2505
+ for field in ['name', 'type', 'model']:
2506
+ if not existing.get(field) and agent.get(field):
2507
+ existing[field] = agent.get(field)
2508
+ # If the new value is longer or more specific, prefer it
2509
+ elif existing.get(field) and agent.get(field) and len(agent.get(field)) > len(existing.get(field)):
2510
+ # Only replace if it's a more informative value (longer and not just "Unknown")
2511
+ field_value = agent.get(field)
2512
+ if field_value and field_value.lower() != "unknown":
2513
+ existing[field] = agent.get(field)
2514
+ else:
2515
+ unique_agents[agent_id] = agent
2516
+
2517
+ # Convert back to list
2518
+ agents = list(unique_agents.values())
2519
+
2520
+ # Sort agents by execution order if possible, otherwise by ID
2521
+ try:
2522
+ if execution_sequence:
2523
+ # First sort by natural execution order using known sequence
2524
+ ordered_agents = []
2525
+ remaining_agents = []
2526
+
2527
+ # First add agents in the execution sequence order
2528
+ for exec_id in execution_sequence:
2529
+ for agent in agents:
2530
+ if agent['id'] == exec_id:
2531
+ ordered_agents.append(agent)
2532
+ break
2533
+
2534
+ # Then add any remaining agents not in the sequence
2535
+ for agent in agents:
2536
+ if agent['id'] not in execution_sequence:
2537
+ remaining_agents.append(agent)
2538
+
2539
+ # Sort remaining agents by ID
2540
+ remaining_agents.sort(key=lambda a: a['id'])
2541
+
2542
+ # Combine ordered and remaining agents
2543
+ agents = ordered_agents + remaining_agents
2544
+ else:
2545
+ # Fall back to sorting by agent ID
2546
+ agents.sort(key=lambda a: a['id'])
2547
+ except Exception as e:
2548
+ logger.debug(f"Error sorting agents: {e}")
2549
+ # If sorting fails, ensure we still return agents in some order
2550
+ agents.sort(key=lambda a: a.get('id', ''))
2551
+
2552
+ # Return with Cache-Control header to prevent stale responses
2553
+ return JSONResponse(
2554
+ {"status": "ok", "agents": agents},
2555
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2556
+ )
2557
+ except Exception as exc:
2558
+ logger.error("Failed to get agents for %s: %s", clean_id, exc, exc_info=True)
2559
+ return JSONResponse(
2560
+ {"status": "error", "message": str(exc)},
2561
+ status_code=500,
2562
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2563
+ )
2564
+
2565
+
2566
+ @_fastapi.get("/api/session/{session_id}/stats", response_class=JSONResponse)
2567
+ async def api_stats(clean_id: str = Depends(get_clean_session_id)) -> Response:
2568
+ """Return statistics for a session."""
2569
+ try:
2570
+ feed_dir = pm().get_feed_dir(clean_id)
2571
+
2572
+ stats = compute_session_stats(feed_dir)
2573
+
2574
+ return JSONResponse(
2575
+ {"status": "ok", "data": stats},
2576
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2577
+ )
2578
+ except Exception as exc:
2579
+ logger.error("Failed to get stats for %s: %s", clean_id, exc, exc_info=True)
2580
+ return JSONResponse(
2581
+ {"status": "error", "message": str(exc)},
2582
+ status_code=500,
2583
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2584
+ )
2585
+
2586
+
2587
+ @_fastapi.get("/api/session/{session_id}/services", response_class=JSONResponse)
2588
+ async def api_services(clean_id: str = Depends(get_clean_session_id)) -> Response:
2589
+ """Return the services information for a session."""
2590
+ logger.debug(f"API services request for cleaned ID: {clean_id}")
2591
+
2592
+ try:
2593
+ # Look for a services.json file in the session directory
2594
+ feed_dir = pm().get_feed_dir(clean_id)
2595
+ services_file = feed_dir.parent / "services.json"
2596
+
2597
+ # If services.json exists, read it directly
2598
+ if services_file.exists():
2599
+ try:
2600
+ with services_file.open("r") as f:
2601
+ services_data = json.load(f)
2602
+
2603
+ return JSONResponse(
2604
+ {"status": "ok", "services": services_data},
2605
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2606
+ )
2607
+ except json.JSONDecodeError:
2608
+ logger.warning(f"Invalid JSON in services file for session {clean_id}")
2609
+ except Exception as e:
2610
+ logger.warning(f"Error reading services file: {e}")
2611
+
2612
+ # If no services.json, extract service info from feed entries (READ-ONLY)
2613
+ services = {}
2614
+
2615
+ if feed_dir.exists():
2616
+ # First, look for available_actions entries which have service grouping
2617
+ for file in sorted(feed_dir.glob("available_actions_*.json"), reverse=True):
2618
+ try:
2619
+ with file.open("r") as f:
2620
+ entry = json.load(f)
2621
+ if entry.get("type") == "available_actions" and "data" in entry:
2622
+ data = entry["data"]
2623
+ if "by_service" in data and isinstance(data["by_service"], dict):
2624
+ service_info = []
2625
+ for service_name, actions in data["by_service"].items():
2626
+ service_info.append({
2627
+ "name": service_name,
2628
+ "type": "controller",
2629
+ "actions": actions,
2630
+ "action_count": len(actions)
2631
+ })
2632
+
2633
+ if service_info:
2634
+ return JSONResponse(
2635
+ {"status": "ok", "services": service_info},
2636
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2637
+ )
2638
+ except Exception as e:
2639
+ logger.debug(f"Error processing services file {file}: {e}")
2640
+ continue
2641
+
2642
+ # If we didn't find service data, look at service_actions entries
2643
+ for file in sorted(feed_dir.glob("service_actions_*.json")):
2644
+ try:
2645
+ with file.open("r") as f:
2646
+ entry = json.load(f)
2647
+ if entry.get("type") == "service_actions" and "data" in entry:
2648
+ data = entry["data"]
2649
+ service_name = data.get("service_name", "Unknown")
2650
+ if service_name not in services:
2651
+ services[service_name] = {
2652
+ "name": service_name,
2653
+ "type": data.get("service_type", "Unknown"),
2654
+ "actions": data.get("available_actions", []),
2655
+ "action_count": data.get("action_count", 0)
2656
+ }
2657
+ except Exception as e:
2658
+ logger.debug(f"Error processing {file}: {e}")
2659
+ continue
2660
+
2661
+ # If still no service data, extract from step actions
2662
+ if not services:
2663
+ for file in sorted(feed_dir.glob("step_*.json")):
2664
+ try:
2665
+ with file.open("r") as f:
2666
+ entry = json.load(f)
2667
+ if entry.get("type") == "step" and "data" in entry:
2668
+ data = entry["data"]
2669
+ if "actions" in data and isinstance(data["actions"], list):
2670
+ for action in data["actions"]:
2671
+ if "service" in action:
2672
+ service_name = action["service"]
2673
+ if service_name not in services:
2674
+ services[service_name] = {
2675
+ "name": service_name,
2676
+ "count": 0,
2677
+ "actions": set()
2678
+ }
2679
+ services[service_name]["count"] += 1
2680
+ if "name" in action:
2681
+ services[service_name]["actions"].add(action["name"])
2682
+ except Exception as e:
2683
+ logger.debug(f"Error processing {file}: {e}")
2684
+ continue
2685
+
2686
+ # Convert sets to lists for JSON serialization
2687
+ for service in services.values():
2688
+ if "actions" in service and isinstance(service["actions"], set):
2689
+ service["actions"] = list(service["actions"])
2690
+
2691
+ return JSONResponse(
2692
+ {"status": "ok", "services": list(services.values())},
2693
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2694
+ )
2695
+ except Exception as exc:
2696
+ logger.error("Failed to get services for %s: %s", clean_id, exc, exc_info=True)
2697
+ return JSONResponse(
2698
+ {"status": "error", "message": str(exc)},
2699
+ status_code=500,
2700
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2701
+ )
2702
+
2703
+
2704
+ @_fastapi.get("/api/session/{session_id}/task", response_class=JSONResponse)
2705
+ async def api_task(clean_id: str = Depends(get_clean_session_id)) -> Response:
2706
+ """Return the current task for a session.
2707
+
2708
+ This function prioritizes returning the initial user-inputted task,
2709
+ not subsequent task updates or derived goals.
2710
+ """
2711
+ logger.debug(f"API task request for cleaned ID: {clean_id}")
2712
+
2713
+ try:
2714
+ # First look for a dedicated task.json file in the session directory
2715
+ session_dir = pm().get_feed_dir(clean_id).parent
2716
+ task_file = session_dir / "task.json"
2717
+
2718
+ # If task.json exists, use it directly
2719
+ if task_file.exists():
2720
+ try:
2721
+ with task_file.open("r") as f:
2722
+ task_data = json.load(f)
2723
+
2724
+ # Check if the task data is valid and contains the task
2725
+ if isinstance(task_data, dict) and "task" in task_data and task_data["task"]:
2726
+ logger.info(f"Found task in task.json: '{task_data['task']}'")
2727
+ # Include timestamp if available, or use file mtime
2728
+ timestamp = task_data.get("timestamp") or task_file.stat().st_mtime
2729
+ return JSONResponse(
2730
+ {"status": "ok", "task": task_data["task"], "timestamp": timestamp},
2731
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2732
+ )
2733
+ except Exception as e:
2734
+ logger.warning(f"Error reading task.json for {clean_id}: {e}")
2735
+
2736
+ # Next check for metadata.json as it often contains the original task
2737
+ metadata_file = session_dir / "metadata.json"
2738
+ if metadata_file.exists():
2739
+ try:
2740
+ with metadata_file.open("r") as f:
2741
+ metadata = json.load(f)
2742
+ if "task" in metadata and metadata["task"]:
2743
+ task = metadata["task"]
2744
+ logger.info(f"Found initial task in metadata.json: '{task}'")
2745
+ # Use metadata timestamp or file mtime
2746
+ timestamp = metadata.get("created_at") or metadata.get("timestamp") or metadata_file.stat().st_mtime
2747
+ return JSONResponse(
2748
+ {"status": "ok", "task": task, "timestamp": timestamp},
2749
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2750
+ )
2751
+ except Exception as e:
2752
+ logger.debug(f"Error reading metadata.json: {e}")
2753
+
2754
+ # Look in the feed directory for session_start events which have the initial task
2755
+ feed_dir = pm().get_feed_dir(clean_id)
2756
+ if feed_dir.exists():
2757
+ # First check for session_start events (most reliable source for initial task)
2758
+ for file in sorted(feed_dir.glob("session_start_*.json"), reverse=True):
2759
+ try:
2760
+ with file.open("r") as f:
2761
+ entry = json.load(f)
2762
+ if entry.get("type") == "session_start" and "data" in entry:
2763
+ data = entry["data"]
2764
+ if "task" in data and data["task"]:
2765
+ task = data["task"]
2766
+ # Use event timestamp or file mtime
2767
+ timestamp = entry.get("timestamp") or file.stat().st_mtime
2768
+ logger.info(f"Found initial task in session_start event: '{task}'")
2769
+
2770
+ return JSONResponse(
2771
+ {"status": "ok", "task": task, "timestamp": timestamp},
2772
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2773
+ )
2774
+ except Exception as e:
2775
+ logger.debug(f"Error processing session start file {file}: {e}")
2776
+ continue
2777
+
2778
+ # Then check for task_update events (but only the first/earliest one which is likely initial)
2779
+ task_update_files = sorted(feed_dir.glob("task_update_*.json"))
2780
+ if task_update_files:
2781
+ # Only look at the first task_update file (chronologically) to get initial task
2782
+ try:
2783
+ first_file = task_update_files[0]
2784
+ with first_file.open("r") as f:
2785
+ entry = json.load(f)
2786
+ if entry.get("type") == "task_update" and "data" in entry:
2787
+ data = entry["data"]
2788
+ if "task" in data and data["task"]:
2789
+ task = data["task"]
2790
+ # Use event timestamp or file mtime
2791
+ timestamp = entry.get("timestamp") or first_file.stat().st_mtime
2792
+ logger.info(f"Found initial task in first task_update event: '{task}'")
2793
+
2794
+ return JSONResponse(
2795
+ {"status": "ok", "task": task, "timestamp": timestamp},
2796
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2797
+ )
2798
+ except Exception as e:
2799
+ logger.debug(f"Error processing task update file: {e}")
2800
+
2801
+ # No task was found anywhere
2802
+ logger.warning(f"No task information found for session {clean_id}")
2803
+ return JSONResponse(
2804
+ {"status": "not_found", "message": "No task information available"},
2805
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2806
+ )
2807
+
2808
+ except Exception as exc:
2809
+ logger.error("Failed to get task for %s: %s", clean_id, exc, exc_info=True)
2810
+ return JSONResponse(
2811
+ {"status": "error", "message": str(exc)},
2812
+ status_code=500,
2813
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2814
+ )
2815
+
2816
+
2817
+ @_fastapi.get("/api/session/{session_id}/skills", response_class=JSONResponse)
2818
+ async def api_skills(clean_id: str = Depends(get_clean_session_id)) -> Response:
2819
+ """Return the skills loaded for a session."""
2820
+ logger.debug(f"API skills request for cleaned ID: {clean_id}")
2821
+
2822
+ try:
2823
+ # Look for a skills.json file in the session directory
2824
+ feed_dir = pm().get_feed_dir(clean_id)
2825
+ session_dir = feed_dir.parent
2826
+ skills_file = session_dir / "skills.json"
2827
+
2828
+ # If skills.json exists, read it directly
2829
+ if skills_file.exists():
2830
+ try:
2831
+ with skills_file.open("r") as f:
2832
+ skills_data = json.load(f)
2833
+
2834
+ return JSONResponse(
2835
+ {"status": "ok", "skills": skills_data},
2836
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2837
+ )
2838
+ except json.JSONDecodeError:
2839
+ logger.warning(f"Invalid JSON in skills file for session {clean_id}")
2840
+ except Exception as e:
2841
+ logger.warning(f"Error reading skills file: {e}")
2842
+
2843
+ # If no skills.json, try to extract from session_start event
2844
+ if feed_dir.exists():
2845
+ for file in sorted(feed_dir.glob("session_start_*.json"), reverse=True):
2846
+ try:
2847
+ with file.open("r") as f:
2848
+ entry = json.load(f)
2849
+ if entry.get("type") == "session_start" and "data" in entry:
2850
+ data = entry["data"]
2851
+ if "skills" in data and isinstance(data["skills"], list):
2852
+ skills = data["skills"]
2853
+ return JSONResponse(
2854
+ {"status": "ok", "skills": skills},
2855
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2856
+ )
2857
+ except Exception as e:
2858
+ logger.debug(f"Error processing session start file {file}: {e}")
2859
+ continue
2860
+
2861
+ # No skills data found - return empty list
2862
+ return JSONResponse(
2863
+ {"status": "ok", "skills": []},
2864
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2865
+ )
2866
+
2867
+ except Exception as exc:
2868
+ logger.error("Failed to get skills for %s: %s", clean_id, exc, exc_info=True)
2869
+ return JSONResponse(
2870
+ {"status": "error", "message": str(exc)},
2871
+ status_code=500,
2872
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2873
+ )
2874
+
2875
+
2876
+ @_fastapi.get("/api/session/{session_id}/feed/events", response_class=JSONResponse)
2877
+ async def api_feed_events(request: Request, session_id: str, event_type: Optional[str] = None, limit: int = FEED_DEFAULT_LIMIT, after_seq: Optional[int] = None) -> Response:
2878
+ """Return specific event types from the feed.
2879
+
2880
+ Args:
2881
+ request: FastAPI request object (for user authentication)
2882
+ session_id: The session ID
2883
+ event_type: Optional filter by event type (step, planner, evaluation, etc.)
2884
+ limit: Maximum number of events to return (configurable, see FEED_DEFAULT_LIMIT/FEED_MAX_LIMIT)
2885
+ after_seq: Optional sequence number for delta sync - only return events with _seq > after_seq
2886
+
2887
+ Note: Uses session owner's user_id to allow public viewing of shared sessions.
2888
+
2889
+ Response includes metadata for delta sync:
2890
+ - last_seq: The highest sequence number in the returned events
2891
+ - total: Total number of events returned
2892
+ """
2893
+ # Validate limit parameter to prevent DoS
2894
+ if limit < 1 or limit > FEED_MAX_LIMIT:
2895
+ raise HTTPException(400, f"Limit must be between 1 and {FEED_MAX_LIMIT}")
2896
+
2897
+ # Use session owner's user_id (not requesting user's) to allow public viewing
2898
+ from utils.auth_utils import get_authenticated_user_id
2899
+ current_user_id = get_authenticated_user_id(request)
2900
+ session_owner_id = pm().get_session_user(session_id)
2901
+
2902
+ # Use session owner's ID for data access (allows public viewing)
2903
+ user_id = session_owner_id if session_owner_id else current_user_id
2904
+
2905
+ # Clean the session ID to handle agent prefixes
2906
+ clean_id = pm().clean_session_id(session_id)
2907
+ logger.info(f"🔍 API feed events request for session {session_id} (cleaned: {clean_id}), user: {user_id}, type: {event_type}")
2908
+
2909
+ try:
2910
+ feed_dir = pm().get_feed_dir(clean_id, user_id=user_id)
2911
+ logger.info(f"📁 Feed directory resolved to: {feed_dir}")
2912
+ except Exception as e:
2913
+ logger.error(f"Error getting feed directory: {e}")
2914
+ raise HTTPException(500, "Internal server error")
2915
+
2916
+ if not feed_dir.exists():
2917
+ logger.warning(f"Feed directory not found: {feed_dir}")
2918
+ raise HTTPException(404, "Session not found")
2919
+
2920
+ # Define valid event types
2921
+ valid_event_types = [
2922
+ 'step', 'planner', 'evaluation', 'multi_agent_relationship',
2923
+ 'agent_registration', 'session_start', 'task_update', 'llm_request',
2924
+ 'service_actions', 'available_actions', 'status',
2925
+ 'user_message', 'queue_status' # Chat UI events
2926
+ ]
2927
+
2928
+ # Validate event_type if provided
2929
+ if event_type and event_type not in valid_event_types:
2930
+ raise HTTPException(400, f"Invalid event_type. Must be one of: {', '.join(valid_event_types)}")
2931
+
2932
+ # Get pattern for file search
2933
+ pattern = f"{event_type}_*.json" if event_type else "*.json"
2934
+ logger.info(f"🔎 Searching for pattern: {pattern}")
2935
+
2936
+ # Find matching files, sorted by name (sequence-based filenames sort correctly)
2937
+ files = sorted(feed_dir.glob(pattern), key=lambda x: x.name)
2938
+ logger.info(f"📋 Found {len(files)} files matching pattern")
2939
+
2940
+ # Process the files
2941
+ items = []
2942
+ last_seq = 0
2943
+ for file in files:
2944
+ try:
2945
+ with file.open("r") as fh:
2946
+ item = json.load(fh)
2947
+
2948
+ # Check sequence-based filtering for delta sync
2949
+ item_seq = item.get('_seq')
2950
+ if after_seq is not None and item_seq is not None:
2951
+ if item_seq <= after_seq:
2952
+ continue # Skip events we already have
2953
+
2954
+ # Track highest sequence number
2955
+ if item_seq is not None and item_seq > last_seq:
2956
+ last_seq = item_seq
2957
+
2958
+ # Normalize to 'type' field (single source of truth)
2959
+ if 'event_type' in item and 'type' not in item:
2960
+ item['type'] = item['event_type']
2961
+
2962
+ # Add timestamp from filename if missing
2963
+ if 'timestamp' not in item:
2964
+ ts_parts = file.stem.split('_')
2965
+ if len(ts_parts) >= 2:
2966
+ try:
2967
+ item['timestamp'] = int(ts_parts[-1])
2968
+ except ValueError:
2969
+ item['timestamp'] = file.stat().st_mtime
2970
+
2971
+ # Add source file for debugging
2972
+ item['_source_file'] = file.name
2973
+
2974
+ # Enrich LLM cost if needed
2975
+ _enrich_llm_event_with_cost(item)
2976
+
2977
+ items.append(item)
2978
+
2979
+ # Check limit after processing (not before, to correctly handle after_seq filtering)
2980
+ if len(items) >= limit:
2981
+ break
2982
+ except Exception as exc:
2983
+ logger.debug(f"Failed to read {file}: {exc}")
2984
+
2985
+ # Return with metadata for delta sync
2986
+ return JSONResponse(
2987
+ {
2988
+ "events": items,
2989
+ "last_seq": last_seq,
2990
+ "total": len(items),
2991
+ "has_more": len(items) >= limit # More events may exist if we hit the limit
2992
+ },
2993
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
2994
+ )
2995
+
2996
+
2997
+ @_fastapi.post("/api/internal/emit")
2998
+ async def internal_emit(request: Request) -> Response:
2999
+ """Internal endpoint for direct event emission from telemetry service.
3000
+
3001
+ SECURITY: Only accepts requests from localhost (127.0.0.1).
3002
+ This endpoint bypasses the file watcher for immediate event delivery.
3003
+
3004
+ Expected JSON body:
3005
+ {
3006
+ "session_id": "session_123",
3007
+ "event": { ... event data with _seq, _ts_ms, _id ... }
3008
+ }
3009
+ """
3010
+ # SECURITY: Localhost-only check
3011
+ client_host = request.client.host if request.client else None
3012
+ if client_host not in ("127.0.0.1", "::1", "localhost"):
3013
+ logger.warning(f"Internal emit rejected from non-localhost: {client_host}")
3014
+ raise HTTPException(403, "Forbidden: localhost only")
3015
+
3016
+ try:
3017
+ body = await request.json()
3018
+ except Exception as e:
3019
+ logger.error(f"Internal emit: invalid JSON body: {e}")
3020
+ raise HTTPException(400, "Invalid JSON body")
3021
+
3022
+ session_id = body.get("session_id")
3023
+ event = body.get("event")
3024
+
3025
+ if not session_id or not event:
3026
+ raise HTTPException(400, "Missing session_id or event")
3027
+
3028
+ # Clean the session ID for consistent room naming. NOTE: clients join the
3029
+ # room named by the BARE clean id (join_session → enter_room(sid, clean_id))
3030
+ # and the file watcher emits there too — a "session:" prefix here would be
3031
+ # a dead room nobody joins (the old bug that silenced this fast path).
3032
+ clean_id = pm().clean_session_id(session_id)
3033
+ room = clean_id
3034
+
3035
+ # Enrich LLM cost if this is an llm_request event
3036
+ _enrich_llm_event_with_cost(event)
3037
+
3038
+ # Emit directly to all clients in the session room
3039
+ await _sio.emit("feed_update", event, room=room)
3040
+ logger.debug(f"Internal emit: sent event to room {room}, _seq={event.get('_seq')}")
3041
+
3042
+ return JSONResponse({"status": "ok", "room": room})
3043
+
3044
+
3045
+ @_fastapi.get("/api/session/{session_id}/status", response_class=JSONResponse)
3046
+ async def api_session_status(request: Request, session_id: str) -> Response:
3047
+ """Return the status of a session.
3048
+
3049
+ Note: Uses session owner's user_id to allow public viewing of shared sessions.
3050
+ """
3051
+ # Clean the session ID to handle agent prefixes
3052
+ clean_id = pm().clean_session_id(session_id)
3053
+ logger.debug(f"API status request for session {session_id} (cleaned: {clean_id})")
3054
+
3055
+ try:
3056
+ # Use session owner's user_id (not requesting user's) to allow public viewing
3057
+ from utils.auth_utils import get_authenticated_user_id
3058
+ current_user_id = get_authenticated_user_id(request)
3059
+ session_owner_id = pm().get_session_user(clean_id)
3060
+ user_id = session_owner_id if session_owner_id else current_user_id
3061
+
3062
+ feed_dir = pm().get_feed_dir(clean_id, user_id=user_id)
3063
+ session_dir = feed_dir.parent
3064
+
3065
+ if not feed_dir.exists():
3066
+ return JSONResponse(
3067
+ {"status": "unknown", "message": "Session not found"},
3068
+ status_code=404,
3069
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
3070
+ )
3071
+
3072
+ # Look for a status file
3073
+ status_file = session_dir / "status.json"
3074
+ status = "unknown"
3075
+ created_at = None
3076
+
3077
+ # First check if we have created time from session directory
3078
+ try:
3079
+ # Try to get creation time from the session directory
3080
+ session_created_time = session_dir.stat().st_mtime
3081
+ created_at = session_created_time
3082
+ except Exception as e:
3083
+ logger.debug(f"Error getting session creation time: {e}")
3084
+
3085
+ # Check for status.json
3086
+ if status_file.exists():
3087
+ try:
3088
+ with status_file.open("r") as f:
3089
+ status_data = json.load(f)
3090
+ status = status_data.get("status", "unknown")
3091
+ # Use created_at from file if available
3092
+ if "created_at" in status_data and not created_at:
3093
+ created_at = status_data["created_at"]
3094
+ except Exception as e:
3095
+ logger.debug(f"Error reading status file: {e}")
3096
+
3097
+ # If we don't have status yet, check for status in feed events
3098
+ if status == "unknown":
3099
+ # Look for status events in the feed
3100
+ status_files = sorted(feed_dir.glob("status_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
3101
+ if status_files:
3102
+ try:
3103
+ with status_files[0].open("r") as f:
3104
+ status_event = json.load(f)
3105
+ if "data" in status_event and "status" in status_event["data"]:
3106
+ status = status_event["data"]["status"]
3107
+ except Exception as e:
3108
+ logger.debug(f"Error reading status event: {e}")
3109
+
3110
+ # If we still don't have creation time, check for the oldest file
3111
+ if not created_at:
3112
+ try:
3113
+ all_files = list(feed_dir.glob("*.json"))
3114
+ if all_files:
3115
+ oldest_file = min(all_files, key=lambda p: p.stat().st_mtime)
3116
+ created_at = oldest_file.stat().st_mtime
3117
+ except Exception as e:
3118
+ logger.debug(f"Error getting oldest file: {e}")
3119
+ # Default to current time if we can't determine creation time
3120
+ created_at = time.time()
3121
+
3122
+ # Determine status based on recent activity if we still don't have a clear status
3123
+ if status == "unknown":
3124
+ # Check if there are any recent files (in the last 5 minutes)
3125
+ now = time.time()
3126
+ recent_activity = False
3127
+
3128
+ try:
3129
+ for file in feed_dir.glob("*.json"):
3130
+ file_mtime = file.stat().st_mtime
3131
+ if now - file_mtime < 300: # 5 minutes
3132
+ recent_activity = True
3133
+ break
3134
+
3135
+ # If there's recent activity, consider the session running
3136
+ if recent_activity:
3137
+ status = "running"
3138
+ else:
3139
+ # Check if there's any completion or error signals
3140
+ for file in feed_dir.glob("*.json"):
3141
+ try:
3142
+ with file.open("r") as f:
3143
+ entry = json.load(f)
3144
+ if entry.get("type") == "status":
3145
+ data_status = entry.get("data", {}).get("status", "")
3146
+ if data_status in ["completed", "finished", "done"]:
3147
+ status = "completed"
3148
+ break
3149
+ elif data_status in ["failed", "error"]:
3150
+ status = "failed"
3151
+ break
3152
+ except (json.JSONDecodeError, KeyError, TypeError):
3153
+ pass # Expected for non-JSON or malformed telemetry files
3154
+ except Exception as e:
3155
+ logger.debug(f"Error determining status based on activity: {e}")
3156
+
3157
+ return JSONResponse(
3158
+ {
3159
+ "status": status,
3160
+ "created_at": created_at,
3161
+ "session_id": clean_id
3162
+ },
3163
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
3164
+ )
3165
+ except Exception as exc:
3166
+ logger.error("Failed to get session status for %s: %s", clean_id, exc, exc_info=True)
3167
+ return JSONResponse(
3168
+ {"status": "error", "message": str(exc)},
3169
+ status_code=500,
3170
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
3171
+ )
3172
+
3173
+
3174
+ @_fastapi.get("/api/session/{session_id}/debug", response_class=JSONResponse)
3175
+ async def api_session_debug(session_id: str) -> Response:
3176
+ """Debug endpoint to inspect session state and troubleshoot chat issues.
3177
+
3178
+ Returns comprehensive information about:
3179
+ - Session directory structure
3180
+ - File existence (task.json, metadata.json, etc.)
3181
+ - Feed file counts by type
3182
+ - Sample feed files
3183
+ - Service connectivity status
3184
+
3185
+ This endpoint is designed to help diagnose why the chat tab might be empty.
3186
+ """
3187
+ clean_id = pm().clean_session_id(session_id)
3188
+ logger.debug(f"API debug request for session {session_id} (cleaned: {clean_id})")
3189
+
3190
+ try:
3191
+ feed_dir = pm().get_feed_dir(clean_id)
3192
+ session_dir = feed_dir.parent
3193
+
3194
+ debug_info = {
3195
+ "session_id": session_id,
3196
+ "clean_id": clean_id,
3197
+ "paths": {
3198
+ "session_dir": str(session_dir),
3199
+ "feed_dir": str(feed_dir),
3200
+ },
3201
+ "existence": {
3202
+ "session_dir_exists": session_dir.exists(),
3203
+ "feed_dir_exists": feed_dir.exists(),
3204
+ "task_json_exists": (session_dir / "task.json").exists(),
3205
+ "metadata_json_exists": (session_dir / "metadata.json").exists(),
3206
+ },
3207
+ "feed_files": {},
3208
+ "task": None,
3209
+ "services": {
3210
+ "webview": "running",
3211
+ "main_api": None
3212
+ }
3213
+ }
3214
+
3215
+ # Count feed files by type
3216
+ if feed_dir.exists():
3217
+ file_types = {}
3218
+ for file in feed_dir.glob("*.json"):
3219
+ file_type = file.name.split('_')[0]
3220
+ file_types[file_type] = file_types.get(file_type, 0) + 1
3221
+
3222
+ debug_info["feed_files"] = {
3223
+ "total_count": sum(file_types.values()),
3224
+ "by_type": file_types,
3225
+ "files": [
3226
+ {
3227
+ "name": f.name,
3228
+ "size": f.stat().st_size,
3229
+ "modified": f.stat().st_mtime
3230
+ }
3231
+ for f in sorted(feed_dir.glob("*.json"), key=lambda x: x.stat().st_mtime)[:10]
3232
+ ]
3233
+ }
3234
+
3235
+ # Try to load task
3236
+ try:
3237
+ import httpx
3238
+ async with httpx.AsyncClient() as client:
3239
+ task_resp = await client.get(
3240
+ f"http://localhost:8008/api/session/{session_id}/task",
3241
+ timeout=2.0
3242
+ )
3243
+ if task_resp.status_code == 200:
3244
+ debug_info["task"] = task_resp.json()
3245
+ else:
3246
+ debug_info["task"] = {"error": f"HTTP {task_resp.status_code}"}
3247
+ except Exception as e:
3248
+ debug_info["task"] = {"error": str(e)}
3249
+
3250
+ # Check main API
3251
+ try:
3252
+ import httpx
3253
+ async with httpx.AsyncClient() as client:
3254
+ api_resp = await client.get("http://127.0.0.1:9000/health", timeout=2.0)
3255
+ debug_info["services"]["main_api"] = "running" if api_resp.status_code == 200 else "error"
3256
+ except Exception:
3257
+ debug_info["services"]["main_api"] = "not_reachable"
3258
+
3259
+ return JSONResponse(
3260
+ debug_info,
3261
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
3262
+ )
3263
+
3264
+ except Exception as e:
3265
+ logger.error(f"Debug endpoint error: {e}", exc_info=True)
3266
+ return JSONResponse(
3267
+ {"error": str(e), "session_id": session_id},
3268
+ status_code=500,
3269
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
3270
+ )
3271
+
3272
+
3273
+ @_fastapi.get("/api/telemetry/health", response_class=JSONResponse)
3274
+ async def api_telemetry_health() -> Response:
3275
+ """Return telemetry system health status."""
3276
+ try:
3277
+ from agents.task.telemetry.service import ProductTelemetry
3278
+
3279
+ # Get the singleton telemetry instance
3280
+ telemetry = ProductTelemetry()
3281
+ health_stats = telemetry.get_health_stats()
3282
+
3283
+ return JSONResponse({
3284
+ "status": "ok",
3285
+ "health": health_stats,
3286
+ "recommendations": _generate_health_recommendations(health_stats)
3287
+ }, headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"})
3288
+
3289
+ except Exception as exc:
3290
+ logger.error(f"Failed to get telemetry health: {exc}")
3291
+ return JSONResponse(
3292
+ {"status": "error", "message": str(exc)},
3293
+ status_code=500,
3294
+ headers={"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
3295
+ )
3296
+
3297
+
3298
+ def _generate_health_recommendations(stats: Dict[str, Any]) -> List[str]:
3299
+ """Generate health recommendations based on stats."""
3300
+ recommendations = []
3301
+
3302
+ # Check success rates
3303
+ if stats.get('success_rate', 0) < 0.95:
3304
+ recommendations.append("Low telemetry capture success rate detected. Check LLM client error logs.")
3305
+
3306
+ if stats.get('llm_capture_success_rate', 0) < 0.95 and stats.get('successful_llm_captures', 0) + stats.get('failed_llm_captures', 0) > 0:
3307
+ recommendations.append("Low LLM file capture success rate. Check file system permissions and disk space.")
3308
+
3309
+ # Check rates
3310
+ if stats.get('duplicate_rate', 0) > 0.05:
3311
+ recommendations.append("High duplicate request rate detected. Check deduplication logic in stats service.")
3312
+
3313
+ if stats.get('token_fallback_rate', 0) > 0.1:
3314
+ recommendations.append("Frequent token estimation fallbacks. Check LLM provider token reporting.")
3315
+
3316
+ # Check for inactive system
3317
+ last_capture = stats.get('last_capture_time')
3318
+ if last_capture:
3319
+ import time
3320
+ time_since_last = time.time() - last_capture
3321
+ if time_since_last > 300: # 5 minutes
3322
+ recommendations.append(f"No telemetry captures in {int(time_since_last/60)} minutes. System may be idle or experiencing issues.")
3323
+
3324
+ # Check overall health status
3325
+ health_status = stats.get('status', 'unknown')
3326
+ if health_status == 'degraded':
3327
+ recommendations.append("System performance is degraded. Review logs for potential issues.")
3328
+ elif health_status == 'unhealthy':
3329
+ recommendations.append("System health is poor. Immediate attention required.")
3330
+
3331
+ # Add proactive recommendations
3332
+ if stats.get('total_captures', 0) == 0:
3333
+ recommendations.append("No telemetry captures recorded. Verify LLM clients are properly configured.")
3334
+
3335
+ uptime_hours = stats.get('uptime_seconds', 0) / 3600
3336
+ if uptime_hours > 24 and stats.get('total_captures', 0) < 100:
3337
+ recommendations.append("Low capture rate for system uptime. Check if agents are active.")
3338
+
3339
+ return recommendations
3340
+
3341
+
3342
+ async def _handle_stream_chunk(session_id: str, request: Request) -> Response:
3343
+ """Internal handler for streaming chunks - shared by both endpoints.
3344
+
3345
+ SECURITY: This endpoint is only accessible from localhost (internal agent calls).
3346
+ External requests will be rejected with 403 Forbidden.
3347
+ """
3348
+ # SECURITY: Only allow requests from localhost (internal agent calls)
3349
+ client_host = request.client.host if request.client else None
3350
+ if client_host not in ("127.0.0.1", "localhost", "::1"):
3351
+ logger.warning(f"Rejected stream chunk from external host: {client_host}")
3352
+ return JSONResponse(
3353
+ {"success": False, "error": "Internal endpoint - localhost only"},
3354
+ status_code=403
3355
+ )
3356
+
3357
+ clean_id = pm().clean_session_id(session_id)
3358
+
3359
+ try:
3360
+ data = await request.json()
3361
+ chunk = data.get("chunk", "")
3362
+ agent_id = data.get("agent_id")
3363
+ step = data.get("step", 0)
3364
+
3365
+ # Broadcast to all clients watching this session
3366
+ await _sio.emit("stream_chunk", {
3367
+ "session_id": clean_id,
3368
+ "agent_id": agent_id,
3369
+ "step": step,
3370
+ "chunk": chunk,
3371
+ "timestamp": time.time()
3372
+ }, room=clean_id)
3373
+
3374
+ return JSONResponse({"success": True})
3375
+
3376
+ except Exception as e:
3377
+ logger.error(f"Error receiving stream chunk: {e}")
3378
+ return JSONResponse(
3379
+ {"success": False, "error": str(e)},
3380
+ status_code=500
3381
+ )
3382
+
3383
+
3384
+ @_fastapi.post("/api/session/{session_id}/stream", response_class=JSONResponse)
3385
+ async def receive_stream_chunk_legacy(session_id: str, request: Request) -> Response:
3386
+ """Legacy endpoint for streaming chunks (backwards compatibility)."""
3387
+ return await _handle_stream_chunk(session_id, request)
3388
+
3389
+
3390
+ @_fastapi.post("/api/webview/sessions/{session_id}/stream", response_class=JSONResponse)
3391
+ async def receive_stream_chunk(session_id: str, request: Request) -> Response:
3392
+ """Receive streaming chunk from agent and broadcast to WebView clients.
3393
+
3394
+ Agents call this endpoint to send LLM streaming output to connected browsers.
3395
+ """
3396
+ return await _handle_stream_chunk(session_id, request)
3397
+
3398
+
3399
+ def _in_process_task_agent():
3400
+ """The TaskAgent living in THIS process (single-service webview deploys),
3401
+ or None when only the classic two-service (:9000) shape is available."""
3402
+ try:
3403
+ from core.container import DependencyContainer
3404
+ container = DependencyContainer.get_instance()
3405
+ agent = container.get_agent("task_agent")
3406
+ if not agent:
3407
+ agent = container.get_service("task_agent")
3408
+ return agent or None
3409
+ except Exception:
3410
+ return None
3411
+
3412
+
3413
+ async def _send_message_in_process(request: Request, clean_id: str, agent,
3414
+ text: str, kind: str, metadata: dict,
3415
+ attached_files, session_owner_id,
3416
+ current_user_id) -> Response:
3417
+ """WS-3.1: deliver a session message via the in-process task router
3418
+ handler — the same code path the :9000 service would run (file
3419
+ verification, pre-queue race fix, resume states, guard_remote), with no
3420
+ network hop and no phantom service dependency.
3421
+
3422
+ Authorization already happened in the wrapper via _check_session_ownership
3423
+ (the posture-aware authority: the own_ops/local owner owns EVERY session).
3424
+ The task handler's own _require_session_owner does a strict string match
3425
+ against the session's user_id, which would false-deny the owner on
3426
+ sessions tagged 'local'/'u_…' — so align the request identity to the
3427
+ session's owner for this internal, already-authorized call.
3428
+ """
3429
+ from api.task_http_api import send_user_message as _task_send
3430
+ from api.models import UserMessage as _UserMessage
3431
+
3432
+ request.state.user_id = session_owner_id or current_user_id
3433
+
3434
+ payload = _UserMessage(
3435
+ text=text,
3436
+ kind=kind or "comment",
3437
+ metadata=metadata or {},
3438
+ attached_files=attached_files,
3439
+ )
3440
+ try:
3441
+ await _task_send(clean_id, payload, request, agent)
3442
+ except HTTPException as e:
3443
+ if e.status_code == 409:
3444
+ # P6/Item 6 honest remote answer: the session is LIVE in another
3445
+ # process (the agent service) — watchable via the feed, not
3446
+ # steerable from here.
3447
+ return JSONResponse(
3448
+ {
3449
+ "success": False,
3450
+ "error": "Session is live in the agent process — watch it "
3451
+ "via the feed; console steering requires the "
3452
+ "session to be resident here.",
3453
+ "detail": e.detail,
3454
+ },
3455
+ status_code=409,
3456
+ headers=getattr(e, "headers", None) or {},
3457
+ )
3458
+ if e.status_code == 404:
3459
+ return JSONResponse(
3460
+ {"success": False, "error": "Session not found or not active"},
3461
+ status_code=404,
3462
+ )
3463
+ detail = e.detail if isinstance(e.detail, str) else json.dumps(e.detail)
3464
+ return JSONResponse({"success": False, "error": detail},
3465
+ status_code=e.status_code)
3466
+
3467
+ logger.info(f"Message sent to session {clean_id} (in-process): '{text[:50]}...'")
3468
+ return JSONResponse({"success": True, "message": "Message sent"})
3469
+
3470
+
3471
+ @_fastapi.post("/api/session/{session_id}/messages", response_class=JSONResponse)
3472
+ async def send_message_to_session(session_id: str, request: Request) -> Response:
3473
+ """Send user message to running session.
3474
+
3475
+ Delivers via the in-process TaskAgent when the task router is mounted in
3476
+ this process (single-service deploys, WS-3.1); otherwise proxies to the
3477
+ main :9000 API with retry logic (classic two-service shape).
3478
+
3479
+ Security:
3480
+ - Requires authentication
3481
+ - Only session owner can send messages (_check_session_ownership,
3482
+ posture-aware: the own_ops/local owner owns every session)
3483
+ """
3484
+ clean_id = pm().clean_session_id(session_id)
3485
+
3486
+ # Read-only console (monitoring deploys): no mutations, period.
3487
+ if webgate.read_only():
3488
+ return JSONResponse(
3489
+ {"success": False, "error": "Console is read-only (WEBVIEW_READ_ONLY)"},
3490
+ status_code=403,
3491
+ )
3492
+
3493
+ # SECURITY: Check authentication and ownership using centralized helper
3494
+ is_owner, current_user_id, session_owner_id = _check_session_ownership(request, clean_id)
3495
+
3496
+ # Check authentication
3497
+ if current_user_id is None:
3498
+ logger.warning(f"Unauthorized message attempt to session {clean_id} - not authenticated")
3499
+ return JSONResponse(
3500
+ {"success": False, "error": "Authentication required to send messages"},
3501
+ status_code=401
3502
+ )
3503
+
3504
+ # Check ownership
3505
+ if not is_owner:
3506
+ logger.warning(f"Unauthorized message attempt to session {clean_id} - user {current_user_id} is not owner {session_owner_id}")
3507
+ return JSONResponse(
3508
+ {"success": False, "error": "Only session owner can send messages"},
3509
+ status_code=403
3510
+ )
3511
+
3512
+ try:
3513
+ data = await request.json()
3514
+ text = data.get("text", "")
3515
+ kind = data.get("kind", "comment")
3516
+ metadata = data.get("metadata", {})
3517
+ attached_files = data.get("attached_files") # NEW: Forward attached files for vision
3518
+
3519
+ # WS-3.1 (2026-07-07): single-service deploys (prod own_ops) have NO
3520
+ # :9000 api service — but the task router + TaskAgent live in THIS
3521
+ # process. Deliver in-process when available; the :9000 proxy below
3522
+ # stays as the fallback for the classic two-service shape.
3523
+ in_proc_agent = _in_process_task_agent() if TASK_ROUTER_MOUNTED else None
3524
+ if in_proc_agent is not None:
3525
+ return await _send_message_in_process(
3526
+ request, clean_id, in_proc_agent, text, kind, metadata,
3527
+ attached_files, session_owner_id, current_user_id)
3528
+
3529
+ import httpx
3530
+
3531
+ # Call main API endpoint with retry logic
3532
+ api_url = "http://127.0.0.1:9000/api/task/sessions/{}/messages".format(clean_id)
3533
+
3534
+ max_retries = 3
3535
+ retry_delay = 1.0 # seconds
3536
+ last_error = None
3537
+
3538
+ for attempt in range(max_retries):
3539
+ try:
3540
+ async with httpx.AsyncClient() as client:
3541
+ # NEW: Include attached_files in proxy request for vision support
3542
+ payload = {"text": text, "kind": kind, "metadata": metadata}
3543
+ if attached_files:
3544
+ payload["attached_files"] = attached_files
3545
+
3546
+ response = await client.post(
3547
+ api_url,
3548
+ json=payload,
3549
+ timeout=10.0
3550
+ )
3551
+
3552
+ if response.status_code == 429:
3553
+ # Rate limited - don't retry
3554
+ logger.warning(f"Rate limit exceeded for session {clean_id}")
3555
+ return JSONResponse(
3556
+ {"success": False, "error": "Rate limit exceeded", "retry_after": 60},
3557
+ status_code=429,
3558
+ headers={"Retry-After": "60"}
3559
+ )
3560
+ elif response.status_code == 404:
3561
+ # Session not found - log and return error
3562
+ logger.error(f"Session {clean_id} not found in main API (404)")
3563
+ return JSONResponse(
3564
+ {"success": False, "error": "Session not found or not active"},
3565
+ status_code=404
3566
+ )
3567
+ elif response.status_code >= 500:
3568
+ # Server error - retry
3569
+ logger.warning(f"Main API error {response.status_code} for session {clean_id}, attempt {attempt+1}/{max_retries}")
3570
+ last_error = f"Server error: {response.status_code}"
3571
+ if attempt < max_retries - 1:
3572
+ await asyncio.sleep(retry_delay * (attempt + 1))
3573
+ continue
3574
+ elif response.status_code >= 400:
3575
+ # Client error - don't retry
3576
+ logger.error(f"Client error {response.status_code} for session {clean_id}: {response.text}")
3577
+ return JSONResponse(
3578
+ {"success": False, "error": response.text or "Request failed"},
3579
+ status_code=response.status_code
3580
+ )
3581
+
3582
+ # Success
3583
+ logger.info(f"Message sent to session {clean_id}: '{text[:50]}...'")
3584
+ return JSONResponse({"success": True, "message": "Message sent"})
3585
+
3586
+ except httpx.ConnectError as e:
3587
+ # Connection error - retry
3588
+ logger.error(f"Cannot connect to main API for session {clean_id}, attempt {attempt+1}/{max_retries}: {e}")
3589
+ last_error = f"Connection error: {str(e)}"
3590
+ if attempt < max_retries - 1:
3591
+ await asyncio.sleep(retry_delay * (attempt + 1))
3592
+ continue
3593
+ else:
3594
+ # Last attempt failed
3595
+ return JSONResponse(
3596
+ {"success": False, "error": "Main API service unavailable. Please check if polyrob-api.service is running."},
3597
+ status_code=503
3598
+ )
3599
+ except httpx.TimeoutException as e:
3600
+ logger.error(f"Timeout sending message to session {clean_id}, attempt {attempt+1}/{max_retries}")
3601
+ last_error = "Request timeout"
3602
+ if attempt < max_retries - 1:
3603
+ await asyncio.sleep(retry_delay * (attempt + 1))
3604
+ continue
3605
+
3606
+ # All retries exhausted
3607
+ logger.error(f"All {max_retries} retries exhausted for session {clean_id}. Last error: {last_error}")
3608
+ return JSONResponse(
3609
+ {"success": False, "error": last_error or "Request failed after retries"},
3610
+ status_code=503
3611
+ )
3612
+
3613
+ except Exception as e:
3614
+ logger.error(f"Unexpected error sending message to session {clean_id}: {e}", exc_info=True)
3615
+ return JSONResponse(
3616
+ {"success": False, "error": f"Internal error: {str(e)}"},
3617
+ status_code=500
3618
+ )
3619
+
3620
+
3621
+ @_fastapi.get("/api/session/{session_id}/queue-status", response_class=JSONResponse)
3622
+ async def get_queue_status(session_id: str, request: Request) -> Response:
3623
+ """Get message queue status for a session.
3624
+
3625
+ In-process TaskAgent when available (single-service deploys, WS-3.1);
3626
+ else proxies to the main :9000 API with retry logic (classic shape).
3627
+ """
3628
+ clean_id = pm().clean_session_id(session_id)
3629
+
3630
+ # In-process path — same read-only data the :9000 handler would return.
3631
+ in_proc_agent = _in_process_task_agent() if TASK_ROUTER_MOUNTED else None
3632
+ if in_proc_agent is not None:
3633
+ from api.task_http_api import get_queue_status as _task_queue_status
3634
+ try:
3635
+ # The task handler gates on the session's OWN user_id; this wrapper
3636
+ # (like the proxy it replaces) exposes only queue depth/status, so
3637
+ # align the identity to the session owner for the internal call.
3638
+ owner = pm().get_session_user(clean_id)
3639
+ if owner:
3640
+ request.state.user_id = owner
3641
+ data = await _task_queue_status(clean_id, request, in_proc_agent)
3642
+ return JSONResponse({
3643
+ "queued_messages": data.get("queued_messages", 0),
3644
+ "agent_status": data.get("agent_status", "unknown"),
3645
+ "streaming_callbacks": data.get("streaming_callbacks", 0),
3646
+ "callback_failures": data.get("callback_failures", 0)
3647
+ })
3648
+ except HTTPException as e:
3649
+ if e.status_code == 404:
3650
+ return JSONResponse(
3651
+ {"queued_messages": 0, "agent_status": "not_active"},
3652
+ status_code=404)
3653
+ if e.status_code == 409:
3654
+ # Live in the agent process — honest, not an error state.
3655
+ return JSONResponse(
3656
+ {"queued_messages": 0, "agent_status": "remote"})
3657
+ logger.warning(f"In-process queue-status failed for {clean_id}: {e.detail}")
3658
+ return JSONResponse({"queued_messages": 0, "agent_status": "error"})
3659
+ except Exception as e:
3660
+ logger.error(f"Unexpected in-process queue-status error for {clean_id}: {e}")
3661
+ return JSONResponse({"queued_messages": 0, "agent_status": "error"})
3662
+
3663
+ try:
3664
+ import httpx
3665
+
3666
+ # Call main API queue-status endpoint with retry logic
3667
+ api_url = f"http://127.0.0.1:9000/api/task/sessions/{clean_id}/queue-status"
3668
+
3669
+ max_retries = 2 # Fewer retries for polling endpoint
3670
+ retry_delay = 0.5 # Shorter delay for polling
3671
+
3672
+ for attempt in range(max_retries):
3673
+ try:
3674
+ async with httpx.AsyncClient() as client:
3675
+ response = await client.get(api_url, timeout=5.0)
3676
+
3677
+ if response.status_code == 200:
3678
+ data = response.json()
3679
+ return JSONResponse({
3680
+ "queued_messages": data.get("queued_messages", 0),
3681
+ "agent_status": data.get("agent_status", "unknown"),
3682
+ "streaming_callbacks": data.get("streaming_callbacks", 0),
3683
+ "callback_failures": data.get("callback_failures", 0)
3684
+ })
3685
+ elif response.status_code == 404:
3686
+ # Session not active - log once but return gracefully
3687
+ if attempt == 0:
3688
+ logger.info(f"Session {clean_id} not found or not active (404)")
3689
+ return JSONResponse({
3690
+ "queued_messages": 0,
3691
+ "agent_status": "not_active"
3692
+ }, status_code=404)
3693
+ elif response.status_code >= 500:
3694
+ # Server error - retry
3695
+ logger.warning(f"Main API error {response.status_code} for queue-status, attempt {attempt+1}/{max_retries}")
3696
+ if attempt < max_retries - 1:
3697
+ await asyncio.sleep(retry_delay)
3698
+ continue
3699
+ else:
3700
+ # Other error from API
3701
+ logger.warning(f"API queue-status returned {response.status_code} for session {clean_id}")
3702
+ return JSONResponse({
3703
+ "queued_messages": 0,
3704
+ "agent_status": "error"
3705
+ })
3706
+
3707
+ except httpx.ConnectError as e:
3708
+ # Connection error - retry
3709
+ if attempt == 0:
3710
+ logger.error(f"Cannot connect to main API for queue-status: {e}")
3711
+ if attempt < max_retries - 1:
3712
+ await asyncio.sleep(retry_delay)
3713
+ continue
3714
+ else:
3715
+ return JSONResponse({
3716
+ "queued_messages": 0,
3717
+ "agent_status": "api_unavailable"
3718
+ }, status_code=503)
3719
+ except httpx.TimeoutException:
3720
+ # Timeout - return unknown status
3721
+ logger.debug(f"Timeout getting queue status for session {clean_id}")
3722
+ return JSONResponse({
3723
+ "queued_messages": 0,
3724
+ "agent_status": "unknown"
3725
+ })
3726
+
3727
+ # All retries exhausted (shouldn't reach here due to returns in loop)
3728
+ return JSONResponse({
3729
+ "queued_messages": 0,
3730
+ "agent_status": "error"
3731
+ })
3732
+
3733
+ except Exception as e:
3734
+ logger.error(f"Unexpected error getting queue status for session {clean_id}: {e}", exc_info=True)
3735
+ return JSONResponse({
3736
+ "queued_messages": 0,
3737
+ "agent_status": "error"
3738
+ })
3739
+
3740
+
3741
+ # NOTE: a second POST /api/webview/sessions/{id}/stream handler
3742
+ # (api_session_stream, emitting "stream_update") used to live here. FastAPI
3743
+ # routes first-match, so it was permanently shadowed by receive_stream_chunk
3744
+ # above — dead code, removed. "stream_chunk" is the one streaming event.
3745
+
3746
+
3747
+ # Socket.IO auth state: sid -> resolved user_id (None = anonymous/unauthenticated).
3748
+ _socket_user: Dict[str, Optional[str]] = {}
3749
+ # sid -> JWT tier claim ("admin" for owner-login tokens; None otherwise).
3750
+ _socket_tier: Dict[str, Optional[str]] = {}
3751
+ # sids currently in the global "activity" room (drives hub start/stop).
3752
+ _activity_clients: set = set()
3753
+
3754
+
3755
+ def _decode_socket_payload(token: Optional[str]) -> Dict:
3756
+ """Decode a client-supplied JWT into its full claim dict ({} on any error).
3757
+ Fail-open to anonymous — mirrors _manual_auth_check's tolerance."""
3758
+ if not token:
3759
+ return {}
3760
+ try:
3761
+ import jwt as pyjwt
3762
+ jwt_secret = os.environ.get("JWT_SECRET_KEY")
3763
+ if not jwt_secret:
3764
+ return {}
3765
+ return pyjwt.decode(token, jwt_secret, algorithms=["HS256"]) or {}
3766
+ except Exception as e:
3767
+ logger.debug(f"Socket.IO auth token decode failed: {e}")
3768
+ return {}
3769
+
3770
+
3771
+ def _decode_socket_token(token: Optional[str]) -> Optional[str]:
3772
+ """Decode a client-supplied JWT from a Socket.IO connect() auth payload.
3773
+ Fail-open to None (anonymous) on any error."""
3774
+ return _decode_socket_payload(token).get("user_id")
3775
+
3776
+
3777
+ def _socket_cookie_token(environ: Dict) -> Optional[str]:
3778
+ """Extract the ``auth_token`` cookie from a Socket.IO connect() environ.
3779
+
3780
+ own_ops authenticates the owner via an httponly cookie minted by
3781
+ ``/owner-login`` (same cookie name the HTTP path reads in
3782
+ ``_manual_auth_check``/``auth_middleware``) — the browser's socket.io
3783
+ client never sees or forwards it as an ``auth={"token": ...}`` payload,
3784
+ so it has to be read off the raw ``HTTP_COOKIE`` header instead.
3785
+ """
3786
+ raw_cookie = (environ or {}).get("HTTP_COOKIE")
3787
+ if not raw_cookie:
3788
+ return None
3789
+ try:
3790
+ from http.cookies import SimpleCookie
3791
+ jar = SimpleCookie()
3792
+ jar.load(raw_cookie)
3793
+ morsel = jar.get("auth_token")
3794
+ return morsel.value if morsel else None
3795
+ except Exception as e:
3796
+ logger.debug(f"Socket.IO cookie parse failed: {e}")
3797
+ return None
3798
+
3799
+
3800
+ @_sio.event
3801
+ async def connect(sid: str, environ: Dict, auth: Dict | None = None) -> None: # noqa: D401 – Socket.IO callback
3802
+ logger.debug("Client connected: %s", sid)
3803
+ # Posture "local": loopback operator IS the owner — no auth, no friction,
3804
+ # byte-identical to before. own_ops AND multitenant both require some
3805
+ # decoded identity (owner-login cookie or wallet/SIWE bearer token
3806
+ # respectively); short-circuiting only "local" here is what closes the
3807
+ # own_ops anonymous-socket gap (E4 follow-up) without touching Posture 0.
3808
+ if not webgate.requires_owner_login():
3809
+ _socket_user[sid] = webgate.local_owner_id()
3810
+ _socket_tier[sid] = None
3811
+ return
3812
+ token = (auth or {}).get("token") if isinstance(auth, dict) else None
3813
+ if not token:
3814
+ token = _socket_cookie_token(environ)
3815
+ payload = _decode_socket_payload(token)
3816
+ _socket_user[sid] = payload.get("user_id")
3817
+ _socket_tier[sid] = payload.get("tier")
3818
+
3819
+
3820
+ async def _stop_hub_if_activity_empty() -> None:
3821
+ """Stop the activity hub's watcher/tails once nobody is watching."""
3822
+ if _activity_clients:
3823
+ return
3824
+ try:
3825
+ from webview.activity import get_hub
3826
+ await get_hub().aclose()
3827
+ except Exception as exc:
3828
+ logger.debug(f"activity hub stop failed: {exc}")
3829
+
3830
+
3831
+ @_sio.event
3832
+ async def disconnect(sid: str) -> None: # noqa: D401 – Socket.IO callback
3833
+ """Handle client disconnect and clean up resources."""
3834
+ _socket_user.pop(sid, None)
3835
+ _socket_tier.pop(sid, None)
3836
+ if sid in _activity_clients:
3837
+ _activity_clients.discard(sid)
3838
+ await _stop_hub_if_activity_empty()
3839
+ # Figure out which session this sid belonged to
3840
+ sess_id = _client_session.get(sid)
3841
+ if sess_id is None:
3842
+ return
3843
+
3844
+ # Remove client from room and tracking - use session_id directly
3845
+ await _sio.leave_room(sid, sess_id)
3846
+ _client_session.pop(sid, None)
3847
+
3848
+ # Remove the client from this session
3849
+ _session_clients[sess_id] = _session_clients.get(sess_id, 1) - 1
3850
+
3851
+ # Only cancel watcher if no more clients for this session
3852
+ if _session_clients.get(sess_id, 0) <= 0:
3853
+ # No more clients for this session - cancel the watcher
3854
+ task = _watch_tasks.pop(sess_id, None)
3855
+ if task and not task.done():
3856
+ logger.debug(f"Cancelling watcher task for session {sess_id}")
3857
+ task.cancel()
3858
+ try:
3859
+ await task
3860
+ except asyncio.CancelledError:
3861
+ pass
3862
+ except Exception as exc:
3863
+ logger.warning(f"Error cancelling watcher for {sess_id}: {exc}")
3864
+
3865
+ # Clean up session tracking
3866
+ _session_clients.pop(sess_id, None)
3867
+
3868
+ logger.debug("Client %s disconnected from %s (remaining=%s)",
3869
+ sid, sess_id, _session_clients.get(sess_id, 0))
3870
+
3871
+
3872
+ @_sio.event
3873
+ async def join_session(sid, data):
3874
+ """Handle a client joining a session via Socket.io."""
3875
+ try:
3876
+ # Rate limiting check
3877
+ environ = _sio.get_environ(sid)
3878
+ client_ip = environ.get('REMOTE_ADDR', 'unknown')
3879
+
3880
+ if not check_rate_limit(client_ip):
3881
+ logger.warning(f"Rate limit exceeded for IP {client_ip}")
3882
+ await _sio.emit("error", {
3883
+ "message": "Rate limit exceeded. Please wait before reconnecting."
3884
+ }, room=sid)
3885
+ await _sio.disconnect(sid)
3886
+ return
3887
+
3888
+ session_id = data.get("session_id")
3889
+ logger.info("join_session: received session_id=%s from client %s", session_id, sid)
3890
+ if not session_id:
3891
+ await _sio.emit("error", {"message": "No session ID provided"}, room=sid)
3892
+ return
3893
+
3894
+ # Clean the session ID to ensure consistency
3895
+ clean_id = pm().clean_session_id(session_id)
3896
+ logger.info("join_session: clean_id=%s for client %s", clean_id, sid)
3897
+
3898
+ # E4 (A6 gap 1): tenant-gate the join BEFORE joining the room or streaming any feed.
3899
+ # requires_owner_login() covers BOTH own_ops (owner-login cookie) and
3900
+ # multitenant (wallet/SIWE JWT) — own_ops was left ungated (E4 follow-up).
3901
+ if webgate.requires_owner_login():
3902
+ current_user_id = _socket_user.get(sid)
3903
+ session_owner_id = pm().get_session_user(clean_id)
3904
+ is_owner = bool(current_user_id) and bool(session_owner_id) and current_user_id == session_owner_id
3905
+ if not is_owner:
3906
+ logger.warning(
3907
+ "join_session denied: sid=%s user=%s session_owner=%s",
3908
+ sid, current_user_id, session_owner_id,
3909
+ )
3910
+ await _sio.emit("error", {"message": "Not authorized to view this session"}, room=sid)
3911
+ return
3912
+
3913
+ # Join socket.io room for this session - use clean_id as room name
3914
+ await _sio.enter_room(sid, clean_id)
3915
+ _client_session[sid] = clean_id
3916
+ _session_clients[clean_id] = _session_clients.get(clean_id, 0) + 1
3917
+ logger.info("Client %s joined session %s (cleaned: %s)", sid, session_id, clean_id)
3918
+
3919
+ # Send initial feed data to the client
3920
+ feed_dir = pm().get_feed_dir(clean_id)
3921
+ logger.info("join_session: checking feed_dir=%s, exists=%s", feed_dir, feed_dir.exists())
3922
+ if feed_dir.exists():
3923
+ # Read all JSON files in feed dir chronologically
3924
+ json_files = sorted(feed_dir.glob("*.json"))
3925
+ logger.info("join_session: found %d JSON files in feed_dir", len(json_files))
3926
+ feed_entries = []
3927
+
3928
+ # Read each file and parse the event
3929
+ for file_path in json_files:
3930
+ try:
3931
+ with file_path.open("r") as f:
3932
+ entry = json.load(f)
3933
+ # Include only events that have valid format
3934
+ if entry and isinstance(entry, dict) and "type" in entry:
3935
+ # Enrich LLM request entries with cost estimates if missing
3936
+ _enrich_llm_event_with_cost(entry)
3937
+
3938
+ feed_entries.append(entry)
3939
+ except Exception as exc:
3940
+ logger.error("Failed to parse feed file %s: %s", file_path, exc)
3941
+ continue
3942
+
3943
+ # Send the feed entries to the client
3944
+ # RAM optimization: Chunk large feed data to prevent memory spikes
3945
+ if len(feed_entries) > 100: # Increased threshold back to 100
3946
+ chunk_size = 50 # Increased chunk size back to 50
3947
+ for i in range(0, len(feed_entries), chunk_size):
3948
+ chunk = feed_entries[i:i + chunk_size]
3949
+ is_last_chunk = i + chunk_size >= len(feed_entries)
3950
+
3951
+ await _sio.emit("initial_feed_chunk", {
3952
+ "chunk": chunk,
3953
+ "chunk_index": i // chunk_size,
3954
+ "total_chunks": (len(feed_entries) + chunk_size - 1) // chunk_size,
3955
+ "is_last": is_last_chunk
3956
+ }, room=sid)
3957
+
3958
+ # Removed artificial delay - was causing lag
3959
+
3960
+ logger.info("Sent %d feed entries in %d chunks to client %s",
3961
+ len(feed_entries), (len(feed_entries) + chunk_size - 1) // chunk_size, sid)
3962
+ else:
3963
+ # Send small feeds normally
3964
+ logger.info("join_session: sending initial_feed with %d entries to %s", len(feed_entries), sid)
3965
+ await _sio.emit("initial_feed", json.dumps(feed_entries), room=sid)
3966
+ logger.info("Sent %d initial feed entries to client %s", len(feed_entries), sid)
3967
+ else:
3968
+ logger.warning("Feed directory %s does not exist", feed_dir)
3969
+ await _sio.emit("initial_feed", "[]", room=sid)
3970
+
3971
+ # Ensure a watcher is running for this session - use clean_id for watcher key
3972
+ if clean_id not in _watch_tasks:
3973
+ _watch_tasks[clean_id] = asyncio.create_task(_feed_watcher(clean_id))
3974
+ logger.debug("Started feed watcher task for session %s", clean_id)
3975
+
3976
+ except Exception as exc:
3977
+ logger.error("Error in join_session handler: %s", exc, exc_info=True)
3978
+ await _sio.emit("error", {"message": str(exc)}, room=sid)
3979
+
3980
+
3981
+ @_sio.event
3982
+ async def join_activity(sid, data=None):
3983
+ """Join the global activity room (the /activity terminal's live stream).
3984
+
3985
+ The stream is inherently cross-tenant, so it is gated harder than
3986
+ join_session: local = open (loopback operator); own_ops/multitenant =
3987
+ the instance owner or an admin-tier JWT ONLY. On the first watcher the
3988
+ ActivityHub lazily starts its feed watcher + DB tails; it stops when the
3989
+ room empties (leave_activity/disconnect).
3990
+ """
3991
+ try:
3992
+ environ = _sio.get_environ(sid) or {}
3993
+ client_ip = environ.get("REMOTE_ADDR", "unknown")
3994
+ if not check_rate_limit(client_ip):
3995
+ logger.warning(f"join_activity rate limit exceeded for IP {client_ip}")
3996
+ await _sio.emit("error", {
3997
+ "message": "Rate limit exceeded. Please wait before reconnecting."
3998
+ }, room=sid)
3999
+ await _sio.disconnect(sid)
4000
+ return
4001
+
4002
+ if not webgate.activity_enabled():
4003
+ await _sio.emit("error", {"message": "Activity stream disabled"}, room=sid)
4004
+ return
4005
+
4006
+ if webgate.requires_owner_login():
4007
+ current_user_id = _socket_user.get(sid)
4008
+ tier = _socket_tier.get(sid)
4009
+ allowed = bool(current_user_id) and (
4010
+ current_user_id == webgate.local_owner_id() or tier == "admin"
4011
+ )
4012
+ if not allowed:
4013
+ logger.warning(
4014
+ "join_activity denied: sid=%s user=%s tier=%s", sid, current_user_id, tier
4015
+ )
4016
+ await _sio.emit("error", {
4017
+ "message": "Not authorized for the activity stream"
4018
+ }, room=sid)
4019
+ return
4020
+
4021
+ from webview.activity import get_hub
4022
+ await _sio.enter_room(sid, "activity")
4023
+ _activity_clients.add(sid)
4024
+ hub = get_hub()
4025
+ hub.start(_sio)
4026
+ await _sio.emit("activity_snapshot", hub.recent(200), room=sid)
4027
+ logger.info("Client %s joined the activity stream (watchers=%d)",
4028
+ sid, len(_activity_clients))
4029
+ except Exception as exc:
4030
+ logger.error(f"join_activity error: {exc}", exc_info=True)
4031
+ await _sio.emit("error", {"message": "Failed to join activity stream"}, room=sid)
4032
+
4033
+
4034
+ @_sio.event
4035
+ async def leave_activity(sid):
4036
+ """Leave the global activity room; stop the hub when it empties."""
4037
+ try:
4038
+ await _sio.leave_room(sid, "activity")
4039
+ except Exception:
4040
+ pass
4041
+ _activity_clients.discard(sid)
4042
+ await _stop_hub_if_activity_empty()
4043
+
4044
+
4045
+ @_sio.event
4046
+ async def leave(sid):
4047
+ """Handle a client disconnecting from Socket.io."""
4048
+ session_id = _client_session.get(sid)
4049
+ if session_id:
4050
+ await _sio.leave_room(sid, session_id)
4051
+ _client_session.pop(sid, None)
4052
+
4053
+ # Decrement client count for this session
4054
+ _session_clients[session_id] = max(0, _session_clients.get(session_id, 1) - 1)
4055
+
4056
+ # If no clients left, cancel the watcher task
4057
+ if _session_clients[session_id] == 0 and session_id in _watch_tasks:
4058
+ _watch_tasks[session_id].cancel()
4059
+ try:
4060
+ await _watch_tasks[session_id]
4061
+ except asyncio.CancelledError:
4062
+ pass
4063
+ del _watch_tasks[session_id]
4064
+ logger.debug("Cancelled feed watcher for session %s", session_id)
4065
+
4066
+ logger.info("Client %s left session %s", sid, session_id)
4067
+
4068
+
4069
+ async def _feed_watcher(session_id: str) -> None:
4070
+ """Watch for changes in a session's feed directory and notify clients.
4071
+
4072
+ This function monitors the feed directory for new JSON files and parses them
4073
+ to send real-time updates to connected clients via websockets.
4074
+
4075
+ Args:
4076
+ session_id: The session ID to watch
4077
+ """
4078
+ clean_id = pm().clean_session_id(session_id)
4079
+ feed_dir = pm().get_feed_dir(clean_id)
4080
+ logger.info("Starting feed watcher for session %s at %s", session_id, feed_dir)
4081
+
4082
+ # Use session_id directly as the room name
4083
+ room = session_id
4084
+
4085
+ if not feed_dir.exists():
4086
+ logger.warning("Feed directory %s does not exist, watcher will wait", feed_dir)
4087
+ # Create the directory if it doesn't exist
4088
+ feed_dir.mkdir(parents=True, exist_ok=True)
4089
+
4090
+ # Track processed files to avoid duplicates - use bounded set for memory efficiency
4091
+ processed_files = set()
4092
+ max_processed_files = 1000 # Reduced from 5000 for better performance
4093
+
4094
+ try:
4095
+ # Use native filesystem watching instead of polling for better performance
4096
+ async for changes in awatch(feed_dir, watch_filter=lambda change, path: path.endswith('.json')):
4097
+ try:
4098
+ for change_type, file_path_str in changes:
4099
+ # Only process new or modified files
4100
+ if change_type not in (Change.added, Change.modified):
4101
+ continue
4102
+
4103
+ file_path = Path(file_path_str)
4104
+
4105
+ # Skip if already processed
4106
+ if file_path.name in processed_files:
4107
+ continue
4108
+
4109
+ try:
4110
+ # Add to processed set right away to avoid race conditions
4111
+ processed_files.add(file_path.name)
4112
+
4113
+ # Parse the file
4114
+ with file_path.open("r") as f:
4115
+ entry = json.load(f)
4116
+
4117
+ # Skip invalid entries
4118
+ if not entry or not isinstance(entry, dict) or "type" not in entry:
4119
+ logger.warning("Invalid feed entry in %s, missing 'type'", file_path)
4120
+ continue
4121
+
4122
+ # Enrich LLM request entries with cost estimates if missing
4123
+ _enrich_llm_event_with_cost(entry)
4124
+
4125
+ # Send the entry to all clients in the room (rate-limited, E5)
4126
+ await _emit_feed_event(entry, room)
4127
+ logger.debug("Sent feed update for file %s to room %s", file_path.name, room)
4128
+
4129
+ except Exception as exc:
4130
+ logger.error("Error processing feed file %s: %s", file_path, exc)
4131
+ continue
4132
+
4133
+ # RAM optimization: Prevent processed files set from growing too large
4134
+ if len(processed_files) > max_processed_files:
4135
+ # Keep only the most recent files based on filename (which includes timestamp)
4136
+ sorted_files = sorted(processed_files)
4137
+ keep_count = max_processed_files // 2 # Keep half when trimming
4138
+ processed_files = set(sorted_files[-keep_count:])
4139
+ logger.debug(f"Trimmed processed files set from {len(sorted_files)} to {len(processed_files)}")
4140
+
4141
+ except asyncio.CancelledError:
4142
+ logger.info("Feed watcher for session %s was cancelled", session_id)
4143
+ break
4144
+
4145
+ except Exception as exc:
4146
+ logger.error("Error in feed watcher for %s: %s", session_id, exc, exc_info=True)
4147
+ # Continue watching despite errors
4148
+ await asyncio.sleep(1)
4149
+
4150
+ finally:
4151
+ logger.info("Feed watcher for session %s exiting", session_id)
4152
+
4153
+
4154
+ async def _sweep_expired_nonces_once(siwe_auth) -> None:
4155
+ """Single sweep tick — factored out of the loop for testability. Fail-open:
4156
+ a sweep error must never take down the background task (E2)."""
4157
+ try:
4158
+ await siwe_auth.cleanup_expired_nonces()
4159
+ except Exception as exc:
4160
+ logger.warning(f"Nonce sweep failed: {exc}")
4161
+
4162
+
4163
+ async def _nonce_sweep_loop(siwe_auth, interval_seconds: int = 300) -> None:
4164
+ while True:
4165
+ await _sweep_expired_nonces_once(siwe_auth)
4166
+ await asyncio.sleep(interval_seconds)
4167
+
4168
+
4169
+ async def _startup_late_services():
4170
+ """Auth/task/wallet startup half — merged into the single startup_event.
4171
+
4172
+ Historically this was a SECOND ``@_fastapi.on_event("startup")`` handler
4173
+ competing with the first (both ran, container init duplicated); it is now
4174
+ invoked explicitly at the end of ``startup_event`` in the same order
4175
+ FastAPI would have run it.
4176
+ """
4177
+ global _container, _wallet_generator
4178
+
4179
+ # Initialize deposit wallet generator (lazy import to avoid circular dependency)
4180
+ from core.payment_config import resolve_master_seed
4181
+ master_seed = resolve_master_seed()
4182
+ if master_seed:
4183
+ try:
4184
+ from modules.payments.wallet_generator import DepositWalletGenerator
4185
+ _wallet_generator = DepositWalletGenerator(master_seed)
4186
+ logger.info("✅ Deposit wallet generator initialized")
4187
+ except Exception as e:
4188
+ logger.error(f"❌ Failed to initialize wallet generator: {e}", exc_info=True)
4189
+ else:
4190
+ logger.warning("⚠️ PAYMENT_MASTER_SEED not configured - deposit address generation disabled")
4191
+
4192
+ if not AUTH_ROUTER_MOUNTED and not TASK_ROUTER_MOUNTED:
4193
+ logger.warning("⚠️ No routers mounted, skipping container initialization")
4194
+ return
4195
+
4196
+ try:
4197
+ logger.info("🚀 Initializing dependency container...")
4198
+ from core.container import DependencyContainer
4199
+
4200
+ # Try to load full config, but fallback to minimal config if needed
4201
+ try:
4202
+ from core.config import BotConfig
4203
+ config = BotConfig()
4204
+ logger.info("✅ Full bot config loaded")
4205
+ except Exception as config_error:
4206
+ logger.warning(f"⚠️ Could not load full bot config: {config_error}")
4207
+ logger.info("Using minimal config for webview-only services")
4208
+ # Create a minimal config object with just what we need
4209
+ from types import SimpleNamespace
4210
+ config = SimpleNamespace()
4211
+ config.session_ttl_seconds = 3600
4212
+ config.max_sessions_in_memory = 100
4213
+ config.session_cleanup_interval = 300
4214
+
4215
+ # Initialize container singleton
4216
+ _container = DependencyContainer.get_instance(config)
4217
+
4218
+ # Verify auth services if auth router is mounted
4219
+ if AUTH_ROUTER_MOUNTED:
4220
+ siwe_auth = _container.get_service('siwe_authenticator')
4221
+ identity_mapper = _container.get_service('identity_mapper')
4222
+
4223
+ if siwe_auth and identity_mapper:
4224
+ logger.info("✅ Auth services initialized successfully")
4225
+ logger.info(f" - SIWE Authenticator: {type(siwe_auth).__name__}")
4226
+ logger.info(f" - Identity Mapper: {type(identity_mapper).__name__}")
4227
+ else:
4228
+ logger.warning("⚠️ Some auth services not available")
4229
+ logger.warning(f" - SIWE Auth: {siwe_auth is not None}")
4230
+ logger.warning(f" - Identity Mapper: {identity_mapper is not None}")
4231
+
4232
+ # E2: periodic nonce-expiry sweep — SIWE (and auth_nonces) only
4233
+ # exists in the multitenant posture.
4234
+ if webgate.is_multitenant():
4235
+ try:
4236
+ if siwe_auth:
4237
+ asyncio.create_task(_nonce_sweep_loop(siwe_auth))
4238
+ logger.info("✅ Nonce expiry sweep started (5min interval)")
4239
+ except Exception as e:
4240
+ logger.warning(f"⚠️ Could not start nonce sweep: {e}")
4241
+
4242
+ # Initialize task agent if task router is mounted AND config is available
4243
+ if TASK_ROUTER_MOUNTED:
4244
+ try:
4245
+ # Only initialize if we have a real BotConfig
4246
+ from core.config import BotConfig
4247
+ if isinstance(config, BotConfig):
4248
+ logger.info("🚀 Initializing task agent...")
4249
+ from agents.task_agent_lite import TaskAgent
4250
+
4251
+ # Create and register task agent
4252
+ task_agent = TaskAgent(name="task_agent", config=config, container=_container)
4253
+ await task_agent.initialize()
4254
+ _container.register_agent("task_agent", task_agent)
4255
+
4256
+ logger.info("✅ Task agent initialized successfully")
4257
+ logger.info(f" - Task Agent: {type(task_agent).__name__}")
4258
+ logger.info(f" - Session Manager Available: {task_agent.session_manager is not None}")
4259
+ else:
4260
+ logger.warning("⚠️ Task agent requires full bot config - skipping initialization")
4261
+ logger.info("Task endpoints will not be available")
4262
+
4263
+ except Exception as e:
4264
+ logger.error(f"❌ Failed to initialize task agent: {e}", exc_info=True)
4265
+ logger.warning("⚠️ Task session creation will not work!")
4266
+
4267
+ except Exception as e:
4268
+ logger.error(f"❌ Failed to initialize container: {e}", exc_info=True)
4269
+ logger.warning("⚠️ Services will not work without container!")
4270
+ _container = None
4271
+
4272
+
4273
+ @_fastapi.on_event("shutdown")
4274
+ async def shutdown_event():
4275
+ """Clean up resources when the server is shutting down."""
4276
+ logger.info("Server shutting down, cancelling all watcher tasks...")
4277
+ try:
4278
+ from webview.activity import get_hub
4279
+ await get_hub().aclose()
4280
+ except Exception as exc:
4281
+ logger.debug(f"activity hub shutdown cleanup failed: {exc}")
4282
+ for session_id, task in _watch_tasks.items():
4283
+ if not task.done():
4284
+ logger.debug(f"Cancelling watcher task for session {session_id}")
4285
+ task.cancel()
4286
+ try:
4287
+ await task
4288
+ except asyncio.CancelledError:
4289
+ pass
4290
+ except Exception as exc:
4291
+ logger.warning(f"Error cleaning up watcher for {session_id}: {exc}")
4292
+ _watch_tasks.clear()
4293
+ _session_clients.clear()
4294
+ _client_session.clear()
4295
+
4296
+
4297
+ @_fastapi.exception_handler(HTTPException)
4298
+ async def http_exception_handler(request: Request, exc: HTTPException) -> Response:
4299
+ """Render a custom error page for HTTP errors."""
4300
+ status_code = exc.status_code
4301
+ detail = exc.detail
4302
+
4303
+ # Get WebSocket URL from environment variable
4304
+ ws_url = os.environ.get("WEBVIEW_WS_URL", "")
4305
+
4306
+ # Get version from environment variable or use a default
4307
+ version = os.environ.get("WEBVIEW_VERSION", get_version())
4308
+
4309
+ return _templates.TemplateResponse(
4310
+ "error.html",
4311
+ {
4312
+ "request": request,
4313
+ "error": f"HTTP {status_code}",
4314
+ "message": detail,
4315
+ "troubleshooting": [
4316
+ "The session may not exist or has been deleted.",
4317
+ "Check the URL and try again."
4318
+ ],
4319
+ "ws_url": ws_url,
4320
+ "version": version
4321
+ },
4322
+ status_code=status_code
4323
+ )