marvisx-cli 0.1.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.
- core/api/__init__.py +0 -0
- core/api/agents/__init__.py +0 -0
- core/api/agents/session_health.py +59 -0
- core/api/agents/session_manager.py +206 -0
- core/api/bin/marvisx-state-hook.py +182 -0
- core/api/config.py +533 -0
- core/api/db.py +1516 -0
- core/api/dependencies/__init__.py +0 -0
- core/api/dependencies/tenant.py +34 -0
- core/api/main.py +1641 -0
- core/api/mcp/__init__.py +8 -0
- core/api/mcp/_adapter.py +184 -0
- core/api/mcp/server.py +58 -0
- core/api/mcp/tools/__init__.py +59 -0
- core/api/mcp/tools/brain.py +599 -0
- core/api/mcp/tools/graph.py +380 -0
- core/api/mcp/tools/handoffs.py +112 -0
- core/api/mcp/tools/ingest.py +326 -0
- core/api/mcp/tools/learnings.py +144 -0
- core/api/mcp/tools/projects.py +99 -0
- core/api/mcp/tools/pull_requests.py +173 -0
- core/api/mcp/tools/safety.py +111 -0
- core/api/mcp/tools/search.py +79 -0
- core/api/mcp/tools/tasks.py +258 -0
- core/api/middleware/__init__.py +0 -0
- core/api/middleware/tool_call_audit.py +111 -0
- core/api/models/__init__.py +346 -0
- core/api/models/auth.py +48 -0
- core/api/models/brain.py +1006 -0
- core/api/models/common.py +76 -0
- core/api/models/costs.py +91 -0
- core/api/models/graph.py +66 -0
- core/api/models/graph_cosmo.py +125 -0
- core/api/models/graph_pr_impact.py +257 -0
- core/api/models/graph_ux.py +141 -0
- core/api/models/inbox.py +230 -0
- core/api/models/ingest_keys.py +108 -0
- core/api/models/kg.py +41 -0
- core/api/models/llm_config.py +56 -0
- core/api/models/monitoring.py +234 -0
- core/api/models/projects.py +161 -0
- core/api/models/search.py +42 -0
- core/api/models/sessions.py +322 -0
- core/api/models/tasks.py +184 -0
- core/api/models/teams.py +63 -0
- core/api/models/users.py +184 -0
- core/api/observability/__init__.py +0 -0
- core/api/observability/tracing.py +92 -0
- core/api/paths.py +26 -0
- core/api/rate_limit.py +24 -0
- core/api/rbac.py +112 -0
- core/api/routers/__init__.py +0 -0
- core/api/routers/_adapter.py +24 -0
- core/api/routers/admin_pr_impact.py +230 -0
- core/api/routers/admin_settings.py +147 -0
- core/api/routers/agent.py +1079 -0
- core/api/routers/agent_tokens.py +276 -0
- core/api/routers/app_settings.py +112 -0
- core/api/routers/audit.py +89 -0
- core/api/routers/auth.py +586 -0
- core/api/routers/bench.py +161 -0
- core/api/routers/brain.py +881 -0
- core/api/routers/brain_directions.py +527 -0
- core/api/routers/ci_checks.py +140 -0
- core/api/routers/comments.py +273 -0
- core/api/routers/costs.py +148 -0
- core/api/routers/docs_coverage.py +217 -0
- core/api/routers/docs_governance.py +63 -0
- core/api/routers/documents.py +318 -0
- core/api/routers/files.py +163 -0
- core/api/routers/finder.py +987 -0
- core/api/routers/graph.py +836 -0
- core/api/routers/handoffs.py +156 -0
- core/api/routers/inbox.py +496 -0
- core/api/routers/ingest_api_keys.py +205 -0
- core/api/routers/ingest_triage.py +1227 -0
- core/api/routers/judge.py +306 -0
- core/api/routers/kg.py +336 -0
- core/api/routers/learnings.py +253 -0
- core/api/routers/llm_config.py +130 -0
- core/api/routers/monitoring.py +347 -0
- core/api/routers/notifications.py +125 -0
- core/api/routers/pr_impact.py +315 -0
- core/api/routers/projects.py +1061 -0
- core/api/routers/pull_requests.py +312 -0
- core/api/routers/push.py +67 -0
- core/api/routers/raci.py +228 -0
- core/api/routers/search.py +125 -0
- core/api/routers/sessions.py +3100 -0
- core/api/routers/settings.py +90 -0
- core/api/routers/share_repo.py +68 -0
- core/api/routers/status_updates.py +96 -0
- core/api/routers/tags.py +45 -0
- core/api/routers/tasks.py +526 -0
- core/api/routers/teams.py +425 -0
- core/api/routers/terminal.py +105 -0
- core/api/routers/users.py +331 -0
- core/api/routers/webhooks.py +330 -0
- core/api/runtime_settings.py +84 -0
- core/api/security.py +652 -0
- core/api/services/__init__.py +0 -0
- core/api/services/audit.py +58 -0
- core/api/services/auto_approval.py +82 -0
- core/api/services/brain/__init__.py +52 -0
- core/api/services/brain/baseline.py +230 -0
- core/api/services/brain/capabilities.py +75 -0
- core/api/services/brain/cascade_rollup.py +388 -0
- core/api/services/brain/compound_bridge.py +215 -0
- core/api/services/brain/cycle.py +1242 -0
- core/api/services/brain/cycle_snapshot.py +371 -0
- core/api/services/brain/digest_collector.py +147 -0
- core/api/services/brain/direction.py +421 -0
- core/api/services/brain/drift.py +356 -0
- core/api/services/brain/drift_router.py +409 -0
- core/api/services/brain/edge_metrics.py +79 -0
- core/api/services/brain/events_reader.py +222 -0
- core/api/services/brain/findings.py +1379 -0
- core/api/services/brain/findings_reader.py +1006 -0
- core/api/services/brain/jobs.py +733 -0
- core/api/services/brain/journal.py +206 -0
- core/api/services/brain/knowledge_forms.py +92 -0
- core/api/services/brain/llm/__init__.py +37 -0
- core/api/services/brain/llm/_runner.py +62 -0
- core/api/services/brain/llm/base.py +70 -0
- core/api/services/brain/llm/cache.py +99 -0
- core/api/services/brain/llm/constants.py +46 -0
- core/api/services/brain/llm/direction_alignment.py +289 -0
- core/api/services/brain/llm/factory.py +132 -0
- core/api/services/brain/llm/finding_reasoning.py +98 -0
- core/api/services/brain/llm/finding_summary.py +92 -0
- core/api/services/brain/llm/grounding.py +46 -0
- core/api/services/brain/llm/journal_polish.py +96 -0
- core/api/services/brain/llm/local_gateway.py +426 -0
- core/api/services/brain/llm/parsers.py +71 -0
- core/api/services/brain/llm/router_glue.py +422 -0
- core/api/services/brain/memory_ops.py +1677 -0
- core/api/services/brain/models.py +140 -0
- core/api/services/brain/owner_hint.py +211 -0
- core/api/services/brain/recap.py +307 -0
- core/api/services/brain/rules/__init__.py +65 -0
- core/api/services/brain/rules/_signals.py +205 -0
- core/api/services/brain/rules/dr1_activity_without_status.py +108 -0
- core/api/services/brain/rules/dr2_decision_without_adr.py +110 -0
- core/api/services/brain/rules/dr3_stale_open_loop.py +127 -0
- core/api/services/brain/rules/dr4_docs_governance_drift.py +79 -0
- core/api/services/brain/rules/dr5_playbook_changed.py +99 -0
- core/api/services/brain/rules/dr6_external_update_unpropagated.py +100 -0
- core/api/services/brain/rules/dr7_claimed_decision_gap.py +123 -0
- core/api/services/brain/rules/dr8_direction_misalignment.py +230 -0
- core/api/services/brain/runs_reader.py +485 -0
- core/api/services/brain/scope.py +79 -0
- core/api/services/brain/sources/__init__.py +46 -0
- core/api/services/brain/sources/base.py +86 -0
- core/api/services/brain/sources/git_kg.py +393 -0
- core/api/services/brain/sources/handoffs.py +157 -0
- core/api/services/brain/sources/ingestor.py +130 -0
- core/api/services/brain/sources/learnings.py +121 -0
- core/api/services/brain/sources/pir_tasks.py +245 -0
- core/api/services/brain/watermarks.py +147 -0
- core/api/services/brain/ws_emitter.py +170 -0
- core/api/services/cc_tasks_reader.py +76 -0
- core/api/services/ci_service.py +263 -0
- core/api/services/claude_metrics.py +796 -0
- core/api/services/codex_metrics.py +364 -0
- core/api/services/conversation_reader.py +102 -0
- core/api/services/cost_service.py +243 -0
- core/api/services/crypto.py +147 -0
- core/api/services/docs_governance/__init__.py +1 -0
- core/api/services/docs_governance/confidence.py +230 -0
- core/api/services/docs_governance/config.py +87 -0
- core/api/services/docs_governance/enrichment.py +65 -0
- core/api/services/docs_governance/frontmatter_validator.py +83 -0
- core/api/services/docs_governance/hard_gates.py +221 -0
- core/api/services/docs_governance/triage_orchestrator.py +98 -0
- core/api/services/embedding_internal.py +395 -0
- core/api/services/embedding_service.py +832 -0
- core/api/services/event_dispatcher.py +167 -0
- core/api/services/events.py +70 -0
- core/api/services/git_ops.py +621 -0
- core/api/services/graph_cosmo_service.py +440 -0
- core/api/services/graph_ranker.py +306 -0
- core/api/services/graph_service.py +1589 -0
- core/api/services/inbox.py +800 -0
- core/api/services/inbox_digest.py +221 -0
- core/api/services/inbox_digest_deep_research.py +80 -0
- core/api/services/inbox_digest_jobs.py +595 -0
- core/api/services/inbox_gmail_sync.py +167 -0
- core/api/services/inbox_llm_classifier.py +906 -0
- core/api/services/inbox_source_identity.py +116 -0
- core/api/services/inbox_sources.py +456 -0
- core/api/services/inbox_taxonomy.py +195 -0
- core/api/services/inbox_tldr.py +1079 -0
- core/api/services/inbox_triage.py +899 -0
- core/api/services/ingest/__init__.py +13 -0
- core/api/services/ingest/api_key_auth.py +136 -0
- core/api/services/ingest/auto_approve.py +120 -0
- core/api/services/ingest/classifier.py +138 -0
- core/api/services/ingest/confidence.py +173 -0
- core/api/services/ingest/dispatch.py +88 -0
- core/api/services/ingest/embedding_router.py +272 -0
- core/api/services/ingest/events.py +33 -0
- core/api/services/ingest/ignore_patterns.py +79 -0
- core/api/services/ingest/image_probe.py +218 -0
- core/api/services/ingest/ingress.py +263 -0
- core/api/services/ingest/insert_saga.py +793 -0
- core/api/services/ingest/llm/__init__.py +13 -0
- core/api/services/ingest/llm/anthropic_haiku.py +23 -0
- core/api/services/ingest/llm/base.py +59 -0
- core/api/services/ingest/llm/byok_provider.py +130 -0
- core/api/services/ingest/llm/classification_context.py +301 -0
- core/api/services/ingest/llm/config_store.py +246 -0
- core/api/services/ingest/llm/factory.py +24 -0
- core/api/services/ingest/llm/kg_enricher.py +306 -0
- core/api/services/ingest/llm/local_gateway.py +821 -0
- core/api/services/ingest/llm/local_vllm.py +23 -0
- core/api/services/ingest/llm/openai_nano.py +349 -0
- core/api/services/ingest/lock_advisory.py +57 -0
- core/api/services/ingest/parser_router.py +1756 -0
- core/api/services/ingest/parsers/__init__.py +1 -0
- core/api/services/ingest/parsers/docling_parser.py +142 -0
- core/api/services/ingest/parsers/docparse_gateway.py +178 -0
- core/api/services/ingest/parsers/docx_parser.py +127 -0
- core/api/services/ingest/parsers/folder_unpacker.py +85 -0
- core/api/services/ingest/parsers/gateway_aux.py +147 -0
- core/api/services/ingest/parsers/image_parser.py +251 -0
- core/api/services/ingest/parsers/internal_markdown.py +89 -0
- core/api/services/ingest/parsers/ocr_gateway.py +117 -0
- core/api/services/ingest/parsers/ocr_pdf_parser.py +112 -0
- core/api/services/ingest/parsers/pdf_types.py +13 -0
- core/api/services/ingest/parsers/transcript_parser.py +445 -0
- core/api/services/ingest/parsers/vision_gateway.py +186 -0
- core/api/services/ingest/parsers/xlsx_parser.py +91 -0
- core/api/services/ingest/parsers/zip_unpacker.py +126 -0
- core/api/services/ingest/preflight.py +393 -0
- core/api/services/ingest/retry_voyage.py +88 -0
- core/api/services/ingest/routing_policy.py +307 -0
- core/api/services/ingest/serializers/__init__.py +1 -0
- core/api/services/ingest/serializers/xlsx_to_markdown.py +80 -0
- core/api/services/ingest/skip_log.py +74 -0
- core/api/services/ingest/watcher.py +637 -0
- core/api/services/kg/__init__.py +0 -0
- core/api/services/kg/audit.py +49 -0
- core/api/services/kg/hybrid_search.py +691 -0
- core/api/services/kg/lens.py +339 -0
- core/api/services/kg/pr_impact.py +770 -0
- core/api/services/kg/queries.py +152 -0
- core/api/services/kg/ranking.py +89 -0
- core/api/services/kg/rrf.py +143 -0
- core/api/services/kg_watcher_control.py +161 -0
- core/api/services/local_llm/__init__.py +19 -0
- core/api/services/local_llm/async_client.py +385 -0
- core/api/services/local_llm/client.py +173 -0
- core/api/services/local_llm/url_validator.py +44 -0
- core/api/services/metrics_collector.py +646 -0
- core/api/services/metrics_providers.py +65 -0
- core/api/services/model_registry.py +266 -0
- core/api/services/model_router.py +137 -0
- core/api/services/n8n_client.py +77 -0
- core/api/services/newsletter_llm_gateway.py +66 -0
- core/api/services/notification_service.py +134 -0
- core/api/services/openai_responses.py +55 -0
- core/api/services/opencode_metrics.py +375 -0
- core/api/services/opencode_sessions.py +173 -0
- core/api/services/pii_redactor.py +138 -0
- core/api/services/pr_impact_pipeline/__init__.py +21 -0
- core/api/services/pr_impact_pipeline/differ.py +421 -0
- core/api/services/pr_impact_pipeline/dispatcher.py +415 -0
- core/api/services/pr_impact_pipeline/gc.py +93 -0
- core/api/services/pr_impact_pipeline/languages.py +192 -0
- core/api/services/pr_impact_pipeline/parser.py +178 -0
- core/api/services/pr_impact_pipeline/writer.py +394 -0
- core/api/services/pr_service.py +1393 -0
- core/api/services/project_paths.py +70 -0
- core/api/services/project_status_updates.py +265 -0
- core/api/services/providers.py +276 -0
- core/api/services/push_service.py +170 -0
- core/api/services/reminder_service.py +89 -0
- core/api/services/runas.py +41 -0
- core/api/services/salience_service.py +69 -0
- core/api/services/security_collector.py +281 -0
- core/api/services/session_catalog.py +385 -0
- core/api/services/session_metrics_service.py +301 -0
- core/api/services/session_ops.py +272 -0
- core/api/services/session_state.py +173 -0
- core/api/services/share_links.py +222 -0
- core/api/services/task_transitions.py +146 -0
- core/api/services/terminal_metrics.py +462 -0
- core/api/services/terminal_metrics_dump.py +203 -0
- core/api/services/tmux.py +1205 -0
- core/api/services/webhook_service.py +422 -0
- core/api/services/workspace_sync.py +164 -0
- core/api/templates/__init__.py +1 -0
- core/api/templates/markdown_share.py +164 -0
- core/api/terminal.py +1031 -0
- core/api/tests/__init__.py +0 -0
- core/api/tests/test_agent_facing_auth_dependencies.py +132 -0
- core/api/tests/test_audit_permissions.py +133 -0
- core/api/tests/test_backfill_session_conversations.py +90 -0
- core/api/tests/test_backfill_working_seconds_msg.py +129 -0
- core/api/tests/test_claude_metrics.py +326 -0
- core/api/tests/test_codex_metrics.py +189 -0
- core/api/tests/test_finder_paths.py +74 -0
- core/api/tests/test_git_ops_merge.py +155 -0
- core/api/tests/test_learnings_check_search.py +81 -0
- core/api/tests/test_metrics_providers.py +133 -0
- core/api/tests/test_migration_087.py +164 -0
- core/api/tests/test_migration_088.py +94 -0
- core/api/tests/test_migration_089.py +116 -0
- core/api/tests/test_openai_responses.py +24 -0
- core/api/tests/test_opencode_metrics.py +740 -0
- core/api/tests/test_opencode_sessions.py +321 -0
- core/api/tests/test_pr_workflow_e2e.py +457 -0
- core/api/tests/test_projects_handoffs.py +31 -0
- core/api/tests/test_providers.py +138 -0
- core/api/tests/test_safety_bridge.py +347 -0
- core/api/tests/test_session_catalog.py +142 -0
- core/api/tests/test_session_conversations.py +512 -0
- core/api/tests/test_session_metrics_service.py +270 -0
- core/api/tests/test_session_resume_paths.py +548 -0
- core/api/tests/test_session_theme_mode_migration.py +56 -0
- core/api/tests/test_sessions_rbac.py +131 -0
- core/api/tests/test_share_edit.py +398 -0
- core/api/tests/test_share_repo.py +200 -0
- core/api/tests/test_terminal_session_manager.py +98 -0
- core/api/tests/test_terminal_upload.py +34 -0
- core/api/tests/test_tmux.py +272 -0
- core/api/tests/test_workspace_sync.py +186 -0
- core/api/tests/test_ws_ticket_in_memory.py +73 -0
- core/api/use_cases/__init__.py +11 -0
- core/api/use_cases/_context.py +89 -0
- core/api/use_cases/_errors.py +62 -0
- core/api/use_cases/_roles.py +16 -0
- core/api/use_cases/audit.py +171 -0
- core/api/use_cases/brain.py +1232 -0
- core/api/use_cases/costs.py +249 -0
- core/api/use_cases/graph.py +1153 -0
- core/api/use_cases/handoffs.py +506 -0
- core/api/use_cases/ingest_triage.py +1229 -0
- core/api/use_cases/learnings.py +538 -0
- core/api/use_cases/projects.py +705 -0
- core/api/use_cases/pull_requests.py +415 -0
- core/api/use_cases/search.py +926 -0
- core/api/use_cases/tasks.py +1495 -0
- core/api/visibility.py +141 -0
- core/cli/__init__.py +5 -0
- core/cli/_index_source.py +632 -0
- core/cli/_runtime_ctx.py +160 -0
- core/cli/_transmute.py +241 -0
- core/cli/marvis_doctor.py +704 -0
- core/cli/marvis_feedback.py +396 -0
- core/cli/marvis_governance.py +315 -0
- core/cli/marvis_hooks.py +515 -0
- core/cli/marvis_init.py +757 -0
- core/cli/marvis_mcp.py +401 -0
- core/cli/marvis_runtime.py +855 -0
- core/cli/marvis_telemetry.py +228 -0
- core/scripts/_drift_check.py +716 -0
- core/scripts/_frontmatter.py +66 -0
- core/scripts/_graph_writer.py +189 -0
- core/scripts/ast_parser.py +1553 -0
- core/scripts/install_hooks/__init__.py +1 -0
- core/scripts/install_hooks/_config.sh +109 -0
- core/scripts/install_hooks/block-dangerous-bash.sh +23 -0
- core/scripts/install_hooks/block-db-direct-write.sh +23 -0
- core/scripts/install_hooks/block-push-no-task.sh +23 -0
- core/scripts/install_hooks/block-staging-to-prod.sh +23 -0
- core/scripts/install_hooks/block-subtree-push.sh +23 -0
- core/scripts/install_hooks/config.json +53 -0
- core/scripts/install_hooks/enforce-no-merge-main.sh +23 -0
- core/scripts/install_hooks/enforce-worktree.sh +23 -0
- core/scripts/install_hooks/quality-gate.sh +170 -0
- core/scripts/install_hooks/safety_bridge.py +968 -0
- core/scripts/install_hooks/secret-scan.sh +23 -0
- core/scripts/migrate_spike_node_ids.py +122 -0
- core/scripts/populate_artifacts.py +2198 -0
- core/scripts/populate_cross_project.py +2457 -0
- core/scripts/populate_inbox_nodes.py +357 -0
- core/scripts/populate_pr_impact.py +267 -0
- core/scripts/populate_project_nodes.py +603 -0
- core/scripts/populate_touch_counter.py +337 -0
- core/scripts/reparse_failed.py +57 -0
- core/scripts/safety_bridge.py +968 -0
- core/telemetry/__init__.py +9 -0
- core/telemetry/client.py +405 -0
- core/telemetry/schema.py +122 -0
- core/wizard/__init__.py +65 -0
- core/wizard/byok_vault.py +147 -0
- core/wizard/defaults.py +58 -0
- core/wizard/state.py +117 -0
- core/wizard/steps.py +70 -0
- core/wizard/validation.py +136 -0
- marvisx_cli-0.1.0.dist-info/METADATA +201 -0
- marvisx_cli-0.1.0.dist-info/RECORD +587 -0
- marvisx_cli-0.1.0.dist-info/WHEEL +5 -0
- marvisx_cli-0.1.0.dist-info/entry_points.txt +3 -0
- marvisx_cli-0.1.0.dist-info/licenses/LICENSE +98 -0
- marvisx_cli-0.1.0.dist-info/top_level.txt +3 -0
- migrations/001_initial.sql +33 -0
- migrations/002_tasks.sql +30 -0
- migrations/003_session_management.sql +7 -0
- migrations/004_projects_comments.sql +65 -0
- migrations/005_session_intelligence.sql +15 -0
- migrations/006_settings.sql +12 -0
- migrations/007_task_scoring.sql +12 -0
- migrations/008_cost_tracking.sql +31 -0
- migrations/009_session_card_metrics.sql +3 -0
- migrations/010_monitoring.sql +55 -0
- migrations/012_agent_api.sql +21 -0
- migrations/013_session_complete.sql +8 -0
- migrations/015_pull_requests.sql +43 -0
- migrations/015_pull_requests_down.sql +5 -0
- migrations/016_users_raci.sql +116 -0
- migrations/017_task_cost_entries.sql +87 -0
- migrations/018_agents.sql +73 -0
- migrations/018_agents_down.sql +13 -0
- migrations/019_review_feedback.sql +18 -0
- migrations/020_pr_commit_sha.sql +4 -0
- migrations/021_webhook_events.sql +18 -0
- migrations/022_devx_agent_managed.sql +11 -0
- migrations/022_devx_agent_managed_down.sql +6 -0
- migrations/023_devx_p1_gate.sql +7 -0
- migrations/023_devx_p1_gate_down.sql +3 -0
- migrations/024_chat_messages.sql +16 -0
- migrations/024_pr_conversation_id.sql +8 -0
- migrations/024_task_indexes.sql +21 -0
- migrations/024_task_indexes_down.sql +7 -0
- migrations/025_audit_log.sql +17 -0
- migrations/026_agent_tokens.sql +20 -0
- migrations/027_teams_auth_phase_b.sql +35 -0
- migrations/028_learnings.sql +23 -0
- migrations/029_team_roles.sql +14 -0
- migrations/030_finder_pins.sql +10 -0
- migrations/031_pr_deploy_status.sql +9 -0
- migrations/032_task_reminders.sql +7 -0
- migrations/033_events_retry_count.sql +6 -0
- migrations/033_session_owner.sql +9 -0
- migrations/034_notifications.sql +38 -0
- migrations/035_shared_links.sql +15 -0
- migrations/036_session_index_upgrade.sql +29 -0
- migrations/037_pr_approval.sql +15 -0
- migrations/038_pr_submitted_by.sql +6 -0
- migrations/039_push_subscriptions.sql +17 -0
- migrations/040_semantic_search.sql +16 -0
- migrations/041_workspaces.sql +63 -0
- migrations/042_oidc_providers.sql +24 -0
- migrations/043_ci_checks.sql +31 -0
- migrations/044_agent_metrics.sql +30 -0
- migrations/045_documents_doc_type.sql +5 -0
- migrations/046_salience.sql +13 -0
- migrations/047_seed_missing_agents.sql +6 -0
- migrations/048_fix_agent_paths_roles.sql +5 -0
- migrations/049_agent_role_and_learnings_schema.sql +3 -0
- migrations/050_session_provider.sql +2 -0
- migrations/051_session_launch_profile.sql +4 -0
- migrations/052_session_theme_mode.sql +2 -0
- migrations/052_task_kind.sql +4 -0
- migrations/053_inbox_items.sql +31 -0
- migrations/054_inbox_triage_contract.sql +30 -0
- migrations/055_inbox_topic_treatment.sql +12 -0
- migrations/056_inbox_treatment_read_save.sql +57 -0
- migrations/057_session_theme_mode_backfill.sql +4 -0
- migrations/058_inbox_item_status_lifecycle.sql +13 -0
- migrations/059_inbox_tldr_and_source_scores.sql +18 -0
- migrations/060_newsletter.sql +16 -0
- migrations/061_inbox_redesign.sql +69 -0
- migrations/062_fix_inbox_sources_backfill.sql +37 -0
- migrations/063_task_completion_mode.sql +23 -0
- migrations/064_judge_mode_setting.sql +4 -0
- migrations/065_knowledge_graph_spike.sql +40 -0
- migrations/066_digest_ranking_inputs.sql +10 -0
- migrations/066_kg_artifact_nodes.sql +129 -0
- migrations/067_inbox_digest_selections.sql +28 -0
- migrations/067_kg_temporal.sql +53 -0
- migrations/068_inbox_digest_app_settings.sql +9 -0
- migrations/068_kg_touch_counter.sql +52 -0
- migrations/069_kg_doc_types.sql +117 -0
- migrations/070_digest_ranking_inputs_recovery.sql +3 -0
- migrations/071_inbox_digest_selections_recovery.sql +3 -0
- migrations/072_inbox_digest_app_settings_recovery.sql +3 -0
- migrations/073_kg_cross_project.sql +216 -0
- migrations/073_kg_cross_project_down.sql +77 -0
- migrations/074_kg_infra_types.sql +208 -0
- migrations/074_kg_infra_types_down.sql +80 -0
- migrations/075_kg_file_state_recovery.sql +35 -0
- migrations/075_kg_file_state_recovery_down.sql +5 -0
- migrations/076_kg_watcher_state.sql +33 -0
- migrations/076_kg_watcher_state_down.sql +3 -0
- migrations/077_kg_doc_types_extend.sql +226 -0
- migrations/077_kg_doc_types_extend_down.sql +80 -0
- migrations/078_kg_fts5.sql +102 -0
- migrations/078_kg_fts5_down.sql +14 -0
- migrations/079_kg_missing_indexes.sql +31 -0
- migrations/079_kg_missing_indexes_down.sql +10 -0
- migrations/080_kg_fts5_extended.sql +232 -0
- migrations/080_kg_fts5_extended_down.sql +25 -0
- migrations/081_kg_lens_indexes.sql +9 -0
- migrations/081_kg_lens_indexes_down.sql +3 -0
- migrations/082_kg_pins.sql +26 -0
- migrations/082_kg_pins_down.sql +14 -0
- migrations/083_kg_graph_nodes_degree.sql +20 -0
- migrations/083_kg_graph_nodes_degree_down.sql +15 -0
- migrations/084_drop_legacy_scheduler_tables.sql +58 -0
- migrations/084_drop_legacy_scheduler_tables_down.sql +112 -0
- migrations/085_kg_edge_resolves_to.sql +142 -0
- migrations/085_kg_edge_resolves_to_down.sql +66 -0
- migrations/086_project_status_updates_feed.sql +20 -0
- migrations/086_project_status_updates_feed_down.sql +36 -0
- migrations/087_session_metrics_dual.sql +50 -0
- migrations/087_session_metrics_dual_down.sql +21 -0
- migrations/088_rename_context_pct_legacy.sql +23 -0
- migrations/088_rename_context_pct_legacy_down.sql +8 -0
- migrations/089_session_metrics_equivalent_cost.sql +26 -0
- migrations/089_session_metrics_equivalent_cost_down.sql +11 -0
- migrations/090_kg_inbox_node_type.sql +26 -0
- migrations/090_kg_inbox_node_type_down.sql +20 -0
- migrations/091_kg_inbox_node_type_check.sql +265 -0
- migrations/091_kg_inbox_node_type_check_down.sql +129 -0
- migrations/092_sessions_activity_state_ts.sql +29 -0
- migrations/092_sessions_activity_state_ts_down.sql +14 -0
- migrations/093_sessions_activity_state_column.sql +29 -0
- migrations/093_sessions_activity_state_column_down.sql +10 -0
- migrations/094_ingest_pending.sql +55 -0
- migrations/094_ingest_pending_down.sql +15 -0
- migrations/095_kg_intent_first.sql +77 -0
- migrations/095_kg_intent_first_down.sql +25 -0
- migrations/096_kg_xlsx_artifact_prefix.sql +17 -0
- migrations/096_kg_xlsx_artifact_prefix_down.sql +11 -0
- migrations/097_ingest_change_history.sql +37 -0
- migrations/097_ingest_change_history_down.sql +13 -0
- migrations/098_kg_node_type_business.sql +254 -0
- migrations/098_kg_node_type_business_down.sql +195 -0
- migrations/099_kg_edges_restore_weight.sql +58 -0
- migrations/099_kg_edges_restore_weight_down.sql +12 -0
- migrations/100_kg_enriched_at.sql +25 -0
- migrations/100_kg_enriched_at_down.sql +12 -0
- migrations/101_local_llm_shadow_comparisons.sql +66 -0
- migrations/101_local_llm_shadow_comparisons_down.sql +15 -0
- migrations/102_promote_llm_costs.sql +69 -0
- migrations/102_promote_llm_costs_down.sql +19 -0
- migrations/103_ingest_skipped_log.sql +46 -0
- migrations/103_ingest_skipped_log_down.sql +15 -0
- migrations/120_docs_governance.sql +50 -0
- migrations/120_docs_governance_down.sql +11 -0
- migrations/121_notification_event_fk_cleanup.sql +21 -0
- migrations/121_notification_event_fk_cleanup_down.sql +10 -0
- migrations/122_docs_drift_history.sql +34 -0
- migrations/122_docs_drift_history_down.sql +15 -0
- migrations/123_ingest_parser_waiting_status.sql +69 -0
- migrations/123_ingest_parser_waiting_status_down.sql +69 -0
- migrations/124_heypocket_recordings.sql +63 -0
- migrations/124_heypocket_recordings_down.sql +13 -0
- migrations/125_kg_node_type_record.sql +219 -0
- migrations/125_kg_node_type_record_down.sql +205 -0
- migrations/126_ingest_terminal_upload_source_kind.sql +69 -0
- migrations/126_ingest_terminal_upload_source_kind_down.sql +69 -0
- migrations/127_brain_v1_substrate.sql +200 -0
- migrations/127_brain_v1_substrate_down.sql +32 -0
- migrations/128_brain_drift_signals.sql +157 -0
- migrations/128_brain_drift_signals_down.sql +23 -0
- migrations/129_brain_memory_operations.sql +232 -0
- migrations/129_brain_memory_operations_down.sql +27 -0
- migrations/130_brain_findings.sql +258 -0
- migrations/130_brain_findings_down.sql +29 -0
- migrations/132_kg_pr_modifies.sql +242 -0
- migrations/132_kg_pr_modifies_down.sql +99 -0
- migrations/133_brain_v1_2_direction_schema.sql +476 -0
- migrations/133_brain_v1_2_direction_schema_down.sql +273 -0
- migrations/134_brain_journal_narrative_polished.sql +8 -0
- migrations/134_brain_journal_narrative_polished_down.sql +6 -0
- migrations/135_kg_edges_provider.sql +21 -0
- migrations/136_documents_fts.sql +56 -0
- migrations/137_promote_llm_costs.sql +59 -0
- migrations/137_promote_llm_costs_down.sql +19 -0
- migrations/138_ingest_api_keys.sql +39 -0
- migrations/138_ingest_api_keys_down.sql +8 -0
- migrations/139_ingest_pending_ingress.sql +91 -0
- migrations/139_ingest_pending_ingress_down.sql +73 -0
- migrations/140_ingest_idempotency_quota.sql +45 -0
- migrations/140_ingest_idempotency_quota_down.sql +9 -0
- migrations/141_ingest_pending_metadata.sql +16 -0
- migrations/141_ingest_pending_metadata_down.sql +7 -0
- migrations/142_llm_function_config.sql +36 -0
- migrations/142_llm_function_config_down.sql +8 -0
- migrations/143_kg_code_embeddings.sql +25 -0
- migrations/143_kg_code_embeddings_down.sql +5 -0
- migrations/__init__.py +4 -0
- projects/_template/project.yaml +46 -0
core/api/security.py
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
# v1.7.0 - 2026-03-10 - Add resolve_session_owner for session-based MCP client attribution
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import aiosqlite
|
|
14
|
+
import bcrypt
|
|
15
|
+
import jwt
|
|
16
|
+
from fastapi import Depends, HTTPException, Request
|
|
17
|
+
from fastapi.responses import Response
|
|
18
|
+
|
|
19
|
+
from core.api.config import settings
|
|
20
|
+
from core.api.db import get_db
|
|
21
|
+
from core.api.models import UserInfo
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
ALGORITHM = "HS256"
|
|
26
|
+
|
|
27
|
+
# Static system identities always considered valid (not in DB).
|
|
28
|
+
_SYSTEM_IDENTITIES: set[str] = {"marvis-local", "console-api"}
|
|
29
|
+
|
|
30
|
+
# Valid agent names for X-Agent-Name header. Used by both auth functions.
|
|
31
|
+
# Spoofable by anyone with the token — for attribution only, not access control.
|
|
32
|
+
# Initialized with generic, name-free identities; extended dynamically from the
|
|
33
|
+
# DB via get_valid_agent_names() and from settings.static_agent_identities
|
|
34
|
+
# (deploy .env). OSS core hardcodes no tenant agent names here.
|
|
35
|
+
_VALID_AGENT_NAMES = {"marvis-local", "console-api"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _valid_static_agent_names() -> set[str]:
|
|
39
|
+
"""Static agent identities: hardcoded generic set ∪ configured tenant names.
|
|
40
|
+
|
|
41
|
+
The configured names (settings.static_agent_identities) come from the deploy
|
|
42
|
+
.env. Read live so tests can override via monkeypatch. This list only matters
|
|
43
|
+
for the two paths that do NOT query the DB users table: the legacy shared
|
|
44
|
+
TASKS_API_TOKEN attribution and a stale-cache fallback. DB-backed agents
|
|
45
|
+
(e.g. seeded via migration) authenticate via get_valid_agent_names().
|
|
46
|
+
"""
|
|
47
|
+
return _VALID_AGENT_NAMES | set(settings.static_agent_identities)
|
|
48
|
+
|
|
49
|
+
# In-memory cache for dynamic agent names from DB
|
|
50
|
+
_agent_names_cache: set[str] = set()
|
|
51
|
+
_cache_expires_at: float = 0.0
|
|
52
|
+
_CACHE_TTL_SECONDS: float = 300.0 # 5 minutes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def get_valid_agent_names(db: aiosqlite.Connection) -> set[str]:
|
|
56
|
+
"""Return valid agent names: DB slugs union system identities. Cached for 5 minutes."""
|
|
57
|
+
global _agent_names_cache, _cache_expires_at
|
|
58
|
+
now = time.monotonic()
|
|
59
|
+
if now < _cache_expires_at and _agent_names_cache:
|
|
60
|
+
return _agent_names_cache | _SYSTEM_IDENTITIES
|
|
61
|
+
try:
|
|
62
|
+
async with db.execute(
|
|
63
|
+
"SELECT slug FROM users WHERE type = 'agent' AND deleted_at IS NULL"
|
|
64
|
+
) as cur:
|
|
65
|
+
rows = await cur.fetchall()
|
|
66
|
+
_agent_names_cache = {row[0] for row in rows}
|
|
67
|
+
_cache_expires_at = now + _CACHE_TTL_SECONDS
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.warning("get_valid_agent_names: DB query failed, using stale cache")
|
|
70
|
+
return _agent_names_cache | _SYSTEM_IDENTITIES
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def verify_password(password: str, hashed: str) -> bool:
|
|
74
|
+
"""Verify password against bcrypt hash. bcrypt.checkpw is already constant-time."""
|
|
75
|
+
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_session_jwt(
|
|
79
|
+
username: str,
|
|
80
|
+
extra_claims: dict | None = None,
|
|
81
|
+
) -> tuple[str, str, datetime]:
|
|
82
|
+
"""Create JWT session token. Returns (token, jti, expires_at).
|
|
83
|
+
|
|
84
|
+
extra_claims: optional dict merged into JWT payload (e.g. workspace_id for SSO).
|
|
85
|
+
"""
|
|
86
|
+
jti = str(uuid.uuid4())
|
|
87
|
+
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expiry_hours)
|
|
88
|
+
payload = {
|
|
89
|
+
"sub": username,
|
|
90
|
+
"jti": jti,
|
|
91
|
+
"exp": expires_at,
|
|
92
|
+
"iat": datetime.now(timezone.utc),
|
|
93
|
+
}
|
|
94
|
+
if extra_claims:
|
|
95
|
+
payload.update(extra_claims)
|
|
96
|
+
token = jwt.encode(payload, settings.pir_jwt_secret, algorithm=ALGORITHM)
|
|
97
|
+
return token, jti, expires_at
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def verify_session_jwt(token: str) -> dict:
|
|
101
|
+
"""Verify and decode JWT. Raises jwt.exceptions on failure."""
|
|
102
|
+
return jwt.decode(
|
|
103
|
+
token,
|
|
104
|
+
settings.pir_jwt_secret,
|
|
105
|
+
algorithms=[ALGORITHM],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def is_token_blacklisted(jti: str, db: aiosqlite.Connection) -> bool:
|
|
110
|
+
"""Check if token JTI is in blacklist."""
|
|
111
|
+
cursor = await db.execute("SELECT 1 FROM token_blacklist WHERE jti = ?", (jti,))
|
|
112
|
+
return await cursor.fetchone() is not None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def blacklist_token(
|
|
116
|
+
jti: str, expires_at: datetime, db: aiosqlite.Connection
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Add token to persistent blacklist."""
|
|
119
|
+
await db.execute(
|
|
120
|
+
"INSERT OR IGNORE INTO token_blacklist (jti, expires_at) VALUES (?, ?)",
|
|
121
|
+
(jti, expires_at.isoformat()),
|
|
122
|
+
)
|
|
123
|
+
await db.commit()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def set_auth_cookie(response: Response, token: str) -> None:
|
|
127
|
+
"""Set httpOnly session cookie."""
|
|
128
|
+
response.set_cookie(
|
|
129
|
+
key="pir_session",
|
|
130
|
+
value=token,
|
|
131
|
+
httponly=True,
|
|
132
|
+
secure=True,
|
|
133
|
+
samesite="lax",
|
|
134
|
+
domain=settings.cookie_domain if settings.is_production else None,
|
|
135
|
+
path="/",
|
|
136
|
+
max_age=settings.jwt_expiry_hours * 3600,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def clear_auth_cookie(response: Response) -> None:
|
|
141
|
+
"""Remove session cookie."""
|
|
142
|
+
response.delete_cookie(
|
|
143
|
+
key="pir_session",
|
|
144
|
+
httponly=True,
|
|
145
|
+
secure=True,
|
|
146
|
+
samesite="lax",
|
|
147
|
+
domain=settings.cookie_domain if settings.is_production else None,
|
|
148
|
+
path="/",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# In-memory WS ticket store (single-worker pir-api). Tickets are ephemeral
|
|
153
|
+
# (30s TTL, single-use), so they do NOT need the transactional main DB — and
|
|
154
|
+
# more importantly must NOT go through the shared _write_lock, whose contention
|
|
155
|
+
# added 8.5s p95 lock-wait on terminal open/connect (diagnostic 2026-05-27).
|
|
156
|
+
# A restart of pir-api drops pending tickets, which is fine: a restart also tears
|
|
157
|
+
# down every WebSocket, so any pending ticket would be useless anyway.
|
|
158
|
+
# Atomicity note: the asyncio event loop is single-threaded, so the check-then-set
|
|
159
|
+
# in consume (used flag) is atomic as long as there is no `await` between them.
|
|
160
|
+
_ws_tickets: dict[str, dict[str, Any]] = {}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _purge_expired_ws_tickets(now: datetime | None = None) -> None:
|
|
164
|
+
"""Drop expired tickets from the in-memory store (lazy GC)."""
|
|
165
|
+
current = now or datetime.now(timezone.utc)
|
|
166
|
+
expired = [t for t, rec in _ws_tickets.items() if current > rec["expires_at"]]
|
|
167
|
+
for t in expired:
|
|
168
|
+
_ws_tickets.pop(t, None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cleanup_expired_ws_tickets() -> int:
|
|
172
|
+
"""Public hook for the periodic cleanup loop. Returns count purged."""
|
|
173
|
+
before = len(_ws_tickets)
|
|
174
|
+
_purge_expired_ws_tickets()
|
|
175
|
+
return before - len(_ws_tickets)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def create_ws_ticket(
|
|
179
|
+
username: str,
|
|
180
|
+
session_name: str,
|
|
181
|
+
*,
|
|
182
|
+
timings: dict[str, float | str] | None = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Create opaque WS ticket. 30s TTL, single-use. In-memory (no DB write_lock)."""
|
|
185
|
+
ticket = secrets.token_urlsafe(32)
|
|
186
|
+
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
187
|
+
seconds=settings.ws_ticket_ttl_seconds
|
|
188
|
+
)
|
|
189
|
+
_purge_expired_ws_tickets()
|
|
190
|
+
_ws_tickets[ticket] = {
|
|
191
|
+
"user_id": username,
|
|
192
|
+
"session_name": session_name,
|
|
193
|
+
"expires_at": expires_at,
|
|
194
|
+
"used": False,
|
|
195
|
+
}
|
|
196
|
+
if timings is not None:
|
|
197
|
+
timings["insert_ms"] = 0.0
|
|
198
|
+
timings["commit_ms"] = 0.0
|
|
199
|
+
return ticket
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def consume_ws_ticket(
|
|
203
|
+
ticket: str,
|
|
204
|
+
session_name: str,
|
|
205
|
+
*,
|
|
206
|
+
timings: dict[str, float | str] | None = None,
|
|
207
|
+
) -> str | None:
|
|
208
|
+
"""Consume WS ticket. Returns username if valid, None otherwise. Single-use."""
|
|
209
|
+
logger.info("consume_ws_ticket: ticket=%s... session=%s", ticket[:15], session_name)
|
|
210
|
+
if timings is not None:
|
|
211
|
+
timings["lookup_ms"] = 0.0
|
|
212
|
+
|
|
213
|
+
rec = _ws_tickets.get(ticket)
|
|
214
|
+
if rec is None:
|
|
215
|
+
logger.warning("consume_ws_ticket: ticket NOT FOUND")
|
|
216
|
+
if timings is not None:
|
|
217
|
+
timings["outcome"] = "not_found"
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
if rec["used"]:
|
|
221
|
+
logger.warning("consume_ws_ticket: ticket already used")
|
|
222
|
+
if timings is not None:
|
|
223
|
+
timings["outcome"] = "used"
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
if rec["session_name"] != session_name:
|
|
227
|
+
logger.warning(
|
|
228
|
+
"consume_ws_ticket: session mismatch: %s != %s",
|
|
229
|
+
rec["session_name"],
|
|
230
|
+
session_name,
|
|
231
|
+
)
|
|
232
|
+
if timings is not None:
|
|
233
|
+
timings["outcome"] = "session_mismatch"
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
expires_at = rec["expires_at"]
|
|
237
|
+
if datetime.now(timezone.utc) > expires_at:
|
|
238
|
+
logger.warning("consume_ws_ticket: ticket expired at %s", expires_at)
|
|
239
|
+
_ws_tickets.pop(ticket, None)
|
|
240
|
+
if timings is not None:
|
|
241
|
+
timings["outcome"] = "expired"
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
# Mark as used — single-use. Atomic: no `await` between the check above and
|
|
245
|
+
# this set on the single-threaded event loop.
|
|
246
|
+
rec["used"] = True
|
|
247
|
+
if timings is not None:
|
|
248
|
+
timings["update_ms"] = 0.0
|
|
249
|
+
timings["commit_ms"] = 0.0
|
|
250
|
+
timings["outcome"] = "ok"
|
|
251
|
+
return rec["user_id"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def get_current_user(
|
|
255
|
+
request: Request, db: aiosqlite.Connection = Depends(get_db)
|
|
256
|
+
) -> UserInfo:
|
|
257
|
+
"""FastAPI dependency: extract and verify user from httpOnly cookie."""
|
|
258
|
+
token = request.cookies.get("pir_session")
|
|
259
|
+
if not token:
|
|
260
|
+
raise HTTPException(
|
|
261
|
+
status_code=401,
|
|
262
|
+
detail=(
|
|
263
|
+
"Not authenticated. Reason: no 'pir_session' cookie on the request. "
|
|
264
|
+
"Fix: POST /api/v1/auth/login with {email, password} to set the httpOnly cookie, "
|
|
265
|
+
"or call this endpoint with Authorization: Bearer <token> if you are an agent "
|
|
266
|
+
"(see get_current_user_or_agent for dual-auth endpoints)."
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
payload = verify_session_jwt(token)
|
|
272
|
+
except jwt.ExpiredSignatureError:
|
|
273
|
+
raise HTTPException(
|
|
274
|
+
status_code=401,
|
|
275
|
+
detail=(
|
|
276
|
+
"Token expired. Reason: the pir_session JWT exp claim is in the past "
|
|
277
|
+
f"(configured TTL: {settings.jwt_expiry_hours}h). "
|
|
278
|
+
"Fix: POST /api/v1/auth/login to obtain a fresh session cookie."
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
except jwt.InvalidTokenError:
|
|
282
|
+
raise HTTPException(
|
|
283
|
+
status_code=401,
|
|
284
|
+
detail=(
|
|
285
|
+
"Invalid token. Reason: pir_session JWT signature verification failed "
|
|
286
|
+
"(wrong secret, tampered payload, or malformed token). "
|
|
287
|
+
"Fix: clear the cookie and POST /api/v1/auth/login to re-authenticate. "
|
|
288
|
+
"If the server was recently redeployed with a new PIR_JWT_SECRET, all old tokens are invalidated by design."
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
jti = payload.get("jti")
|
|
293
|
+
if jti and await is_token_blacklisted(jti, db):
|
|
294
|
+
raise HTTPException(
|
|
295
|
+
status_code=401,
|
|
296
|
+
detail=(
|
|
297
|
+
"Token revoked. Reason: this token's jti is in the token_blacklist table (logout or admin revoke). "
|
|
298
|
+
"Fix: POST /api/v1/auth/login to obtain a fresh session."
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
slug = payload.get("sub")
|
|
303
|
+
if not slug:
|
|
304
|
+
raise HTTPException(
|
|
305
|
+
status_code=401,
|
|
306
|
+
detail=(
|
|
307
|
+
"Invalid token payload. Reason: decoded JWT is missing the 'sub' claim (user slug). "
|
|
308
|
+
"Fix: this token was not issued by our auth flow — clear the cookie and POST /api/v1/auth/login."
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# DB lookup obbligatorio -- verifica utente esiste e non cancellato
|
|
313
|
+
async with db.execute(
|
|
314
|
+
"SELECT id, system_role, display_name, workspace_id FROM users WHERE slug = ? AND deleted_at IS NULL",
|
|
315
|
+
(slug,),
|
|
316
|
+
) as cursor:
|
|
317
|
+
row = await cursor.fetchone()
|
|
318
|
+
|
|
319
|
+
if row is None:
|
|
320
|
+
raise HTTPException(
|
|
321
|
+
status_code=401,
|
|
322
|
+
detail=(
|
|
323
|
+
f"User not found or deactivated (slug='{slug}'). "
|
|
324
|
+
"Reason: JWT 'sub' claim references a users row that was deleted (deleted_at IS NOT NULL) or never existed. "
|
|
325
|
+
"Fix: clear the cookie and POST /api/v1/auth/login with a valid account. "
|
|
326
|
+
"If this was your account, contact an admin to restore it (UPDATE users SET deleted_at = NULL)."
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# workspace_id from JWT claim (future SSO) or from users table, fallback ws_default
|
|
331
|
+
ws_id = (
|
|
332
|
+
payload.get("workspace_id")
|
|
333
|
+
or (row["workspace_id"] if "workspace_id" in row.keys() else None)
|
|
334
|
+
or "ws_default"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return UserInfo(
|
|
338
|
+
username=slug,
|
|
339
|
+
user_id=row["id"],
|
|
340
|
+
system_role=row["system_role"],
|
|
341
|
+
display_name=row["display_name"],
|
|
342
|
+
workspace_id=ws_id,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _hash_token(token: str) -> str:
|
|
347
|
+
"""Return SHA-256 hex digest of a raw bearer token."""
|
|
348
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def _lookup_agent_token(
|
|
352
|
+
bearer_token: str, db: aiosqlite.Connection
|
|
353
|
+
) -> tuple[str, list[str], str] | None:
|
|
354
|
+
"""Check agent_tokens table for a matching active token.
|
|
355
|
+
|
|
356
|
+
Returns (agent_name, scopes, workspace_id) if found and active, else None.
|
|
357
|
+
Uses constant-time comparison via secrets.compare_digest on the hash.
|
|
358
|
+
"""
|
|
359
|
+
token_hash = _hash_token(bearer_token)
|
|
360
|
+
try:
|
|
361
|
+
async with db.execute(
|
|
362
|
+
"SELECT id, agent_name, scopes, workspace_id FROM agent_tokens WHERE token_hash = ? AND is_active = 1",
|
|
363
|
+
(token_hash,),
|
|
364
|
+
) as cursor:
|
|
365
|
+
row = await cursor.fetchone()
|
|
366
|
+
except Exception:
|
|
367
|
+
# agent_tokens table not yet migrated — fall through to legacy token fallback
|
|
368
|
+
return None
|
|
369
|
+
if row is None:
|
|
370
|
+
return None
|
|
371
|
+
# Auth read paths use a read-only DB pool under single-writer mode, so
|
|
372
|
+
# best-effort telemetry updates must not happen inline here.
|
|
373
|
+
scopes_raw = row["scopes"] or "[]"
|
|
374
|
+
try:
|
|
375
|
+
scopes = json.loads(scopes_raw)
|
|
376
|
+
except (json.JSONDecodeError, TypeError):
|
|
377
|
+
scopes = []
|
|
378
|
+
ws_id = row["workspace_id"] or "ws_default"
|
|
379
|
+
return row["agent_name"], scopes, ws_id
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def _resolve_agent_userinfo(
|
|
383
|
+
agent_name: str,
|
|
384
|
+
db: aiosqlite.Connection,
|
|
385
|
+
scopes: list[str] | None = None,
|
|
386
|
+
workspace_id: str = "ws_default",
|
|
387
|
+
) -> UserInfo:
|
|
388
|
+
"""Resolve agent_name to UserInfo via DB lookup (users table).
|
|
389
|
+
|
|
390
|
+
Falls back to viewer role if agent not found.
|
|
391
|
+
workspace_id comes from the token lookup (authoritative), not from users table.
|
|
392
|
+
scopes propagated for downstream scope enforcement via require_scope().
|
|
393
|
+
"""
|
|
394
|
+
valid_names = await get_valid_agent_names(db)
|
|
395
|
+
if agent_name not in valid_names and agent_name not in _valid_static_agent_names():
|
|
396
|
+
agent_name = "agent"
|
|
397
|
+
|
|
398
|
+
async with db.execute(
|
|
399
|
+
"SELECT id, system_role, display_name, type FROM users WHERE slug = ? AND deleted_at IS NULL",
|
|
400
|
+
(agent_name,),
|
|
401
|
+
) as cursor:
|
|
402
|
+
row = await cursor.fetchone()
|
|
403
|
+
|
|
404
|
+
if row is None:
|
|
405
|
+
return UserInfo(
|
|
406
|
+
username=f"agent:{agent_name}",
|
|
407
|
+
user_id="",
|
|
408
|
+
system_role="viewer",
|
|
409
|
+
user_type="agent",
|
|
410
|
+
display_name=agent_name,
|
|
411
|
+
scopes=scopes or [],
|
|
412
|
+
workspace_id=workspace_id,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
return UserInfo(
|
|
416
|
+
username=agent_name,
|
|
417
|
+
user_id=row["id"],
|
|
418
|
+
system_role=row["system_role"],
|
|
419
|
+
user_type=row["type"] or "agent",
|
|
420
|
+
display_name=row["display_name"],
|
|
421
|
+
scopes=scopes or [],
|
|
422
|
+
workspace_id=workspace_id,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def get_current_user_or_agent(
|
|
427
|
+
request: Request, db: aiosqlite.Connection = Depends(get_db)
|
|
428
|
+
) -> UserInfo:
|
|
429
|
+
"""Dual auth: cookie (Console PiR web) OR Bearer token (agent).
|
|
430
|
+
|
|
431
|
+
Used for read-only endpoints accessible by both humans and agents.
|
|
432
|
+
Bearer token resolution order:
|
|
433
|
+
1. Per-agent token: check agent_tokens table (SHA-256 hash lookup)
|
|
434
|
+
2. Legacy single token: compare against settings.tasks_api_token
|
|
435
|
+
3. Fall back to pir_session cookie (existing Console PiR auth)
|
|
436
|
+
|
|
437
|
+
X-Agent-Name is attribution-only here — not verified against token owner.
|
|
438
|
+
Strict X-Agent-Name enforcement is reserved for get_agent_user (operator endpoints).
|
|
439
|
+
"""
|
|
440
|
+
# Path 1 & 2: Bearer token for agents
|
|
441
|
+
auth_header = request.headers.get("authorization", "")
|
|
442
|
+
if auth_header.startswith("Bearer "):
|
|
443
|
+
bearer_token = auth_header[7:]
|
|
444
|
+
|
|
445
|
+
# 1. Per-agent token lookup (DB)
|
|
446
|
+
result = await _lookup_agent_token(bearer_token, db)
|
|
447
|
+
if result is not None:
|
|
448
|
+
agent_name, scopes, ws_id = result
|
|
449
|
+
# X-Agent-Name is attribution-only for read endpoints — no mismatch check.
|
|
450
|
+
# Strict verification is in get_agent_user (operator-level write endpoints).
|
|
451
|
+
return await _resolve_agent_userinfo(agent_name, db, scopes, ws_id)
|
|
452
|
+
|
|
453
|
+
# 2. Legacy single shared token fallback — bound to ws_default (P1-3 review fix)
|
|
454
|
+
if settings.tasks_api_token and secrets.compare_digest(
|
|
455
|
+
bearer_token, settings.tasks_api_token
|
|
456
|
+
):
|
|
457
|
+
agent_name = request.headers.get("x-agent-name", "agent")
|
|
458
|
+
return await _resolve_agent_userinfo(
|
|
459
|
+
agent_name, db, workspace_id="ws_default"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
raise HTTPException(
|
|
463
|
+
status_code=401,
|
|
464
|
+
detail=(
|
|
465
|
+
"Invalid API token. "
|
|
466
|
+
"Reason: Bearer token in Authorization header not found in agent_tokens DB "
|
|
467
|
+
"and does not match legacy TASKS_API_TOKEN. "
|
|
468
|
+
"Fix: (1) verify the token in Authorization: Bearer <token> is correct; "
|
|
469
|
+
"(2) check TASKS_API_TOKEN env is set and matches in /data/pir/.env for the legacy fallback; "
|
|
470
|
+
"(3) if using a per-agent token, rotate via POST /api/v1/agent-tokens (must include agent_name + scopes). "
|
|
471
|
+
"Docs: api/security.py::get_current_user_or_agent for resolution order."
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Path 3: Cookie for Console PiR web
|
|
476
|
+
return await get_current_user(request, db)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
async def get_agent_user(
|
|
480
|
+
request: Request, db: aiosqlite.Connection = Depends(get_db)
|
|
481
|
+
) -> UserInfo:
|
|
482
|
+
"""Bearer-only auth per router /agent. Nessun fallback cookie.
|
|
483
|
+
|
|
484
|
+
Policy esplicita: solo agenti, no console web. Se il token manca o e errato → 401
|
|
485
|
+
senza tentare il cookie. Questo rende la policy visibile nel codice.
|
|
486
|
+
|
|
487
|
+
Resolution order:
|
|
488
|
+
1. Per-agent token (agent_tokens table)
|
|
489
|
+
2. Legacy single shared token (settings.tasks_api_token)
|
|
490
|
+
|
|
491
|
+
For per-agent tokens (path 1): if X-Agent-Name header is present,
|
|
492
|
+
it MUST match the agent_name bound to the token in DB. Returns 403 on mismatch.
|
|
493
|
+
Legacy token (path 2): X-Agent-Name is attribution-only, not verified.
|
|
494
|
+
"""
|
|
495
|
+
auth_header = request.headers.get("authorization", "")
|
|
496
|
+
if not auth_header.startswith("Bearer "):
|
|
497
|
+
raise HTTPException(
|
|
498
|
+
status_code=401,
|
|
499
|
+
detail=(
|
|
500
|
+
"Bearer token required. Reason: this endpoint is agent-only (no cookie fallback); "
|
|
501
|
+
"the Authorization header must be 'Bearer <token>'. "
|
|
502
|
+
"Fix: add `Authorization: Bearer $TASKS_API_TOKEN` (legacy) or a per-agent token. "
|
|
503
|
+
"For MCP clients, set X-Agent-Name header too so actions are correctly attributed."
|
|
504
|
+
),
|
|
505
|
+
)
|
|
506
|
+
bearer_token = auth_header[7:]
|
|
507
|
+
|
|
508
|
+
# 1. Per-agent token lookup
|
|
509
|
+
result = await _lookup_agent_token(bearer_token, db)
|
|
510
|
+
if result is not None:
|
|
511
|
+
agent_name, scopes, ws_id = result
|
|
512
|
+
# Verify X-Agent-Name header matches the token owner when present
|
|
513
|
+
claimed_name = request.headers.get("x-agent-name", "").strip()
|
|
514
|
+
if claimed_name and claimed_name != agent_name:
|
|
515
|
+
raise HTTPException(
|
|
516
|
+
status_code=403,
|
|
517
|
+
detail=(
|
|
518
|
+
f"X-Agent-Name '{claimed_name}' does not match token owner '{agent_name}'. "
|
|
519
|
+
"Reason: per-agent tokens are bound to a specific agent_name in agent_tokens; "
|
|
520
|
+
"spoofing another agent's name is rejected (strict check on operator endpoints). "
|
|
521
|
+
f"Fix: either remove the X-Agent-Name header (defaults to token owner '{agent_name}'), "
|
|
522
|
+
f"or set it exactly to '{agent_name}', or use a token issued for '{claimed_name}'."
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
return await _resolve_agent_userinfo(agent_name, db, scopes, ws_id)
|
|
526
|
+
|
|
527
|
+
# 2. Legacy single shared token — bound to ws_default (P1-3 review fix)
|
|
528
|
+
if settings.tasks_api_token and secrets.compare_digest(
|
|
529
|
+
bearer_token, settings.tasks_api_token
|
|
530
|
+
):
|
|
531
|
+
agent_name = request.headers.get("x-agent-name", "agent")
|
|
532
|
+
if (
|
|
533
|
+
agent_name not in _valid_static_agent_names()
|
|
534
|
+
and agent_name not in _SYSTEM_IDENTITIES
|
|
535
|
+
):
|
|
536
|
+
agent_name = "agent"
|
|
537
|
+
return UserInfo(username=f"agent:{agent_name}", workspace_id="ws_default")
|
|
538
|
+
|
|
539
|
+
raise HTTPException(
|
|
540
|
+
status_code=401,
|
|
541
|
+
detail=(
|
|
542
|
+
"Invalid API token. "
|
|
543
|
+
"Reason: Bearer token in Authorization header not found in agent_tokens DB "
|
|
544
|
+
"and does not match legacy TASKS_API_TOKEN. This endpoint is agent-only (no cookie fallback). "
|
|
545
|
+
"Fix: (1) verify the token is correct; (2) check TASKS_API_TOKEN env in /data/pir/.env matches; "
|
|
546
|
+
"(3) rotate a per-agent token via POST /api/v1/agent-tokens (admin). "
|
|
547
|
+
"Docs: api/security.py::get_agent_user for resolution order."
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def require_agent_token_scope(*required_scopes: str):
|
|
553
|
+
"""Require a per-agent token with explicit scopes.
|
|
554
|
+
|
|
555
|
+
Unlike get_agent_user(), this rejects the legacy shared token and enforces that
|
|
556
|
+
every requested scope is present on the matched agent_tokens row.
|
|
557
|
+
"""
|
|
558
|
+
if not required_scopes:
|
|
559
|
+
raise ValueError("require_agent_token_scope() requires at least one scope")
|
|
560
|
+
|
|
561
|
+
async def check(
|
|
562
|
+
request: Request,
|
|
563
|
+
db: aiosqlite.Connection = Depends(get_db),
|
|
564
|
+
) -> UserInfo:
|
|
565
|
+
auth_header = request.headers.get("authorization", "")
|
|
566
|
+
if not auth_header.startswith("Bearer "):
|
|
567
|
+
raise HTTPException(
|
|
568
|
+
status_code=401,
|
|
569
|
+
detail=(
|
|
570
|
+
"Bearer token required. Reason: this endpoint requires a scoped per-agent token "
|
|
571
|
+
f"(needs scopes: {list(required_scopes)}). "
|
|
572
|
+
"Fix: add `Authorization: Bearer <per-agent-token>` header — the legacy shared token "
|
|
573
|
+
"is NOT accepted here, only tokens issued via POST /api/v1/agent-tokens with the right scopes."
|
|
574
|
+
),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
bearer_token = auth_header[7:]
|
|
578
|
+
result = await _lookup_agent_token(bearer_token, db)
|
|
579
|
+
if result is None:
|
|
580
|
+
raise HTTPException(
|
|
581
|
+
status_code=401,
|
|
582
|
+
detail=(
|
|
583
|
+
"Per-agent token required. Reason: the Bearer token is not present in agent_tokens "
|
|
584
|
+
f"(this endpoint needs scopes {list(required_scopes)} and the legacy shared token is not accepted). "
|
|
585
|
+
"Fix: mint a per-agent token via POST /api/v1/agent-tokens (admin) with the required scopes, "
|
|
586
|
+
"then retry."
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
agent_name, scopes, ws_id = result
|
|
591
|
+
claimed_name = request.headers.get("x-agent-name", "").strip()
|
|
592
|
+
if claimed_name and claimed_name != agent_name:
|
|
593
|
+
raise HTTPException(
|
|
594
|
+
status_code=403,
|
|
595
|
+
detail=(
|
|
596
|
+
f"X-Agent-Name '{claimed_name}' does not match token owner '{agent_name}'. "
|
|
597
|
+
"Reason: per-agent tokens are bound to a specific agent_name; spoofing is rejected. "
|
|
598
|
+
f"Fix: remove X-Agent-Name header, or set it to '{agent_name}', or use a token issued for '{claimed_name}'."
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
missing = [scope for scope in required_scopes if scope not in scopes]
|
|
603
|
+
if missing:
|
|
604
|
+
raise HTTPException(
|
|
605
|
+
status_code=403,
|
|
606
|
+
detail=(
|
|
607
|
+
f"Token missing required scopes: {missing}. "
|
|
608
|
+
f"Reason: this endpoint needs {list(required_scopes)} but token '{agent_name}' only has {scopes}. "
|
|
609
|
+
f"Fix: mint a new token for '{agent_name}' that includes {missing} "
|
|
610
|
+
"(admin: POST /api/v1/agent-tokens with scopes=<full list>), or have an admin add the scopes."
|
|
611
|
+
),
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
return await _resolve_agent_userinfo(agent_name, db, scopes, ws_id)
|
|
615
|
+
|
|
616
|
+
return check
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
async def resolve_session_owner(
|
|
620
|
+
request: Request, db: aiosqlite.Connection
|
|
621
|
+
) -> str | None:
|
|
622
|
+
"""Resolve owner slug from X-Session-Name header -> sessions_meta.owner_id -> users.slug.
|
|
623
|
+
|
|
624
|
+
Used by task creation/update endpoints to attribute actions to the session's
|
|
625
|
+
owner (human user) instead of the agent identity.
|
|
626
|
+
|
|
627
|
+
Returns owner slug or None if not resolvable (no header, no owner_id set,
|
|
628
|
+
or pre-migration — safe to call before migration 033 is applied).
|
|
629
|
+
"""
|
|
630
|
+
session_name = request.headers.get("x-session-name", "").strip()
|
|
631
|
+
if not session_name:
|
|
632
|
+
return None
|
|
633
|
+
try:
|
|
634
|
+
cursor = await db.execute(
|
|
635
|
+
"SELECT sm.owner_id, u.slug FROM sessions_meta sm "
|
|
636
|
+
"LEFT JOIN users u ON sm.owner_id = u.id "
|
|
637
|
+
"WHERE sm.name = ? AND sm.owner_id IS NOT NULL",
|
|
638
|
+
(session_name,),
|
|
639
|
+
)
|
|
640
|
+
row = await cursor.fetchone()
|
|
641
|
+
if row and row["slug"]:
|
|
642
|
+
return row["slug"]
|
|
643
|
+
except Exception:
|
|
644
|
+
pass # pre-migration safety: owner_id column may not exist yet
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# Aliases for declarative use in routers — semantic clarity over raw function names.
|
|
649
|
+
# require_cookie_auth: human console sessions only (no bearer token fallback)
|
|
650
|
+
# require_any_auth: cookie OR bearer token (agents + humans)
|
|
651
|
+
require_cookie_auth = get_current_user
|
|
652
|
+
require_any_auth = get_current_user_or_agent
|
|
File without changes
|