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.
- agents/README.md +453 -0
- agents/__init__.py +164 -0
- agents/base_agent.py +743 -0
- agents/personality/character.py +119 -0
- agents/personality/character_manager.py +309 -0
- agents/personality/characters/rob.character.json +56 -0
- agents/personality/characters/trump.character.json +63 -0
- agents/personality/persona_render.py +78 -0
- agents/personality/persona_resolver.py +115 -0
- agents/prompt/__init__.py +58 -0
- agents/prompt/base_prompt.py +92 -0
- agents/prompt/system.py +417 -0
- agents/task/README_PATH_MANAGEMENT.md +141 -0
- agents/task/__init__.py +162 -0
- agents/task/agent/__init__.py +70 -0
- agents/task/agent/agent_state.py +408 -0
- agents/task/agent/async_delegation.py +274 -0
- agents/task/agent/autonomy_state.py +275 -0
- agents/task/agent/conversation.py +75 -0
- agents/task/agent/core/__init__.py +6 -0
- agents/task/agent/core/aux_metering.py +61 -0
- agents/task/agent/core/background_review.py +157 -0
- agents/task/agent/core/construction.py +1179 -0
- agents/task/agent/core/conversational_exit.py +49 -0
- agents/task/agent/core/correspondent_gate.py +227 -0
- agents/task/agent/core/curator.py +246 -0
- agents/task/agent/core/episodic_digest.py +103 -0
- agents/task/agent/core/error_recovery.py +480 -0
- agents/task/agent/core/history_io.py +257 -0
- agents/task/agent/core/llm_provisioning.py +287 -0
- agents/task/agent/core/llm_runner.py +547 -0
- agents/task/agent/core/logging_io.py +276 -0
- agents/task/agent/core/loop_detection.py +262 -0
- agents/task/agent/core/memory_prefetch.py +325 -0
- agents/task/agent/core/memory_writer.py +667 -0
- agents/task/agent/core/model_introspection.py +82 -0
- agents/task/agent/core/model_swap.py +177 -0
- agents/task/agent/core/next_action_internal.py +1331 -0
- agents/task/agent/core/output_validation.py +303 -0
- agents/task/agent/core/project_context.py +272 -0
- agents/task/agent/core/resources.py +93 -0
- agents/task/agent/core/result_processing.py +389 -0
- agents/task/agent/core/run_loop.py +661 -0
- agents/task/agent/core/safety_lifecycle.py +228 -0
- agents/task/agent/core/secret_guard.py +293 -0
- agents/task/agent/core/self_wake.py +280 -0
- agents/task/agent/core/session_metadata.py +104 -0
- agents/task/agent/core/step.py +781 -0
- agents/task/agent/core/step_execution.py +398 -0
- agents/task/agent/core/step_telemetry.py +222 -0
- agents/task/agent/core/test_aux_metering.py +50 -0
- agents/task/agent/core/test_billing_failover.py +68 -0
- agents/task/agent/core/test_fatal_error_classifier.py +38 -0
- agents/task/agent/core/test_judge_validation.py +275 -0
- agents/task/agent/core/test_model_swap.py +190 -0
- agents/task/agent/core/test_reflection_offload.py +54 -0
- agents/task/agent/core/turn_input.py +8 -0
- agents/task/agent/core/untrusted_wrap.py +103 -0
- agents/task/agent/core/user_ingress.py +228 -0
- agents/task/agent/hitl_manager.py +396 -0
- agents/task/agent/log_sanitize.py +36 -0
- agents/task/agent/message_manager/config.py +97 -0
- agents/task/agent/message_manager/service.py +758 -0
- agents/task/agent/message_manager/tests.py +305 -0
- agents/task/agent/message_manager/tool_call_builder.py +539 -0
- agents/task/agent/message_manager/tool_message_repair.py +364 -0
- agents/task/agent/message_manager/views.py +143 -0
- agents/task/agent/messages/__init__.py +8 -0
- agents/task/agent/messages/builders.py +309 -0
- agents/task/agent/messages/compactor.py +611 -0
- agents/task/agent/messages/context_references.py +271 -0
- agents/task/agent/messages/filters.py +333 -0
- agents/task/agent/messages/guidance.py +328 -0
- agents/task/agent/messages/persistence.py +448 -0
- agents/task/agent/messages/retrieval.py +506 -0
- agents/task/agent/messages/sqlite_persistence.py +85 -0
- agents/task/agent/messages/test_compaction_upgrade.py +349 -0
- agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
- agents/task/agent/messages/test_sqlite_persistence.py +54 -0
- agents/task/agent/messages/token_counter.py +532 -0
- agents/task/agent/orchestrator.py +776 -0
- agents/task/agent/profile_manager.py +105 -0
- agents/task/agent/profile_registry.py +173 -0
- agents/task/agent/prompts.py +1090 -0
- agents/task/agent/scenario_registry.py +226 -0
- agents/task/agent/service.py +509 -0
- agents/task/agent/session.py +984 -0
- agents/task/agent/skill_discovery.py +110 -0
- agents/task/agent/skill_frontmatter.py +94 -0
- agents/task/agent/skill_manager.py +1201 -0
- agents/task/agent/skill_store.py +627 -0
- agents/task/agent/skill_validation.py +56 -0
- agents/task/agent/skill_writer.py +492 -0
- agents/task/agent/sub_agent_manager.py +1137 -0
- agents/task/agent/test_session_disk_recovery.py +34 -0
- agents/task/agent/tests.py +513 -0
- agents/task/agent/tool_call_tracker.py +511 -0
- agents/task/agent/views.py +469 -0
- agents/task/config.py +432 -0
- agents/task/constants.py +1384 -0
- agents/task/flag_defaults.py +63 -0
- agents/task/goals/__init__.py +1 -0
- agents/task/goals/autonomy_marker.py +28 -0
- agents/task/goals/board.py +690 -0
- agents/task/goals/completion_judge.py +247 -0
- agents/task/goals/context.py +62 -0
- agents/task/goals/dispatcher.py +848 -0
- agents/task/goals/escalation.py +102 -0
- agents/task/goals/planner.py +114 -0
- agents/task/logging_config.py +155 -0
- agents/task/path.py +991 -0
- agents/task/robust_parse_config.py +400 -0
- agents/task/runtime/__init__.py +0 -0
- agents/task/runtime/run_as_session.py +130 -0
- agents/task/runtime_safety.py +308 -0
- agents/task/session/__init__.py +5 -0
- agents/task/session/browser_pool.py +130 -0
- agents/task/session/cleanup.py +612 -0
- agents/task/session/execution.py +397 -0
- agents/task/session/feed.py +200 -0
- agents/task/session/hitl_ingress.py +201 -0
- agents/task/session/hooks.py +83 -0
- agents/task/session/multi_agent.py +243 -0
- agents/task/session/workspace.py +91 -0
- agents/task/session_registry.py +91 -0
- agents/task/session_route.py +35 -0
- agents/task/sqlite_session_registry.py +261 -0
- agents/task/surface_config.py +185 -0
- agents/task/telemetry/__init__.py +58 -0
- agents/task/telemetry/event_log.py +180 -0
- agents/task/telemetry/formatters.py +642 -0
- agents/task/telemetry/manager.py +571 -0
- agents/task/telemetry/memory_events.py +66 -0
- agents/task/telemetry/self_events.py +53 -0
- agents/task/telemetry/sequence.py +160 -0
- agents/task/telemetry/service.py +1265 -0
- agents/task/telemetry/views.py +882 -0
- agents/task/templates.py +98 -0
- agents/task/test_aux_chain.py +151 -0
- agents/task/test_aux_router.py +41 -0
- agents/task/tests/test_parse_robustness.py +170 -0
- agents/task/tool_defaults.py +110 -0
- agents/task/utils.py +762 -0
- agents/task/utils_json.py +768 -0
- agents/task/utils_webview.py +86 -0
- agents/task/workspace_context.py +229 -0
- agents/task_agent_lite.py +2398 -0
- api/README.md +683 -0
- api/TASK_API_DOCS.md +151 -0
- api/__init__.py +0 -0
- api/a2a/__init__.py +37 -0
- api/a2a/agent_card.py +337 -0
- api/a2a/client.py +657 -0
- api/a2a/endpoints.py +375 -0
- api/a2a/models.py +268 -0
- api/a2a/streaming.py +419 -0
- api/a2a/task_handler.py +929 -0
- api/admin_endpoints.py +1584 -0
- api/app.py +923 -0
- api/auth_constants.py +28 -0
- api/auth_endpoints.py +402 -0
- api/auth_state.py +36 -0
- api/chat_via_task.py +42 -0
- api/conversation_manager.py +231 -0
- api/dependencies.py +186 -0
- api/eip8004_endpoints.py +452 -0
- api/hyperliquid_models.py +174 -0
- api/hyperliquid_routes.py +450 -0
- api/interfaces.py +207 -0
- api/jwt_middleware.py +83 -0
- api/kb/__init__.py +1 -0
- api/kb/endpoints.py +314 -0
- api/mcp_models.py +279 -0
- api/mcp_routes.py +438 -0
- api/middleware.py +526 -0
- api/models.py +176 -0
- api/openai_compat/__init__.py +0 -0
- api/openai_compat/model_map.py +25 -0
- api/openai_compat/models.py +59 -0
- api/openai_compat/router.py +140 -0
- api/payment_endpoints.py +444 -0
- api/payment_verification.py +138 -0
- api/polymarket_models.py +155 -0
- api/polymarket_routes.py +501 -0
- api/pricing_endpoints.py +89 -0
- api/session_routing.py +54 -0
- api/skill_endpoints.py +453 -0
- api/task_http_api.py +1856 -0
- api/webhooks.py +52 -0
- api/x402_endpoints.py +280 -0
- avatar/README.md +44 -0
- avatar/__init__.py +1 -0
- avatar/config/rob.json +7 -0
- avatar/mindprint.js +395 -0
- avatar/renders/rob.meta.json +20 -0
- avatar/renders/rob.png +0 -0
- avatar/studio.html +373 -0
- avatar/webview/avatar-live.js +44 -0
- cli/README.md +89 -0
- cli/__init__.py +0 -0
- cli/cli_surface.py +67 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_bootstrap.py +49 -0
- cli/commands/_errors.py +53 -0
- cli/commands/chat.py +973 -0
- cli/commands/config.py +100 -0
- cli/commands/dashboard.py +103 -0
- cli/commands/doctor.py +222 -0
- cli/commands/email.py +174 -0
- cli/commands/gateway.py +346 -0
- cli/commands/goals.py +509 -0
- cli/commands/init.py +276 -0
- cli/commands/journey.py +30 -0
- cli/commands/kb.py +280 -0
- cli/commands/model.py +107 -0
- cli/commands/owner.py +431 -0
- cli/commands/pfp.py +232 -0
- cli/commands/run.py +414 -0
- cli/commands/serve.py +41 -0
- cli/commands/session.py +684 -0
- cli/commands/skill_install.py +786 -0
- cli/commands/skills.py +128 -0
- cli/commands/subagents.py +100 -0
- cli/commands/surface.py +81 -0
- cli/commands/telegram.py +185 -0
- cli/commands/todos.py +209 -0
- cli/commands/tools.py +102 -0
- cli/commands/update.py +316 -0
- cli/commands/whatsapp.py +175 -0
- cli/config_store.py +276 -0
- cli/gitignore.py +43 -0
- cli/inventory.py +46 -0
- cli/keys.py +101 -0
- cli/persona.py +17 -0
- cli/polyrob.py +137 -0
- cli/ui/__init__.py +64 -0
- cli/ui/activity.py +141 -0
- cli/ui/app.py +538 -0
- cli/ui/banner.py +233 -0
- cli/ui/blocks.py +534 -0
- cli/ui/bootstrap_notice.py +47 -0
- cli/ui/commands/__init__.py +41 -0
- cli/ui/commands/h_cron.py +64 -0
- cli/ui/commands/h_journey.py +164 -0
- cli/ui/commands/h_kb.py +121 -0
- cli/ui/commands/h_learn.py +100 -0
- cli/ui/commands/h_mcp.py +215 -0
- cli/ui/commands/h_self.py +113 -0
- cli/ui/commands/h_skills.py +314 -0
- cli/ui/commands/handlers.py +1672 -0
- cli/ui/commands/registry.py +426 -0
- cli/ui/dialog.py +425 -0
- cli/ui/event_registry.py +110 -0
- cli/ui/event_specs.py +45 -0
- cli/ui/events.py +433 -0
- cli/ui/identity.py +15 -0
- cli/ui/lifecycle.py +159 -0
- cli/ui/live_hooks.py +43 -0
- cli/ui/model_selector.py +532 -0
- cli/ui/persistent_loop.py +181 -0
- cli/ui/pick.py +113 -0
- cli/ui/plain_renderer.py +351 -0
- cli/ui/renderer.py +384 -0
- cli/ui/rich_renderer.py +533 -0
- cli/ui/secrets.py +115 -0
- cli/ui/state.py +376 -0
- cli/ui/statusbar.py +231 -0
- cli/ui/streaming.py +254 -0
- cli/ui/terminal_render.py +105 -0
- cli/ui/theme.py +132 -0
- cli/update/__init__.py +6 -0
- cli/update/context.py +121 -0
- cli/update/detect.py +176 -0
- cli/update/engine.py +95 -0
- cli/update/migrate_guarded.py +55 -0
- cli/update/process_guard.py +215 -0
- cli/update/runners.py +78 -0
- cli/update/snapshot.py +270 -0
- cli/update/versions.py +205 -0
- core/README.md +377 -0
- core/__init__.py +174 -0
- core/assets.py +87 -0
- core/async_bridge.py +76 -0
- core/autonomy_runtime.py +259 -0
- core/base_component.py +302 -0
- core/bootstrap.py +681 -0
- core/bot.py +310 -0
- core/config.py +833 -0
- core/constants.py +126 -0
- core/container.py +462 -0
- core/db_manifest.py +101 -0
- core/embedding.py +57 -0
- core/env.py +39 -0
- core/exceptions.py +287 -0
- core/flags.py +144 -0
- core/flags_catalog.py +345 -0
- core/home_migration.py +62 -0
- core/identity.py +118 -0
- core/initialization.py +1004 -0
- core/instance.py +537 -0
- core/interactive_gate.py +96 -0
- core/logging.py +436 -0
- core/owner_doc_writer.py +176 -0
- core/pairing.py +193 -0
- core/path_safety.py +20 -0
- core/paths.py +27 -0
- core/payment_config.py +21 -0
- core/permissions.py +322 -0
- core/runtime_config.py +96 -0
- core/runtime_paths.py +135 -0
- core/seams.py +35 -0
- core/secret_scan.py +25 -0
- core/secret_scrub.py +71 -0
- core/secrets.py +35 -0
- core/security_logging_filter.py +148 -0
- core/self_context_writer.py +333 -0
- core/self_evolution.py +294 -0
- core/session_context.py +47 -0
- core/sqlite_util.py +59 -0
- core/surfaces/__init__.py +16 -0
- core/surfaces/access.py +118 -0
- core/surfaces/binding.py +88 -0
- core/surfaces/bootstrap.py +78 -0
- core/surfaces/circuit.py +168 -0
- core/surfaces/continuity.py +77 -0
- core/surfaces/correspondents.py +228 -0
- core/surfaces/dispatcher.py +216 -0
- core/surfaces/envelopes.py +75 -0
- core/surfaces/gc.py +30 -0
- core/surfaces/idempotency.py +57 -0
- core/surfaces/inbound_webhook.py +124 -0
- core/surfaces/media.py +32 -0
- core/surfaces/message_router.py +91 -0
- core/surfaces/outbound_allowlist.py +80 -0
- core/surfaces/outbound_dispatcher.py +110 -0
- core/surfaces/outbound_mirror.py +37 -0
- core/surfaces/outbound_queue.py +107 -0
- core/surfaces/outbound_target.py +12 -0
- core/surfaces/owner_admin.py +29 -0
- core/surfaces/proactive.py +78 -0
- core/surfaces/progress.py +108 -0
- core/surfaces/rate_bucket.py +21 -0
- core/surfaces/registry.py +51 -0
- core/surfaces/rendering.py +53 -0
- core/surfaces/send_policy.py +14 -0
- core/surfaces/serialize.py +24 -0
- core/surfaces/session_chat_registry.py +112 -0
- core/surfaces/session_policy.py +60 -0
- core/surfaces/surface.py +288 -0
- core/surfaces/transcription.py +88 -0
- core/surfaces/voice_echo.py +26 -0
- core/surfaces/voice_guard.py +23 -0
- core/tickers.py +174 -0
- core/tool_catalog.py +192 -0
- core/version.py +75 -0
- core/wallet/__init__.py +5 -0
- core/wallet/agent_wallet.py +59 -0
- core/wallet/audit_sink.py +61 -0
- core/wallet/config.py +70 -0
- core/wallet/factory.py +87 -0
- core/wallet/policy.py +104 -0
- core/wallet/signer.py +51 -0
- cron/README.md +65 -0
- cron/__init__.py +0 -0
- cron/delivery.py +277 -0
- cron/digest.py +124 -0
- cron/jobs.py +207 -0
- cron/runner.py +297 -0
- cron/schedule.py +194 -0
- cron/scheduler.py +201 -0
- cron/service.py +57 -0
- cron/wake_gate.py +223 -0
- data/__init__.py +1 -0
- data/prompts/autov2_prompts.json +87 -0
- data/prompts/skills/browser-automation/SKILL.md +43 -0
- data/prompts/skills/coding-workflow/SKILL.md +36 -0
- data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
- data/prompts/skills/document-writing/SKILL.md +33 -0
- data/prompts/skills/email-comms/SKILL.md +33 -0
- data/prompts/skills/file-data-ops/SKILL.md +35 -0
- data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
- data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
- data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
- data/prompts/skills/lead-research/SKILL.md +86 -0
- data/prompts/skills/market-research-brief/SKILL.md +104 -0
- data/prompts/skills/person-analyzer/SKILL.md +124 -0
- data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
- data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
- data/prompts/skills/polymarket-trading/SKILL.md +55 -0
- data/prompts/skills/presentation-creator/SKILL.md +36 -0
- data/prompts/skills/project-analyzer/SKILL.md +169 -0
- data/prompts/skills/rules.json +268 -0
- data/prompts/skills/secret-handling/SKILL.md +20 -0
- data/prompts/skills/skill-authoring/SKILL.md +31 -0
- data/prompts/skills/skill-security-review/SKILL.md +27 -0
- data/prompts/skills/social-discovery/SKILL.md +57 -0
- data/prompts/skills/task-planning/SKILL.md +33 -0
- data/prompts/skills/web-research/SKILL.md +39 -0
- data/prompts/skills/web-scraping/SKILL.md +73 -0
- data/prompts/skills/x-engagement/SKILL.md +69 -0
- data/prompts/system_prompts.json +4 -0
- data/subscription_channels.json +5 -0
- modules/README.md +503 -0
- modules/__init__.py +124 -0
- modules/auth/__init__.py +13 -0
- modules/auth/api_key_manager.py +157 -0
- modules/auth/identity_mapper.py +380 -0
- modules/auth/siwe_auth.py +273 -0
- modules/auth/tier_manager.py +173 -0
- modules/base_module.py +190 -0
- modules/credits/__init__.py +29 -0
- modules/credits/balance_manager.py +237 -0
- modules/credits/cost_utils.py +129 -0
- modules/credits/pricing.py +139 -0
- modules/credits/unified_ledger.py +148 -0
- modules/credits/usage_meter.py +310 -0
- modules/credits/usage_tracker.py +674 -0
- modules/database/__init__.py +33 -0
- modules/database/audit_log.py +322 -0
- modules/database/auth_tables.py +276 -0
- modules/database/connection.py +644 -0
- modules/database/connection_pool.py +305 -0
- modules/database/conversation_contexts.py +446 -0
- modules/database/database_manager.py +237 -0
- modules/database/hyperliquid.py +375 -0
- modules/database/polymarket.py +574 -0
- modules/database/user_mcp_servers.py +728 -0
- modules/database/user_profiles.py +562 -0
- modules/database/utils.py +107 -0
- modules/database/x402_tables.py +92 -0
- modules/eip8004/README.md +1069 -0
- modules/eip8004/__init__.py +65 -0
- modules/eip8004/contracts.py +498 -0
- modules/eip8004/models.py +271 -0
- modules/eip8004/registration.py +148 -0
- modules/eip8004/reputation.py +367 -0
- modules/eip8004/validation.py +277 -0
- modules/llm/__init__.py +97 -0
- modules/llm/adapters.py +963 -0
- modules/llm/anthropic_client.py +831 -0
- modules/llm/available_models.py +106 -0
- modules/llm/brain_scrubber.py +137 -0
- modules/llm/cache_hints.py +162 -0
- modules/llm/deepseek_client.py +562 -0
- modules/llm/gemini_client.py +1367 -0
- modules/llm/llm_client.py +1139 -0
- modules/llm/llm_client_registry.py +188 -0
- modules/llm/llm_factory.py +237 -0
- modules/llm/llm_manager.py +1036 -0
- modules/llm/messages.py +374 -0
- modules/llm/model_registry.py +1935 -0
- modules/llm/nvidia_client.py +125 -0
- modules/llm/openai_client.py +800 -0
- modules/llm/openrouter_client.py +969 -0
- modules/llm/profiles.py +178 -0
- modules/llm/test_anthropic_cache_capture.py +35 -0
- modules/llm/test_cached_pricing_derivation.py +24 -0
- modules/llm/think_scrubber.py +444 -0
- modules/llm/token_counter.py +268 -0
- modules/memory/__init__.py +35 -0
- modules/memory/backend_factory.py +83 -0
- modules/memory/cache_manager.py +186 -0
- modules/memory/episodic.py +108 -0
- modules/memory/local_vector_memory_provider.py +552 -0
- modules/memory/memory_manager.py +225 -0
- modules/memory/models.py +312 -0
- modules/memory/provider.py +210 -0
- modules/memory/registry.py +205 -0
- modules/memory/sqlite_memory_provider.py +767 -0
- modules/memory/task/__init__.py +52 -0
- modules/memory/task/compaction_manager.py +157 -0
- modules/memory/task/context_retriever.py +809 -0
- modules/memory/task/hierarchical_memory.py +1002 -0
- modules/memory/task/lexical_retriever.py +71 -0
- modules/memory/task/null_context_manager.py +213 -0
- modules/memory/task/phase_manager.py +625 -0
- modules/memory/task/reflection_service.py +157 -0
- modules/memory/task/semantic_retriever.py +218 -0
- modules/memory/task/task_context_manager.py +1165 -0
- modules/memory/task/test_forgetting_engages.py +24 -0
- modules/memory/task/test_reflection_llm.py +39 -0
- modules/memory/task/test_threat_scan.py +53 -0
- modules/memory/task/threat_scan.py +90 -0
- modules/memory/test_sqlite_memory_provider.py +106 -0
- modules/memory/user_profile_manager.py +527 -0
- modules/payments/__init__.py +11 -0
- modules/payments/deposit_monitor.py +478 -0
- modules/payments/price_oracle.py +70 -0
- modules/payments/treasury_sweeper.py +401 -0
- modules/payments/wallet_generator.py +145 -0
- modules/pfp/__init__.py +1 -0
- modules/pfp/config.py +92 -0
- modules/pfp/mesh.py +565 -0
- modules/pfp/push.py +69 -0
- modules/pfp/renderer.py +85 -0
- modules/pfp/store.py +82 -0
- modules/skills/__init__.py +1 -0
- modules/skills/skill_usage.py +248 -0
- modules/transcription/__init__.py +24 -0
- modules/transcription/base.py +20 -0
- modules/transcription/faster_whisper_transcriber.py +55 -0
- modules/x402/README.md +249 -0
- modules/x402/__init__.py +22 -0
- modules/x402/invoicing.py +416 -0
- modules/x402/middleware.py +364 -0
- modules/x402/settlement_watcher.py +145 -0
- modules/x402/x402_integration.py +344 -0
- polyrob-0.5.0.dist-info/METADATA +482 -0
- polyrob-0.5.0.dist-info/RECORD +745 -0
- polyrob-0.5.0.dist-info/WHEEL +5 -0
- polyrob-0.5.0.dist-info/entry_points.txt +2 -0
- polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
- polyrob-0.5.0.dist-info/top_level.txt +12 -0
- surfaces/README.md +70 -0
- surfaces/__init__.py +0 -0
- surfaces/email/__init__.py +0 -0
- surfaces/email/dedup.py +44 -0
- surfaces/email/harness.py +187 -0
- surfaces/email/inbound.py +159 -0
- surfaces/email/seed.py +60 -0
- surfaces/email/surface.py +105 -0
- surfaces/telegram/__init__.py +0 -0
- surfaces/telegram/dedup.py +83 -0
- surfaces/telegram/harness.py +886 -0
- surfaces/telegram/inbound.py +145 -0
- surfaces/telegram/interactive_tools.py +49 -0
- surfaces/telegram/markdown.py +347 -0
- surfaces/telegram/rate_limit.py +167 -0
- surfaces/telegram/surface.py +187 -0
- surfaces/telegram/voice.py +52 -0
- surfaces/whatsapp/__init__.py +0 -0
- surfaces/whatsapp/client.py +51 -0
- surfaces/whatsapp/harness.py +62 -0
- surfaces/whatsapp/inbound.py +132 -0
- surfaces/whatsapp/surface.py +64 -0
- surfaces/whatsapp/window.py +34 -0
- tools/README.md +578 -0
- tools/__init__.py +424 -0
- tools/alchemy/__init__.py +5 -0
- tools/alchemy/alchemy_tool.py +389 -0
- tools/anysite/__init__.py +17 -0
- tools/anysite/client.py +88 -0
- tools/anysite/tool.py +89 -0
- tools/base_tool.py +468 -0
- tools/browser/__init__.py +22 -0
- tools/browser/actions.py +118 -0
- tools/browser/browser.py +1457 -0
- tools/browser/browser_manager.py +690 -0
- tools/browser/context.py +1748 -0
- tools/browser/playwright_utils.py +137 -0
- tools/browser/views.py +56 -0
- tools/code_exec/SANDBOX_SECURITY.md +144 -0
- tools/code_exec/__init__.py +178 -0
- tools/code_exec/backend.py +73 -0
- tools/code_exec/backends/__init__.py +1 -0
- tools/code_exec/backends/docker.py +779 -0
- tools/code_exec/backends/local_subprocess.py +154 -0
- tools/code_exec/env_policy.py +45 -0
- tools/code_exec/result.py +42 -0
- tools/code_exec/sandbox_guard.py +59 -0
- tools/code_exec/tool.py +227 -0
- tools/coding/__init__.py +50 -0
- tools/coding/edit.py +107 -0
- tools/coding/search.py +113 -0
- tools/coding/tool.py +353 -0
- tools/collabland/__init__.py +5 -0
- tools/collabland/collabland_tool.py +471 -0
- tools/controller/__init__.py +0 -0
- tools/controller/_helpers.py +165 -0
- tools/controller/action_registration.py +1765 -0
- tools/controller/approval.py +244 -0
- tools/controller/approval_interactive.py +90 -0
- tools/controller/delegation.py +195 -0
- tools/controller/execution.py +797 -0
- tools/controller/execution_context.py +141 -0
- tools/controller/hooks.py +135 -0
- tools/controller/introspection.py +344 -0
- tools/controller/mcp_registrar.py +190 -0
- tools/controller/message_send.py +31 -0
- tools/controller/registry/__init__.py +4 -0
- tools/controller/registry/schema_generators.py +395 -0
- tools/controller/registry/schema_sanitizer.py +406 -0
- tools/controller/registry/service.py +1266 -0
- tools/controller/registry/views.py +151 -0
- tools/controller/service.py +409 -0
- tools/controller/tool_management.py +334 -0
- tools/controller/types.py +28 -0
- tools/controller/views.py +450 -0
- tools/cronjob_tools.py +166 -0
- tools/crypto_trade_gate.py +59 -0
- tools/descriptors.py +452 -0
- tools/dom/__init__.py +0 -0
- tools/dom/history_tree_processor/service.py +244 -0
- tools/dom/history_tree_processor/view.py +99 -0
- tools/dom/service.py +471 -0
- tools/dom/views.py +346 -0
- tools/email_tool.py +403 -0
- tools/exceptions.py +169 -0
- tools/filesystem.py +1038 -0
- tools/filesystem_docproc.py +331 -0
- tools/filesystem_pdf.py +755 -0
- tools/git/__init__.py +42 -0
- tools/git/tool.py +258 -0
- tools/github/__init__.py +33 -0
- tools/github/client.py +94 -0
- tools/github/tool.py +237 -0
- tools/goal_tools.py +309 -0
- tools/hyperliquid/__init__.py +56 -0
- tools/hyperliquid/models.py +233 -0
- tools/hyperliquid/service.py +1680 -0
- tools/knowledge_ingest.py +950 -0
- tools/mcp/README.md +535 -0
- tools/mcp/__init__.py +52 -0
- tools/mcp/catalog.py +115 -0
- tools/mcp/config.py +337 -0
- tools/mcp/mcp_tool.py +1415 -0
- tools/mcp/param_coercion.py +289 -0
- tools/mcp/protocol.py +1520 -0
- tools/mcp/rate_limit.py +50 -0
- tools/mcp/security.py +424 -0
- tools/mcp/self_install.py +111 -0
- tools/mcp/server_manager.py +829 -0
- tools/mcp/subscriptions.py +72 -0
- tools/mcp/user_mcp_service.py +899 -0
- tools/mcp/validation_tracker.py +89 -0
- tools/mcp/views.py +346 -0
- tools/oauth/__init__.py +17 -0
- tools/oauth/manager.py +75 -0
- tools/oauth/provider.py +69 -0
- tools/oauth/providers/__init__.py +1 -0
- tools/oauth/providers/generic_oauth2.py +96 -0
- tools/perplexity_tool.py +280 -0
- tools/polymarket/__init__.py +41 -0
- tools/polymarket/clob_adapter.py +76 -0
- tools/polymarket/models.py +182 -0
- tools/polymarket/service.py +2082 -0
- tools/self_env/__init__.py +61 -0
- tools/self_env/tool.py +274 -0
- tools/shell/__init__.py +80 -0
- tools/shell/backend_pool.py +118 -0
- tools/shell/discipline.py +62 -0
- tools/shell/executor.py +157 -0
- tools/shell/loopback_allow.py +87 -0
- tools/shell/process_registry.py +113 -0
- tools/shell/process_tool.py +131 -0
- tools/shell/state.py +129 -0
- tools/shell/tool.py +151 -0
- tools/task_tool.py +637 -0
- tools/twitter_tool.py +2086 -0
- tools/user_directory.py +179 -0
- tools/web_fetch/__init__.py +3 -0
- tools/web_fetch/fetcher.py +144 -0
- tools/web_fetch/render.py +44 -0
- tools/web_fetch/tool.py +60 -0
- tools/x402/__init__.py +60 -0
- tools/x402/client.py +47 -0
- tools/x402/invoice_tool.py +143 -0
- tools/x402/real_client.py +250 -0
- tools/x402/service.py +131 -0
- utils/README.md +498 -0
- utils/__init__.py +46 -0
- utils/auth_utils.py +114 -0
- utils/bounded_collections.py +162 -0
- utils/circuit_breaker.py +389 -0
- utils/gif_utils.py +435 -0
- utils/markdown_utils.py +291 -0
- utils/message_utils.py +147 -0
- utils/metrics.py +36 -0
- utils/path_validator.py +227 -0
- utils/rate_limit_manager.py +399 -0
- utils/result_size.py +296 -0
- utils/time_utils.py +206 -0
- utils/user_utils.py +188 -0
- webview/README.md +111 -0
- webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
- webview/__init__.py +8 -0
- webview/activity.py +636 -0
- webview/owner_auth.py +108 -0
- webview/pages.py +448 -0
- webview/repair_sessions.py +399 -0
- webview/server.py +4323 -0
- webview/server_launcher.py +112 -0
- webview/static/css/activity.css +89 -0
- webview/static/css/chat.css +1772 -0
- webview/static/css/components.css +1190 -0
- webview/static/css/config-panel.css +651 -0
- webview/static/css/pages/admin.css +766 -0
- webview/static/css/pages/profile.css +231 -0
- webview/static/css/pages/settings.css +297 -0
- webview/static/css/pages/signin.css +316 -0
- webview/static/css/style.css +4250 -0
- webview/static/css/variables.css +147 -0
- webview/static/css/workspace-fullwidth-fix.css +109 -0
- webview/static/img/favicon.ico +0 -0
- webview/static/js/activity.js +394 -0
- webview/static/js/admin/activity.js +186 -0
- webview/static/js/admin/dashboard.js +163 -0
- webview/static/js/admin/user_detail.js +400 -0
- webview/static/js/admin/users.js +174 -0
- webview/static/js/admin/utils.js +214 -0
- webview/static/js/chat.js +3178 -0
- webview/static/js/config-panel.js +522 -0
- webview/static/js/constants.js +20 -0
- webview/static/js/error-handler.js +226 -0
- webview/static/js/ethers.min.js +1 -0
- webview/static/js/event-filter.js +137 -0
- webview/static/js/event-store.js +351 -0
- webview/static/js/file-attachments.js +374 -0
- webview/static/js/file-loader.js +163 -0
- webview/static/js/index.js +103 -0
- webview/static/js/performance-utils.js +196 -0
- webview/static/js/profile.js +396 -0
- webview/static/js/screenshot.js +386 -0
- webview/static/js/session.js +1527 -0
- webview/static/js/settings.js +1288 -0
- webview/static/js/sidebar-data.js +766 -0
- webview/static/js/sidebar-toggle.js +296 -0
- webview/static/js/socket.io.min.js +7 -0
- webview/static/js/stats.js +355 -0
- webview/static/js/ui-utils.js +420 -0
- webview/static/js/workspace.js +966 -0
- webview/stats_service.py +613 -0
- webview/templates/__init__.py +1 -0
- webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- webview/templates/activity.html +36 -0
- webview/templates/admin/activity.html +122 -0
- webview/templates/admin/dashboard.html +112 -0
- webview/templates/admin/user_detail.html +228 -0
- webview/templates/admin/users.html +105 -0
- webview/templates/autonomy.html +60 -0
- webview/templates/error.html +133 -0
- webview/templates/finance.html +90 -0
- webview/templates/identity.html +73 -0
- webview/templates/index.html +42 -0
- webview/templates/layout.html +125 -0
- webview/templates/memory.html +59 -0
- webview/templates/owner_login.html +15 -0
- webview/templates/profile.html +121 -0
- webview/templates/session.html +312 -0
- webview/templates/settings.html +534 -0
- webview/templates/sidebar.html +112 -0
- webview/templates/signin.html +213 -0
- webview/templates/status.html +14 -0
- webview/templates/system.html +45 -0
- 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
|
+
)
|