From 27b66d6753571fc63b1ef2cc448c66921602a649 Mon Sep 17 00:00:00 2001 From: greatmengqi Date: Wed, 8 Apr 2026 00:31:43 +0800 Subject: [PATCH] feat(auth): authentication module with multi-tenant isolation (RFC-001) Introduce an always-on auth layer with auto-created admin on first boot, multi-tenant isolation for threads/stores, and a full setup/login flow. Backend - JWT access tokens with `ver` field for stale-token rejection; bump on password/email change - Password hashing, HttpOnly+Secure cookies (Secure derived from request scheme at runtime) - CSRF middleware covering both REST and LangGraph routes - IP-based login rate limiting (5 attempts / 5-min lockout) with bounded dict growth and X-Forwarded-For bypass fix - Multi-worker-safe admin auto-creation (single DB write, WAL once) - needs_setup + token_version on User model; SQLite schema migration - Thread/store isolation by owner; orphan thread migration on first admin registration - thread_id validated as UUID to prevent log injection - CLI tool to reset admin password - Decorator-based authz module extracted from auth core Frontend - Login and setup pages with SSR guard for needs_setup flow - Account settings page (change password / email) - AuthProvider + route guards; skips redirect when no users registered - i18n (en-US / zh-CN) for auth surfaces - Typed auth API client; parseAuthError unwraps FastAPI detail envelope Infra & tooling - Unified `serve.sh` with gateway mode + auto dep install - Public PyPI uv.toml pin for CI compatibility - Regenerated uv.lock with public index Tests - HTTP vs HTTPS cookie security tests - Auth middleware, rate limiter, CSRF, setup flow coverage --- .env.example | 7 + .gitignore | 1 + CONTRIBUTING.md | 2 +- Makefile | 82 +- README.md | 77 + README_zh.md | 18 + backend/CLAUDE.md | 25 +- backend/Dockerfile | 78 +- backend/Makefile | 2 +- backend/app/channels/feishu.py | 4 +- backend/app/channels/manager.py | 148 +- backend/app/channels/service.py | 1 + backend/app/channels/slack.py | 6 +- backend/app/channels/telegram.py | 4 +- backend/app/channels/wecom.py | 394 ++++ backend/app/gateway/app.py | 120 +- backend/app/gateway/auth/__init__.py | 42 + backend/app/gateway/auth/config.py | 55 + backend/app/gateway/auth/errors.py | 44 + backend/app/gateway/auth/jwt.py | 55 + backend/app/gateway/auth/local_provider.py | 87 + backend/app/gateway/auth/models.py | 41 + backend/app/gateway/auth/password.py | 33 + backend/app/gateway/auth/providers.py | 24 + .../app/gateway/auth/repositories/__init__.py | 0 backend/app/gateway/auth/repositories/base.py | 82 + .../app/gateway/auth/repositories/sqlite.py | 196 ++ backend/app/gateway/auth/reset_admin.py | 66 + backend/app/gateway/auth_middleware.py | 71 + backend/app/gateway/authz.py | 261 +++ backend/app/gateway/csrf_middleware.py | 112 ++ backend/app/gateway/deps.py | 125 +- backend/app/gateway/langgraph_auth.py | 106 + backend/app/gateway/routers/__init__.py | 4 +- backend/app/gateway/routers/agents.py | 8 +- backend/app/gateway/routers/auth.py | 303 +++ backend/app/gateway/routers/suggestions.py | 12 +- backend/app/gateway/routers/thread_runs.py | 44 +- backend/app/gateway/routers/threads.py | 116 +- backend/app/gateway/services.py | 21 +- backend/docs/AUTH_TEST_PLAN.md | 1786 +++++++++++++++++ backend/docs/AUTH_UPGRADE.md | 129 ++ backend/docs/AUTO_TITLE_GENERATION.md | 2 +- .../docs/TITLE_GENERATION_IMPLEMENTATION.md | 4 +- backend/docs/rfc-grep-glob-tools.md | 446 ++++ backend/langgraph.json | 3 + .../deerflow/agents/lead_agent/prompt.py | 12 +- .../harness/deerflow/agents/memory/prompt.py | 4 + .../harness/deerflow/agents/memory/queue.py | 6 + .../harness/deerflow/agents/memory/updater.py | 16 +- .../middlewares/loop_detection_middleware.py | 19 +- .../agents/middlewares/memory_middleware.py | 41 + .../agents/middlewares/title_middleware.py | 37 +- .../tool_error_handling_middleware.py | 2 +- .../agents/middlewares/uploads_middleware.py | 107 +- backend/packages/harness/deerflow/client.py | 6 + .../community/aio_sandbox/aio_sandbox.py | 81 + .../harness/deerflow/config/app_config.py | 66 +- .../deerflow/config/extensions_config.py | 34 +- .../packages/harness/deerflow/config/paths.py | 15 +- .../harness/deerflow/config/skills_config.py | 9 +- .../deerflow/config/subagents_config.py | 43 +- .../deerflow/runtime/stream_bridge/memory.py | 50 +- .../deerflow/sandbox/local/list_dir.py | 70 +- .../deerflow/sandbox/local/local_sandbox.py | 112 +- .../sandbox/local/local_sandbox_provider.py | 69 +- .../harness/deerflow/sandbox/sandbox.py | 21 + .../harness/deerflow/sandbox/search.py | 210 ++ .../harness/deerflow/sandbox/tools.py | 302 ++- .../deerflow/subagents/builtins/bash_agent.py | 2 +- .../subagents/builtins/general_purpose.py | 2 +- .../harness/deerflow/subagents/registry.py | 22 +- .../tools/builtins/invoke_acp_agent_tool.py | 46 +- .../harness/deerflow/utils/file_conversion.py | 282 ++- backend/packages/harness/pyproject.toml | 8 +- backend/pyproject.toml | 4 + backend/tests/test_auth.py | 506 +++++ backend/tests/test_auth_config.py | 54 + backend/tests/test_auth_errors.py | 75 + backend/tests/test_auth_middleware.py | 216 ++ backend/tests/test_auth_type_system.py | 675 +++++++ backend/tests/test_channels.py | 242 ++- backend/tests/test_client.py | 13 +- backend/tests/test_custom_agent.py | 9 + backend/tests/test_ensure_admin.py | 214 ++ backend/tests/test_file_conversion.py | 459 +++++ backend/tests/test_invoke_acp_agent_tool.py | 138 +- backend/tests/test_langgraph_auth.py | 312 +++ .../test_local_sandbox_provider_mounts.py | 388 ++++ .../tests/test_loop_detection_middleware.py | 121 +- backend/tests/test_memory_prompt_injection.py | 19 + backend/tests/test_memory_queue.py | 41 + backend/tests/test_memory_updater.py | 153 ++ backend/tests/test_memory_upload_filtering.py | 72 +- backend/tests/test_sandbox_search_tools.py | 393 ++++ backend/tests/test_sandbox_tools_security.py | 235 +++ backend/tests/test_stream_bridge.py | 187 ++ backend/tests/test_subagent_timeout_config.py | 128 +- backend/tests/test_suggestions_router.py | 10 +- backend/tests/test_thread_runs_router.py | 111 + backend/tests/test_threads_router.py | 40 +- .../tests/test_title_middleware_core_logic.py | 69 +- .../test_uploads_middleware_core_logic.py | 129 ++ backend/uv.lock | 204 +- backend/uv.toml | 1 + config.example.yaml | 36 +- docker/docker-compose-dev.yaml | 22 +- docker/docker-compose.yaml | 22 +- docker/nginx/nginx.conf | 59 +- docker/nginx/nginx.local.conf | 18 +- docker/provisioner/Dockerfile | 2 + .../plans/2026-04-04-auth-permission-init.md | 1201 +++++++++++ .../specs/2026-04-04-auth-module-design.md | 228 +++ frontend/next.config.js | 58 +- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 1372 ++++++++++++- frontend/src/app/(auth)/layout.tsx | 45 + frontend/src/app/(auth)/login/page.tsx | 183 ++ frontend/src/app/(auth)/setup/page.tsx | 115 ++ .../app/[lang]/docs/[[...mdxPath]]/page.tsx | 29 + frontend/src/app/[lang]/docs/layout.tsx | 51 + frontend/src/app/api/auth/[...all]/route.ts | 5 - .../[agent_name]/chats/[thread_id]/page.tsx | 16 +- .../src/app/workspace/agents/new/page.tsx | 197 +- .../app/workspace/chats/[thread_id]/page.tsx | 80 +- frontend/src/app/workspace/layout.tsx | 91 +- .../src/app/workspace/workspace-content.tsx | 50 + .../components/ai-elements/prompt-input.tsx | 83 +- frontend/src/components/landing/header.tsx | 45 +- frontend/src/components/ui/button.tsx | 4 +- .../components/workspace/command-palette.tsx | 8 +- .../src/components/workspace/input-box.tsx | 113 +- .../workspace/messages/message-list.tsx | 5 +- .../settings/account-settings-page.tsx | 132 ++ .../workspace/settings/settings-dialog.tsx | 10 + .../workspace/workspace-nav-menu.tsx | 52 +- frontend/src/content/en/_meta.ts | 27 + frontend/src/content/en/application/_meta.ts | 27 + .../en/application/agents-and-threads.mdx | 3 + .../content/en/application/configuration.mdx | 3 + .../en/application/deployment-guide.mdx | 3 + frontend/src/content/en/application/index.mdx | 3 + .../operations-and-troubleshooting.mdx | 3 + .../content/en/application/quick-start.mdx | 3 + .../en/application/workspace-usage.mdx | 3 + frontend/src/content/en/harness/_meta.ts | 36 + .../src/content/en/harness/configuration.mdx | 3 + .../src/content/en/harness/customization.mdx | 3 + .../content/en/harness/design-principles.mdx | 3 + frontend/src/content/en/harness/index.mdx | 50 + .../content/en/harness/integration-guide.mdx | 3 + frontend/src/content/en/harness/memory.mdx | 3 + .../src/content/en/harness/quick-start.mdx | 3 + frontend/src/content/en/harness/sandbox.mdx | 3 + frontend/src/content/en/harness/skills.mdx | 3 + frontend/src/content/en/harness/tools.mdx | 3 + frontend/src/content/en/index.mdx | 95 + frontend/src/content/en/introduction/_meta.ts | 15 + .../content/en/introduction/core-concepts.mdx | 113 ++ .../en/introduction/harness-vs-app.mdx | 103 + .../content/en/introduction/why-deerflow.mdx | 73 + frontend/src/content/en/reference/_meta.ts | 21 + .../en/reference/api-gateway-reference.mdx | 3 + .../en/reference/concepts-glossary.mdx | 3 + .../en/reference/configuration-reference.mdx | 3 + .../en/reference/runtime-flags-and-modes.mdx | 3 + .../src/content/en/reference/source-map.mdx | 3 + frontend/src/content/en/tutorials/_meta.ts | 21 + .../tutorials/create-your-first-harness.mdx | 3 + .../en/tutorials/deploy-your-own-deerflow.mdx | 3 + .../en/tutorials/first-conversation.mdx | 3 + .../en/tutorials/use-tools-and-skills.mdx | 3 + .../content/en/tutorials/work-with-memory.mdx | 3 + frontend/src/content/zh/_meta.ts | 12 + frontend/src/content/zh/index.mdx | 6 + frontend/src/core/agents/api.ts | 9 +- frontend/src/core/api/api-client.ts | 6 + frontend/src/core/api/fetcher.ts | 39 + frontend/src/core/auth/AuthProvider.tsx | 165 ++ frontend/src/core/auth/gateway-config.ts | 34 + frontend/src/core/auth/proxy-policy.ts | 55 + frontend/src/core/auth/server.ts | 57 + frontend/src/core/auth/types.ts | 72 + frontend/src/core/i18n/hooks.ts | 9 +- frontend/src/core/i18n/locale.ts | 10 + frontend/src/core/i18n/locales/en-US.ts | 19 + frontend/src/core/i18n/locales/types.ts | 14 + frontend/src/core/i18n/locales/zh-CN.ts | 19 + frontend/src/core/i18n/server.ts | 26 +- frontend/src/core/i18n/translations.ts | 7 + frontend/src/core/mcp/api.ts | 3 + frontend/src/core/memory/api.ts | 11 + frontend/src/core/messages/utils.ts | 8 + frontend/src/core/rehype/index.ts | 7 + frontend/src/core/skills/api.ts | 5 + frontend/src/core/threads/hooks.ts | 68 +- frontend/src/core/uploads/api.ts | 5 + .../src/core/uploads/file-validation.test.mjs | 55 + frontend/src/core/uploads/file-validation.ts | 34 + frontend/src/core/uploads/index.ts | 2 + .../core/uploads/prompt-input-files.test.mjs | 150 ++ .../src/core/uploads/prompt-input-files.ts | 52 + frontend/src/mdx-components.ts | 11 + frontend/src/server/better-auth/client.ts | 5 - frontend/src/server/better-auth/config.ts | 9 - frontend/src/server/better-auth/index.ts | 1 - frontend/src/server/better-auth/server.ts | 8 - scripts/deploy.sh | 137 +- scripts/docker.sh | 45 +- scripts/serve.sh | 382 ++-- scripts/start-daemon.sh | 139 +- skills/public/academic-paper-review/SKILL.md | 289 +++ skills/public/code-documentation/SKILL.md | 415 ++++ skills/public/newsletter-generation/SKILL.md | 343 ++++ 214 files changed, 18830 insertions(+), 1065 deletions(-) create mode 100644 backend/app/channels/wecom.py create mode 100644 backend/app/gateway/auth/__init__.py create mode 100644 backend/app/gateway/auth/config.py create mode 100644 backend/app/gateway/auth/errors.py create mode 100644 backend/app/gateway/auth/jwt.py create mode 100644 backend/app/gateway/auth/local_provider.py create mode 100644 backend/app/gateway/auth/models.py create mode 100644 backend/app/gateway/auth/password.py create mode 100644 backend/app/gateway/auth/providers.py create mode 100644 backend/app/gateway/auth/repositories/__init__.py create mode 100644 backend/app/gateway/auth/repositories/base.py create mode 100644 backend/app/gateway/auth/repositories/sqlite.py create mode 100644 backend/app/gateway/auth/reset_admin.py create mode 100644 backend/app/gateway/auth_middleware.py create mode 100644 backend/app/gateway/authz.py create mode 100644 backend/app/gateway/csrf_middleware.py create mode 100644 backend/app/gateway/langgraph_auth.py create mode 100644 backend/app/gateway/routers/auth.py create mode 100644 backend/docs/AUTH_TEST_PLAN.md create mode 100644 backend/docs/AUTH_UPGRADE.md create mode 100644 backend/docs/rfc-grep-glob-tools.md create mode 100644 backend/packages/harness/deerflow/sandbox/search.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_auth_config.py create mode 100644 backend/tests/test_auth_errors.py create mode 100644 backend/tests/test_auth_middleware.py create mode 100644 backend/tests/test_auth_type_system.py create mode 100644 backend/tests/test_ensure_admin.py create mode 100644 backend/tests/test_file_conversion.py create mode 100644 backend/tests/test_langgraph_auth.py create mode 100644 backend/tests/test_local_sandbox_provider_mounts.py create mode 100644 backend/tests/test_sandbox_search_tools.py create mode 100644 backend/tests/test_thread_runs_router.py create mode 100644 backend/uv.toml create mode 100644 docs/superpowers/plans/2026-04-04-auth-permission-init.md create mode 100644 docs/superpowers/specs/2026-04-04-auth-module-design.md create mode 100644 frontend/src/app/(auth)/layout.tsx create mode 100644 frontend/src/app/(auth)/login/page.tsx create mode 100644 frontend/src/app/(auth)/setup/page.tsx create mode 100644 frontend/src/app/[lang]/docs/[[...mdxPath]]/page.tsx create mode 100644 frontend/src/app/[lang]/docs/layout.tsx delete mode 100644 frontend/src/app/api/auth/[...all]/route.ts create mode 100644 frontend/src/app/workspace/workspace-content.tsx create mode 100644 frontend/src/components/workspace/settings/account-settings-page.tsx create mode 100644 frontend/src/content/en/_meta.ts create mode 100644 frontend/src/content/en/application/_meta.ts create mode 100644 frontend/src/content/en/application/agents-and-threads.mdx create mode 100644 frontend/src/content/en/application/configuration.mdx create mode 100644 frontend/src/content/en/application/deployment-guide.mdx create mode 100644 frontend/src/content/en/application/index.mdx create mode 100644 frontend/src/content/en/application/operations-and-troubleshooting.mdx create mode 100644 frontend/src/content/en/application/quick-start.mdx create mode 100644 frontend/src/content/en/application/workspace-usage.mdx create mode 100644 frontend/src/content/en/harness/_meta.ts create mode 100644 frontend/src/content/en/harness/configuration.mdx create mode 100644 frontend/src/content/en/harness/customization.mdx create mode 100644 frontend/src/content/en/harness/design-principles.mdx create mode 100644 frontend/src/content/en/harness/index.mdx create mode 100644 frontend/src/content/en/harness/integration-guide.mdx create mode 100644 frontend/src/content/en/harness/memory.mdx create mode 100644 frontend/src/content/en/harness/quick-start.mdx create mode 100644 frontend/src/content/en/harness/sandbox.mdx create mode 100644 frontend/src/content/en/harness/skills.mdx create mode 100644 frontend/src/content/en/harness/tools.mdx create mode 100644 frontend/src/content/en/index.mdx create mode 100644 frontend/src/content/en/introduction/_meta.ts create mode 100644 frontend/src/content/en/introduction/core-concepts.mdx create mode 100644 frontend/src/content/en/introduction/harness-vs-app.mdx create mode 100644 frontend/src/content/en/introduction/why-deerflow.mdx create mode 100644 frontend/src/content/en/reference/_meta.ts create mode 100644 frontend/src/content/en/reference/api-gateway-reference.mdx create mode 100644 frontend/src/content/en/reference/concepts-glossary.mdx create mode 100644 frontend/src/content/en/reference/configuration-reference.mdx create mode 100644 frontend/src/content/en/reference/runtime-flags-and-modes.mdx create mode 100644 frontend/src/content/en/reference/source-map.mdx create mode 100644 frontend/src/content/en/tutorials/_meta.ts create mode 100644 frontend/src/content/en/tutorials/create-your-first-harness.mdx create mode 100644 frontend/src/content/en/tutorials/deploy-your-own-deerflow.mdx create mode 100644 frontend/src/content/en/tutorials/first-conversation.mdx create mode 100644 frontend/src/content/en/tutorials/use-tools-and-skills.mdx create mode 100644 frontend/src/content/en/tutorials/work-with-memory.mdx create mode 100644 frontend/src/content/zh/_meta.ts create mode 100644 frontend/src/content/zh/index.mdx create mode 100644 frontend/src/core/api/fetcher.ts create mode 100644 frontend/src/core/auth/AuthProvider.tsx create mode 100644 frontend/src/core/auth/gateway-config.ts create mode 100644 frontend/src/core/auth/proxy-policy.ts create mode 100644 frontend/src/core/auth/server.ts create mode 100644 frontend/src/core/auth/types.ts create mode 100644 frontend/src/core/i18n/translations.ts create mode 100644 frontend/src/core/uploads/file-validation.test.mjs create mode 100644 frontend/src/core/uploads/file-validation.ts create mode 100644 frontend/src/core/uploads/prompt-input-files.test.mjs create mode 100644 frontend/src/core/uploads/prompt-input-files.ts create mode 100644 frontend/src/mdx-components.ts delete mode 100644 frontend/src/server/better-auth/client.ts delete mode 100644 frontend/src/server/better-auth/config.ts delete mode 100644 frontend/src/server/better-auth/index.ts delete mode 100644 frontend/src/server/better-auth/server.ts create mode 100644 skills/public/academic-paper-review/SKILL.md create mode 100644 skills/public/code-documentation/SKILL.md create mode 100644 skills/public/newsletter-generation/SKILL.md diff --git a/.env.example b/.env.example index 59b93dd1b..f255d9da9 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ JINA_API_KEY=your-jina-api-key # InfoQuest API Key INFOQUEST_API_KEY=your-infoquest-api-key +# Authentication โ€” JWT secret for session signing +# If not set, an ephemeral secret is auto-generated (sessions lost on restart) +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))" +# AUTH_JWT_SECRET=your-secure-jwt-secret-here + # CORS Origins (comma-separated) - e.g., http://localhost:3000,http://localhost:3001 # CORS_ORIGINS=http://localhost:3000 @@ -32,3 +37,5 @@ INFOQUEST_API_KEY=your-infoquest-api-key # GitHub API Token # GITHUB_TOKEN=your-github-token +# WECOM_BOT_ID=your-wecom-bot-id +# WECOM_BOT_SECRET=your-wecom-bot-secret diff --git a/.gitignore b/.gitignore index 014f56327..c094c3e91 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ web/ # Deployment artifacts backend/Dockerfile.langgraph config.yaml.bak +.gstack/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03486cd39..c64a60d2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -310,7 +310,7 @@ Every pull request runs the backend regression workflow at [.github/workflows/ba - [Configuration Guide](backend/docs/CONFIGURATION.md) - Setup and configuration - [Architecture Overview](backend/CLAUDE.md) - Technical architecture -- [MCP Setup Guide](MCP_SETUP.md) - Model Context Protocol configuration +- [MCP Setup Guide](backend/docs/MCP_SERVER.md) - Model Context Protocol configuration ## Need Help? diff --git a/Makefile b/Makefile index e74a02db3..d190de3e6 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # DeerFlow - Unified Development Environment -.PHONY: help config config-upgrade check install dev dev-daemon start stop up down clean docker-init docker-start docker-stop docker-logs docker-logs-frontend docker-logs-gateway +.PHONY: help config config-upgrade check install dev dev-pro dev-daemon dev-daemon-pro start start-pro start-daemon start-daemon-pro stop up up-pro down clean docker-init docker-start docker-start-pro docker-stop docker-logs docker-logs-frontend docker-logs-gateway BASH ?= bash @@ -20,18 +20,25 @@ help: @echo " make install - Install all dependencies (frontend + backend)" @echo " make setup-sandbox - Pre-pull sandbox container image (recommended)" @echo " make dev - Start all services in development mode (with hot-reloading)" - @echo " make dev-daemon - Start all services in background (daemon mode)" + @echo " make dev-pro - Start in dev + Gateway mode (experimental, no LangGraph server)" + @echo " make dev-daemon - Start dev services in background (daemon mode)" + @echo " make dev-daemon-pro - Start dev daemon + Gateway mode (experimental)" @echo " make start - Start all services in production mode (optimized, no hot-reloading)" + @echo " make start-pro - Start in prod + Gateway mode (experimental)" + @echo " make start-daemon - Start prod services in background (daemon mode)" + @echo " make start-daemon-pro - Start prod daemon + Gateway mode (experimental)" @echo " make stop - Stop all running services" @echo " make clean - Clean up processes and temporary files" @echo "" @echo "Docker Production Commands:" @echo " make up - Build and start production Docker services (localhost:2026)" + @echo " make up-pro - Build and start production Docker in Gateway mode (experimental)" @echo " make down - Stop and remove production Docker containers" @echo "" @echo "Docker Development Commands:" @echo " make docker-init - Pull the sandbox image" @echo " make docker-start - Start Docker services (mode-aware from config.yaml, localhost:2026)" + @echo " make docker-start-pro - Start Docker in Gateway mode (experimental, no LangGraph container)" @echo " make docker-stop - Stop Docker development services" @echo " make docker-logs - View Docker development logs" @echo " make docker-logs-frontend - View Docker frontend logs" @@ -105,6 +112,15 @@ else @./scripts/serve.sh --dev endif +# Start all services in dev + Gateway mode (experimental: agent runtime embedded in Gateway) +dev-pro: + @$(PYTHON) ./scripts/check.py +ifeq ($(OS),Windows_NT) + @call scripts\run-with-git-bash.cmd ./scripts/serve.sh --dev --gateway +else + @./scripts/serve.sh --dev --gateway +endif + # Start all services in production mode (with optimizations) start: @$(PYTHON) ./scripts/check.py @@ -114,30 +130,54 @@ else @./scripts/serve.sh --prod endif +# Start all services in prod + Gateway mode (experimental) +start-pro: + @$(PYTHON) ./scripts/check.py +ifeq ($(OS),Windows_NT) + @call scripts\run-with-git-bash.cmd ./scripts/serve.sh --prod --gateway +else + @./scripts/serve.sh --prod --gateway +endif + # Start all services in daemon mode (background) dev-daemon: @$(PYTHON) ./scripts/check.py ifeq ($(OS),Windows_NT) - @call scripts\run-with-git-bash.cmd ./scripts/start-daemon.sh + @call scripts\run-with-git-bash.cmd ./scripts/serve.sh --dev --daemon else - @./scripts/start-daemon.sh + @./scripts/serve.sh --dev --daemon +endif + +# Start daemon + Gateway mode (experimental) +dev-daemon-pro: + @$(PYTHON) ./scripts/check.py +ifeq ($(OS),Windows_NT) + @call scripts\run-with-git-bash.cmd ./scripts/serve.sh --dev --gateway --daemon +else + @./scripts/serve.sh --dev --gateway --daemon +endif + +# Start prod services in daemon mode (background) +start-daemon: + @$(PYTHON) ./scripts/check.py +ifeq ($(OS),Windows_NT) + @call scripts\run-with-git-bash.cmd ./scripts/serve.sh --prod --daemon +else + @./scripts/serve.sh --prod --daemon +endif + +# Start prod daemon + Gateway mode (experimental) +start-daemon-pro: + @$(PYTHON) ./scripts/check.py +ifeq ($(OS),Windows_NT) + @call scripts\run-with-git-bash.cmd ./scripts/serve.sh --prod --gateway --daemon +else + @./scripts/serve.sh --prod --gateway --daemon endif # Stop all services stop: - @echo "Stopping all services..." - @-pkill -f "langgraph dev" 2>/dev/null || true - @-pkill -f "uvicorn app.gateway.app:app" 2>/dev/null || true - @-pkill -f "next dev" 2>/dev/null || true - @-pkill -f "next start" 2>/dev/null || true - @-pkill -f "next-server" 2>/dev/null || true - @-pkill -f "next-server" 2>/dev/null || true - @-nginx -c $(PWD)/docker/nginx/nginx.local.conf -p $(PWD) -s quit 2>/dev/null || true - @sleep 1 - @-pkill -9 nginx 2>/dev/null || true - @echo "Cleaning up sandbox containers..." - @-./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true - @echo "โœ“ All services stopped" + @./scripts/serve.sh --stop # Clean up clean: stop @@ -159,6 +199,10 @@ docker-init: docker-start: @./scripts/docker.sh start +# Start Docker in Gateway mode (experimental) +docker-start-pro: + @./scripts/docker.sh start --gateway + # Stop Docker development environment docker-stop: @./scripts/docker.sh stop @@ -181,6 +225,10 @@ docker-logs-gateway: up: @./scripts/deploy.sh +# Build and start production services in Gateway mode +up-pro: + @./scripts/deploy.sh --gateway + # Stop and remove production containers down: @./scripts/deploy.sh down diff --git a/README.md b/README.md index 41ab57cc7..14aec9fc6 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe - [๐ŸฆŒ DeerFlow - 2.0](#-deerflow---20) - [Official Website](#official-website) + - [Coding Plan from ByteDance Volcengine](#coding-plan-from-bytedance-volcengine) - [InfoQuest](#infoquest) - [Table of Contents](#table-of-contents) - [One-Line Agent Setup](#one-line-agent-setup) @@ -59,6 +60,8 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe - [MCP Server](#mcp-server) - [IM Channels](#im-channels) - [LangSmith Tracing](#langsmith-tracing) + - [Langfuse Tracing](#langfuse-tracing) + - [Using Both Providers](#using-both-providers) - [From Deep Research to Super Agent Harness](#from-deep-research-to-super-agent-harness) - [Core Features](#core-features) - [Skills \& Tools](#skills--tools) @@ -71,6 +74,8 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe - [Embedded Python Client](#embedded-python-client) - [Documentation](#documentation) - [โš ๏ธ Security Notice](#๏ธ-security-notice) + - [Improper Deployment May Introduce Security Risks](#improper-deployment-may-introduce-security-risks) + - [Security Recommendations](#security-recommendations) - [Contributing](#contributing) - [License](#license) - [Acknowledgments](#acknowledgments) @@ -275,6 +280,60 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P 6. **Access**: http://localhost:2026 +#### Startup Modes + +DeerFlow supports multiple startup modes across two dimensions: + +- **Dev / Prod** โ€” dev enables hot-reload; prod uses pre-built frontend +- **Standard / Gateway** โ€” standard uses a separate LangGraph server (4 processes); Gateway mode (experimental) embeds the agent runtime in the Gateway API (3 processes) + +| | **Local Foreground** | **Local Daemon** | **Docker Dev** | **Docker Prod** | +|---|---|---|---|---| +| **Dev** | `./scripts/serve.sh --dev`
`make dev` | `./scripts/serve.sh --dev --daemon`
`make dev-daemon` | `./scripts/docker.sh start`
`make docker-start` | โ€” | +| **Dev + Gateway** | `./scripts/serve.sh --dev --gateway`
`make dev-pro` | `./scripts/serve.sh --dev --gateway --daemon`
`make dev-daemon-pro` | `./scripts/docker.sh start --gateway`
`make docker-start-pro` | โ€” | +| **Prod** | `./scripts/serve.sh --prod`
`make start` | `./scripts/serve.sh --prod --daemon`
`make start-daemon` | โ€” | `./scripts/deploy.sh`
`make up` | +| **Prod + Gateway** | `./scripts/serve.sh --prod --gateway`
`make start-pro` | `./scripts/serve.sh --prod --gateway --daemon`
`make start-daemon-pro` | โ€” | `./scripts/deploy.sh --gateway`
`make up-pro` | + +| Action | Local | Docker Dev | Docker Prod | +|---|---|---|---| +| **Stop** | `./scripts/serve.sh --stop`
`make stop` | `./scripts/docker.sh stop`
`make docker-stop` | `./scripts/deploy.sh down`
`make down` | +| **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | โ€” | + +> **Gateway mode** eliminates the LangGraph server process โ€” the Gateway API handles agent execution directly via async tasks, managing its own concurrency. + +#### Why Gateway Mode? + +In standard mode, DeerFlow runs a dedicated [LangGraph Platform](https://langchain-ai.github.io/langgraph/) server alongside the Gateway API. This architecture works well but has trade-offs: + +| | Standard Mode | Gateway Mode | +|---|---|---| +| **Architecture** | Gateway (REST API) + LangGraph (agent runtime) | Gateway embeds agent runtime | +| **Concurrency** | `--n-jobs-per-worker` per worker (requires license) | `--workers` ร— async tasks (no per-worker cap) | +| **Containers / Processes** | 4 (frontend, gateway, langgraph, nginx) | 3 (frontend, gateway, nginx) | +| **Resource usage** | Higher (two Python runtimes) | Lower (single Python runtime) | +| **LangGraph Platform license** | Required for production images | Not required | +| **Cold start** | Slower (two services to initialize) | Faster | + +Both modes are functionally equivalent โ€” the same agents, tools, and skills work in either mode. + +#### Docker Production Deployment + +`deploy.sh` supports building and starting separately. Images are mode-agnostic โ€” runtime mode is selected at start time: + +```bash +# One-step (build + start) +deploy.sh # standard mode (default) +deploy.sh --gateway # gateway mode + +# Two-step (build once, start with any mode) +deploy.sh build # build all images +deploy.sh start # start in standard mode +deploy.sh start --gateway # start in gateway mode + +# Stop +deploy.sh down +``` + ### Advanced #### Sandbox Mode @@ -302,6 +361,7 @@ DeerFlow supports receiving tasks from messaging apps. Channels auto-start when | Telegram | Bot API (long-polling) | Easy | | Slack | Socket Mode | Moderate | | Feishu / Lark | WebSocket | Moderate | +| WeCom | WebSocket | Moderate | **Configuration in `config.yaml`:** @@ -329,6 +389,11 @@ channels: # domain: https://open.feishu.cn # China (default) # domain: https://open.larksuite.com # International + wecom: + enabled: true + bot_id: $WECOM_BOT_ID + bot_secret: $WECOM_BOT_SECRET + slack: enabled: true bot_token: $SLACK_BOT_TOKEN # xoxb-... @@ -372,6 +437,10 @@ SLACK_APP_TOKEN=xapp-... # Feishu / Lark FEISHU_APP_ID=cli_xxxx FEISHU_APP_SECRET=your_app_secret + +# WeCom +WECOM_BOT_ID=your_bot_id +WECOM_BOT_SECRET=your_bot_secret ``` **Telegram Setup** @@ -394,6 +463,14 @@ FEISHU_APP_SECRET=your_app_secret 3. Under **Events**, subscribe to `im.message.receive_v1` and select **Long Connection** mode. 4. Copy the App ID and App Secret. Set `FEISHU_APP_ID` and `FEISHU_APP_SECRET` in `.env` and enable the channel in `config.yaml`. +**WeCom Setup** + +1. Create a bot on the WeCom AI Bot platform and obtain the `bot_id` and `bot_secret`. +2. Enable `channels.wecom` in `config.yaml` and fill in `bot_id` / `bot_secret`. +3. Set `WECOM_BOT_ID` and `WECOM_BOT_SECRET` in `.env`. +4. Make sure backend dependencies include `wecom-aibot-python-sdk`. The channel uses a WebSocket long connection and does not require a public callback URL. +5. The current integration supports inbound text, image, and file messages. Final images/files generated by the agent are also sent back to the WeCom conversation. + When DeerFlow runs in Docker Compose, IM channels execute inside the `gateway` container. In that case, do not point `channels.langgraph_url` or `channels.gateway_url` at `localhost`; use container service names such as `http://langgraph:2024` and `http://gateway:8001`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` and `DEER_FLOW_CHANNELS_GATEWAY_URL`. **Commands** diff --git a/README_zh.md b/README_zh.md index cbb3b5601..9b832eb0d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -232,6 +232,7 @@ DeerFlow ๆ”ฏๆŒไปŽๅณๆ—ถ้€š่ฎฏๅบ”็”จๆŽฅๆ”ถไปปๅŠกใ€‚ๅช่ฆ้…็ฝฎๅฎŒๆˆ๏ผŒๅฏนๅบ” | Telegram | Bot API๏ผˆlong-polling๏ผ‰ | ็ฎ€ๅ• | | Slack | Socket Mode | ไธญ็ญ‰ | | Feishu / Lark | WebSocket | ไธญ็ญ‰ | +| ไผไธšๅพฎไฟกๆ™บ่ƒฝๆœบๅ™จไบบ | WebSocket | ไธญ็ญ‰ | **`config.yaml` ไธญ็š„้…็ฝฎ็คบไพ‹๏ผš** @@ -259,6 +260,11 @@ channels: # domain: https://open.feishu.cn # ๅ›ฝๅ†…็‰ˆ๏ผˆ้ป˜่ฎค๏ผ‰ # domain: https://open.larksuite.com # ๅ›ฝ้™…็‰ˆ + wecom: + enabled: true + bot_id: $WECOM_BOT_ID + bot_secret: $WECOM_BOT_SECRET + slack: enabled: true bot_token: $SLACK_BOT_TOKEN # xoxb-... @@ -302,6 +308,10 @@ SLACK_APP_TOKEN=xapp-... # Feishu / Lark FEISHU_APP_ID=cli_xxxx FEISHU_APP_SECRET=your_app_secret + +# ไผไธšๅพฎไฟกๆ™บ่ƒฝๆœบๅ™จไบบ +WECOM_BOT_ID=your_bot_id +WECOM_BOT_SECRET=your_bot_secret ``` **Telegram ้…็ฝฎ** @@ -324,6 +334,14 @@ FEISHU_APP_SECRET=your_app_secret 3. ๅœจ **ไบ‹ไปถ่ฎข้˜…** ไธญ่ฎข้˜… `im.message.receive_v1`๏ผŒ่ฟžๆŽฅๆ–นๅผ้€‰ๆ‹ฉ **้•ฟ่ฟžๆŽฅ**ใ€‚ 4. ๅคๅˆถ App ID ๅ’Œ App Secret๏ผŒๅœจ `.env` ไธญ่ฎพ็ฝฎ `FEISHU_APP_ID` ๅ’Œ `FEISHU_APP_SECRET`๏ผŒๅนถๅœจ `config.yaml` ไธญๅฏ็”จ่ฏฅๆธ ้“ใ€‚ +**ไผไธšๅพฎไฟกๆ™บ่ƒฝๆœบๅ™จไบบ้…็ฝฎ** + +1. ๅœจไผไธšๅพฎไฟกๆ™บ่ƒฝๆœบๅ™จไบบๅนณๅฐๅˆ›ๅปบๆœบๅ™จไบบ๏ผŒ่Žทๅ– `bot_id` ๅ’Œ `bot_secret`ใ€‚ +2. ๅœจ `config.yaml` ไธญๅฏ็”จ `channels.wecom`๏ผŒๅนถๅกซๅ…ฅ `bot_id` / `bot_secret`ใ€‚ +3. ๅœจ `.env` ไธญ่ฎพ็ฝฎ `WECOM_BOT_ID` ๅ’Œ `WECOM_BOT_SECRET`ใ€‚ +4. ๅฎ‰่ฃ…ๅŽ็ซฏไพ่ต–ๆ—ถ็กฎไฟๅŒ…ๅซ `wecom-aibot-python-sdk`๏ผŒๆธ ้“ไผš้€š่ฟ‡ WebSocket ้•ฟ่ฟžๆŽฅๆŽฅๆ”ถๆถˆๆฏ๏ผŒๆ— ้œ€ๅ…ฌ็ฝ‘ๅ›ž่ฐƒๅœฐๅ€ใ€‚ +5. ๅฝ“ๅ‰ๆ”ฏๆŒๆ–‡ๆœฌใ€ๅ›พ็‰‡ๅ’Œๆ–‡ไปถๅ…ฅ็ซ™ๆถˆๆฏ๏ผ›agent ็”Ÿๆˆ็š„ๆœ€็ปˆๅ›พ็‰‡/ๆ–‡ไปถไนŸไผšๅ›žไผ ๅˆฐไผไธšๅพฎไฟกไผš่ฏไธญใ€‚ + **ๅ‘ฝไปค** ๆธ ้“่ฟžๆŽฅๅฎŒๆˆๅŽ๏ผŒไฝ ๅฏไปฅ็›ดๆŽฅๅœจ่Šๅคฉ็ช—ๅฃ้‡Œๅ’Œ DeerFlow ไบคไบ’๏ผš diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a45b14253..846429e40 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -13,6 +13,10 @@ DeerFlow is a LangGraph-based AI super agent system with a full-stack architectu - **Nginx** (port 2026): Unified reverse proxy entry point - **Provisioner** (port 8002, optional in Docker dev): Started only when sandbox is configured for provisioner/Kubernetes mode +**Runtime Modes**: +- **Standard mode** (`make dev`): LangGraph Server handles agent execution as a separate process. 4 processes total. +- **Gateway mode** (`make dev-pro`, experimental): Agent runtime embedded in Gateway via `RunManager` + `run_agent()` + `StreamBridge` (`packages/harness/deerflow/runtime/`). Service manages its own concurrency via async tasks. 3 processes total, no LangGraph Server. + **Project Structure**: ``` deer-flow/ @@ -80,6 +84,8 @@ When making code changes, you MUST update the relevant documentation: make check # Check system requirements make install # Install all dependencies (frontend + backend) make dev # Start all services (LangGraph + Gateway + Frontend + Nginx), with config.yaml preflight +make dev-pro # Gateway mode (experimental): skip LangGraph, agent runtime embedded in Gateway +make start-pro # Production + Gateway mode (experimental) make stop # Stop all services ``` @@ -436,8 +442,25 @@ make dev This starts all services and makes the application available at `http://localhost:2026`. +**All startup modes:** + +| | **Local Foreground** | **Local Daemon** | **Docker Dev** | **Docker Prod** | +|---|---|---|---|---| +| **Dev** | `./scripts/serve.sh --dev`
`make dev` | `./scripts/serve.sh --dev --daemon`
`make dev-daemon` | `./scripts/docker.sh start`
`make docker-start` | โ€” | +| **Dev + Gateway** | `./scripts/serve.sh --dev --gateway`
`make dev-pro` | `./scripts/serve.sh --dev --gateway --daemon`
`make dev-daemon-pro` | `./scripts/docker.sh start --gateway`
`make docker-start-pro` | โ€” | +| **Prod** | `./scripts/serve.sh --prod`
`make start` | `./scripts/serve.sh --prod --daemon`
`make start-daemon` | โ€” | `./scripts/deploy.sh`
`make up` | +| **Prod + Gateway** | `./scripts/serve.sh --prod --gateway`
`make start-pro` | `./scripts/serve.sh --prod --gateway --daemon`
`make start-daemon-pro` | โ€” | `./scripts/deploy.sh --gateway`
`make up-pro` | + +| Action | Local | Docker Dev | Docker Prod | +|---|---|---|---| +| **Stop** | `./scripts/serve.sh --stop`
`make stop` | `./scripts/docker.sh stop`
`make docker-stop` | `./scripts/deploy.sh down`
`make down` | +| **Restart** | `./scripts/serve.sh --restart [flags]` | `./scripts/docker.sh restart` | โ€” | + +Gateway mode embeds the agent runtime in Gateway, no LangGraph server. + **Nginx routing**: -- `/api/langgraph/*` โ†’ LangGraph Server (2024) +- Standard mode: `/api/langgraph/*` โ†’ LangGraph Server (2024) +- Gateway mode: `/api/langgraph/*` โ†’ Gateway embedded runtime (8001) (via envsubst) - `/api/*` (other) โ†’ Gateway API (8001) - `/` (non-API) โ†’ Frontend (3000) diff --git a/backend/Dockerfile b/backend/Dockerfile index f4063d7ae..b69e088a9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,34 +1,86 @@ -# Backend Development Dockerfile +# Backend Dockerfile โ€” multi-stage build +# Stage 1 (builder): compiles native Python extensions with build-essential +# Stage 2 (dev): retains toolchain for dev containers (uv sync at startup) +# Stage 3 (runtime): clean image without compiler toolchain for production # UV source image (override for restricted networks that cannot reach ghcr.io) ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.7.20 FROM ${UV_IMAGE} AS uv-source -FROM python:3.12-slim-bookworm +# โ”€โ”€ Stage 1: Builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +FROM python:3.12-slim-bookworm AS builder ARG NODE_MAJOR=22 +ARG NODE_VERSION=22.16.0 ARG APT_MIRROR ARG UV_INDEX_URL +ARG NODE_DIST_URL -# Optionally override apt mirror for restricted networks (e.g. APT_MIRROR=mirrors.aliyun.com) +# Optionally override apt mirror for restricted networks (e.g. APT_MIRROR=mirrors.byted.org) RUN if [ -n "${APT_MIRROR}" ]; then \ sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || true; \ sed -i "s|deb.debian.org|${APT_MIRROR}|g" /etc/apt/sources.list 2>/dev/null || true; \ fi -# Install system dependencies + Node.js (provides npx for MCP servers) +# Install build tools + Node.js (build-essential needed for native Python extensions) +# NODE_DIST_URL: base URL for Node.js binary tarballs in restricted networks. +# npmmirror: https://registry.npmmirror.com/-/binary/node +# official: https://nodejs.org/dist (default, via nodesource apt) RUN apt-get update && apt-get install -y \ curl \ build-essential \ gnupg \ ca-certificates \ - && mkdir -p /etc/apt/keyrings \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ - && apt-get update \ - && apt-get install -y nodejs \ + xz-utils \ + && if [ -n "${NODE_DIST_URL}" ]; then \ + curl -fsSL "${NODE_DIST_URL}/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + | tar -xJ --strip-components=1 -C /usr/local \ + && ln -sf /usr/local/bin/node /usr/bin/node \ + && ln -sf /usr/local/lib/node_modules /usr/lib/node_modules; \ + else \ + mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs; \ + fi \ && rm -rf /var/lib/apt/lists/* +# Install uv (source image overridable via UV_IMAGE build arg) +COPY --from=uv-source /uv /uvx /usr/local/bin/ + +# Set working directory +WORKDIR /app + +# Copy backend source code +COPY backend ./backend + +# Install dependencies with cache mount +RUN --mount=type=cache,target=/root/.cache/uv \ + sh -c "cd backend && UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync" + +# โ”€โ”€ Stage 2: Dev โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Retains compiler toolchain from builder so startup-time `uv sync` can build +# source distributions in development containers. +FROM builder AS dev + +# Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket) +COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker + +EXPOSE 8001 2024 + +CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"] + +# โ”€โ”€ Stage 3: Runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Clean image without build-essential โ€” reduces size (~200 MB) and attack surface. +FROM python:3.12-slim-bookworm + +# Copy Node.js runtime from builder (provides npx for MCP servers) +COPY --from=builder /usr/bin/node /usr/bin/node +COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules +RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm \ + && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx + # Install Docker CLI (for DooD: allows starting sandbox containers via host Docker socket) COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker @@ -38,12 +90,8 @@ COPY --from=uv-source /uv /uvx /usr/local/bin/ # Set working directory WORKDIR /app -# Copy frontend source code -COPY backend ./backend - -# Install dependencies with cache mount -RUN --mount=type=cache,target=/root/.cache/uv \ - sh -c "cd backend && UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync" +# Copy backend with pre-built virtualenv from builder +COPY --from=builder /app/backend ./backend # Expose ports (gateway: 8001, langgraph: 2024) EXPOSE 8001 2024 diff --git a/backend/Makefile b/backend/Makefile index e16359f73..dd06742a0 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -2,7 +2,7 @@ install: uv sync dev: - uv run langgraph dev --no-browser --allow-blocking --no-reload --n-jobs-per-worker 10 + uv run langgraph dev --no-browser --no-reload --n-jobs-per-worker 10 gateway: PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index 32f71f1f6..6492d28e5 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -206,7 +206,9 @@ class FeishuChannel(Channel): await asyncio.sleep(delay) logger.error("[Feishu] send failed after %d attempts: %s", _max_retries, last_exc) - raise last_exc # type: ignore[misc] + if last_exc is None: + raise RuntimeError("Feishu send failed without an exception from any attempt") + raise last_exc async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: if not self._api_client: diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index ab63100fa..96e9c755e 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -7,9 +7,10 @@ import logging import mimetypes import re import time -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Mapping from typing import Any +import httpx from langgraph_sdk.errors import ConflictError from app.channels.commands import KNOWN_CHANNEL_COMMANDS @@ -36,8 +37,49 @@ CHANNEL_CAPABILITIES = { "feishu": {"supports_streaming": True}, "slack": {"supports_streaming": False}, "telegram": {"supports_streaming": False}, + "wecom": {"supports_streaming": True}, } +InboundFileReader = Callable[[dict[str, Any], httpx.AsyncClient], Awaitable[bytes | None]] + + +INBOUND_FILE_READERS: dict[str, InboundFileReader] = {} + + +def register_inbound_file_reader(channel_name: str, reader: InboundFileReader) -> None: + INBOUND_FILE_READERS[channel_name] = reader + + +async def _read_http_inbound_file(file_info: dict[str, Any], client: httpx.AsyncClient) -> bytes | None: + url = file_info.get("url") + if not isinstance(url, str) or not url: + return None + + resp = await client.get(url) + resp.raise_for_status() + return resp.content + + +async def _read_wecom_inbound_file(file_info: dict[str, Any], client: httpx.AsyncClient) -> bytes | None: + data = await _read_http_inbound_file(file_info, client) + if data is None: + return None + + aeskey = file_info.get("aeskey") if isinstance(file_info.get("aeskey"), str) else None + if not aeskey: + return data + + try: + from aibot.crypto_utils import decrypt_file + except Exception: + logger.exception("[Manager] failed to import WeCom decrypt_file") + return None + + return decrypt_file(data, aeskey) + + +register_inbound_file_reader("wecom", _read_wecom_inbound_file) + class InvalidChannelSessionConfigError(ValueError): """Raised when IM channel session overrides contain invalid agent config.""" @@ -342,6 +384,105 @@ def _prepare_artifact_delivery( return response_text, attachments +async def _ingest_inbound_files(thread_id: str, msg: InboundMessage) -> list[dict[str, Any]]: + if not msg.files: + return [] + + from deerflow.uploads.manager import claim_unique_filename, ensure_uploads_dir, normalize_filename + + uploads_dir = ensure_uploads_dir(thread_id) + seen_names = {entry.name for entry in uploads_dir.iterdir() if entry.is_file()} + + created: list[dict[str, Any]] = [] + file_reader = INBOUND_FILE_READERS.get(msg.channel_name, _read_http_inbound_file) + async with httpx.AsyncClient(timeout=httpx.Timeout(20.0)) as client: + for idx, f in enumerate(msg.files): + if not isinstance(f, dict): + continue + + ftype = f.get("type") if isinstance(f.get("type"), str) else "file" + filename = f.get("filename") if isinstance(f.get("filename"), str) else "" + + try: + data = await file_reader(f, client) + except Exception: + logger.exception( + "[Manager] failed to read inbound file: channel=%s, file=%s", + msg.channel_name, + f.get("url") or filename or idx, + ) + continue + + if data is None: + logger.warning( + "[Manager] inbound file reader returned no data: channel=%s, file=%s", + msg.channel_name, + f.get("url") or filename or idx, + ) + continue + + if not filename: + ext = ".bin" + if ftype == "image": + ext = ".png" + filename = f"{msg.thread_ts or 'msg'}_{idx}{ext}" + + try: + safe_name = claim_unique_filename(normalize_filename(filename), seen_names) + except ValueError: + logger.warning( + "[Manager] skipping inbound file with unsafe filename: channel=%s, file=%r", + msg.channel_name, + filename, + ) + continue + + dest = uploads_dir / safe_name + try: + dest.write_bytes(data) + except Exception: + logger.exception("[Manager] failed to write inbound file: %s", dest) + continue + + created.append( + { + "filename": safe_name, + "size": len(data), + "path": f"/mnt/user-data/uploads/{safe_name}", + "is_image": ftype == "image", + } + ) + + return created + + +def _format_uploaded_files_block(files: list[dict[str, Any]]) -> str: + lines = [ + "", + "The following files were uploaded in this message:", + "", + ] + if not files: + lines.append("(empty)") + else: + for f in files: + filename = f.get("filename", "") + size = int(f.get("size") or 0) + size_kb = size / 1024 if size else 0 + size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB" + path = f.get("path", "") + is_image = bool(f.get("is_image")) + file_kind = "image" if is_image else "file" + lines.append(f"- {filename} ({size_str})") + lines.append(f" Type: {file_kind}") + lines.append(f" Path: {path}") + lines.append("") + lines.append("Use `read_file` for text-based files and documents.") + lines.append("Use `view_image` for image files (jpg, jpeg, png, webp) so the model can inspect the image content.") + lines.append("") + return "\n".join(lines) + + class ChannelManager: """Core dispatcher that bridges IM channels to the DeerFlow agent. @@ -536,6 +677,11 @@ class ChannelManager: assistant_id, run_config, run_context = self._resolve_run_params(msg, thread_id) if extra_context: run_context.update(extra_context) + + uploaded = await _ingest_inbound_files(thread_id, msg) + if uploaded: + msg.text = f"{_format_uploaded_files_block(uploaded)}\n\n{msg.text}".strip() + if self._channel_supports_streaming(msg.channel_name): await self._handle_streaming_chat( client, diff --git a/backend/app/channels/service.py b/backend/app/channels/service.py index 5c4b0d252..672b67295 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -17,6 +17,7 @@ _CHANNEL_REGISTRY: dict[str, str] = { "feishu": "app.channels.feishu:FeishuChannel", "slack": "app.channels.slack:SlackChannel", "telegram": "app.channels.telegram:TelegramChannel", + "wecom": "app.channels.wecom:WeComChannel", } _CHANNELS_LANGGRAPH_URL_ENV = "DEER_FLOW_CHANNELS_LANGGRAPH_URL" diff --git a/backend/app/channels/slack.py b/backend/app/channels/slack.py index 760911083..c9ad6a6ec 100644 --- a/backend/app/channels/slack.py +++ b/backend/app/channels/slack.py @@ -30,7 +30,7 @@ class SlackChannel(Channel): self._socket_client = None self._web_client = None self._loop: asyncio.AbstractEventLoop | None = None - self._allowed_users: set[str] = set(config.get("allowed_users", [])) + self._allowed_users: set[str] = {str(user_id) for user_id in config.get("allowed_users", [])} async def start(self) -> None: if self._running: @@ -126,7 +126,9 @@ class SlackChannel(Channel): ) except Exception: pass - raise last_exc # type: ignore[misc] + if last_exc is None: + raise RuntimeError("Slack send failed without an exception from any attempt") + raise last_exc async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: if not self._web_client: diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py index 97c50aa9f..9985fd43f 100644 --- a/backend/app/channels/telegram.py +++ b/backend/app/channels/telegram.py @@ -125,7 +125,9 @@ class TelegramChannel(Channel): await asyncio.sleep(delay) logger.error("[Telegram] send failed after %d attempts: %s", _max_retries, last_exc) - raise last_exc # type: ignore[misc] + if last_exc is None: + raise RuntimeError("Telegram send failed without an exception from any attempt") + raise last_exc async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: if not self._application: diff --git a/backend/app/channels/wecom.py b/backend/app/channels/wecom.py new file mode 100644 index 000000000..5a8948bd4 --- /dev/null +++ b/backend/app/channels/wecom.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import logging +from collections.abc import Awaitable, Callable +from typing import Any, cast + +from app.channels.base import Channel +from app.channels.message_bus import ( + InboundMessageType, + MessageBus, + OutboundMessage, + ResolvedAttachment, +) + +logger = logging.getLogger(__name__) + + +class WeComChannel(Channel): + def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None: + super().__init__(name="wecom", bus=bus, config=config) + self._bot_id: str | None = None + self._bot_secret: str | None = None + self._ws_client = None + self._ws_task: asyncio.Task | None = None + self._ws_frames: dict[str, dict[str, Any]] = {} + self._ws_stream_ids: dict[str, str] = {} + self._working_message = "Working on it..." + + def _clear_ws_context(self, thread_ts: str | None) -> None: + if not thread_ts: + return + self._ws_frames.pop(thread_ts, None) + self._ws_stream_ids.pop(thread_ts, None) + + async def _send_ws_upload_command(self, req_id: str, body: dict[str, Any], cmd: str) -> dict[str, Any]: + if not self._ws_client: + raise RuntimeError("WeCom WebSocket client is not available") + + ws_manager = getattr(self._ws_client, "_ws_manager", None) + send_reply = getattr(ws_manager, "send_reply", None) + if not callable(send_reply): + raise RuntimeError("Installed wecom-aibot-python-sdk does not expose the WebSocket media upload API expected by DeerFlow. Use wecom-aibot-python-sdk==0.1.6 or update the adapter.") + + send_reply_async = cast(Callable[[str, dict[str, Any], str], Awaitable[dict[str, Any]]], send_reply) + return await send_reply_async(req_id, body, cmd) + + async def start(self) -> None: + if self._running: + return + + bot_id = self.config.get("bot_id") + bot_secret = self.config.get("bot_secret") + working_message = self.config.get("working_message") + + self._bot_id = bot_id if isinstance(bot_id, str) and bot_id else None + self._bot_secret = bot_secret if isinstance(bot_secret, str) and bot_secret else None + self._working_message = working_message if isinstance(working_message, str) and working_message else "Working on it..." + + if not self._bot_id or not self._bot_secret: + logger.error("WeCom channel requires bot_id and bot_secret") + return + + try: + from aibot import WSClient, WSClientOptions + except ImportError: + logger.error("wecom-aibot-python-sdk is not installed. Install it with: uv add wecom-aibot-python-sdk") + return + else: + self._ws_client = WSClient(WSClientOptions(bot_id=self._bot_id, secret=self._bot_secret, logger=logger)) + self._ws_client.on("message.text", self._on_ws_text) + self._ws_client.on("message.mixed", self._on_ws_mixed) + self._ws_client.on("message.image", self._on_ws_image) + self._ws_client.on("message.file", self._on_ws_file) + self._ws_task = asyncio.create_task(self._ws_client.connect()) + + self._running = True + self.bus.subscribe_outbound(self._on_outbound) + logger.info("WeCom channel started") + + async def stop(self) -> None: + self._running = False + self.bus.unsubscribe_outbound(self._on_outbound) + if self._ws_task: + try: + self._ws_task.cancel() + except Exception: + pass + self._ws_task = None + if self._ws_client: + try: + self._ws_client.disconnect() + except Exception: + pass + self._ws_client = None + self._ws_frames.clear() + self._ws_stream_ids.clear() + logger.info("WeCom channel stopped") + + async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None: + if self._ws_client: + await self._send_ws(msg, _max_retries=_max_retries) + return + logger.warning("[WeCom] send called but WebSocket client is not available") + + async def _on_outbound(self, msg: OutboundMessage) -> None: + if msg.channel_name != self.name: + return + + try: + await self.send(msg) + except Exception: + logger.exception("Failed to send outbound message on channel %s", self.name) + if msg.is_final: + self._clear_ws_context(msg.thread_ts) + return + + for attachment in msg.attachments: + try: + success = await self.send_file(msg, attachment) + if not success: + logger.warning("[%s] file upload skipped for %s", self.name, attachment.filename) + except Exception: + logger.exception("[%s] failed to upload file %s", self.name, attachment.filename) + + if msg.is_final: + self._clear_ws_context(msg.thread_ts) + + async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: + if not msg.is_final: + return True + if not self._ws_client: + return False + if not msg.thread_ts: + return False + frame = self._ws_frames.get(msg.thread_ts) + if not frame: + return False + + media_type = "image" if attachment.is_image else "file" + size_limit = 2 * 1024 * 1024 if attachment.is_image else 20 * 1024 * 1024 + if attachment.size > size_limit: + logger.warning( + "[WeCom] %s too large (%d bytes), skipping: %s", + media_type, + attachment.size, + attachment.filename, + ) + return False + + try: + media_id = await self._upload_media_ws( + media_type=media_type, + filename=attachment.filename, + path=str(attachment.actual_path), + size=attachment.size, + ) + if not media_id: + return False + + body = {media_type: {"media_id": media_id}, "msgtype": media_type} + await self._ws_client.reply(frame, body) + logger.debug("[WeCom] %s sent via ws: %s", media_type, attachment.filename) + return True + except Exception: + logger.exception("[WeCom] failed to upload/send file via ws: %s", attachment.filename) + return False + + async def _on_ws_text(self, frame: dict[str, Any]) -> None: + body = frame.get("body", {}) or {} + text = ((body.get("text") or {}).get("content") or "").strip() + quote = body.get("quote", {}).get("text", {}).get("content", "").strip() + if not text and not quote: + return + await self._publish_ws_inbound(frame, text + (f"\nQuote message: {quote}" if quote else "")) + + async def _on_ws_mixed(self, frame: dict[str, Any]) -> None: + body = frame.get("body", {}) or {} + mixed = body.get("mixed") or {} + items = mixed.get("msg_item") or [] + parts: list[str] = [] + files: list[dict[str, Any]] = [] + for item in items: + item_type = (item or {}).get("msgtype") + if item_type == "text": + content = (((item or {}).get("text") or {}).get("content") or "").strip() + if content: + parts.append(content) + elif item_type in ("image", "file"): + payload = (item or {}).get(item_type) or {} + url = payload.get("url") + aeskey = payload.get("aeskey") + if isinstance(url, str) and url: + files.append( + { + "type": item_type, + "url": url, + "aeskey": (aeskey if isinstance(aeskey, str) and aeskey else None), + } + ) + text = "\n\n".join(parts).strip() + if not text and not files: + return + if not text: + text = "๏ผˆreceive image/file๏ผ‰" + await self._publish_ws_inbound(frame, text, files=files) + + async def _on_ws_image(self, frame: dict[str, Any]) -> None: + body = frame.get("body", {}) or {} + image = body.get("image") or {} + url = image.get("url") + aeskey = image.get("aeskey") + if not isinstance(url, str) or not url: + return + await self._publish_ws_inbound( + frame, + "๏ผˆreceive image ๏ผ‰", + files=[ + { + "type": "image", + "url": url, + "aeskey": aeskey if isinstance(aeskey, str) and aeskey else None, + } + ], + ) + + async def _on_ws_file(self, frame: dict[str, Any]) -> None: + body = frame.get("body", {}) or {} + file_obj = body.get("file") or {} + url = file_obj.get("url") + aeskey = file_obj.get("aeskey") + if not isinstance(url, str) or not url: + return + await self._publish_ws_inbound( + frame, + "๏ผˆreceive file๏ผ‰", + files=[ + { + "type": "file", + "url": url, + "aeskey": aeskey if isinstance(aeskey, str) and aeskey else None, + } + ], + ) + + async def _publish_ws_inbound( + self, + frame: dict[str, Any], + text: str, + *, + files: list[dict[str, Any]] | None = None, + ) -> None: + if not self._ws_client: + return + try: + from aibot import generate_req_id + except Exception: + return + + body = frame.get("body", {}) or {} + msg_id = body.get("msgid") + if not msg_id: + return + + user_id = (body.get("from") or {}).get("userid") + + inbound_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT + inbound = self._make_inbound( + chat_id=user_id, # keep user's conversation in memory + user_id=user_id, + text=text, + msg_type=inbound_type, + thread_ts=msg_id, + files=files or [], + metadata={"aibotid": body.get("aibotid"), "chattype": body.get("chattype")}, + ) + inbound.topic_id = user_id # keep the same thread + + stream_id = generate_req_id("stream") + self._ws_frames[msg_id] = frame + self._ws_stream_ids[msg_id] = stream_id + + try: + await self._ws_client.reply_stream(frame, stream_id, self._working_message, False) + except Exception: + pass + + await self.bus.publish_inbound(inbound) + + async def _send_ws(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None: + if not self._ws_client: + return + try: + from aibot import generate_req_id + except Exception: + generate_req_id = None + + if msg.thread_ts and msg.thread_ts in self._ws_frames: + frame = self._ws_frames[msg.thread_ts] + stream_id = self._ws_stream_ids.get(msg.thread_ts) + if not stream_id and generate_req_id: + stream_id = generate_req_id("stream") + self._ws_stream_ids[msg.thread_ts] = stream_id + if not stream_id: + return + + last_exc: Exception | None = None + for attempt in range(_max_retries): + try: + await self._ws_client.reply_stream(frame, stream_id, msg.text, bool(msg.is_final)) + return + except Exception as exc: + last_exc = exc + if attempt < _max_retries - 1: + await asyncio.sleep(2**attempt) + if last_exc: + raise last_exc + + body = {"msgtype": "markdown", "markdown": {"content": msg.text}} + last_exc = None + for attempt in range(_max_retries): + try: + await self._ws_client.send_message(msg.chat_id, body) + return + except Exception as exc: + last_exc = exc + if attempt < _max_retries - 1: + await asyncio.sleep(2**attempt) + if last_exc: + raise last_exc + + async def _upload_media_ws( + self, + *, + media_type: str, + filename: str, + path: str, + size: int, + ) -> str | None: + if not self._ws_client: + return None + try: + from aibot import generate_req_id + except Exception: + return None + + chunk_size = 512 * 1024 + total_chunks = (size + chunk_size - 1) // chunk_size + if total_chunks < 1 or total_chunks > 100: + logger.warning("[WeCom] invalid total_chunks=%d for %s", total_chunks, filename) + return None + + md5_hasher = hashlib.md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + md5_hasher.update(chunk) + md5 = md5_hasher.hexdigest() + + init_req_id = generate_req_id("aibot_upload_media_init") + init_body = { + "type": media_type, + "filename": filename, + "total_size": int(size), + "total_chunks": int(total_chunks), + "md5": md5, + } + init_ack = await self._send_ws_upload_command(init_req_id, init_body, "aibot_upload_media_init") + upload_id = (init_ack.get("body") or {}).get("upload_id") + if not upload_id: + logger.warning("[WeCom] upload init returned no upload_id: %s", init_ack) + return None + + with open(path, "rb") as f: + for idx in range(total_chunks): + data = f.read(chunk_size) + if not data: + break + chunk_req_id = generate_req_id("aibot_upload_media_chunk") + chunk_body = { + "upload_id": upload_id, + "chunk_index": int(idx), + "base64_data": base64.b64encode(data).decode("utf-8"), + } + await self._send_ws_upload_command(chunk_req_id, chunk_body, "aibot_upload_media_chunk") + + finish_req_id = generate_req_id("aibot_upload_media_finish") + finish_ack = await self._send_ws_upload_command(finish_req_id, {"upload_id": upload_id}, "aibot_upload_media_finish") + media_id = (finish_ack.get("body") or {}).get("media_id") + if not media_id: + logger.warning("[WeCom] upload finish returned no media_id: %s", finish_ack) + return None + return media_id diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 39d17498f..53fa2d74c 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -1,15 +1,21 @@ import logging +import os from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from datetime import UTC from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.gateway.auth_middleware import AuthMiddleware from app.gateway.config import get_gateway_config +from app.gateway.csrf_middleware import CSRFMiddleware from app.gateway.deps import langgraph_runtime from app.gateway.routers import ( agents, artifacts, assistants_compat, + auth, channels, mcp, memory, @@ -33,6 +39,88 @@ logging.basicConfig( logger = logging.getLogger(__name__) +async def _ensure_admin_user(app: FastAPI) -> None: + """Auto-create the admin user on first boot if no users exist. + + Prints the generated password to stdout so the operator can log in. + On subsequent boots, warns if any user still needs setup. + + Multi-worker safe: relies on SQLite UNIQUE constraint to resolve races. + Only the worker that successfully creates/updates the admin prints the + password; losers silently skip. + """ + import secrets + + from app.gateway.deps import get_local_provider + + provider = get_local_provider() + user_count = await provider.count_users() + + if user_count == 0: + password = secrets.token_urlsafe(16) + try: + admin = await provider.create_user(email="admin@deerflow.dev", password=password, system_role="admin", needs_setup=True) + except ValueError: + return # Another worker already created the admin. + + # Migrate orphaned threads (no user_id) to this admin + store = getattr(app.state, "store", None) + if store is not None: + await _migrate_orphaned_threads(store, str(admin.id)) + + logger.info("=" * 60) + logger.info(" Admin account created on first boot") + logger.info(" Email: %s", admin.email) + logger.info(" Password: %s", password) + logger.info(" Change it after login: Settings -> Account") + logger.info("=" * 60) + return + + # Admin exists but setup never completed โ€” reset password so operator + # can always find it in the console without needing the CLI. + # Multi-worker guard: if admin was created less than 5s ago, another + # worker just created it and will print the password โ€” skip reset. + admin = await provider.get_user_by_email("admin@deerflow.dev") + if admin and admin.needs_setup: + import time + + age = time.time() - admin.created_at.replace(tzinfo=UTC).timestamp() + if age < 30: + return # Just created by another worker in this startup; its password is still valid. + + from app.gateway.auth.password import hash_password_async + + password = secrets.token_urlsafe(16) + admin.password_hash = await hash_password_async(password) + admin.token_version += 1 + await provider.update_user(admin) + + logger.info("=" * 60) + logger.info(" Admin account setup incomplete โ€” password reset") + logger.info(" Email: %s", admin.email) + logger.info(" Password: %s", password) + logger.info(" Change it after login: Settings -> Account") + logger.info("=" * 60) + + +async def _migrate_orphaned_threads(store, admin_user_id: str) -> None: + """Migrate threads with no user_id to the given admin.""" + try: + migrated = 0 + results = await store.asearch(("threads",), limit=1000) + for item in results: + metadata = item.value.get("metadata", {}) + if not metadata.get("user_id"): + metadata["user_id"] = admin_user_id + item.value["metadata"] = metadata + await store.aput(("threads",), item.key, item.value) + migrated += 1 + if migrated: + logger.info("Migrated %d orphaned thread(s) to admin", migrated) + except Exception: + logger.exception("Thread migration failed (non-fatal)") + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan handler.""" @@ -52,6 +140,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async with langgraph_runtime(app): logger.info("LangGraph runtime initialised") + # Ensure admin user exists (auto-create on first boot) + # Must run AFTER langgraph_runtime so app.state.store is available for thread migration + await _ensure_admin_user(app) + # Start IM channel service if any channels are configured try: from app.channels.service import start_channel_service @@ -163,7 +255,30 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an ], ) - # CORS is handled by nginx - no need for FastAPI middleware + # Auth: reject unauthenticated requests to non-public paths (fail-closed safety net) + app.add_middleware(AuthMiddleware) + + # CSRF: Double Submit Cookie pattern for state-changing requests + app.add_middleware(CSRFMiddleware) + + # CORS: when GATEWAY_CORS_ORIGINS is set (dev without nginx), add CORS middleware + cors_origins_env = os.environ.get("GATEWAY_CORS_ORIGINS", "") + if cors_origins_env: + cors_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()] + # Validate: wildcard origin with credentials is a security misconfiguration + for origin in cors_origins: + if origin == "*": + logger.error("GATEWAY_CORS_ORIGINS contains wildcard '*' with allow_credentials=True. This is a security misconfiguration โ€” browsers will reject the response. Use explicit scheme://host:port origins instead.") + cors_origins = [o for o in cors_origins if o != "*"] + break + if cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) # Include routers # Models API is mounted at /api/models @@ -199,6 +314,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Assistants compatibility API (LangGraph Platform stub) app.include_router(assistants_compat.router) + # Auth API is mounted at /api/v1/auth + app.include_router(auth.router) + # Thread Runs API (LangGraph Platform-compatible runs lifecycle) app.include_router(thread_runs.router) diff --git a/backend/app/gateway/auth/__init__.py b/backend/app/gateway/auth/__init__.py new file mode 100644 index 000000000..4e9b71c42 --- /dev/null +++ b/backend/app/gateway/auth/__init__.py @@ -0,0 +1,42 @@ +"""Authentication module for DeerFlow. + +This module provides: +- JWT-based authentication +- Provider Factory pattern for extensible auth methods +- UserRepository interface for storage backends (SQLite) +""" + +from app.gateway.auth.config import AuthConfig, get_auth_config, set_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError +from app.gateway.auth.jwt import TokenPayload, create_access_token, decode_token +from app.gateway.auth.local_provider import LocalAuthProvider +from app.gateway.auth.models import User, UserResponse +from app.gateway.auth.password import hash_password, verify_password +from app.gateway.auth.providers import AuthProvider +from app.gateway.auth.repositories.base import UserRepository + +__all__ = [ + # Config + "AuthConfig", + "get_auth_config", + "set_auth_config", + # Errors + "AuthErrorCode", + "AuthErrorResponse", + "TokenError", + # JWT + "TokenPayload", + "create_access_token", + "decode_token", + # Password + "hash_password", + "verify_password", + # Models + "User", + "UserResponse", + # Providers + "AuthProvider", + "LocalAuthProvider", + # Repository + "UserRepository", +] diff --git a/backend/app/gateway/auth/config.py b/backend/app/gateway/auth/config.py new file mode 100644 index 000000000..ca10acc20 --- /dev/null +++ b/backend/app/gateway/auth/config.py @@ -0,0 +1,55 @@ +"""Authentication configuration for DeerFlow.""" + +import logging +import os +import secrets + +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +load_dotenv() + +logger = logging.getLogger(__name__) + + +class AuthConfig(BaseModel): + """JWT and auth-related configuration. Parsed once at startup.""" + + jwt_secret: str = Field( + ..., + description="Secret key for JWT signing. MUST be set via AUTH_JWT_SECRET.", + ) + token_expiry_days: int = Field(default=7, ge=1, le=30) + users_db_path: str | None = Field( + default=None, + description="Path to users SQLite DB. Defaults to .deer-flow/users.db", + ) + oauth_github_client_id: str | None = Field(default=None) + oauth_github_client_secret: str | None = Field(default=None) + + +_auth_config: AuthConfig | None = None + + +def get_auth_config() -> AuthConfig: + """Get the global AuthConfig instance. Parses from env on first call.""" + global _auth_config + if _auth_config is None: + jwt_secret = os.environ.get("AUTH_JWT_SECRET") + if not jwt_secret: + jwt_secret = secrets.token_urlsafe(32) + os.environ["AUTH_JWT_SECRET"] = jwt_secret + logger.warning( + "โš  AUTH_JWT_SECRET is not set โ€” using an auto-generated ephemeral secret. " + "Sessions will be invalidated on restart. " + "For production, add AUTH_JWT_SECRET to your .env file: " + 'python -c "import secrets; print(secrets.token_urlsafe(32))"' + ) + _auth_config = AuthConfig(jwt_secret=jwt_secret) + return _auth_config + + +def set_auth_config(config: AuthConfig) -> None: + """Set the global AuthConfig instance (for testing).""" + global _auth_config + _auth_config = config diff --git a/backend/app/gateway/auth/errors.py b/backend/app/gateway/auth/errors.py new file mode 100644 index 000000000..55ae004db --- /dev/null +++ b/backend/app/gateway/auth/errors.py @@ -0,0 +1,44 @@ +"""Typed error definitions for auth module. + +AuthErrorCode: exhaustive enum of all auth failure conditions. +TokenError: exhaustive enum of JWT decode failures. +AuthErrorResponse: structured error payload for HTTP responses. +""" + +from enum import StrEnum + +from pydantic import BaseModel + + +class AuthErrorCode(StrEnum): + """Exhaustive list of auth error conditions.""" + + INVALID_CREDENTIALS = "invalid_credentials" + TOKEN_EXPIRED = "token_expired" + TOKEN_INVALID = "token_invalid" + USER_NOT_FOUND = "user_not_found" + EMAIL_ALREADY_EXISTS = "email_already_exists" + PROVIDER_NOT_FOUND = "provider_not_found" + NOT_AUTHENTICATED = "not_authenticated" + + +class TokenError(StrEnum): + """Exhaustive list of JWT decode failure reasons.""" + + EXPIRED = "expired" + INVALID_SIGNATURE = "invalid_signature" + MALFORMED = "malformed" + + +class AuthErrorResponse(BaseModel): + """Structured error response โ€” replaces bare `detail` strings.""" + + code: AuthErrorCode + message: str + + +def token_error_to_code(err: TokenError) -> AuthErrorCode: + """Map TokenError to AuthErrorCode โ€” single source of truth.""" + if err == TokenError.EXPIRED: + return AuthErrorCode.TOKEN_EXPIRED + return AuthErrorCode.TOKEN_INVALID diff --git a/backend/app/gateway/auth/jwt.py b/backend/app/gateway/auth/jwt.py new file mode 100644 index 000000000..3853692b7 --- /dev/null +++ b/backend/app/gateway/auth/jwt.py @@ -0,0 +1,55 @@ +"""JWT token creation and verification.""" + +from datetime import UTC, datetime, timedelta + +import jwt +from pydantic import BaseModel + +from app.gateway.auth.config import get_auth_config +from app.gateway.auth.errors import TokenError + + +class TokenPayload(BaseModel): + """JWT token payload.""" + + sub: str # user_id + exp: datetime + iat: datetime | None = None + ver: int = 0 # token_version โ€” must match User.token_version + + +def create_access_token(user_id: str, expires_delta: timedelta | None = None, token_version: int = 0) -> str: + """Create a JWT access token. + + Args: + user_id: The user's UUID as string + expires_delta: Optional custom expiry, defaults to 7 days + token_version: User's current token_version for invalidation + + Returns: + Encoded JWT string + """ + config = get_auth_config() + expiry = expires_delta or timedelta(days=config.token_expiry_days) + + now = datetime.now(UTC) + payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version} + return jwt.encode(payload, config.jwt_secret, algorithm="HS256") + + +def decode_token(token: str) -> TokenPayload | TokenError: + """Decode and validate a JWT token. + + Returns: + TokenPayload if valid, or a specific TokenError variant. + """ + config = get_auth_config() + try: + payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"]) + return TokenPayload(**payload) + except jwt.ExpiredSignatureError: + return TokenError.EXPIRED + except jwt.InvalidSignatureError: + return TokenError.INVALID_SIGNATURE + except jwt.PyJWTError: + return TokenError.MALFORMED diff --git a/backend/app/gateway/auth/local_provider.py b/backend/app/gateway/auth/local_provider.py new file mode 100644 index 000000000..e051f982b --- /dev/null +++ b/backend/app/gateway/auth/local_provider.py @@ -0,0 +1,87 @@ +"""Local email/password authentication provider.""" + +from app.gateway.auth.models import User +from app.gateway.auth.password import hash_password_async, verify_password_async +from app.gateway.auth.providers import AuthProvider +from app.gateway.auth.repositories.base import UserRepository + + +class LocalAuthProvider(AuthProvider): + """Email/password authentication provider using local database.""" + + def __init__(self, repository: UserRepository): + """Initialize with a UserRepository. + + Args: + repository: UserRepository implementation (SQLite) + """ + self._repo = repository + + async def authenticate(self, credentials: dict) -> User | None: + """Authenticate with email and password. + + Args: + credentials: dict with 'email' and 'password' keys + + Returns: + User if authentication succeeds, None otherwise + """ + email = credentials.get("email") + password = credentials.get("password") + + if not email or not password: + return None + + user = await self._repo.get_user_by_email(email) + if user is None: + return None + + if user.password_hash is None: + # OAuth user without local password + return None + + if not await verify_password_async(password, user.password_hash): + return None + + return user + + async def get_user(self, user_id: str) -> User | None: + """Get user by ID.""" + return await self._repo.get_user_by_id(user_id) + + async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User: + """Create a new local user. + + Args: + email: User email address + password: Plain text password (will be hashed) + system_role: Role to assign ("admin" or "user") + needs_setup: If True, user must complete setup on first login + + Returns: + Created User instance + """ + password_hash = await hash_password_async(password) if password else None + user = User( + email=email, + password_hash=password_hash, + system_role=system_role, + needs_setup=needs_setup, + ) + return await self._repo.create_user(user) + + async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: + """Get user by OAuth provider and ID.""" + return await self._repo.get_user_by_oauth(provider, oauth_id) + + async def count_users(self) -> int: + """Return total number of registered users.""" + return await self._repo.count_users() + + async def update_user(self, user: User) -> User: + """Update an existing user.""" + return await self._repo.update_user(user) + + async def get_user_by_email(self, email: str) -> User | None: + """Get user by email.""" + return await self._repo.get_user_by_email(email) diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py new file mode 100644 index 000000000..d8f9b954a --- /dev/null +++ b/backend/app/gateway/auth/models.py @@ -0,0 +1,41 @@ +"""User Pydantic models for authentication.""" + +from datetime import UTC, datetime +from typing import Literal +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +def _utc_now() -> datetime: + """Return current UTC time (timezone-aware).""" + return datetime.now(UTC) + + +class User(BaseModel): + """Internal user representation.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(default_factory=uuid4, description="Primary key") + email: EmailStr = Field(..., description="Unique email address") + password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users") + system_role: Literal["admin", "user"] = Field(default="user") + created_at: datetime = Field(default_factory=_utc_now) + + # OAuth linkage (optional) + oauth_provider: str | None = Field(None, description="e.g. 'github', 'google'") + oauth_id: str | None = Field(None, description="User ID from OAuth provider") + + # Auth lifecycle + needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") + token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") + + +class UserResponse(BaseModel): + """Response model for user info endpoint.""" + + id: str + email: str + system_role: Literal["admin", "user"] + needs_setup: bool = False diff --git a/backend/app/gateway/auth/password.py b/backend/app/gateway/auth/password.py new file mode 100644 index 000000000..588b7a643 --- /dev/null +++ b/backend/app/gateway/auth/password.py @@ -0,0 +1,33 @@ +"""Password hashing utilities using bcrypt directly.""" + +import asyncio + +import bcrypt + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt.""" + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + + +async def hash_password_async(password: str) -> str: + """Hash a password using bcrypt (non-blocking). + + Wraps the blocking bcrypt operation in a thread pool to avoid + blocking the event loop during password hashing. + """ + return await asyncio.to_thread(hash_password, password) + + +async def verify_password_async(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash (non-blocking). + + Wraps the blocking bcrypt operation in a thread pool to avoid + blocking the event loop during password verification. + """ + return await asyncio.to_thread(verify_password, plain_password, hashed_password) diff --git a/backend/app/gateway/auth/providers.py b/backend/app/gateway/auth/providers.py new file mode 100644 index 000000000..25e782ce3 --- /dev/null +++ b/backend/app/gateway/auth/providers.py @@ -0,0 +1,24 @@ +"""Auth provider abstraction.""" + +from abc import ABC, abstractmethod + + +class AuthProvider(ABC): + """Abstract base class for authentication providers.""" + + @abstractmethod + async def authenticate(self, credentials: dict) -> "User | None": + """Authenticate user with given credentials. + + Returns User if authentication succeeds, None otherwise. + """ + ... + + @abstractmethod + async def get_user(self, user_id: str) -> "User | None": + """Retrieve user by ID.""" + ... + + +# Import User at runtime to avoid circular imports +from app.gateway.auth.models import User # noqa: E402 diff --git a/backend/app/gateway/auth/repositories/__init__.py b/backend/app/gateway/auth/repositories/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/gateway/auth/repositories/base.py b/backend/app/gateway/auth/repositories/base.py new file mode 100644 index 000000000..152569513 --- /dev/null +++ b/backend/app/gateway/auth/repositories/base.py @@ -0,0 +1,82 @@ +"""User repository interface for abstracting database operations.""" + +from abc import ABC, abstractmethod + +from app.gateway.auth.models import User + + +class UserRepository(ABC): + """Abstract interface for user data storage. + + Implement this interface to support different storage backends + (SQLite) + """ + + @abstractmethod + async def create_user(self, user: User) -> User: + """Create a new user. + + Args: + user: User object to create + + Returns: + Created User with ID assigned + + Raises: + ValueError: If email already exists + """ + ... + + @abstractmethod + async def get_user_by_id(self, user_id: str) -> User | None: + """Get user by ID. + + Args: + user_id: User UUID as string + + Returns: + User if found, None otherwise + """ + ... + + @abstractmethod + async def get_user_by_email(self, email: str) -> User | None: + """Get user by email. + + Args: + email: User email address + + Returns: + User if found, None otherwise + """ + ... + + @abstractmethod + async def update_user(self, user: User) -> User: + """Update an existing user. + + Args: + user: User object with updated fields + + Returns: + Updated User + """ + ... + + @abstractmethod + async def count_users(self) -> int: + """Return total number of registered users.""" + ... + + @abstractmethod + async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: + """Get user by OAuth provider and ID. + + Args: + provider: OAuth provider name (e.g. 'github', 'google') + oauth_id: User ID from the OAuth provider + + Returns: + User if found, None otherwise + """ + ... diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py new file mode 100644 index 000000000..93b768cfe --- /dev/null +++ b/backend/app/gateway/auth/repositories/sqlite.py @@ -0,0 +1,196 @@ +"""SQLite implementation of UserRepository.""" + +import asyncio +import sqlite3 +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from uuid import UUID + +from app.gateway.auth.config import get_auth_config +from app.gateway.auth.models import User +from app.gateway.auth.repositories.base import UserRepository + +_resolved_db_path: Path | None = None +_table_initialized: bool = False + + +def _get_users_db_path() -> Path: + """Get the users database path (resolved and cached once).""" + global _resolved_db_path + if _resolved_db_path is not None: + return _resolved_db_path + config = get_auth_config() + if config.users_db_path: + _resolved_db_path = Path(config.users_db_path) + else: + _resolved_db_path = Path(".deer-flow/users.db") + _resolved_db_path.parent.mkdir(parents=True, exist_ok=True) + return _resolved_db_path + + +def _get_connection() -> sqlite3.Connection: + """Get a SQLite connection for the users database.""" + db_path = _get_users_db_path() + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + return conn + + +def _init_users_table(conn: sqlite3.Connection) -> None: + """Initialize the users table if it doesn't exist.""" + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT, + system_role TEXT NOT NULL DEFAULT 'user', + created_at REAL NOT NULL, + oauth_provider TEXT, + oauth_id TEXT, + needs_setup INTEGER NOT NULL DEFAULT 0, + token_version INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + # Add unique constraint for OAuth identity to prevent duplicate social logins + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oauth_identity + ON users(oauth_provider, oauth_id) + WHERE oauth_provider IS NOT NULL AND oauth_id IS NOT NULL + """ + ) + conn.commit() + + +@contextmanager +def _get_users_conn(): + """Context manager for users database connection.""" + global _table_initialized + conn = _get_connection() + try: + if not _table_initialized: + _init_users_table(conn) + _table_initialized = True + yield conn + finally: + conn.close() + + +class SQLiteUserRepository(UserRepository): + """SQLite implementation of UserRepository.""" + + async def create_user(self, user: User) -> User: + """Create a new user in SQLite.""" + return await asyncio.to_thread(self._create_user_sync, user) + + def _create_user_sync(self, user: User) -> User: + """Synchronous user creation (runs in thread pool).""" + with _get_users_conn() as conn: + try: + conn.execute( + """ + INSERT INTO users (id, email, password_hash, system_role, created_at, oauth_provider, oauth_id, needs_setup, token_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(user.id), + user.email, + user.password_hash, + user.system_role, + datetime.now(UTC).timestamp(), + user.oauth_provider, + user.oauth_id, + int(user.needs_setup), + user.token_version, + ), + ) + conn.commit() + except sqlite3.IntegrityError as e: + if "UNIQUE constraint failed: users.email" in str(e): + raise ValueError(f"Email already registered: {user.email}") from e + raise + return user + + async def get_user_by_id(self, user_id: str) -> User | None: + """Get user by ID from SQLite.""" + return await asyncio.to_thread(self._get_user_by_id_sync, user_id) + + def _get_user_by_id_sync(self, user_id: str) -> User | None: + """Synchronous get by ID (runs in thread pool).""" + with _get_users_conn() as conn: + cursor = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + row = cursor.fetchone() + if row is None: + return None + return self._row_to_user(dict(row)) + + async def get_user_by_email(self, email: str) -> User | None: + """Get user by email from SQLite.""" + return await asyncio.to_thread(self._get_user_by_email_sync, email) + + def _get_user_by_email_sync(self, email: str) -> User | None: + """Synchronous get by email (runs in thread pool).""" + with _get_users_conn() as conn: + cursor = conn.execute("SELECT * FROM users WHERE email = ?", (email,)) + row = cursor.fetchone() + if row is None: + return None + return self._row_to_user(dict(row)) + + async def update_user(self, user: User) -> User: + """Update an existing user in SQLite.""" + return await asyncio.to_thread(self._update_user_sync, user) + + def _update_user_sync(self, user: User) -> User: + with _get_users_conn() as conn: + conn.execute( + "UPDATE users SET email = ?, password_hash = ?, system_role = ?, oauth_provider = ?, oauth_id = ?, needs_setup = ?, token_version = ? WHERE id = ?", + (user.email, user.password_hash, user.system_role, user.oauth_provider, user.oauth_id, int(user.needs_setup), user.token_version, str(user.id)), + ) + conn.commit() + return user + + async def count_users(self) -> int: + """Return total number of registered users.""" + return await asyncio.to_thread(self._count_users_sync) + + def _count_users_sync(self) -> int: + with _get_users_conn() as conn: + cursor = conn.execute("SELECT COUNT(*) FROM users") + return cursor.fetchone()[0] + + async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None: + """Get user by OAuth provider and ID from SQLite.""" + return await asyncio.to_thread(self._get_user_by_oauth_sync, provider, oauth_id) + + def _get_user_by_oauth_sync(self, provider: str, oauth_id: str) -> User | None: + """Synchronous get by OAuth (runs in thread pool).""" + with _get_users_conn() as conn: + cursor = conn.execute( + "SELECT * FROM users WHERE oauth_provider = ? AND oauth_id = ?", + (provider, oauth_id), + ) + row = cursor.fetchone() + if row is None: + return None + return self._row_to_user(dict(row)) + + @staticmethod + def _row_to_user(row: dict[str, Any]) -> User: + """Convert a database row to a User model.""" + return User( + id=UUID(row["id"]), + email=row["email"], + password_hash=row["password_hash"], + system_role=row["system_role"], + created_at=datetime.fromtimestamp(row["created_at"], tz=UTC), + oauth_provider=row.get("oauth_provider"), + oauth_id=row.get("oauth_id"), + needs_setup=bool(row["needs_setup"]), + token_version=int(row["token_version"]), + ) diff --git a/backend/app/gateway/auth/reset_admin.py b/backend/app/gateway/auth/reset_admin.py new file mode 100644 index 000000000..0ac2e0701 --- /dev/null +++ b/backend/app/gateway/auth/reset_admin.py @@ -0,0 +1,66 @@ +"""CLI tool to reset admin password. + +Usage: + python -m app.gateway.auth.reset_admin + python -m app.gateway.auth.reset_admin --email admin@example.com +""" + +import argparse +import secrets +import sys + +from app.gateway.auth.password import hash_password +from app.gateway.auth.repositories.sqlite import SQLiteUserRepository + + +def main() -> None: + parser = argparse.ArgumentParser(description="Reset admin password") + parser.add_argument("--email", help="Admin email (default: first admin found)") + args = parser.parse_args() + + repo = SQLiteUserRepository() + + # Find admin user synchronously (CLI context, no event loop) + import asyncio + + user = asyncio.run(_find_admin(repo, args.email)) + if user is None: + if args.email: + print(f"Error: user '{args.email}' not found.", file=sys.stderr) + else: + print("Error: no admin user found.", file=sys.stderr) + sys.exit(1) + + new_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(new_password) + user.token_version += 1 + user.needs_setup = True + asyncio.run(repo.update_user(user)) + + print(f"Password reset for: {user.email}") + print(f"New password: {new_password}") + print("Next login will require setup (new email + password).") + + +async def _find_admin(repo: SQLiteUserRepository, email: str | None): + if email: + return await repo.get_user_by_email(email) + # Find first admin + import asyncio + + from app.gateway.auth.repositories.sqlite import _get_users_conn + + def _find_sync(): + with _get_users_conn() as conn: + cursor = conn.execute("SELECT id FROM users WHERE system_role = 'admin' LIMIT 1") + row = cursor.fetchone() + return dict(row)["id"] if row else None + + admin_id = await asyncio.to_thread(_find_sync) + if admin_id: + return await repo.get_user_by_id(admin_id) + return None + + +if __name__ == "__main__": + main() diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py new file mode 100644 index 000000000..cca505688 --- /dev/null +++ b/backend/app/gateway/auth_middleware.py @@ -0,0 +1,71 @@ +"""Global authentication middleware โ€” fail-closed safety net. + +Rejects unauthenticated requests to non-public paths with 401. +Fine-grained permission checks remain in authz.py decorators. +""" + +from collections.abc import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.types import ASGIApp + +from app.gateway.auth.errors import AuthErrorCode + +# Paths that never require authentication. +_PUBLIC_PATH_PREFIXES: tuple[str, ...] = ( + "/health", + "/docs", + "/redoc", + "/openapi.json", +) + +# Exact auth paths that are public (login/register/status check). +# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public. +_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( + { + "/api/v1/auth/login/local", + "/api/v1/auth/register", + "/api/v1/auth/logout", + "/api/v1/auth/setup-status", + } +) + + +def _is_public(path: str) -> bool: + stripped = path.rstrip("/") + if stripped in _PUBLIC_EXACT_PATHS: + return True + return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES) + + +class AuthMiddleware(BaseHTTPMiddleware): + """Coarse-grained auth gate: reject requests without a valid session cookie. + + This does NOT verify JWT signature or user existence โ€” that is the job of + ``get_current_user_from_request`` in deps.py (called by ``@require_auth``). + The middleware only checks *presence* of the cookie so that new endpoints + that forget ``@require_auth`` are not completely exposed. + """ + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + if _is_public(request.url.path): + return await call_next(request) + + # Non-public path: require session cookie + if not request.cookies.get("access_token"): + return JSONResponse( + status_code=401, + content={ + "detail": { + "code": AuthErrorCode.NOT_AUTHENTICATED, + "message": "Authentication required", + } + }, + ) + + return await call_next(request) diff --git a/backend/app/gateway/authz.py b/backend/app/gateway/authz.py new file mode 100644 index 000000000..c0411275b --- /dev/null +++ b/backend/app/gateway/authz.py @@ -0,0 +1,261 @@ +"""Authorization decorators and context for DeerFlow. + +Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blob/main/libs/sdk-py/langgraph_sdk/auth/__init__.py + +**Usage:** + +1. Use ``@require_auth`` on routes that need authentication +2. Use ``@require_permission("resource", "action", filter_key=...)`` for permission checks +3. The decorator chain processes from bottom to top + +**Example:** + + @router.get("/{thread_id}") + @require_auth + @require_permission("threads", "read", owner_check=True) + async def get_thread(thread_id: str, request: Request): + # User is authenticated and has threads:read permission + ... + +**Permission Model:** + +- threads:read - View thread +- threads:write - Create/update thread +- threads:delete - Delete thread +- runs:create - Run agent +- runs:read - View run +- runs:cancel - Cancel run +""" + +from __future__ import annotations + +import functools +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar + +from fastapi import HTTPException, Request + +if TYPE_CHECKING: + from app.gateway.auth.models import User + +P = ParamSpec("P") +T = TypeVar("T") + + +# Permission constants +class Permissions: + """Permission constants for resource:action format.""" + + # Threads + THREADS_READ = "threads:read" + THREADS_WRITE = "threads:write" + THREADS_DELETE = "threads:delete" + + # Runs + RUNS_CREATE = "runs:create" + RUNS_READ = "runs:read" + RUNS_CANCEL = "runs:cancel" + + +class AuthContext: + """Authentication context for the current request. + + Stored in request.state.auth after require_auth decoration. + + Attributes: + user: The authenticated user, or None if anonymous + permissions: List of permission strings (e.g., "threads:read") + """ + + __slots__ = ("user", "permissions") + + def __init__(self, user: User | None = None, permissions: list[str] | None = None): + self.user = user + self.permissions = permissions or [] + + @property + def is_authenticated(self) -> bool: + """Check if user is authenticated.""" + return self.user is not None + + def has_permission(self, resource: str, action: str) -> bool: + """Check if context has permission for resource:action. + + Args: + resource: Resource name (e.g., "threads") + action: Action name (e.g., "read") + + Returns: + True if user has permission + """ + permission = f"{resource}:{action}" + return permission in self.permissions + + def require_user(self) -> User: + """Get user or raise 401. + + Raises: + HTTPException 401 if not authenticated + """ + if not self.user: + raise HTTPException(status_code=401, detail="Authentication required") + return self.user + + +def get_auth_context(request: Request) -> AuthContext | None: + """Get AuthContext from request state.""" + return getattr(request.state, "auth", None) + + +_ALL_PERMISSIONS: list[str] = [ + Permissions.THREADS_READ, + Permissions.THREADS_WRITE, + Permissions.THREADS_DELETE, + Permissions.RUNS_CREATE, + Permissions.RUNS_READ, + Permissions.RUNS_CANCEL, +] + + +async def _authenticate(request: Request) -> AuthContext: + """Authenticate request and return AuthContext. + + Delegates to deps.get_optional_user_from_request() for the JWTโ†’User pipeline. + Returns AuthContext with user=None for anonymous requests. + """ + from app.gateway.deps import get_optional_user_from_request + + user = await get_optional_user_from_request(request) + if user is None: + return AuthContext(user=None, permissions=[]) + + # In future, permissions could be stored in user record + return AuthContext(user=user, permissions=_ALL_PERMISSIONS) + + +def require_auth[**P, T](func: Callable[P, T]) -> Callable[P, T]: + """Decorator that authenticates the request and sets AuthContext. + + Must be placed ABOVE other decorators (executes after them). + + Usage: + @router.get("/{thread_id}") + @require_auth # Bottom decorator (executes first after permission check) + @require_permission("threads", "read") + async def get_thread(thread_id: str, request: Request): + auth: AuthContext = request.state.auth + ... + + Raises: + ValueError: If 'request' parameter is missing + """ + + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + request = kwargs.get("request") + if request is None: + raise ValueError("require_auth decorator requires 'request' parameter") + + # Authenticate and set context + auth_context = await _authenticate(request) + request.state.auth = auth_context + + return await func(*args, **kwargs) + + return wrapper + + +def require_permission( + resource: str, + action: str, + owner_check: bool = False, + owner_filter_key: str = "user_id", + inject_record: bool = False, +) -> Callable[[Callable[P, T]], Callable[P, T]]: + """Decorator that checks permission for resource:action. + + Must be used AFTER @require_auth. + + Args: + resource: Resource name (e.g., "threads", "runs") + action: Action name (e.g., "read", "write", "delete") + owner_check: If True, validates that the current user owns the resource. + Requires 'thread_id' path parameter and performs ownership check. + owner_filter_key: Field name for ownership filter (default: "user_id") + inject_record: If True and owner_check is True, injects the thread record + into kwargs['thread_record'] for use in the handler. + + Usage: + # Simple permission check + @require_permission("threads", "read") + async def get_thread(thread_id: str, request: Request): + ... + + # With ownership check (for /threads/{thread_id} endpoints) + @require_permission("threads", "delete", owner_check=True) + async def delete_thread(thread_id: str, request: Request): + ... + + # With ownership check and record injection + @require_permission("threads", "delete", owner_check=True, inject_record=True) + async def delete_thread(thread_id: str, request: Request, thread_record: dict = None): + # thread_record is injected if found + ... + + Raises: + HTTPException 401: If authentication required but user is anonymous + HTTPException 403: If user lacks permission + HTTPException 404: If owner_check=True but user doesn't own the thread + ValueError: If owner_check=True but 'thread_id' parameter is missing + """ + + def decorator(func: Callable[P, T]) -> Callable[P, T]: + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + request = kwargs.get("request") + if request is None: + raise ValueError("require_permission decorator requires 'request' parameter") + + auth: AuthContext = getattr(request.state, "auth", None) + if auth is None: + auth = await _authenticate(request) + request.state.auth = auth + + if not auth.is_authenticated: + raise HTTPException(status_code=401, detail="Authentication required") + + # Check permission + if not auth.has_permission(resource, action): + raise HTTPException( + status_code=403, + detail=f"Permission denied: {resource}:{action}", + ) + + # Owner check for thread-specific resources + if owner_check: + thread_id = kwargs.get("thread_id") + if thread_id is None: + raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter") + + # Get thread and verify ownership + from app.gateway.routers.threads import _store_get, get_store + + store = get_store(request) + if store is not None: + record = await _store_get(store, thread_id) + if record: + owner_id = record.get("metadata", {}).get(owner_filter_key) + if owner_id and owner_id != str(auth.user.id): + raise HTTPException( + status_code=404, + detail=f"Thread {thread_id} not found", + ) + # Inject record if requested + if inject_record: + kwargs["thread_record"] = record + + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py new file mode 100644 index 000000000..fc96878b6 --- /dev/null +++ b/backend/app/gateway/csrf_middleware.py @@ -0,0 +1,112 @@ +"""CSRF protection middleware for FastAPI. + +Per RFC-001: +State-changing operations require CSRF protection. +""" + +import secrets +from collections.abc import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse +from starlette.types import ASGIApp + +CSRF_COOKIE_NAME = "csrf_token" +CSRF_HEADER_NAME = "X-CSRF-Token" +CSRF_TOKEN_LENGTH = 64 # bytes + + +def is_secure_request(request: Request) -> bool: + """Detect whether the original client request was made over HTTPS.""" + return request.headers.get("x-forwarded-proto", request.url.scheme) == "https" + + +def generate_csrf_token() -> str: + """Generate a secure random CSRF token.""" + return secrets.token_urlsafe(CSRF_TOKEN_LENGTH) + + +def should_check_csrf(request: Request) -> bool: + """Determine if a request needs CSRF validation. + + CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH). + GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231. + """ + if request.method not in ("POST", "PUT", "DELETE", "PATCH"): + return False + + path = request.url.path.rstrip("/") + # Exempt /api/v1/auth/me endpoint + if path == "/api/v1/auth/me": + return False + return True + + +_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset( + { + "/api/v1/auth/login/local", + "/api/v1/auth/logout", + "/api/v1/auth/register", + } +) + + +def is_auth_endpoint(request: Request) -> bool: + """Check if the request is to an auth endpoint. + + Auth endpoints don't need CSRF validation on first call (no token). + """ + return request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS + + +class CSRFMiddleware(BaseHTTPMiddleware): + """Middleware that implements CSRF protection using Double Submit Cookie pattern.""" + + def __init__(self, app: ASGIApp) -> None: + super().__init__(app) + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + _is_auth = is_auth_endpoint(request) + + if should_check_csrf(request) and not _is_auth: + cookie_token = request.cookies.get(CSRF_COOKIE_NAME) + header_token = request.headers.get(CSRF_HEADER_NAME) + + if not cookie_token or not header_token: + return JSONResponse( + status_code=403, + content={"detail": "CSRF token missing. Include X-CSRF-Token header."}, + ) + + if not secrets.compare_digest(cookie_token, header_token): + return JSONResponse( + status_code=403, + content={"detail": "CSRF token mismatch."}, + ) + + response = await call_next(request) + + # For auth endpoints that set up session, also set CSRF cookie + if _is_auth and request.method == "POST": + # Generate a new CSRF token for the session + csrf_token = generate_csrf_token() + is_https = is_secure_request(request) + response.set_cookie( + key=CSRF_COOKIE_NAME, + value=csrf_token, + httponly=False, # Must be JS-readable for Double Submit Cookie pattern + secure=is_https, + samesite="strict", + ) + + return response + + +def get_csrf_token(request: Request) -> str | None: + """Get the CSRF token from the current request's cookies. + + This is useful for server-side rendering where you need to embed + token in forms or headers. + """ + return request.cookies.get(CSRF_COOKIE_NAME) diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 115868331..e60e8a72f 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -3,38 +3,22 @@ **Getters** (used by routers): raise 503 when a required dependency is missing, except ``get_store`` which returns ``None``. -Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`. +Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack``. """ from __future__ import annotations from collections.abc import AsyncGenerator from contextlib import AsyncExitStack, asynccontextmanager +from typing import TYPE_CHECKING from fastapi import FastAPI, HTTPException, Request from deerflow.runtime import RunManager, StreamBridge - -@asynccontextmanager -async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: - """Bootstrap and tear down all LangGraph runtime singletons. - - Usage in ``app.py``:: - - async with langgraph_runtime(app): - yield - """ - from deerflow.agents.checkpointer.async_provider import make_checkpointer - from deerflow.runtime import make_store, make_stream_bridge - - async with AsyncExitStack() as stack: - app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge()) - app.state.checkpointer = await stack.enter_async_context(make_checkpointer()) - app.state.store = await stack.enter_async_context(make_store()) - app.state.run_manager = RunManager() - yield - +if TYPE_CHECKING: + from app.gateway.auth.local_provider import LocalAuthProvider + from app.gateway.auth.repositories.sqlite import SQLiteUserRepository # --------------------------------------------------------------------------- # Getters โ€“ called by routers per-request @@ -68,3 +52,102 @@ def get_checkpointer(request: Request): def get_store(request: Request): """Return the global store (may be ``None`` if not configured).""" return getattr(request.app.state, "store", None) + + +# --------------------------------------------------------------------------- +# Auth helpers (used by authz.py) +# --------------------------------------------------------------------------- + +# Cached singletons to avoid repeated instantiation per request +_cached_local_provider: LocalAuthProvider | None = None +_cached_repo: SQLiteUserRepository | None = None + + +def get_local_provider() -> LocalAuthProvider: + """Get or create the cached LocalAuthProvider singleton.""" + global _cached_local_provider, _cached_repo + if _cached_repo is None: + from app.gateway.auth.repositories.sqlite import SQLiteUserRepository + + _cached_repo = SQLiteUserRepository() + if _cached_local_provider is None: + from app.gateway.auth.local_provider import LocalAuthProvider + + _cached_local_provider = LocalAuthProvider(repository=_cached_repo) + return _cached_local_provider + + +async def get_current_user_from_request(request: Request): + """Get the current authenticated user from the request cookie. + + Raises HTTPException 401 if not authenticated. + """ + from app.gateway.auth import decode_token + from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code + + access_token = request.cookies.get("access_token") + if not access_token: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(), + ) + + payload = decode_token(access_token) + if isinstance(payload, TokenError): + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(), + ) + + provider = get_local_provider() + user = await provider.get_user(payload.sub) + if user is None: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(), + ) + + # Token version mismatch โ†’ password was changed, token is stale + if user.token_version != payload.ver: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(), + ) + + return user + + +async def get_optional_user_from_request(request: Request): + """Get optional authenticated user from request. + + Returns None if not authenticated. + """ + try: + return await get_current_user_from_request(request) + except HTTPException: + return None + + +# --------------------------------------------------------------------------- +# Runtime bootstrap +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: + """Bootstrap and tear down all LangGraph runtime singletons. + + Usage in ``app.py``:: + + async with langgraph_runtime(app): + yield + """ + from deerflow.agents.checkpointer.async_provider import make_checkpointer + from deerflow.runtime import make_store, make_stream_bridge + + async with AsyncExitStack() as stack: + app.state.stream_bridge = await stack.enter_async_context(make_stream_bridge()) + app.state.checkpointer = await stack.enter_async_context(make_checkpointer()) + app.state.store = await stack.enter_async_context(make_store()) + app.state.run_manager = RunManager() + yield diff --git a/backend/app/gateway/langgraph_auth.py b/backend/app/gateway/langgraph_auth.py new file mode 100644 index 000000000..06074b9b8 --- /dev/null +++ b/backend/app/gateway/langgraph_auth.py @@ -0,0 +1,106 @@ +"""LangGraph Server auth handler โ€” shares JWT logic with Gateway. + +Loaded by LangGraph Server via langgraph.json ``auth.path``. +Reuses the same ``decode_token`` / ``get_auth_config`` as Gateway, +so both modes validate tokens with the same secret and rules. + +Two layers: + 1. @auth.authenticate โ€” validates JWT cookie, extracts user_id, + and enforces CSRF on state-changing methods (POST/PUT/DELETE/PATCH) + 2. @auth.on โ€” returns metadata filter so each user only sees own threads +""" + +import secrets + +from langgraph_sdk import Auth + +from app.gateway.auth.errors import TokenError +from app.gateway.auth.jwt import decode_token +from app.gateway.deps import get_local_provider + +auth = Auth() + +# Methods that require CSRF validation (state-changing per RFC 7231). +_CSRF_METHODS = frozenset({"POST", "PUT", "DELETE", "PATCH"}) + + +def _check_csrf(request) -> None: + """Enforce Double Submit Cookie CSRF check for state-changing requests. + + Mirrors Gateway's CSRFMiddleware logic so that LangGraph routes + proxied directly by nginx have the same CSRF protection. + """ + method = getattr(request, "method", "") or "" + if method.upper() not in _CSRF_METHODS: + return + + cookie_token = request.cookies.get("csrf_token") + header_token = request.headers.get("x-csrf-token") + + if not cookie_token or not header_token: + raise Auth.exceptions.HTTPException( + status_code=403, + detail="CSRF token missing. Include X-CSRF-Token header.", + ) + + if not secrets.compare_digest(cookie_token, header_token): + raise Auth.exceptions.HTTPException( + status_code=403, + detail="CSRF token mismatch.", + ) + + +@auth.authenticate +async def authenticate(request): + """Validate the session cookie, decode JWT, and check token_version. + + Same validation chain as Gateway's get_current_user_from_request: + cookie โ†’ decode JWT โ†’ DB lookup โ†’ token_version match + Also enforces CSRF on state-changing methods. + """ + # CSRF check before authentication so forged cross-site requests + # are rejected early, even if the cookie carries a valid JWT. + _check_csrf(request) + + token = request.cookies.get("access_token") + if not token: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Not authenticated", + ) + + payload = decode_token(token) + if isinstance(payload, TokenError): + raise Auth.exceptions.HTTPException( + status_code=401, + detail=f"Token error: {payload.value}", + ) + + user = await get_local_provider().get_user(payload.sub) + if user is None: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="User not found", + ) + if user.token_version != payload.ver: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Token revoked (password changed)", + ) + + return payload.sub + + +@auth.on +async def add_owner_filter(ctx: Auth.types.AuthContext, value: dict): + """Inject user_id metadata on writes; filter by user_id on reads. + + Gateway stores thread ownership as ``metadata.user_id``. + This handler ensures LangGraph Server enforces the same isolation. + """ + # On create/update: stamp user_id into metadata + metadata = value.setdefault("metadata", {}) + metadata["user_id"] = ctx.user.identity + + # Return filter dict โ€” LangGraph applies it to search/read/delete + return {"user_id": ctx.user.identity} diff --git a/backend/app/gateway/routers/__init__.py b/backend/app/gateway/routers/__init__.py index c5f67a396..4e9b24d4a 100644 --- a/backend/app/gateway/routers/__init__.py +++ b/backend/app/gateway/routers/__init__.py @@ -1,3 +1,3 @@ -from . import artifacts, assistants_compat, mcp, models, skills, suggestions, thread_runs, threads, uploads +from . import artifacts, assistants_compat, auth, mcp, models, skills, suggestions, thread_runs, threads, uploads -__all__ = ["artifacts", "assistants_compat", "mcp", "models", "skills", "suggestions", "threads", "thread_runs", "uploads"] +__all__ = ["artifacts", "assistants_compat", "auth", "mcp", "models", "skills", "suggestions", "threads", "thread_runs", "uploads"] diff --git a/backend/app/gateway/routers/agents.py b/backend/app/gateway/routers/agents.py index 00b35857f..ec5e2faac 100644 --- a/backend/app/gateway/routers/agents.py +++ b/backend/app/gateway/routers/agents.py @@ -24,7 +24,7 @@ class AgentResponse(BaseModel): description: str = Field(default="", description="Agent description") model: str | None = Field(default=None, description="Optional model override") tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist") - soul: str | None = Field(default=None, description="SOUL.md content (included on GET /{name})") + soul: str | None = Field(default=None, description="SOUL.md content") class AgentsListResponse(BaseModel): @@ -92,17 +92,17 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False "/agents", response_model=AgentsListResponse, summary="List Custom Agents", - description="List all custom agents available in the agents directory.", + description="List all custom agents available in the agents directory, including their soul content.", ) async def list_agents() -> AgentsListResponse: """List all custom agents. Returns: - List of all custom agents with their metadata (without soul content). + List of all custom agents with their metadata and soul content. """ try: agents = list_custom_agents() - return AgentsListResponse(agents=[_agent_config_to_response(a) for a in agents]) + return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True) for a in agents]) except Exception as e: logger.error(f"Failed to list agents: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py new file mode 100644 index 000000000..843dd7185 --- /dev/null +++ b/backend/app/gateway/routers/auth.py @@ -0,0 +1,303 @@ +"""Authentication endpoints.""" + +import logging +import time + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, EmailStr, Field + +from app.gateway.auth import ( + UserResponse, + create_access_token, +) +from app.gateway.auth.config import get_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse +from app.gateway.csrf_middleware import is_secure_request +from app.gateway.deps import get_current_user_from_request, get_local_provider + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + + +# โ”€โ”€ Request/Response Models โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class LoginResponse(BaseModel): + """Response model for login โ€” token only lives in HttpOnly cookie.""" + + expires_in: int # seconds + needs_setup: bool = False + + +class RegisterRequest(BaseModel): + """Request model for user registration.""" + + email: EmailStr + password: str = Field(..., min_length=8) + + +class ChangePasswordRequest(BaseModel): + """Request model for password change (also handles setup flow).""" + + current_password: str + new_password: str = Field(..., min_length=8) + new_email: EmailStr | None = None + + +class MessageResponse(BaseModel): + """Generic message response.""" + + message: str + + +# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _set_session_cookie(response: Response, token: str, request: Request) -> None: + """Set the access_token HttpOnly cookie on the response.""" + config = get_auth_config() + is_https = is_secure_request(request) + response.set_cookie( + key="access_token", + value=token, + httponly=True, + secure=is_https, + samesite="lax", + max_age=config.token_expiry_days * 24 * 3600 if is_https else None, + ) + + +# โ”€โ”€ Rate Limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# In-process dict โ€” not shared across workers. Sufficient for single-worker deployments. + +_MAX_LOGIN_ATTEMPTS = 5 +_LOCKOUT_SECONDS = 300 # 5 minutes + +# ip โ†’ (fail_count, lock_until_timestamp) +_login_attempts: dict[str, tuple[int, float]] = {} + + +def _get_client_ip(request: Request) -> str: + """Extract the real client IP for rate limiting. + + Uses ``X-Real-IP`` header set by nginx (``proxy_set_header X-Real-IP + $remote_addr``). Nginx unconditionally overwrites any client-supplied + ``X-Real-IP``, so the value seen by Gateway is always the TCP peer IP + that nginx observed โ€” it cannot be spoofed by the client. + + ``request.client.host`` is NOT reliable because uvicorn's default + ``proxy_headers=True`` replaces it with the *first* entry from + ``X-Forwarded-For``, which IS client-spoofable. + + ``X-Forwarded-For`` is intentionally NOT used for the same reason. + """ + real_ip = request.headers.get("x-real-ip", "").strip() + if real_ip: + return real_ip + + # Fallback: direct connection without nginx (e.g. unit tests, dev). + return request.client.host if request.client else "unknown" + + +def _check_rate_limit(ip: str) -> None: + """Raise 429 if the IP is currently locked out.""" + record = _login_attempts.get(ip) + if record is None: + return + fail_count, lock_until = record + if fail_count >= _MAX_LOGIN_ATTEMPTS: + if time.time() < lock_until: + raise HTTPException( + status_code=429, + detail="Too many login attempts. Try again later.", + ) + del _login_attempts[ip] + + +_MAX_TRACKED_IPS = 10000 + + +def _record_login_failure(ip: str) -> None: + """Record a failed login attempt for the given IP.""" + # Evict expired lockouts when dict grows too large + if len(_login_attempts) >= _MAX_TRACKED_IPS: + now = time.time() + expired = [k for k, (c, t) in _login_attempts.items() if c >= _MAX_LOGIN_ATTEMPTS and now >= t] + for k in expired: + del _login_attempts[k] + # If still too large, evict cheapest-to-lose half: below-threshold + # IPs (lock_until=0.0) sort first, then earliest-expiring lockouts. + if len(_login_attempts) >= _MAX_TRACKED_IPS: + by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1]) + for k, _ in by_time[: len(by_time) // 2]: + del _login_attempts[k] + + record = _login_attempts.get(ip) + if record is None: + _login_attempts[ip] = (1, 0.0) + else: + new_count = record[0] + 1 + lock_until = time.time() + _LOCKOUT_SECONDS if new_count >= _MAX_LOGIN_ATTEMPTS else 0.0 + _login_attempts[ip] = (new_count, lock_until) + + +def _record_login_success(ip: str) -> None: + """Clear failure counter for the given IP on successful login.""" + _login_attempts.pop(ip, None) + + +# โ”€โ”€ Endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@router.post("/login/local", response_model=LoginResponse) +async def login_local( + request: Request, + response: Response, + form_data: OAuth2PasswordRequestForm = Depends(), +): + """Local email/password login.""" + client_ip = _get_client_ip(request) + _check_rate_limit(client_ip) + + user = await get_local_provider().authenticate({"email": form_data.username, "password": form_data.password}) + + if user is None: + _record_login_failure(client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(), + ) + + _record_login_success(client_ip) + token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, token, request) + + return LoginResponse( + expires_in=get_auth_config().token_expiry_days * 24 * 3600, + needs_setup=user.needs_setup, + ) + + +@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def register(request: Request, response: Response, body: RegisterRequest): + """Register a new user account (always 'user' role). + + Admin is auto-created on first boot. This endpoint creates regular users. + Auto-login by setting the session cookie. + """ + try: + user = await get_local_provider().create_user(email=body.email, password=body.password, system_role="user") + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already registered").model_dump(), + ) + + token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, token, request) + + return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role) + + +@router.post("/logout", response_model=MessageResponse) +async def logout(request: Request, response: Response): + """Logout current user by clearing the cookie.""" + response.delete_cookie(key="access_token", secure=is_secure_request(request), samesite="lax") + return MessageResponse(message="Successfully logged out") + + +@router.post("/change-password", response_model=MessageResponse) +async def change_password(request: Request, response: Response, body: ChangePasswordRequest): + """Change password for the currently authenticated user. + + Also handles the first-boot setup flow: + - If new_email is provided, updates email (checks uniqueness) + - If user.needs_setup is True and new_email is given, clears needs_setup + - Always increments token_version to invalidate old sessions + - Re-issues session cookie with new token_version + """ + from app.gateway.auth.password import hash_password_async, verify_password_async + + user = await get_current_user_from_request(request) + + if user.password_hash is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump()) + + if not await verify_password_async(body.current_password, user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Current password is incorrect").model_dump()) + + provider = get_local_provider() + + # Update email if provided + if body.new_email is not None: + existing = await provider.get_user_by_email(body.new_email) + if existing and str(existing.id) != str(user.id): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already in use").model_dump()) + user.email = body.new_email + + # Update password + bump version + user.password_hash = await hash_password_async(body.new_password) + user.token_version += 1 + + # Clear setup flag if this is the setup flow + if user.needs_setup and body.new_email is not None: + user.needs_setup = False + + await provider.update_user(user) + + # Re-issue cookie with new token_version + token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, token, request) + + return MessageResponse(message="Password changed successfully") + + +@router.get("/me", response_model=UserResponse) +async def get_me(request: Request): + """Get current authenticated user info.""" + user = await get_current_user_from_request(request) + return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup) + + +@router.get("/setup-status") +async def setup_status(): + """Check if admin account exists. Always False after first boot.""" + user_count = await get_local_provider().count_users() + return {"needs_setup": user_count == 0} + + +# โ”€โ”€ OAuth Endpoints (Future/Placeholder) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@router.get("/oauth/{provider}") +async def oauth_login(provider: str): + """Initiate OAuth login flow. + + Redirects to the OAuth provider's authorization URL. + Currently a placeholder - requires OAuth provider implementation. + """ + if provider not in ["github", "google"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported OAuth provider: {provider}", + ) + + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="OAuth login not yet implemented", + ) + + +@router.get("/callback/{provider}") +async def oauth_callback(provider: str, code: str, state: str): + """OAuth callback endpoint. + + Handles the OAuth provider's callback after user authorization. + Currently a placeholder. + """ + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="OAuth callback not yet implemented", + ) diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index 3fac15dd6..ac54e674d 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -2,6 +2,7 @@ import json import logging from fastapi import APIRouter +from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field from deerflow.models import create_chat_model @@ -106,22 +107,21 @@ async def generate_suggestions(thread_id: str, request: SuggestionsRequest) -> S if not conversation: return SuggestionsResponse(suggestions=[]) - prompt = ( + system_instruction = ( "You are generating follow-up questions to help the user continue the conversation.\n" f"Based on the conversation below, produce EXACTLY {n} short questions the user might ask next.\n" "Requirements:\n" - "- Questions must be relevant to the conversation.\n" + "- Questions must be relevant to the preceding conversation.\n" "- Questions must be written in the same language as the user.\n" "- Keep each question concise (ideally <= 20 words / <= 40 Chinese characters).\n" "- Do NOT include numbering, markdown, or any extra text.\n" - "- Output MUST be a JSON array of strings only.\n\n" - "Conversation:\n" - f"{conversation}\n" + "- Output MUST be a JSON array of strings only.\n" ) + user_content = f"Conversation Context:\n{conversation}\n\nGenerate {n} follow-up questions" try: model = create_chat_model(name=request.model_name, thinking_enabled=False) - response = model.invoke(prompt) + response = await model.ainvoke([SystemMessage(content=system_instruction), HumanMessage(content=user_content)]) raw = _extract_response_text(response.content) suggestions = _parse_json_string_list(raw) or [] cleaned = [s.replace("\n", " ").strip() for s in suggestions if s.strip()] diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index d29786edd..1d6d8be2a 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -19,6 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel, Field +from app.gateway.authz import require_auth, require_permission from app.gateway.deps import get_checkpointer, get_run_manager, get_stream_bridge from app.gateway.services import sse_consumer, start_run from deerflow.runtime import RunRecord, serialize_channel_values @@ -92,19 +93,28 @@ def _record_to_response(record: RunRecord) -> RunResponse: @router.post("/{thread_id}/runs", response_model=RunResponse) +@require_auth +@require_permission("runs", "create", owner_check=True) async def create_run(thread_id: str, body: RunCreateRequest, request: Request) -> RunResponse: - """Create a background run (returns immediately).""" + """Create a background run (returns immediately). + + Multi-tenant isolation: only the thread owner can create runs. + """ record = await start_run(body, thread_id, request) return _record_to_response(record) @router.post("/{thread_id}/runs/stream") +@require_auth +@require_permission("runs", "create", owner_check=True) async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) -> StreamingResponse: """Create a run and stream events via SSE. The response includes a ``Content-Location`` header with the run's resource URL, matching the LangGraph Platform protocol. The ``useStream`` React hook uses this to extract run metadata. + + Multi-tenant isolation: only the thread owner can stream runs. """ bridge = get_stream_bridge(request) run_mgr = get_run_manager(request) @@ -125,8 +135,13 @@ async def stream_run(thread_id: str, body: RunCreateRequest, request: Request) - @router.post("/{thread_id}/runs/wait", response_model=dict) +@require_auth +@require_permission("runs", "create", owner_check=True) async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> dict: - """Create a run and block until it completes, returning the final state.""" + """Create a run and block until it completes, returning the final state. + + Multi-tenant isolation: only the thread owner can wait for runs. + """ record = await start_run(body, thread_id, request) if record.task is not None: @@ -150,16 +165,26 @@ async def wait_run(thread_id: str, body: RunCreateRequest, request: Request) -> @router.get("/{thread_id}/runs", response_model=list[RunResponse]) +@require_auth +@require_permission("runs", "read", owner_check=True) async def list_runs(thread_id: str, request: Request) -> list[RunResponse]: - """List all runs for a thread.""" + """List all runs for a thread. + + Multi-tenant isolation: only the thread owner can list runs. + """ run_mgr = get_run_manager(request) records = await run_mgr.list_by_thread(thread_id) return [_record_to_response(r) for r in records] @router.get("/{thread_id}/runs/{run_id}", response_model=RunResponse) +@require_auth +@require_permission("runs", "read", owner_check=True) async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse: - """Get details of a specific run.""" + """Get details of a specific run. + + Multi-tenant isolation: only the thread owner can get runs. + """ run_mgr = get_run_manager(request) record = run_mgr.get(run_id) if record is None or record.thread_id != thread_id: @@ -168,6 +193,8 @@ async def get_run(thread_id: str, run_id: str, request: Request) -> RunResponse: @router.post("/{thread_id}/runs/{run_id}/cancel") +@require_auth +@require_permission("runs", "cancel", owner_check=True) async def cancel_run( thread_id: str, run_id: str, @@ -181,6 +208,8 @@ async def cancel_run( - action=rollback: Stop execution, revert to pre-run checkpoint state - wait=true: Block until the run fully stops, return 204 - wait=false: Return immediately with 202 + + Multi-tenant isolation: only the thread owner can cancel runs. """ run_mgr = get_run_manager(request) record = run_mgr.get(run_id) @@ -205,8 +234,13 @@ async def cancel_run( @router.get("/{thread_id}/runs/{run_id}/join") +@require_auth +@require_permission("runs", "read", owner_check=True) async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingResponse: - """Join an existing run's SSE stream.""" + """Join an existing run's SSE stream. + + Multi-tenant isolation: only the thread owner can join runs. + """ bridge = get_stream_bridge(request) run_mgr = get_run_manager(request) record = run_mgr.get(run_id) diff --git a/backend/app/gateway/routers/threads.py b/backend/app/gateway/routers/threads.py index 562edfdb7..5f6bcc96f 100644 --- a/backend/app/gateway/routers/threads.py +++ b/backend/app/gateway/routers/threads.py @@ -13,17 +13,26 @@ matching the LangGraph Platform wire format expected by the from __future__ import annotations import logging +import re import time import uuid -from typing import Any +from typing import Annotated, Any -from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel, Field +from fastapi import APIRouter, HTTPException, Path, Request +from pydantic import BaseModel, Field, field_validator +from app.gateway.authz import require_auth, require_permission from app.gateway.deps import get_checkpointer, get_store from deerflow.config.paths import Paths, get_paths from deerflow.runtime import serialize_channel_values +# --------------------------------------------------------------------------- +# Thread ID validation (prevents log-injection via control characters) +# --------------------------------------------------------------------------- + +_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +ThreadId = Annotated[str, Path(description="Thread UUID", pattern=_UUID_RE.pattern)] + # --------------------------------------------------------------------------- # Store namespace # --------------------------------------------------------------------------- @@ -65,6 +74,13 @@ class ThreadCreateRequest(BaseModel): thread_id: str | None = Field(default=None, description="Optional thread ID (auto-generated if omitted)") metadata: dict[str, Any] = Field(default_factory=dict, description="Initial metadata") + @field_validator("thread_id") + @classmethod + def _validate_uuid(cls, v: str | None) -> str | None: + if v is not None and not _UUID_RE.match(v): + raise ValueError("thread_id must be a valid UUID") + return v + class ThreadSearchRequest(BaseModel): """Request body for searching threads.""" @@ -215,17 +231,23 @@ def _derive_thread_status(checkpoint_tuple) -> str: @router.delete("/{thread_id}", response_model=ThreadDeleteResponse) -async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteResponse: +@require_auth +@require_permission("threads", "delete", owner_check=True) +async def delete_thread_data(thread_id: ThreadId, request: Request) -> ThreadDeleteResponse: """Delete local persisted filesystem data for a thread. Cleans DeerFlow-managed thread directories, removes checkpoint data, and removes the thread record from the Store. + + Multi-tenant isolation: only the thread owner can delete their thread. """ + store = get_store(request) + checkpointer = get_checkpointer(request) + # Clean local filesystem response = _delete_thread_data(thread_id) # Remove from Store (best-effort) - store = get_store(request) if store is not None: try: await store.adelete(THREADS_NS, thread_id) @@ -233,7 +255,6 @@ async def delete_thread_data(thread_id: str, request: Request) -> ThreadDeleteRe logger.debug("Could not delete store record for thread %s (not critical)", thread_id) # Remove checkpoints (best-effort) - checkpointer = getattr(request.app.state, "checkpointer", None) if checkpointer is not None: try: if hasattr(checkpointer, "adelete_thread"): @@ -251,12 +272,23 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe The thread record is written to the Store (for fast listing) and an empty checkpoint is written to the checkpointer (for state reads). Idempotent: returns the existing record when ``thread_id`` already exists. + + If authenticated, the user's ID is injected into the thread metadata + for multi-tenant isolation. """ store = get_store(request) checkpointer = get_checkpointer(request) thread_id = body.thread_id or str(uuid.uuid4()) now = time.time() + from app.gateway.deps import get_optional_user_from_request + + user = await get_optional_user_from_request(request) + + thread_metadata = dict(body.metadata) + if user: + thread_metadata["user_id"] = str(user.id) + # Idempotency: return existing record from Store when already present if store is not None: existing_record = await _store_get(store, thread_id) @@ -279,7 +311,7 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe "status": "idle", "created_at": now, "updated_at": now, - "metadata": body.metadata, + "metadata": thread_metadata, }, ) except Exception: @@ -296,7 +328,7 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe "source": "input", "writes": None, "parents": {}, - **body.metadata, + **thread_metadata, "created_at": now, } await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {}) @@ -304,13 +336,13 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe logger.exception("Failed to create checkpoint for thread %s", thread_id) raise HTTPException(status_code=500, detail="Failed to create thread") - logger.info("Thread created: %s", thread_id) + logger.info("Thread created: %s (user_id=%s)", thread_id, thread_metadata.get("user_id")) return ThreadResponse( thread_id=thread_id, status="idle", created_at=str(now), updated_at=str(now), - metadata=body.metadata, + metadata=thread_metadata, ) @@ -330,10 +362,18 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th newly found thread is immediately written to the Store so that the next search skips Phase 2 for that thread โ€” the Store converges to a full index over time without a one-shot migration job. + + If authenticated, only threads belonging to the current user are returned + (enforced by user_id metadata filter for multi-tenant isolation). """ store = get_store(request) checkpointer = get_checkpointer(request) + from app.gateway.deps import get_optional_user_from_request + + user = await get_optional_user_from_request(request) + user_id = str(user.id) if user else None + # ----------------------------------------------------------------------- # Phase 1: Store # ----------------------------------------------------------------------- @@ -409,6 +449,10 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th # ----------------------------------------------------------------------- results = list(merged.values()) + # Multi-tenant isolation: filter by user_id if authenticated + if user_id: + results = [r for r in results if r.metadata.get("user_id") == user_id] + if body.metadata: results = [r for r in results if all(r.metadata.get(k) == v for k, v in body.metadata.items())] @@ -420,13 +464,20 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th @router.patch("/{thread_id}", response_model=ThreadResponse) -async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Request) -> ThreadResponse: - """Merge metadata into a thread record.""" +@require_auth +@require_permission("threads", "write", owner_check=True, inject_record=True) +async def patch_thread(thread_id: ThreadId, request: Request, body: ThreadPatchRequest, thread_record: dict = None) -> ThreadResponse: + """Merge metadata into a thread record. + + Multi-tenant isolation: only the thread owner can patch their thread. + """ store = get_store(request) if store is None: raise HTTPException(status_code=503, detail="Store not available") - record = await _store_get(store, thread_id) + record = thread_record + if record is None: + record = await _store_get(store, thread_id) if record is None: raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") @@ -451,12 +502,17 @@ async def patch_thread(thread_id: str, body: ThreadPatchRequest, request: Reques @router.get("/{thread_id}", response_model=ThreadResponse) -async def get_thread(thread_id: str, request: Request) -> ThreadResponse: +@require_auth +@require_permission("threads", "read", owner_check=True) +async def get_thread(thread_id: ThreadId, request: Request) -> ThreadResponse: """Get thread info. Reads metadata from the Store and derives the accurate execution status from the checkpointer. Falls back to the checkpointer alone for threads that pre-date Store adoption (backward compat). + + Multi-tenant isolation: returns 404 if the thread does not belong to + the authenticated user. """ store = get_store(request) checkpointer = get_checkpointer(request) @@ -488,26 +544,33 @@ async def get_thread(thread_id: str, request: Request) -> ThreadResponse: "metadata": {k: v for k, v in ckpt_meta.items() if k not in ("created_at", "updated_at", "step", "source", "writes", "parents")}, } - status = _derive_thread_status(checkpoint_tuple) if checkpoint_tuple is not None else record.get("status", "idle") # type: ignore[union-attr] + if record is None: + raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found") + + status = _derive_thread_status(checkpoint_tuple) if checkpoint_tuple is not None else record.get("status", "idle") checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {} if checkpoint_tuple is not None else {} channel_values = checkpoint.get("channel_values", {}) return ThreadResponse( thread_id=thread_id, status=status, - created_at=str(record.get("created_at", "")), # type: ignore[union-attr] - updated_at=str(record.get("updated_at", "")), # type: ignore[union-attr] - metadata=record.get("metadata", {}), # type: ignore[union-attr] + created_at=str(record.get("created_at", "")), + updated_at=str(record.get("updated_at", "")), + metadata=record.get("metadata", {}), values=serialize_channel_values(channel_values), ) @router.get("/{thread_id}/state", response_model=ThreadStateResponse) -async def get_thread_state(thread_id: str, request: Request) -> ThreadStateResponse: +@require_auth +@require_permission("threads", "read", owner_check=True) +async def get_thread_state(thread_id: ThreadId, request: Request) -> ThreadStateResponse: """Get the latest state snapshot for a thread. Channel values are serialized to ensure LangChain message objects are converted to JSON-safe dicts. + + Multi-tenant isolation: returns 404 if thread does not belong to user. """ checkpointer = get_checkpointer(request) @@ -552,12 +615,16 @@ async def get_thread_state(thread_id: str, request: Request) -> ThreadStateRespo @router.post("/{thread_id}/state", response_model=ThreadStateResponse) -async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse: +@require_auth +@require_permission("threads", "write", owner_check=True) +async def update_thread_state(thread_id: ThreadId, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse: """Update thread state (e.g. for human-in-the-loop resume or title rename). Writes a new checkpoint that merges *body.values* into the latest channel values, then syncs any updated ``title`` field back to the Store so that ``/threads/search`` reflects the change immediately. + + Multi-tenant isolation: only the thread owner can update their thread. """ checkpointer = get_checkpointer(request) store = get_store(request) @@ -635,8 +702,13 @@ async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, re @router.post("/{thread_id}/history", response_model=list[HistoryEntry]) -async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request: Request) -> list[HistoryEntry]: - """Get checkpoint history for a thread.""" +@require_auth +@require_permission("threads", "read", owner_check=True) +async def get_thread_history(thread_id: ThreadId, body: ThreadHistoryRequest, request: Request) -> list[HistoryEntry]: + """Get checkpoint history for a thread. + + Multi-tenant isolation: returns 404 if thread does not belong to user. + """ checkpointer = get_checkpointer(request) config: dict[str, Any] = {"configurable": {"thread_id": thread_id}} diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 272801b6a..90004640d 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -116,6 +116,7 @@ def build_run_config( metadata: dict[str, Any] | None, *, assistant_id: str | None = None, + user_id: str | None = None, ) -> dict[str, Any]: """Build a RunnableConfig dict for the agent. @@ -128,6 +129,9 @@ def build_run_config( This mirrors the channel manager's ``_resolve_run_params`` logic so that the LangGraph Platform-compatible HTTP API and the IM channel path behave identically. + + If *user_id* is provided, it is injected into the config metadata for + multi-tenant isolation. """ config: dict[str, Any] = {"recursion_limit": 100} if request_config: @@ -161,6 +165,11 @@ def build_run_config( if not normalized or not re.fullmatch(r"[a-z0-9-]+", normalized): raise ValueError(f"Invalid assistant_id {assistant_id!r}: must contain only letters, digits, and hyphens after normalization.") config["configurable"]["agent_name"] = normalized + + # Multi-tenant isolation: inject user_id into metadata + if user_id: + config.setdefault("metadata", {})["user_id"] = user_id + if metadata: config.setdefault("metadata", {}).update(metadata) return config @@ -260,6 +269,10 @@ async def start_run( disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ + # Reuse auth context set by @require_auth decorator to avoid redundant DB lookup + auth = getattr(request.state, "auth", None) + user_id = str(auth.user.id) if auth and auth.user else None + try: record = await run_mgr.create_or_reject( thread_id, @@ -282,7 +295,13 @@ async def start_run( agent_factory = resolve_agent_factory(body.assistant_id) graph_input = normalize_input(body.input) - config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id) + config = build_run_config( + thread_id, + body.config, + body.metadata, + assistant_id=body.assistant_id, + user_id=user_id, + ) # Merge DeerFlow-specific context overrides into configurable. # The ``context`` field is a custom extension for the langgraph-compat layer diff --git a/backend/docs/AUTH_TEST_PLAN.md b/backend/docs/AUTH_TEST_PLAN.md new file mode 100644 index 000000000..2f3ff0811 --- /dev/null +++ b/backend/docs/AUTH_TEST_PLAN.md @@ -0,0 +1,1786 @@ +# Auth ๆจกๅ—ๆต‹่ฏ•่ฎกๅˆ’ + +## ๆต‹่ฏ•็Ÿฉ้˜ต + +| ๆจกๅผ | ๅฏๅŠจๅ‘ฝไปค | Auth ๅฑ‚ | ็ซฏๅฃ | +|------|---------|---------|------| +| ๆ ‡ๅ‡†ๆจกๅผ | `make dev` | Gateway AuthMiddleware + LangGraph auth | 2026 (nginx) | +| Gateway ๆจกๅผ | `make dev-pro` | Gateway AuthMiddleware๏ผˆๅ…จ้‡๏ผ‰ | 2026 (nginx) | +| ็›ด่ฟž Gateway | `cd backend && make gateway` | Gateway AuthMiddleware | 8001 | +| ็›ด่ฟž LangGraph | `cd backend && make dev` | LangGraph auth | 2024 | + +ๆฏ็งๆจกๅผไธ‹้ƒฝ้œ€ๆ‰ง่กŒไปฅไธ‹ๆต‹่ฏ•ใ€‚ + +--- + +## ไธ€ใ€็Žฏๅขƒๅ‡†ๅค‡ + +### 1.1 ้ฆ–ๆฌกๅฏๅŠจ๏ผˆๅนฒๅ‡€ๆ•ฐๆฎๅบ“๏ผ‰ + +```bash +# ๆธ…้™คๅทฒๆœ‰ๆ•ฐๆฎ +rm -f backend/.deer-flow/users.db + +# ้€‰ๆ‹ฉๆจกๅผๅฏๅŠจ +make dev # ๆ ‡ๅ‡†ๆจกๅผ +# ๆˆ– +make dev-pro # Gateway ๆจกๅผ +``` + +**้ชŒ่ฏ็‚น๏ผš** +- [ ] ๆŽงๅˆถๅฐ่พ“ๅ‡บ admin ้‚ฎ็ฎฑๅ’Œ้šๆœบๅฏ†็  +- [ ] ๅฏ†็ ๆ ผๅผไธบ `secrets.token_urlsafe(16)` ็š„ 22 ๅญ—็ฌฆๅญ—็ฌฆไธฒ +- [ ] ้‚ฎ็ฎฑไธบ `admin@deerflow.dev` +- [ ] ๆ็คบ `Change it after login: Settings -> Account` + +### 1.2 ้ž้ฆ–ๆฌกๅฏๅŠจ + +```bash +# ไธๆธ…้™คๆ•ฐๆฎๅบ“๏ผŒ็›ดๆŽฅๅฏๅŠจ +make dev +``` + +**้ชŒ่ฏ็‚น๏ผš** +- [ ] ๆŽงๅˆถๅฐไธ่พ“ๅ‡บๅฏ†็  +- [ ] ๅฆ‚ๆžœ admin ไป `needs_setup=True`๏ผŒๆŽงๅˆถๅฐๆœ‰ warning ๆ็คบ + +### 1.3 ็Žฏๅขƒๅ˜้‡้…็ฝฎ + +| ๅ˜้‡ | ้ชŒ่ฏ | +|------|------| +| `AUTH_JWT_SECRET` ๆœช่ฎพ | ๅฏๅŠจๆ—ถ warning๏ผŒ่‡ชๅŠจ็”Ÿๆˆไธดๆ—ถๅฏ†้’ฅ | +| `AUTH_JWT_SECRET` ๅทฒ่ฎพ | ๆ—  warning๏ผŒ้‡ๅฏๅŽ session ไฟๆŒ | + +--- + +## ไบŒใ€ๆŽฅๅฃๆต็จ‹ๆต‹่ฏ• + +> ไปฅไธ‹็”จ `BASE=http://localhost:2026` ไธบไพ‹ใ€‚ๆ ‡ๅ‡†ๆจกๅผๅ’Œ Gateway ๆจกๅผ้ƒฝ็”จๆญคๅœฐๅ€ใ€‚ +> ็›ด่ฟžๆต‹่ฏ•ๆ›ฟๆขไธบๅฏนๅบ”็ซฏๅฃใ€‚ +> +> **CSRF token ๆๅ–**๏ผšๅคšๅค„็”จๅˆฐไปŽ cookie jar ๆๅ– CSRF token๏ผŒ็ปŸไธ€ไฝฟ็”จ๏ผš +> ```bash +> CSRF=$(python3 -c " +> import http.cookiejar +> cj = http.cookiejar.MozillaCookieJar('cookies.txt'); cj.load() +> print(next(c.value for c in cj if c.name == 'csrf_token')) +> ") +> ``` +> ๆˆ–็ฎ€ๅ†™๏ผˆๅคšๆ•ฐๅœบๆ™ฏๅคŸ็”จ๏ผ‰๏ผš`CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}')` + +### 2.1 ๆณจๅ†Œ + ็™ปๅฝ• + ไผš่ฏ + +#### TC-API-01: Setup ็Šถๆ€ๆŸฅ่ฏข + +```bash +curl -s $BASE/api/v1/auth/setup-status | jq . +``` + +**้ข„ๆœŸ๏ผš** ่ฟ”ๅ›ž `{"needs_setup": false}`๏ผˆadmin ๅœจๅฏๅŠจๆ—ถๅทฒ่‡ชๅŠจๅˆ›ๅปบ๏ผŒ`count_users() > 0`๏ผ‰ใ€‚ไป…ๅœจๅฏๅŠจๅฎŒๆˆๅ‰็š„ๆž็Ÿญ็ช—ๅฃๅ†…ๅฏ่ƒฝ่ฟ”ๅ›ž `true`ใ€‚ + +#### TC-API-02: Admin ้ฆ–ๆฌก็™ปๅฝ• + +```bash +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=<ๆŽงๅˆถๅฐๅฏ†็ >" \ + -c cookies.txt | jq . +``` + +**้ข„ๆœŸ๏ผš** +- ็Šถๆ€็  200 +- Body: `{"expires_in": 604800, "needs_setup": true}` +- `cookies.txt` ๅŒ…ๅซ `access_token`๏ผˆHttpOnly๏ผ‰ๅ’Œ `csrf_token`๏ผˆ้ž HttpOnly๏ผ‰ + +#### TC-API-03: ่Žทๅ–ๅฝ“ๅ‰็”จๆˆท + +```bash +curl -s $BASE/api/v1/auth/me -b cookies.txt | jq . +``` + +**้ข„ๆœŸ๏ผš** `{"id": "...", "email": "admin@deerflow.dev", "system_role": "admin", "needs_setup": true}` + +#### TC-API-04: Setup ๆต็จ‹๏ผˆๆ”น้‚ฎ็ฎฑ + ๆ”นๅฏ†็ ๏ผ‰ + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"<ๆŽงๅˆถๅฐๅฏ†็ >","new_password":"NewPass123!","new_email":"admin@example.com"}' | jq . +``` + +**้ข„ๆœŸ๏ผš** +- ็Šถๆ€็  200 +- `{"message": "Password changed successfully"}` +- ๅ†่ฐƒ `/auth/me` ้‚ฎ็ฎฑๅ˜ไธบ `admin@example.com`๏ผŒ`needs_setup` ๅ˜ไธบ `false` + +#### TC-API-05: ๆ™ฎ้€š็”จๆˆทๆณจๅ†Œ + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"user1@example.com","password":"UserPass1!"}' \ + -c user_cookies.txt | jq . +``` + +**้ข„ๆœŸ๏ผš** ็Šถๆ€็  201๏ผŒ`system_role` ไธบ `"user"`๏ผŒ่‡ชๅŠจ็™ปๅฝ•๏ผˆcookie ๅทฒ่ฎพ๏ผ‰ + +#### TC-API-06: ็™ปๅ‡บ + +```bash +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt | jq . +``` + +**้ข„ๆœŸ๏ผš** `{"message": "Successfully logged out"}`๏ผŒๅŽ็ปญ็”จ cookies.txt ่ฎฟ้—ฎ `/auth/me` ่ฟ”ๅ›ž 401 + +### 2.2 ๅคš็งŸๆˆท้š”็ฆป + +#### TC-API-07: ็”จๆˆท A ๅˆ›ๅปบ Thread + +```bash +# ไปฅ user1 ็™ปๅฝ• +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=user1@example.com&password=UserPass1!" \ + -c user1.txt + +CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}') + +# ๅˆ›ๅปบ thread +curl -s -X POST $BASE/api/threads \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF1" \ + -d '{"metadata":{}}' | jq .thread_id +# ่ฎฐๅฝ• THREAD_ID +``` + +#### TC-API-08: ็”จๆˆท B ๆ— ๆณ•่ฎฟ้—ฎ็”จๆˆท A ็š„ Thread + +```bash +# ๆณจๅ†Œๅนถ็™ปๅฝ• user2 +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"user2@example.com","password":"UserPass2!"}' \ + -c user2.txt + +# ๅฐ่ฏ•่ฎฟ้—ฎ user1 ็š„ thread +curl -s $BASE/api/threads/$THREAD_ID -b user2.txt +``` + +**้ข„ๆœŸ๏ผš** ็Šถๆ€็  404๏ผˆไธๆ˜ฏ 403๏ผŒ้ฟๅ…ๆณ„้œฒ thread ๅญ˜ๅœจๆ€ง๏ผ‰ + +#### TC-API-09: ็”จๆˆท B ๆœ็ดข Thread ็œ‹ไธๅˆฐ็”จๆˆท A ็š„ + +```bash +CSRF2=$(grep csrf_token user2.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/threads/search \ + -b user2.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF2" \ + -d '{}' | jq length +``` + +**้ข„ๆœŸ๏ผš** ่ฟ”ๅ›ž 0 ๆˆ–ไป…ๅŒ…ๅซ user2 ่‡ชๅทฑ็š„ thread + +### 2.3 ๆ ‡ๅ‡†ๆจกๅผ LangGraph Server ้š”็ฆป + +> ไป…ๅœจๆ ‡ๅ‡†ๆจกๅผไธ‹ๆต‹่ฏ•ใ€‚Gateway ๆจกๅผไธ่ท‘ LangGraph Serverใ€‚ + +#### TC-API-10: LangGraph ็ซฏ็‚น้œ€่ฆ cookie + +```bash +# ไธๅธฆ cookie ่ฎฟ้—ฎ LangGraph ๆŽฅๅฃ +curl -s -w "%{http_code}" $BASE/api/langgraph/threads +``` + +**้ข„ๆœŸ๏ผš** 401 + +#### TC-API-11: LangGraph ๅธฆ cookie ๅฏ่ฎฟ้—ฎ + +```bash +curl -s $BASE/api/langgraph/threads -b user1.txt | jq length +``` + +**้ข„ๆœŸ๏ผš** 200๏ผŒ่ฟ”ๅ›ž user1 ็š„ thread ๅˆ—่กจ + +#### TC-API-12: LangGraph ้š”็ฆป โ€” ็”จๆˆทๅช็œ‹ๅˆฐ่‡ชๅทฑ็š„ + +```bash +# user2 ๆŸฅ LangGraph threads +curl -s $BASE/api/langgraph/threads -b user2.txt | jq length +``` + +**้ข„ๆœŸ๏ผš** ไธๅŒ…ๅซ user1 ็š„ thread + +### 2.4 Token ๅคฑๆ•ˆ + +#### TC-API-13: ๆ”นๅฏ†็ ๅŽๆ—ง token ็ซ‹ๅณๅคฑๆ•ˆ + +```bash +# ไฟๅญ˜ๅฝ“ๅ‰ cookie +cp user1.txt user1_old.txt + +# ๆ”นๅฏ†็  +CSRF1=$(grep csrf_token user1.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF1" \ + -d '{"current_password":"UserPass1!","new_password":"NewUserPass1!"}' \ + -c user1.txt + +# ็”จๆ—ง cookie ่ฎฟ้—ฎ +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b user1_old.txt +``` + +**้ข„ๆœŸ๏ผš** 401๏ผˆtoken_version ไธๅŒน้…๏ผ‰ + +#### TC-API-14: ๆ”นๅฏ†็ ๅŽๆ–ฐ cookie ๅฏ็”จ + +```bash +curl -s $BASE/api/v1/auth/me -b user1.txt | jq .email +``` + +**้ข„ๆœŸ๏ผš** 200๏ผŒ่ฟ”ๅ›ž็”จๆˆทไฟกๆฏ + +### 2.5 ้”™่ฏฏๅ“ๅบ”ๆ ผๅผ + +#### TC-API-15: ็ป“ๆž„ๅŒ–้”™่ฏฏๅ“ๅบ” + +```bash +# ้”™่ฏฏๅฏ†็ ็™ปๅฝ• +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" | jq .detail +``` + +**้ข„ๆœŸ๏ผš** +```json +{"code": "invalid_credentials", "message": "Incorrect email or password"} +``` + +#### TC-API-16: ้‡ๅค้‚ฎ็ฎฑๆณจๅ†Œ + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"user1@example.com","password":"AnyPass123"}' -w "\n%{http_code}" +``` + +**้ข„ๆœŸ๏ผš** 400๏ผŒ`{"code": "email_already_exists", ...}` + +--- + +## ไธ‰ใ€ๆ”ปๅ‡ปๆต‹่ฏ• + +### 3.1 ๆšดๅŠ›็ ด่งฃ้˜ฒๆŠค + +#### TC-ATK-01: IP ้™้€Ÿ + +```bash +# ่ฟž็ปญ 6 ๆฌก้”™่ฏฏๅฏ†็  +for i in $(seq 1 6); do + echo "Attempt $i:" + curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong$i" -w " HTTP %{http_code}\n" +done +``` + +**้ข„ๆœŸ๏ผš** ๅ‰ 5 ๆฌก่ฟ”ๅ›ž 401๏ผŒ็ฌฌ 6 ๆฌก่ฟ”ๅ›ž 429 `"Too many login attempts. Try again later."` + +#### TC-ATK-02: ้™้€ŸๅŽๆญฃ็กฎๅฏ†็ ไนŸ่ขซๆ‹’ + +```bash +# ็ดงๆŽฅไธŠไธ€ๆญฅ +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " -w " HTTP %{http_code}\n" +``` + +**้ข„ๆœŸ๏ผš** 429๏ผˆ้”ๅฎš 5 ๅˆ†้’Ÿ๏ผ‰ + +#### TC-ATK-03: ๆˆๅŠŸ็™ปๅฝ•ๆธ…้™ค้™้€Ÿ + +```bash +# ็ญ‰ๅพ…้”ๅฎš่ฟ‡ๆœŸๅŽ๏ผˆๆˆ–้‡ๅฏๆœๅŠก๏ผ‰๏ผŒ็”จๆญฃ็กฎๅฏ†็ ็™ปๅฝ• +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " -w " HTTP %{http_code}\n" +``` + +**้ข„ๆœŸ๏ผš** 200๏ผŒ่ฎกๆ•ฐๅ™จ้‡็ฝฎ + +### 3.2 CSRF ้˜ฒๆŠค + +#### TC-ATK-04: ๆ—  CSRF token ็š„ POST ่ฏทๆฑ‚ + +```bash +curl -s -X POST $BASE/api/threads \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -d '{"metadata":{}}' -w "\nHTTP %{http_code}" +``` + +**้ข„ๆœŸ๏ผš** 403 `"CSRF token missing"` + +#### TC-ATK-05: ้”™่ฏฏ CSRF token + +```bash +curl -s -X POST $BASE/api/threads \ + -b user1.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: fake-token" \ + -d '{"metadata":{}}' -w "\nHTTP %{http_code}" +``` + +**้ข„ๆœŸ๏ผš** 403 `"CSRF token mismatch"` + +### 3.3 Cookie ๅฎ‰ๅ…จ + +> HTTP ไธŽ HTTPS ่กŒไธบๅทฎๅผ‚้€š่ฟ‡ `X-Forwarded-Proto: https` ๆจกๆ‹Ÿใ€‚ +> **ๆณจๆ„๏ผš** ็ป nginx ไปฃ็†ๆ—ถ๏ผŒnginx ็š„ `proxy_set_header X-Forwarded-Proto $scheme` ไผš่ฆ†็›– +> ๅฎขๆˆท็ซฏๅ‘็š„ๅ€ผ๏ผˆ`$scheme` = nginx ็›‘ๅฌ็ซฏๅฃ็š„ scheme๏ผ‰๏ผŒๅ› ๆญค HTTPS ๆจกๆ‹Ÿๅฟ…้กป**็›ด่ฟž Gateway๏ผˆ็ซฏๅฃ 8001๏ผ‰**ใ€‚ +> ๆฏไธช case ้œ€ๅœจ **login** ๅ’Œ **register** ไธคไธช็ซฏ็‚นๅ„้ชŒ่ฏไธ€ๆฌกใ€‚ + +#### TC-ATK-06: HTTP ๆจกๅผ Cookie ๅฑžๆ€ง + +```bash +# ็™ปๅฝ• +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " 2>/dev/null | grep -i set-cookie +``` + +**้ข„ๆœŸ๏ผš** +- `access_token`: `HttpOnly; Path=/; SameSite=lax`๏ผŒๆ—  `Secure`๏ผŒๆ—  `Max-Age` +- `csrf_token`: `Path=/; SameSite=strict`๏ผŒๆ—  `HttpOnly`๏ผˆJS ้œ€่ฆ่ฏปๅ–๏ผ‰๏ผŒๆ—  `Secure` + +```bash +# ๆณจๅ†Œ +curl -s -D - -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"cookie-http@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie +``` + +**้ข„ๆœŸ๏ผš** ๅŒไธŠ + +#### TC-ATK-07: HTTPS ๆจกๅผ Cookie ๅฑžๆ€ง + +> **ๅฟ…้กป็›ด่ฟž Gateway**๏ผˆ`GW=http://localhost:8001`๏ผ‰๏ผŒ็ป nginx ไผš่ขซ `$scheme` ่ฆ†็›–ใ€‚ + +```bash +GW=http://localhost:8001 + +# ็™ปๅฝ•๏ผˆๆจกๆ‹Ÿ HTTPS๏ผ‰ +curl -s -D - -X POST $GW/api/v1/auth/login/local \ + -H "X-Forwarded-Proto: https" \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " 2>/dev/null | grep -i set-cookie +``` + +**้ข„ๆœŸ๏ผš** +- `access_token`: `HttpOnly; Secure; Path=/; SameSite=lax; Max-Age=604800` +- `csrf_token`: `Secure; Path=/; SameSite=strict`๏ผŒๆ—  `HttpOnly` + +```bash +# ๆณจๅ†Œ๏ผˆๆจกๆ‹Ÿ HTTPS๏ผ‰ +curl -s -D - -X POST $GW/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -H "X-Forwarded-Proto: https" \ + -d '{"email":"cookie-https@example.com","password":"CookieTest1!"}' 2>/dev/null | grep -i set-cookie +``` + +**้ข„ๆœŸ๏ผš** ๅŒไธŠ + +#### TC-ATK-07a: HTTP/HTTPS ๅทฎๅผ‚ๅฏนๆฏ” + +> ็›ด่ฟž Gateway ๆ‰ง่กŒ๏ผŒ้ฟๅ… nginx ่ฆ†็›– `X-Forwarded-Proto`ใ€‚ + +```bash +GW=http://localhost:8001 + +for proto in "" "https"; do + HEADER="" + LABEL="HTTP" + if [ -n "$proto" ]; then + HEADER="-H X-Forwarded-Proto:$proto" + LABEL="HTTPS" + fi + echo "=== $LABEL ===" + EMAIL="compare-${LABEL,,}-$(date +%s)@example.com" + curl -s -D - -X POST $GW/api/v1/auth/register \ + -H "Content-Type: application/json" $HEADER \ + -d "{\"email\":\"$EMAIL\",\"password\":\"Compare1!\"}" 2>/dev/null | grep -i set-cookie | while read line; do + if echo "$line" | grep -q "access_token="; then + echo " access_token:" + echo " HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)" + echo " Secure: $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)" + echo " Max-Age: $(echo "$line" | grep -oi "max-age=[0-9]*" || echo NONE)" + echo " SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")" + fi + if echo "$line" | grep -q "csrf_token="; then + echo " csrf_token:" + echo " HttpOnly: $(echo "$line" | grep -qi httponly && echo YES || echo NO)" + echo " Secure: $(echo "$line" | grep -qi "secure" && echo "$line" | grep -v samesite | grep -qi secure && echo YES || echo NO)" + echo " SameSite: $(echo "$line" | grep -oi "samesite=[a-z]*")" + fi + done +done +``` + +**้ข„ๆœŸๅฏนๆฏ”่กจ๏ผš** + +| ๅฑžๆ€ง | HTTP access_token | HTTPS access_token | HTTP csrf_token | HTTPS csrf_token | +|------|------|------|------|------| +| HttpOnly | Yes | Yes | No | No | +| Secure | No | **Yes** | No | **Yes** | +| SameSite | Lax | Lax | Strict | Strict | +| Max-Age | ๆ— ๏ผˆsession cookie๏ผ‰ | **604800**๏ผˆ7ๅคฉ๏ผ‰ | ๆ—  | ๆ—  | + +### 3.4 ่ถŠๆƒ่ฎฟ้—ฎ + +#### TC-ATK-08: ๆ—  cookie ่ฎฟ้—ฎๅ—ไฟๆŠคๆŽฅๅฃ + +```bash +for path in /api/models /api/mcp/config /api/memory /api/skills \ + /api/agents /api/channels; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)" +done +``` + +**้ข„ๆœŸ๏ผš** ๅ…จ้ƒจ 401 + +#### TC-ATK-09: ไผช้€  JWT + +```bash +# ็”จไธๅŒ secret ็ญพๅ็š„ token +FAKE_TOKEN=$(python3 -c " +import jwt +print(jwt.encode({'sub':'admin-id','ver':0,'exp':9999999999}, 'wrong-secret', algorithm='HS256')) +") + +curl -s -w "%{http_code}" $BASE/api/v1/auth/me \ + --cookie "access_token=$FAKE_TOKEN" +``` + +**้ข„ๆœŸ๏ผš** 401๏ผˆ็ญพๅ้ชŒ่ฏๅคฑ่ดฅ๏ผ‰ + +#### TC-ATK-10: ่ฟ‡ๆœŸ JWT + +```bash +# ไธไพ่ต–็Žฏๅขƒๅ˜้‡๏ผŒ็›ดๆŽฅ็”จไธ€ไธชๅทฒ่ฟ‡ๆœŸ็š„ใ€้šๆœบ secret ็ญพๅ็š„ token +# ๆ— ่ฎบ secret ๆ˜ฏๅฆๅŒน้…๏ผŒ่ฟ‡ๆœŸ token ้ƒฝไผš่ขซๆ‹’็ป +EXPIRED_TOKEN=$(python3 -c " +import jwt, time +print(jwt.encode({'sub':'x','ver':0,'exp':int(time.time())-100}, 'any-secret-32chars-placeholder!!', algorithm='HS256')) +") + +curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me \ + --cookie "access_token=$EXPIRED_TOKEN" +``` + +**้ข„ๆœŸ๏ผš** 401๏ผˆ่ฟ‡ๆœŸ or ็ญพๅไธๅŒน้…๏ผŒๅ‡่ขซๆ‹’็ป๏ผ‰ + +### 3.5 ๅฏ†็ ๅฎ‰ๅ…จ + +#### TC-ATK-11: ๅฏ†็ ้•ฟๅบฆไธ่ถณ + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"short@example.com","password":"1234567"}' -w "\nHTTP %{http_code}" +``` + +**้ข„ๆœŸ๏ผš** 422๏ผˆPydantic validation: min_length=8๏ผ‰ + +#### TC-ATK-12: ๅฏ†็ ไธไปฅๆ˜Žๆ–‡ๅญ˜ๅ‚จ + +```bash +# ๆฃ€ๆŸฅๆ•ฐๆฎๅบ“ +sqlite3 backend/.deer-flow/users.db "SELECT email, password_hash FROM users LIMIT 3;" +``` + +**้ข„ๆœŸ๏ผš** `password_hash` ไปฅ `$2b$` ๅผ€ๅคด๏ผˆbcrypt ๆ ผๅผ๏ผ‰ + +--- + +## ๅ››ใ€UI ๆ“ไฝœๆต‹่ฏ• + +> ๆต่งˆๅ™จไธญๆ“ไฝœ๏ผŒ้ชŒ่ฏๅ‰ๅŽ็ซฏ่”ๅŠจใ€‚ + +### 4.1 ้ฆ–ๆฌก็™ปๅฝ•ๆต็จ‹ + +#### TC-UI-01: ่ฎฟ้—ฎ้ฆ–้กต่ทณ่ฝฌ็™ปๅฝ• + +1. ๆ‰“ๅผ€ `http://localhost:2026/workspace` +2. **้ข„ๆœŸ๏ผš** ่‡ชๅŠจ่ทณ่ฝฌๅˆฐ `/login` + +#### TC-UI-02: Login ้กต้ข + +1. ่พ“ๅ…ฅ admin ้‚ฎ็ฎฑๅ’ŒๆŽงๅˆถๅฐๅฏ†็  +2. ็‚นๅ‡ป Login +3. **้ข„ๆœŸ๏ผš** ่ทณ่ฝฌๅˆฐ `/setup`๏ผˆๅ› ไธบ `needs_setup=true`๏ผ‰ + +#### TC-UI-03: Setup ้กต้ข + +1. ่พ“ๅ…ฅๆ–ฐ้‚ฎ็ฎฑใ€ๆŽงๅˆถๅฐๅฏ†็ ๏ผˆcurrent๏ผ‰ใ€ๆ–ฐๅฏ†็ ใ€็กฎ่ฎคๅฏ†็  +2. ็‚นๅ‡ป Complete Setup +3. **้ข„ๆœŸ๏ผš** ่ทณ่ฝฌๅˆฐ `/workspace` +4. ๅˆทๆ–ฐ้กต้ขไธ่ทณๅ›ž `/setup` + +#### TC-UI-04: Setup ๅฏ†็ ไธๅŒน้… + +1. ๆ–ฐๅฏ†็ ๅ’Œ็กฎ่ฎคๅฏ†็ ไธไธ€่‡ด +2. ็‚นๅ‡ป Complete Setup +3. **้ข„ๆœŸ๏ผš** ๆ˜พ็คบ "Passwords do not match" ้”™่ฏฏ + +### 4.2 ๆ—ฅๅธธไฝฟ็”จ + +#### TC-UI-05: ๅˆ›ๅปบๅฏน่ฏ + +1. ๅœจ workspace ๅ‘้€ไธ€ๆกๆถˆๆฏ +2. **้ข„ๆœŸ๏ผš** ๅทฆไพงๆ ๅ‡บ็Žฐๆ–ฐ thread + +#### TC-UI-06: ๅฏน่ฏๆŒไน…ๅŒ– + +1. ๅˆ›ๅปบๅฏน่ฏๅŽๅˆทๆ–ฐ้กต้ข +2. **้ข„ๆœŸ๏ผš** ๅฏน่ฏๅˆ—่กจๅ’Œๅ†…ๅฎนไป็„ถๅญ˜ๅœจ + +#### TC-UI-07: ็™ปๅ‡บ + +1. ็‚นๅ‡ปๅคดๅƒ โ†’ Logout +2. **้ข„ๆœŸ๏ผš** ่ทณ่ฝฌๅˆฐ้ฆ–้กต `/` +3. ็›ดๆŽฅ่ฎฟ้—ฎ `/workspace` โ†’ ่ทณ่ฝฌๅˆฐ `/login` + +### 4.3 ๅคš็”จๆˆท้š”็ฆป + +#### TC-UI-08: ็”จๆˆท A ็œ‹ไธๅˆฐ็”จๆˆท B ็š„ๅฏน่ฏ + +1. ็”จๆˆท A ๅœจๆต่งˆๅ™จ 1 ็™ปๅฝ•๏ผŒๅˆ›ๅปบไธ€ไธชๅฏน่ฏๅนถๅ‘ๆถˆๆฏ +2. ็”จๆˆท B ๅœจๆต่งˆๅ™จ 2๏ผˆๆˆ–้š่บซ็ช—ๅฃ๏ผ‰ๆณจๅ†Œๅนถ็™ปๅฝ• +3. **้ข„ๆœŸ๏ผš** ็”จๆˆท B ็š„ workspace ๅทฆไพงๆ ไธบ็ฉบ๏ผŒ็œ‹ไธๅˆฐ็”จๆˆท A ็š„ๅฏน่ฏ + +#### TC-UI-09: ็›ดๆŽฅ URL ่ฎฟ้—ฎไป–ไบบ Thread + +1. ๅคๅˆถ็”จๆˆท A ็š„ thread URL +2. ๅœจ็”จๆˆท B ็š„ๆต่งˆๅ™จไธญ่ฎฟ้—ฎ +3. **้ข„ๆœŸ๏ผš** 404 ๆˆ–็ฉบ็™ฝ้กต๏ผŒไธๆ˜พ็คบๅฏน่ฏๅ†…ๅฎน + +### 4.4 Session ็ฎก็† + +#### TC-UI-10: Tab ๅˆ‡ๆข Session ๆฃ€ๆŸฅ + +1. ็™ปๅฝ• workspace +2. ๅˆ‡ๆขๅˆฐๅ…ถไป– tab ็ญ‰ๅพ… 60+ ็ง’ +3. ๅˆ‡ๅ›ž workspace tab +4. **้ข„ๆœŸ๏ผš** ้™้ป˜ๆฃ€ๆŸฅ session๏ผŒ้กต้ขๆญฃๅธธ๏ผˆๆŽงๅˆถๅฐๆ—  401 ๅˆทๅฑ๏ผ‰ + +#### TC-UI-11: Session ่ฟ‡ๆœŸๅŽ Tab ๅˆ‡ๅ›ž + +1. ็™ปๅฝ• workspace +2. ๅœจๅฆไธ€ไธช tab ๆ”นๅฏ†็ ๏ผˆไฝฟๅฝ“ๅ‰ session ๅคฑๆ•ˆ๏ผ‰ +3. ๅˆ‡ๅ›ž workspace tab +4. **้ข„ๆœŸ๏ผš** ่‡ชๅŠจ่ทณ่ฝฌๅˆฐ `/login` + +#### TC-UI-12: ๆ”นๅฏ†็ ๅŽ Settings ้กต้ข + +1. ่ฟ›ๅ…ฅ Settings โ†’ Account +2. ไฟฎๆ”นๅฏ†็  +3. **้ข„ๆœŸ๏ผš** ๆˆๅŠŸๆ็คบ๏ผŒ้กต้ขไธ้œ€่ฆ้‡ๆ–ฐ็™ปๅฝ•๏ผˆcookie ๅทฒ่‡ชๅŠจๆ›ดๆ–ฐ๏ผ‰ + +### 4.5 ๆณจๅ†Œๆต็จ‹ + +#### TC-UI-13: ไปŽ็™ปๅฝ•้กต่ทณ่ฝฌๆณจๅ†Œ + +1. ๅœจ `/login` ้กต้ข็‚นๅ‡ปๆณจๅ†Œ้“พๆŽฅ +2. ่พ“ๅ…ฅ้‚ฎ็ฎฑๅ’Œๅฏ†็  +3. **้ข„ๆœŸ๏ผš** ๆณจๅ†ŒๆˆๅŠŸๅŽ่‡ชๅŠจ่ทณ่ฝฌ `/workspace` + +#### TC-UI-14: ้‡ๅค้‚ฎ็ฎฑๆณจๅ†Œ + +1. ็”จๅทฒๆณจๅ†Œ็š„้‚ฎ็ฎฑๅฐ่ฏ•ๆณจๅ†Œ +2. **้ข„ๆœŸ๏ผš** ๆ˜พ็คบ "Email already registered" ้”™่ฏฏ + +### 4.6 ๅฏ†็ ้‡็ฝฎ๏ผˆCLI๏ผ‰ + +#### TC-UI-15: reset_admin ๅŽ้‡ๆ–ฐ็™ปๅฝ• + +1. ๆ‰ง่กŒ `cd backend && python -m app.gateway.auth.reset_admin` +2. ไฝฟ็”จๆ–ฐๅฏ†็ ็™ปๅฝ• +3. **้ข„ๆœŸ๏ผš** ่ทณ่ฝฌๅˆฐ `/setup` ้กต้ข๏ผˆ`needs_setup` ่ขซ้‡็ฝฎไธบ true๏ผ‰ +4. ๆ—ง session ๅทฒๅคฑๆ•ˆ + +--- + +## ไบ”ใ€ๅ‡็บงๆต‹่ฏ• + +> ๆจกๆ‹ŸไปŽๆ—  auth ็‰ˆๆœฌ๏ผˆmain ๅˆ†ๆ”ฏ๏ผ‰ๅ‡็บงๅˆฐ auth ็‰ˆๆœฌ๏ผˆfeat/rfc-001-auth-module๏ผ‰ใ€‚ + +### 5.1 ๅ‡†ๅค‡ๆ—ง็‰ˆๆ•ฐๆฎ + +```bash +# 1. ๅˆ‡ๅˆฐ main ๅˆ†ๆ”ฏ๏ผŒๅฏๅŠจๆœๅŠก +git stash && git checkout main +make dev + +# 2. ๅˆ›ๅปบไธ€ไบ›ๅฏน่ฏๆ•ฐๆฎ๏ผˆๆ—  auth๏ผŒ็›ดๆŽฅ่ฎฟ้—ฎ๏ผ‰ +curl -s -X POST http://localhost:2026/api/langgraph/threads \ + -H "Content-Type: application/json" \ + -d '{"metadata":{"title":"old-thread-1"}}' | jq .thread_id + +curl -s -X POST http://localhost:2026/api/langgraph/threads \ + -H "Content-Type: application/json" \ + -d '{"metadata":{"title":"old-thread-2"}}' | jq .thread_id + +# 3. ่ฎฐๅฝ• thread ๆ•ฐ้‡ +curl -s http://localhost:2026/api/langgraph/threads | jq length +# ้ข„ๆœŸ: 2+ + +# 4. ๅœๆญขๆœๅŠก +make stop +``` + +### 5.2 ๅ‡็บงๅนถๅฏๅŠจ + +```bash +# 5. ๅˆ‡ๅˆฐ auth ๅˆ†ๆ”ฏ +git checkout feat/rfc-001-auth-module && git stash pop +make install +make dev +``` + +#### TC-UPG-01: ้ฆ–ๆฌกๅฏๅŠจๅˆ›ๅปบ admin + +**้ข„ๆœŸ๏ผš** +- [ ] ๆŽงๅˆถๅฐ่พ“ๅ‡บ admin ้‚ฎ็ฎฑ๏ผˆ`admin@deerflow.dev`๏ผ‰ๅ’Œ้šๆœบๅฏ†็  +- [ ] ๆ— ๆŠฅ้”™๏ผŒๆญฃๅธธๅฏๅŠจ + +#### TC-UPG-02: ๆ—ง Thread ่ฟ็งปๅˆฐ admin + +```bash +# ็™ปๅฝ• admin +curl -s -X POST http://localhost:2026/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=<ๆŽงๅˆถๅฐๅฏ†็ >" \ + -c cookies.txt + +# ๆŸฅ็œ‹ thread ๅˆ—่กจ +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST http://localhost:2026/api/threads/search \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{}' | jq length +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ่ฟ”ๅ›ž็š„ thread ๆ•ฐ้‡ โ‰ฅ ๆ—ง็‰ˆๅˆ›ๅปบ็š„ๆ•ฐ้‡ +- [ ] ๆŽงๅˆถๅฐๆ—ฅๅฟ—ๆœ‰ `Migrated N orphaned thread(s) to admin` +- [ ] ๆฏไธช thread ็š„ `metadata.user_id` ้ƒฝๅทฒ่ขซ่ฎพไธบ admin ็š„ ID + +#### TC-UPG-03: ๆ—ง Thread ๅ†…ๅฎนๅฎŒๆ•ด + +```bash +# ๆฃ€ๆŸฅๆŸไธชๆ—ง thread ็š„ๅ†…ๅฎน +curl -s http://localhost:2026/api/threads/ \ + -b cookies.txt | jq .metadata +``` + +**้ข„ๆœŸ๏ผš** +- [ ] `metadata.title` ไฟ็•™ๅŽŸๅ€ผ๏ผˆๅฆ‚ `old-thread-1`๏ผ‰ +- [ ] `metadata.user_id` ๅทฒๅกซๅ…… + +#### TC-UPG-04: ๆ–ฐ็”จๆˆท็œ‹ไธๅˆฐๆ—ง Thread + +```bash +# ๆณจๅ†Œๆ–ฐ็”จๆˆท +curl -s -X POST http://localhost:2026/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"newuser@example.com","password":"NewPass123!"}' \ + -c newuser.txt + +CSRF2=$(grep csrf_token newuser.txt | awk '{print $NF}') +curl -s -X POST http://localhost:2026/api/threads/search \ + -b newuser.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF2" \ + -d '{}' | jq length +``` + +**้ข„ๆœŸ๏ผš** ่ฟ”ๅ›ž 0๏ผˆๆ—ง thread ๅฑžไบŽ admin๏ผŒๆ–ฐ็”จๆˆทไธๅฏ่ง๏ผ‰ + +### 5.3 ๆ•ฐๆฎๅบ“ Schema ๅ…ผๅฎน + +#### TC-UPG-05: ๆ—  users.db ๆ—ถ่‡ชๅŠจๅˆ›ๅปบ + +```bash +ls -la backend/.deer-flow/users.db +``` + +**้ข„ๆœŸ๏ผš** ๆ–‡ไปถๅญ˜ๅœจ๏ผŒ`sqlite3` ๅฏๆŸฅๅˆฐ `users` ่กจๅซ `needs_setup`ใ€`token_version` ๅˆ— + +#### TC-UPG-06: users.db WAL ๆจกๅผ + +```bash +sqlite3 backend/.deer-flow/users.db "PRAGMA journal_mode;" +``` + +**้ข„ๆœŸ๏ผš** ่ฟ”ๅ›ž `wal` + +### 5.4 ้…็ฝฎๅ…ผๅฎน + +#### TC-UPG-07: ๆ—  AUTH_JWT_SECRET ็š„ๆ—ง .env ๆ–‡ไปถ + +```bash +# ็กฎ่ฎค .env ไธญๆฒกๆœ‰ AUTH_JWT_SECRET +grep AUTH_JWT_SECRET backend/.env || echo "NOT SET" +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ๅฏๅŠจๆ—ถ warning๏ผš`AUTH_JWT_SECRET is not set โ€” using auto-generated ephemeral secret` +- [ ] ๆœๅŠกๆญฃๅธธๅฏ็”จ +- [ ] ้‡ๅฏๅŽๆ—ง session ๅคฑๆ•ˆ๏ผˆไธดๆ—ถๅฏ†้’ฅๅ˜ไบ†๏ผ‰ + +#### TC-UPG-08: ๆ—ง config.yaml ๆ—  auth ็›ธๅ…ณ้…็ฝฎ + +```bash +# ๆฃ€ๆŸฅ config.yaml ๆฒกๆœ‰ auth ๆฎต +grep -c "auth" config.yaml || echo "0" +``` + +**้ข„ๆœŸ๏ผš** auth ๆจกๅ—ไธไพ่ต– config.yaml๏ผˆ้…็ฝฎ่ตฐ็Žฏๅขƒๅ˜้‡๏ผ‰๏ผŒๆ—ง config.yaml ไธๅฝฑๅ“ๅฏๅŠจ + +### 5.5 ๅ‰็ซฏๅ…ผๅฎน + +#### TC-UPG-09: ๆ—งๅ‰็ซฏ็ผ“ๅญ˜ + +1. ็”จๆ—ง็‰ˆๅ‰็ซฏ็š„ๆต่งˆๅ™จ็ผ“ๅญ˜่ฎฟ้—ฎๅ‡็บงๅŽ็š„ๆœๅŠก +2. **้ข„ๆœŸ๏ผš** ่ขซ AuthMiddleware ๆ‹ฆๆˆช่ฟ”ๅ›ž 401๏ผˆๆ—งๅ‰็ซฏๆ—  cookie๏ผ‰๏ผŒ้กต้ข่‡ช็„ถๅˆทๆ–ฐๅŽๅŠ ่ฝฝๆ–ฐๅ‰็ซฏ + +#### TC-UPG-10: ไนฆ็ญพ URL + +1. ็”จๅ‡็บงๅ‰ไฟๅญ˜็š„ workspace URL๏ผˆๅฆ‚ `localhost:2026/workspace/chats/xxx`๏ผ‰็›ดๆŽฅ่ฎฟ้—ฎ +2. **้ข„ๆœŸ๏ผš** ่ทณ่ฝฌๅˆฐ `/login`๏ผŒ็™ปๅฝ•ๅŽ่ทณๅ›žๅŽŸ URL๏ผˆ`?next=` ๅ‚ๆ•ฐ๏ผ‰ + +### 5.6 ้™็บงๅ›žๆปš + +#### TC-UPG-11: ๅ›ž้€€ๅˆฐ main ๅˆ†ๆ”ฏ + +```bash +make stop +git checkout main +make dev +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ๆœๅŠกๆญฃๅธธๅฏๅŠจ๏ผˆๅฟฝ็•ฅ `users.db`๏ผŒๆ—  auth ็›ธๅ…ณไปฃ็ ไธๆŠฅ้”™๏ผ‰ +- [ ] ๆ—งๅฏน่ฏๆ•ฐๆฎไป็„ถๅฏ่ฎฟ้—ฎ +- [ ] `users.db` ๆ–‡ไปถๆฎ‹็•™ไฝ†ไธๅฝฑๅ“่ฟ่กŒ + +#### TC-UPG-12: ๅ†ๆฌกๅ‡็บงๅˆฐ auth ๅˆ†ๆ”ฏ + +```bash +make stop +git checkout feat/rfc-001-auth-module +make dev +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ่ฏ†ๅˆซๅทฒๆœ‰ `users.db`๏ผŒไธ้‡ๆ–ฐๅˆ›ๅปบ admin +- [ ] ๆ—ง็š„ admin ่ดฆๅทไปๅฏ็™ปๅฝ•๏ผˆๅฆ‚ๆžœๅ›ž้€€ๆœŸ้—ดๆœชๅˆ  `users.db`๏ผ‰ + +### 5.7 ไผ‘็œ  Admin๏ผˆๅˆๅง‹ๅฏ†็ ๆœชไฝฟ็”จ/ๆœชๆ›ดๆ”น๏ผ‰ + +> ้ฆ–ๆฌกๅฏๅŠจ็”Ÿๆˆ admin + ้šๆœบๅฏ†็ ๏ผŒไฝ†่ฟ็ปดๆœช็™ปๅฝ•ใ€ๆœชๆ”นๅฏ†็ ใ€‚ +> ๅฏ†็ ๅชๅœจ้ฆ–ๆฌกๅฏๅŠจ็š„ๆŽงๅˆถๅฐ้—ช่ฟ‡ไธ€ๆฌก๏ผŒๅŽ็ปญๅฏๅŠจไธๅ†ๆ˜พ็คบใ€‚ + +#### TC-UPG-13: ้‡ๅฏๅŽ่‡ชๅŠจ้‡็ฝฎๅฏ†็ ๅนถๆ‰“ๅฐ + +```bash +# ้ฆ–ๆฌกๅฏๅŠจ๏ผŒ่ฎฐๅฝ•ๅฏ†็  +rm -f backend/.deer-flow/users.db +make dev +# ๆŽงๅˆถๅฐ่พ“ๅ‡บๅฏ†็  P0๏ผŒไธ็™ปๅฝ• +make stop + +# ้š”ไบ†ๅ‡ ๅคฉ๏ผŒๅ†ๆฌกๅฏๅŠจ +make dev +# ๆŽงๅˆถๅฐ่พ“ๅ‡บๆ–ฐๅฏ†็  P1 +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ๆŽงๅˆถๅฐ่พ“ๅ‡บ `Admin account setup incomplete โ€” password reset` +- [ ] ่พ“ๅ‡บๆ–ฐๅฏ†็  P1๏ผˆP0 ๅทฒๅคฑๆ•ˆ๏ผ‰ +- [ ] ็”จ P1 ๅฏไปฅ็™ปๅฝ•๏ผŒP0 ไธๅฏไปฅ +- [ ] ็™ปๅฝ•ๅŽ `needs_setup=true`๏ผŒ่ทณ่ฝฌ `/setup` +- [ ] `token_version` ้€’ๅขž๏ผˆๆ—ง session ๅฆ‚ๆœ‰ไนŸๅคฑๆ•ˆ๏ผ‰ + +#### TC-UPG-14: ๅฏ†็ ไธขๅคฑ โ€” ๆ— ้œ€ CLI๏ผŒ้‡ๅฏๅณๅฏ + +```bash +# ๅฟ˜่ฎฐไบ†ๆŽงๅˆถๅฐๅฏ†็  โ†’ ็›ดๆŽฅ้‡ๅฏๆœๅŠก +make stop && make dev +# ๆŽงๅˆถๅฐ่‡ชๅŠจ่พ“ๅ‡บๆ–ฐๅฏ†็  +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ๆ— ้œ€ `reset_admin`๏ผŒ้‡ๅฏๆœๅŠกๅณๅฏๆ‹ฟๅˆฐๆ–ฐๅฏ†็  +- [ ] `reset_admin` CLI ไป็„ถๅฏ็”จไฝœๆ‰‹ๅŠจๅค‡้€‰ๆ–นๆกˆ + +#### TC-UPG-15: ไผ‘็œ  admin ๆœŸ้—ดๆ™ฎ้€š็”จๆˆทๆณจๅ†Œ + +```bash +# admin ๅญ˜ๅœจไฝ†ไปŽๆœช็™ปๅฝ•๏ผŒๆ™ฎ้€š็”จๆˆทๅ…ˆๆณจๅ†Œ +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"earlybird@example.com","password":"EarlyPass1!"}' \ + -c early.txt -w "\nHTTP %{http_code}" +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ๆณจๅ†ŒๆˆๅŠŸ๏ผˆ201๏ผ‰๏ผŒ่ง’่‰ฒไธบ `user` +- [ ] ๆ— ๆณ•ๆๆƒไธบ admin +- [ ] ๆ™ฎ้€š็”จๆˆท็š„ๆ•ฐๆฎไธŽ admin ้š”็ฆป + +#### TC-UPG-16: ไผ‘็œ  admin ไธๅฝฑๅ“ๅŽ็ปญๆ“ไฝœ + +```bash +# ๆ™ฎ้€š็”จๆˆทๆญฃๅธธๅˆ›ๅปบ threadใ€ๅ‘ๆถˆๆฏ +CSRF=$(grep csrf_token early.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/threads \ + -b early.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}' | jq .thread_id +``` + +**้ข„ๆœŸ๏ผš** ๆญฃๅธธๅˆ›ๅปบ๏ผŒไธๅ—ไผ‘็œ  admin ๅฝฑๅ“ + +#### TC-UPG-17: ไผ‘็œ  admin ๆœ€็ปˆๅฎŒๆˆ Setup + +```bash +# ่ฟ็ปด็ปˆไบŽ็™ปๅฝ• +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=" \ + -c admin.txt | jq .needs_setup +# ้ข„ๆœŸ: true + +# ๅฎŒๆˆ setup +CSRF=$(grep csrf_token admin.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b admin.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"<ๅฏ†็ >","new_password":"AdminFinal1!","new_email":"admin@real.com"}' \ + -c admin.txt + +# ้ชŒ่ฏ +curl -s $BASE/api/v1/auth/me -b admin.txt | jq '{email, needs_setup}' +``` + +**้ข„ๆœŸ๏ผš** +- [ ] `email` ๅ˜ไธบ `admin@real.com` +- [ ] `needs_setup` ๅ˜ไธบ `false` +- [ ] ๅŽ็ปญ้‡ๅฏๆŽงๅˆถๅฐไธๅ†ๆœ‰ warning + +#### TC-UPG-18: ้•ฟๆœŸๆœช็”จๅŽ JWT ๅฏ†้’ฅ่ฝฎๆข + +```bash +# ๅœบๆ™ฏ๏ผšadmin ๆœช็™ปๅฝ•ๆœŸ้—ด๏ผŒ่ฟ็ปดๆ›ดๆขไบ† AUTH_JWT_SECRET +# 1. ้ฆ–ๆฌกๅฏๅŠจ็”จ่‡ชๅŠจ็”Ÿๆˆ็š„ไธดๆ—ถๅฏ†้’ฅ +# 2. ๆŸๅคฉ่ฟ็ปดๅœจ .env ่ฎพ็ฝฎไบ†ๅ›บๅฎšๅฏ†้’ฅ +echo "AUTH_JWT_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" >> .env +make stop && make dev +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ๆœๅŠกๆญฃๅธธๅฏๅŠจ +- [ ] ๆ—งๅฏ†็ ไปๅฏ็™ปๅฝ•๏ผˆๅฏ†็ ๅญ˜ๅœจ DB๏ผŒไธŽ JWT ๅฏ†้’ฅๆ— ๅ…ณ๏ผ‰ +- [ ] ๆ—ง็š„ JWT token ๅคฑๆ•ˆ๏ผˆๅฏ†้’ฅๅ˜ไบ†็ญพๅไธๅŒน้…๏ผ‰โ€” ไฝ†ๅ› ไธบไปŽๆœช็™ปๅฝ•่ฟ‡ไนŸๆฒกๆœ‰ๆ—ง token + +--- + +## ๅ…ญใ€ๅฏ้‡ๅ…ฅๆต‹่ฏ• + +> ้ชŒ่ฏ auth ๆจกๅ—ๅœจ้‡ๅคๆ“ไฝœใ€ๅนถๅ‘ใ€ไธญๆ–ญๆขๅค็ญ‰ๅœบๆ™ฏไธ‹่กŒไธบๆญฃ็กฎ๏ผŒๆ— ็ซžๆ€ๆกไปถใ€‚ + +### 6.1 ๅฏๅŠจๅฏ้‡ๅ…ฅ + +#### TC-REENT-01: ่ฟž็ปญ้‡ๅฏไธ้‡ๅคๅˆ›ๅปบ admin + +```bash +# ่ฟž็ปญๅฏๅŠจ 3 ๆฌก๏ผˆdaemon ๆจกๅผ๏ผŒ้ฟๅ…ๅ‰ๅฐ้˜ปๅกž๏ผ‰ +for i in 1 2 3; do + make dev-daemon && sleep 10 && make stop +done + +# ๆฃ€ๆŸฅ admin ๆ•ฐ้‡ +sqlite3 backend/.deer-flow/users.db \ + "SELECT COUNT(*) FROM users WHERE system_role='admin';" +``` + +**้ข„ๆœŸ๏ผš** ๅง‹็ปˆไธบ 1ใ€‚ไธไผšๅ› ้‡ๅฏๅˆ›ๅปบๅคšไธช adminใ€‚ + +#### TC-REENT-02: ๅคš่ฟ›็จ‹ๅŒๆ—ถๅฏๅŠจ + +```bash +# ๆจกๆ‹Ÿไธคไธช gateway ่ฟ›็จ‹ๅŒๆ—ถๅฏๅŠจ๏ผˆ็ซžไบ‰ admin ๅˆ›ๅปบ๏ผ‰ +cd backend +PYTHONPATH=. uv run python -c " +import asyncio +from app.gateway.app import create_app, _ensure_admin_user + +async def boot(): + app = create_app() + # ๆจกๆ‹Ÿไธคไธชๅนถๅ‘ ensure_admin + await asyncio.gather( + _ensure_admin_user(app), + _ensure_admin_user(app), + ) + +asyncio.run(boot()) +" 2>&1 | grep -i "admin\|error\|duplicate" +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ไธๆŠฅ้”™๏ผˆSQLite UNIQUE ็บฆๆŸๆ•่Žท็ซžไบ‰๏ผŒ็ฌฌไบŒไธช้™้ป˜่ทณ่ฟ‡๏ผ‰ +- [ ] ๆœ€็ปˆๅชๆœ‰ 1 ไธช admin + +#### TC-REENT-03: Thread ่ฟ็งปๅน‚็ญ‰ + +```bash +# ่ฟž็ปญ่ฐƒ็”จ _migrate_orphaned_threads ไธคๆฌก +# ็ฌฌไบŒๆฌกๅบ”ๆ—  thread ้œ€่ฆ่ฟ็งป๏ผˆๅทฒๆœ‰ user_id๏ผ‰ +``` + +**้ข„ๆœŸ๏ผš** ็ฌฌไบŒๆฌก `migrated = 0`๏ผŒๆ— ๅ‰ฏไฝœ็”จ + +### 6.2 ็™ปๅฝ•ๅฏ้‡ๅ…ฅ + +#### TC-REENT-04: ้‡ๅค็™ปๅฝ•่Žทๅ–ๆ–ฐ cookie + +```bash +# ๅŒไธ€็”จๆˆท่ฟž็ปญ็™ปๅฝ• 3 ๆฌก +for i in 1 2 3; do + curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " \ + -c "cookies_$i.txt" -o /dev/null +done + +# ไธ‰ไธช cookie ้ƒฝๆœ‰ๆ•ˆ +for i in 1 2 3; do + echo "Cookie $i: $(curl -s -w '%{http_code}' -o /dev/null $BASE/api/v1/auth/me -b cookies_$i.txt)" +done +``` + +**้ข„ๆœŸ๏ผš** ไธ‰ไธช cookie ้ƒฝ่ฟ”ๅ›ž 200๏ผˆๆœชๆ”นๅฏ†็ ๏ผŒtoken_version ็›ธๅŒ๏ผŒๅคš session ๅ…ฑๅญ˜๏ผ‰ + +#### TC-REENT-05: ็™ปๅฝ•-็™ปๅ‡บ-็™ปๅฝ• + +```bash +# ็™ปๅฝ• +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " \ + -c cookies.txt -o /dev/null + +# ็™ปๅ‡บ +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt -o /dev/null + +# ๅ†ๆฌก็™ปๅฝ• +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " \ + -c cookies.txt + +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt +``` + +**้ข„ๆœŸ๏ผš** 200ใ€‚็™ปๅ‡บโ†’ๅ†็™ปๅฝ•ๆต็จ‹ๆ— ็Šถๆ€ๆฎ‹็•™ใ€‚ + +### 6.3 ๆ”นๅฏ†็ ๅฏ้‡ๅ…ฅ + +#### TC-REENT-06: ่ฟž็ปญไธคๆฌกๆ”นๅฏ†็  + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# ็ฌฌไธ€ๆฌกๆ”นๅฏ†็  +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"Pass1","new_password":"Pass2"}' \ + -c cookies.txt + +# ็”จๆ–ฐ cookie ็š„ CSRF ๅ†ๆ”นไธ€ๆฌก +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"Pass2","new_password":"Pass3"}' \ + -c cookies.txt + +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies.txt +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ไธคๆฌกๆ”นๅฏ†็ ้ƒฝๆˆๅŠŸ +- [ ] ๆœ€็ปˆๅฏ†็ ไธบ Pass3 +- [ ] `token_version` ้€’ๅขžไธคๆฌก๏ผˆ+2๏ผ‰ +- [ ] ๆœ€ๆ–ฐ cookie ๆœ‰ๆ•ˆ + +#### TC-REENT-07: ๆ”นๅฏ†็ ๅŽๆ—ง cookie ๅ…จ้ƒจๅคฑๆ•ˆ + +```bash +# ไฟๅญ˜ไธ‰ไธชๆ—ถ้—ด็‚น็š„ cookie +# t1: ๅˆๅง‹็™ปๅฝ• โ†’ cookies_t1.txt +# t2: ็ฌฌไธ€ๆฌกๆ”นๅฏ†็ ๅŽ โ†’ cookies_t2.txt +# t3: ็ฌฌไบŒๆฌกๆ”นๅฏ†็ ๅŽ โ†’ cookies_t3.txt + +# ็”จ t1 ๅ’Œ t2 ็š„ cookie ่ฎฟ้—ฎ +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t1.txt # ้ข„ๆœŸ 401 +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t2.txt # ้ข„ๆœŸ 401 +curl -s -w "%{http_code}" $BASE/api/v1/auth/me -b cookies_t3.txt # ้ข„ๆœŸ 200 +``` + +**้ข„ๆœŸ๏ผš** ๅชๆœ‰ๆœ€ๆ–ฐ็š„ cookie ๆœ‰ๆ•ˆ๏ผŒๅކๅฒ cookie ๅ›  token_version ไธๅŒน้…ๅ…จ้ƒจ 401 + +### 6.4 ๆณจๅ†Œๅฏ้‡ๅ…ฅ + +#### TC-REENT-08: ๅŒไธ€้‚ฎ็ฎฑๅนถๅ‘ๆณจๅ†Œ + +```bash +# ๅนถๅ‘ๅ‘้€ไธคไธช็›ธๅŒ้‚ฎ็ฎฑ็š„ๆณจๅ†Œ่ฏทๆฑ‚ +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"race@example.com","password":"RacePass1!"}' & +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"race@example.com","password":"RacePass1!"}' & +wait + +# ๆฃ€ๆŸฅ็”จๆˆทๆ•ฐ +sqlite3 backend/.deer-flow/users.db \ + "SELECT COUNT(*) FROM users WHERE email='race@example.com';" +``` + +**้ข„ๆœŸ๏ผš** +- [ ] ไธ€ไธชๆˆๅŠŸ๏ผˆ201๏ผ‰๏ผŒไธ€ไธชๅคฑ่ดฅ๏ผˆ400 `email_already_exists`๏ผ‰ +- [ ] ๆ•ฐๆฎๅบ“ไธญๅชๆœ‰ 1 ๆก่ฎฐๅฝ•๏ผˆUNIQUE ็บฆๆŸไฟๆŠค๏ผ‰ + +### 6.5 Rate Limiter ๅฏ้‡ๅ…ฅ + +#### TC-REENT-09: ้™้€Ÿ่ฟ‡ๆœŸๅŽ้‡ๆ–ฐ่ฎกๆ•ฐ + +```bash +# ่งฆๅ‘้”ๅฎš๏ผˆ5 ๆฌก้”™่ฏฏ๏ผ‰ +for i in $(seq 1 5); do + curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done + +# ็กฎ่ฎค่ขซ้”ๅฎš +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +# ้ข„ๆœŸ: 429 + +# ็ญ‰ๅพ…้”ๅฎš่ฟ‡ๆœŸ๏ผˆ5 ๅˆ†้’Ÿ๏ผ‰ๆˆ–้‡ๅฏๆœๅŠกๆธ…้™คๅ†…ๅญ˜่ฎกๆ•ฐๅ™จ +make stop && make dev + +# ้‡ๆ–ฐๅฐ่ฏ• โ€” ่ฎกๆ•ฐๅ™จๅบ”ๅทฒ้‡็ฝฎ +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +# ้ข„ๆœŸ: 401๏ผˆไธๆ˜ฏ 429๏ผ‰ +``` + +**้ข„ๆœŸ๏ผš** ้”ๅฎš่ฟ‡ๆœŸๅŽๆขๅคๆญฃๅธธ้™้€Ÿ๏ผˆไปŽ 0 ๅผ€ๅง‹่ฎกๆ•ฐ๏ผ‰๏ผŒ่€Œ้ž็ดฏ็งฏ + +#### TC-REENT-10: ๆˆๅŠŸ็™ปๅฝ•้‡็ฝฎ่ฎกๆ•ฐๅŽๅ†ๆฌกๅคฑ่ดฅ + +```bash +# 3 ๆฌกๅคฑ่ดฅ +for i in $(seq 1 3); do + curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done + +# 1 ๆฌกๆˆๅŠŸ๏ผˆ้‡็ฝฎ่ฎกๆ•ฐ๏ผ‰ +curl -s -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " + +# ๅ† 4 ๆฌกๅคฑ่ดฅ๏ผˆไปŽ 0 ้‡ๆ–ฐ่ฎกๆ•ฐ๏ผŒๆœช่พพ้˜ˆๅ€ผ 5๏ผ‰ +for i in $(seq 1 4); do + curl -s -w "attempt $i: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done +``` + +**้ข„ๆœŸ๏ผš** 4 ๆฌกๅ…จ้ƒจ่ฟ”ๅ›ž 401๏ผˆๆœช้”ๅฎš๏ผ‰๏ผŒๅ› ไธบๆˆๅŠŸ็™ปๅฝ•ๅทฒ้‡็ฝฎ่ฎกๆ•ฐๅ™จ + +### 6.6 CSRF Token ๅฏ้‡ๅ…ฅ + +#### TC-REENT-11: ็™ปๅฝ•ๅŽๅคšๆฌก POST ไฝฟ็”จๅŒไธ€ CSRF token + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# ๅŒไธ€ CSRF token ๅคšๆฌกไฝฟ็”จ +for i in 1 2 3; do + echo "Request $i: $(curl -s -w '%{http_code}' -o /dev/null \ + -X POST $BASE/api/threads \ + -b cookies.txt \ + -H 'Content-Type: application/json' \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}')" +done +``` + +**้ข„ๆœŸ๏ผš** ไธ‰ๆฌก้ƒฝๆˆๅŠŸ๏ผˆCSRF token ๆ˜ฏ Double Submit Cookie๏ผŒไธๆ˜ฏไธ€ๆฌกๆ€ง nonce๏ผ‰ + +### 6.7 Thread ๆ“ไฝœๅฏ้‡ๅ…ฅ + +#### TC-REENT-12: ้‡ๅคๅˆ ้™คๅŒไธ€ Thread + +```bash +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# ๅˆ›ๅปบ thread +TID=$(curl -s -X POST $BASE/api/threads \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}' | jq -r .thread_id) + +# ็ฌฌไธ€ๆฌกๅˆ ้™ค +curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \ + -b cookies.txt -H "X-CSRF-Token: $CSRF" +# ้ข„ๆœŸ: 200 + +# ็ฌฌไบŒๆฌกๅˆ ้™ค๏ผˆๅน‚็ญ‰๏ผ‰ +curl -s -w "%{http_code}" -X DELETE "$BASE/api/threads/$TID" \ + -b cookies.txt -H "X-CSRF-Token: $CSRF" +``` + +**้ข„ๆœŸ๏ผš** ็ฌฌไบŒๆฌก่ฟ”ๅ›ž 200 ๆˆ– 404๏ผŒไธๆŠฅ 500 + +### 6.8 reset_admin ๅฏ้‡ๅ…ฅ + +#### TC-REENT-13: ่ฟž็ปญไธคๆฌก reset_admin + +```bash +cd backend +python -m app.gateway.auth.reset_admin +# ่ฎฐๅฝ•ๅฏ†็  P1 + +python -m app.gateway.auth.reset_admin +# ่ฎฐๅฝ•ๅฏ†็  P2 +``` + +**้ข„ๆœŸ๏ผš** +- [ ] P1 โ‰  P2๏ผˆๆฏๆฌก็”Ÿๆˆๆ–ฐ้šๆœบๅฏ†็ ๏ผ‰ +- [ ] P1 ไธๅฏ็”จ๏ผŒๅชๆœ‰ P2 ๆœ‰ๆ•ˆ +- [ ] `token_version` ้€’ๅขžไบ† 2 +- [ ] `needs_setup` ไธบ True + +### 6.9 Setup ๆต็จ‹ๅฏ้‡ๅ…ฅ + +#### TC-REENT-14: ๅฎŒๆˆ Setup ๅŽๅ†่ฎฟ้—ฎ /setup ้กต้ข + +1. ๅฎŒๆˆ admin setup๏ผˆๆ”น้‚ฎ็ฎฑ + ๆ”นๅฏ†็ ๏ผ‰ +2. ็›ดๆŽฅ่ฎฟ้—ฎ `/setup` +3. **้ข„ๆœŸ๏ผš** ๅบ”่ทณ่ฝฌๅˆฐ `/workspace`๏ผˆ`needs_setup` ๅทฒไธบ false๏ผŒSSR guard ไธไผš่ฟ”ๅ›ž `needs_setup` tag๏ผ‰ + +#### TC-REENT-15: Setup ไธญ้€”ๅˆทๆ–ฐ้กต้ข + +1. ๅœจ `/setup` ้กต้ขๅกซๅ†™ไธ€ๅŠ +2. ๅˆทๆ–ฐ้กต้ข +3. **้ข„ๆœŸ๏ผš** ไปๅœจ `/setup`๏ผˆ`needs_setup` ไปไธบ true๏ผ‰๏ผŒ่กจๅ•ๆธ…็ฉบไฝ†ไธๆŠฅ้”™ + +--- + +## ไธƒใ€ๆจกๅผๅทฎๅผ‚ๆต‹่ฏ• + +> ไปฅไธ‹็”จ `GW=http://localhost:8001` ่กจ็คบ็›ด่ฟž Gateway๏ผŒ`BASE=http://localhost:2026` ่กจ็คบ็ป nginxใ€‚ +> Gateway ๆจกๅผๅฏๅŠจๅ‘ฝไปค๏ผš`make dev-pro`๏ผˆๆˆ– `./scripts/serve.sh --dev --gateway`๏ผ‰ใ€‚ + +### 7.1 ๆ ‡ๅ‡†ๆจกๅผ็‹ฌๆœ‰ + +> ๅฏๅŠจๅ‘ฝไปค๏ผš`make dev`๏ผˆๆˆ– `./scripts/serve.sh --dev`๏ผ‰ + +#### TC-MODE-01: LangGraph Server ็‹ฌ็ซ‹่ฟ่กŒ๏ผŒ้œ€ cookie + +```bash +# ๆ—  cookie ่ฎฟ้—ฎ LangGraph +curl -s -w "%{http_code}" -o /dev/null $BASE/api/langgraph/threads/search +# ้ข„ๆœŸ: 403๏ผˆLangGraph auth handler ๆ‹’็ป๏ผ‰ +``` + +#### TC-MODE-02: LangGraph auth ็š„ token_version ๆฃ€ๆŸฅ + +```bash +# ็™ปๅฝ•ๆ‹ฟ cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " -c cookies.txt + +# ๆ”นๅฏ†็ ๏ผˆbumps token_version๏ผ‰ +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/v1/auth/change-password \ + -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \ + -d '{"current_password":"ๆญฃ็กฎๅฏ†็ ","new_password":"NewPass1!"}' -c new_cookies.txt + +# ็”จๆ—ง cookie ่ฎฟ้—ฎ LangGraph +curl -s -w "%{http_code}" $BASE/api/langgraph/threads/search -b cookies.txt +# ้ข„ๆœŸ: 403๏ผˆtoken_version ไธๅŒน้…๏ผ‰ + +# ็”จๆ–ฐ cookie ่ฎฟ้—ฎ +CSRF2=$(grep csrf_token new_cookies.txt | awk '{print $NF}') +curl -s -w "%{http_code}" -X POST $BASE/api/langgraph/threads/search \ + -b new_cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}' +# ้ข„ๆœŸ: 200 +``` + +#### TC-MODE-03: LangGraph auth ็š„ owner filter ้š”็ฆป + +```bash +# user1 ๅˆ›ๅปบ thread +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=user1@example.com&password=UserPass1!" -c u1.txt +CSRF1=$(grep csrf_token u1.txt | awk '{print $NF}') +TID=$(curl -s -X POST $BASE/api/langgraph/threads \ + -b u1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \ + -d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])") + +# user2 ๆœ็ดข โ€” ๅบ”็œ‹ไธๅˆฐ user1 ็š„ thread +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=user2@example.com&password=UserPass2!" -c u2.txt +CSRF2=$(grep csrf_token u2.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/langgraph/threads/search \ + -b u2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" -d '{}' | python3 -c " +import sys,json +threads = json.load(sys.stdin) +ids = [t['thread_id'] for t in threads] +assert '$TID' not in ids, 'LEAK: user2 can see user1 thread' +print('OK: user2 sees', len(threads), 'threads, none belong to user1') +" +``` + +### 7.2 Gateway ๆจกๅผ็‹ฌๆœ‰ + +> ๅฏๅŠจๅ‘ฝไปค๏ผš`make dev-pro`๏ผˆๆˆ– `./scripts/serve.sh --dev --gateway`๏ผ‰ +> ๆ—  LangGraph Server ่ฟ›็จ‹๏ผŒagent runtime ๅตŒๅ…ฅ Gatewayใ€‚ + +#### TC-MODE-04: ๆ‰€ๆœ‰่ฏทๆฑ‚็ป AuthMiddleware + +```bash +# ็กฎ่ฎค LangGraph Server ๆœช่ฟ่กŒ +curl -s -w "%{http_code}" -o /dev/null http://localhost:2024/ok +# ้ข„ๆœŸ: 000๏ผˆ่ฟžๆŽฅ่ขซๆ‹’๏ผ‰ + +# Gateway API ๅ—ไฟๆŠค +curl -s -w "%{http_code}" -o /dev/null $BASE/api/models +# ้ข„ๆœŸ: 401 + +# LangGraph ๅ…ผๅฎน่ทฏ็”ฑ๏ผˆrewrite ๅˆฐ Gateway๏ผ‰ไนŸๅ—ไฟๆŠค +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads/search \ + -H "Content-Type: application/json" -d '{}' +# ้ข„ๆœŸ: 401 +``` + +#### TC-MODE-05: Gateway ๆจกๅผไธ‹ๅฎŒๆ•ด auth ๆต็จ‹ + +```bash +# ็™ปๅฝ• +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " -c cookies.txt + +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# ๅˆ›ๅปบ thread๏ผˆ่ตฐ Gateway ๅ†…ๅตŒ runtime๏ผ‰ +curl -s -X POST $BASE/api/langgraph/threads \ + -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{}}' | python3 -c "import sys,json; print(json.load(sys.stdin)['thread_id'])" +# ้ข„ๆœŸ: ่ฟ”ๅ›ž thread_id + +# CSRF ไฟๆŠค๏ผˆGateway ๆจกๅผไธ‹ CSRFMiddleware ็›ดๆŽฅ่ฆ†็›–ๆ‰€ๆœ‰่ทฏ็”ฑ๏ผ‰ +curl -s -w "%{http_code}" -o /dev/null -X POST $BASE/api/langgraph/threads \ + -b cookies.txt -H "Content-Type: application/json" -d '{"metadata":{}}' +# ้ข„ๆœŸ: 403๏ผˆCSRF token missing๏ผ‰ +``` + +### 7.3 ็›ด่ฟž Gateway๏ผˆๆ—  nginx๏ผ‰ + +> ๅฏๅŠจๅ‘ฝไปค๏ผš`cd backend && make gateway`๏ผˆ็ซฏๅฃ 8001๏ผ‰ +> ไธ็ป่ฟ‡ nginx๏ผŒ็›ดๆŽฅๆต‹่ฏ• Gateway ็š„ auth ๅฑ‚ใ€‚ + +#### TC-GW-01: AuthMiddleware ไฟๆŠคๆ‰€ๆœ‰้ž public ่ทฏ็”ฑ + +```bash +GW=http://localhost:8001 + +for path in /api/models /api/mcp/config /api/memory /api/skills \ + /api/v1/auth/me /api/v1/auth/change-password; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)" +done +# ้ข„ๆœŸ: ๅ…จ้ƒจ 401 +``` + +#### TC-GW-02: Public ่ทฏ็”ฑไธ้œ€่ฆ cookie + +```bash +GW=http://localhost:8001 + +for path in /health /api/v1/auth/setup-status /api/v1/auth/login/local /api/v1/auth/register; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $GW$path)" +done +# ้ข„ๆœŸ: 200 ๆˆ– 405/422๏ผˆๆ–นๆณ•ไธๅฏนไฝ†ไธๆ˜ฏ 401๏ผ‰ +``` + +#### TC-GW-03: ็›ด่ฟž Gateway ๆณจๅ†Œ + ็™ปๅฝ• + CSRF ๅฎŒๆ•ดๆต็จ‹ + +```bash +GW=http://localhost:8001 + +# ๆณจๅ†Œ +curl -s -X POST $GW/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"gwtest@example.com","password":"GwTest123!"}' \ + -c gw_cookies.txt -w "\nHTTP %{http_code}" +# ้ข„ๆœŸ: 201 + +# ็™ปๅฝ• +curl -s -X POST $GW/api/v1/auth/login/local \ + -d "username=gwtest@example.com&password=GwTest123!" \ + -c gw_cookies.txt -w "\nHTTP %{http_code}" +# ้ข„ๆœŸ: 200 + +# GET๏ผˆไธ้œ€่ฆ CSRF๏ผ‰ +curl -s -w "%{http_code}" $GW/api/models -b gw_cookies.txt +# ้ข„ๆœŸ: 200 + +# POST ๆ—  CSRF +curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload -b gw_cookies.txt +# ้ข„ๆœŸ: 403๏ผˆCSRF token missing๏ผ‰ + +# POST ๆœ‰ CSRF +CSRF=$(grep csrf_token gw_cookies.txt | awk '{print $NF}') +curl -s -w "%{http_code}" -o /dev/null -X POST $GW/api/memory/reload \ + -b gw_cookies.txt -H "X-CSRF-Token: $CSRF" +# ้ข„ๆœŸ: 200 +``` + +#### TC-GW-04: ็›ด่ฟž Gateway ็š„ Rate Limiter + +```bash +GW=http://localhost:8001 + +# ็›ด่ฟžๆ—ถ request.client.host ๆ˜ฏ็œŸๅฎž IP๏ผˆๆ—  nginx ไปฃ็†๏ผ‰๏ผŒไธ่ฏป X-Real-IP +for i in $(seq 1 6); do + echo -n "attempt $i: " + curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \ + -d "username=admin@example.com&password=wrong" +done +# ้ข„ๆœŸ: ๅ‰ 5 ๆฌก 401๏ผŒ็ฌฌ 6 ๆฌก 429 +``` + +#### TC-GW-05: ็›ด่ฟž Gateway ไธๅ— X-Real-IP ๆฌบ้ช— + +```bash +GW=http://localhost:8001 + +# ็›ด่ฟžๆ—ถ client.host ไธๆ˜ฏ trusted proxy๏ผŒX-Real-IP ่ขซๅฟฝ็•ฅ +for i in $(seq 1 6); do + echo -n "attempt $i (X-Real-IP spoofed): " + curl -s -w "%{http_code}\n" -o /dev/null -X POST $GW/api/v1/auth/login/local \ + -H "X-Real-IP: 10.0.0.$i" \ + -d "username=admin@example.com&password=wrong" +done +# ้ข„ๆœŸ: ๅ‰ 5 ๆฌก 401๏ผŒ็ฌฌ 6 ๆฌก 429๏ผˆไผช้€ ็š„ X-Real-IP ๆ— ๆ•ˆ๏ผŒๆ‰€ๆœ‰่ฏทๆฑ‚ๅ…ฑไบซ็œŸๅฎž IP ็š„ๆกถ๏ผ‰ +``` + +### 7.4 Docker ้ƒจ็ฝฒ + +> ๅฏๅŠจๅ‘ฝไปค๏ผš`./scripts/deploy.sh`๏ผˆๆ ‡ๅ‡†๏ผ‰ๆˆ– `./scripts/deploy.sh --gateway`๏ผˆGateway ๆจกๅผ๏ผ‰ +> Docker Compose ๆ–‡ไปถ๏ผš`docker/docker-compose.yaml` +> +> ๅ‰็ฝฎๆกไปถ๏ผš +> - `.env` ไธญ่ฎพ็ฝฎ `AUTH_JWT_SECRET`๏ผˆๅฆๅˆ™ๆฏๆฌกๅฎนๅ™จ้‡ๅฏ session ๅ…จ้ƒจๅคฑๆ•ˆ๏ผ‰ +> - `DEER_FLOW_HOME` ๆŒ‚่ฝฝๅˆฐๅฎฟไธปๆœบ็›ฎๅฝ•๏ผˆๆŒไน…ๅŒ– `users.db`๏ผ‰ + +#### TC-DOCKER-01: users.db ้€š่ฟ‡ volume ๆŒไน…ๅŒ– + +```bash +# ๅฏๅŠจๅฎนๅ™จ +./scripts/deploy.sh + +# ็ญ‰ๅพ…ๅฏๅŠจๅฎŒๆˆ +sleep 15 +BASE=http://localhost:2026 + +# ๆณจๅ†Œ็”จๆˆท +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"docker-test@example.com","password":"DockerTest1!"}' -w "\nHTTP %{http_code}" + +# ๆฃ€ๆŸฅๅฎฟไธปๆœบไธŠ็š„ users.db +ls -la ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db +sqlite3 ${DEER_FLOW_HOME:-backend/.deer-flow}/users.db \ + "SELECT email FROM users WHERE email='docker-test@example.com';" +``` + +**้ข„ๆœŸ๏ผš** users.db ๅœจๅฎฟไธปๆœบ `DEER_FLOW_HOME` ็›ฎๅฝ•ไธญ๏ผŒๆŸฅ่ฏขๅฏ่งๅˆšๆณจๅ†Œ็š„็”จๆˆทใ€‚ + +#### TC-DOCKER-02: ้‡ๅฏๅฎนๅ™จๅŽ session ไฟๆŒ + +```bash +# ็™ปๅฝ•ๆ‹ฟ cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=docker-test@example.com&password=DockerTest1!" \ + -c docker_cookies.txt -o /dev/null + +# ้ชŒ่ฏ cookie ๆœ‰ๆ•ˆ +curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt +# ้ข„ๆœŸ: 200 + +# ้‡ๅฏๅฎนๅ™จ๏ผˆไธๅˆ  volume๏ผ‰ +./scripts/deploy.sh down && ./scripts/deploy.sh +sleep 15 + +# ็”จๆ—ง cookie ่ฎฟ้—ฎ +curl -s -w "%{http_code}" -o /dev/null $BASE/api/v1/auth/me -b docker_cookies.txt +``` + +**้ข„ๆœŸ๏ผš** +- ๆœ‰ `AUTH_JWT_SECRET` โ†’ 200๏ผˆsession ไฟๆŒ๏ผ‰ +- ๆ—  `AUTH_JWT_SECRET` โ†’ 401๏ผˆๆฏๆฌกๅฏๅŠจ็”Ÿๆˆๆ–ฐไธดๆ—ถๅฏ†้’ฅ๏ผŒๆ—ง JWT ็ญพๅๅคฑๆ•ˆ๏ผ‰ + +#### TC-DOCKER-03: ๅคš Worker ไธ‹ Rate Limiter ็‹ฌ็ซ‹ + +```bash +# docker-compose.yaml ไธญ gateway ้ป˜่ฎค 4 workers +# ๆฏไธช worker ๆœ‰็‹ฌ็ซ‹็š„ _login_attempts dict +# ้™้€Ÿๅฏ่ƒฝไธ็ฒพ็กฎ๏ผˆ่ฏทๆฑ‚ๅˆ†ๆ•ฃๅˆฐไธๅŒ worker๏ผ‰๏ผŒไฝ†ไธไผšๅฎŒๅ…จๅคฑๆ•ˆ + +for i in $(seq 1 20); do + echo -n "attempt $i: " + curl -s -w "%{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/login/local \ + -d "username=docker-test@example.com&password=wrong" +done +``` + +**้ข„ๆœŸ๏ผš** ๅœจๆŸไธช็‚นๅผ€ๅง‹่ฟ”ๅ›ž 429๏ผˆๆฏไธช worker ็‹ฌ็ซ‹่ฎกๆ•ฐ๏ผŒ้˜ˆๅ€ผๅฏ่ƒฝๅœจ 5~20 ไน‹้—ด่งฆๅ‘๏ผŒๅ–ๅ†ณไบŽ่ดŸ่ฝฝๅ‡่กกๅˆ†ๅธƒ๏ผ‰ใ€‚ + +**ๅทฒ็Ÿฅ้™ๅˆถ๏ผš** In-process rate limiter ไธ่ทจ worker ๅ…ฑไบซใ€‚็”Ÿไบง็Žฏๅขƒๅฆ‚้œ€็ฒพ็กฎ้™้€Ÿ๏ผŒ้œ€่ฆ Redis ็ญ‰ๅค–้ƒจๅญ˜ๅ‚จใ€‚ + +#### TC-DOCKER-04: IM ๆธ ้“ไธ็ป่ฟ‡ auth + +```bash +# IM ๆธ ้“๏ผˆFeishu/Slack/Telegram๏ผ‰ๅœจ gateway ๅฎนๅ™จๅ†…้ƒจ้€š่ฟ‡ LangGraph SDK ้€šไฟก +# ไธ่ตฐ nginx๏ผŒไธ็ป่ฟ‡ AuthMiddleware + +# ้ชŒ่ฏๆ–นๅผ๏ผšๆฃ€ๆŸฅ gateway ๆ—ฅๅฟ—ไธญ channel manager ็š„่ฏทๆฑ‚ไธๅŒ…ๅซ auth ้”™่ฏฏ +docker logs deer-flow-gateway 2>&1 | grep -E "ChannelManager|channel" | head -10 +``` + +**้ข„ๆœŸ๏ผš** ๆ—  auth ็›ธๅ…ณ้”™่ฏฏใ€‚ๆธ ้“้€š่ฟ‡ `langgraph-sdk` ็›ด่ฟž LangGraph Server๏ผˆ`http://langgraph:2024`๏ผ‰๏ผŒไธ่ตฐ auth ๅฑ‚ใ€‚ + +#### TC-DOCKER-05: admin ๅฏ†็ ๅœจๅฎนๅ™จๆ—ฅๅฟ—ไธญๅฏ่ง + +```bash +docker logs deer-flow-gateway 2>&1 | grep "Password:" +``` + +**้ข„ๆœŸ๏ผš** ้ฆ–ๆฌกๅฏๅŠจๆ—ถ่พ“ๅ‡บ admin ๅฏ†็ ๏ผŒ่ฟ็ปดๅฏ้€š่ฟ‡ `docker logs` ่Žทๅ–ใ€‚ + +#### TC-DOCKER-06: Gateway ๆจกๅผ Docker ้ƒจ็ฝฒ + +```bash +# Gateway ๆจกๅผ๏ผšๆ—  langgraph ๅฎนๅ™จ +./scripts/deploy.sh --gateway +sleep 15 + +# ็กฎ่ฎค langgraph ๅฎนๅ™จไธๅญ˜ๅœจ +docker ps --filter name=deer-flow-langgraph --format '{{.Names}}' | wc -l +# ้ข„ๆœŸ: 0 + +# auth ๆต็จ‹ๆญฃๅธธ +curl -s -w "%{http_code}" -o /dev/null $BASE/api/models +# ้ข„ๆœŸ: 401 + +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@deerflow.dev&password=<ๆ—ฅๅฟ—ๅฏ†็ >" \ + -c cookies.txt -w "\nHTTP %{http_code}" +# ้ข„ๆœŸ: 200 +``` + +### 7.4 ่กฅๅ……่พน็•Œ็”จไพ‹ + +#### TC-EDGE-01: ๆ ผๅผๆญฃ็กฎไฝ†้šๆœบ JWT + +```bash +RANDOM_JWT=$(python3 -c " +import jwt, time, uuid +print(jwt.encode({'sub':str(uuid.uuid4()),'ver':0,'exp':int(time.time())+3600}, 'wrong-secret-32chars-placeholder!!', algorithm='HS256')) +") +curl -s --cookie "access_token=$RANDOM_JWT" $BASE/api/v1/auth/me | jq .detail +``` + +**้ข„ๆœŸ๏ผš** `{"code": "token_invalid", "message": "Token error: invalid_signature"}` + +#### TC-EDGE-02: ๆณจๅ†Œๆ—ถไผ  system_role=admin + +```bash +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"hacker@example.com","password":"HackPass1!","system_role":"admin"}' | jq .system_role +``` + +**้ข„ๆœŸ๏ผš** `"user"`๏ผˆ`system_role` ๅญ—ๆฎต่ขซๅฟฝ็•ฅ๏ผ‰ + +#### TC-EDGE-03: ๅนถๅ‘ๆ”นๅฏ†็  + +```bash +# ๆณจๅ†Œ็”จๆˆท๏ผŒ็™ปๅฝ•ไธคไธช session +curl -s -X POST $BASE/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"edge03@example.com","password":"EdgePass3!"}' -o /dev/null +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=edge03@example.com&password=EdgePass3!" -c s1.txt -o /dev/null +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=edge03@example.com&password=EdgePass3!" -c s2.txt -o /dev/null + +CSRF1=$(grep csrf_token s1.txt | awk '{print $NF}') +CSRF2=$(grep csrf_token s2.txt | awk '{print $NF}') + +# ๅนถๅ‘ๆ”นๅฏ†็  +curl -s -w "S1: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \ + -b s1.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF1" \ + -d '{"current_password":"EdgePass3!","new_password":"NewEdge3a!"}' & +curl -s -w "S2: %{http_code}\n" -o /dev/null -X POST $BASE/api/v1/auth/change-password \ + -b s2.txt -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF2" \ + -d '{"current_password":"EdgePass3!","new_password":"NewEdge3b!"}' & +wait +``` + +**้ข„ๆœŸ๏ผš** ไธ€ไธช 200ใ€ไธ€ไธช 400๏ผˆcurrent_password ๅทฒๅ˜ๅฏผ่‡ด้ชŒ่ฏๅคฑ่ดฅ๏ผ‰ใ€‚ๆž็ซฏๅนถๅ‘ไธ‹ๅฏ่ƒฝไธคไธช้ƒฝ 200๏ผˆSQLite ไธฒ่กŒๅ†™๏ผ‰๏ผŒไฝ†ๆœ€็ปˆๅชๆœ‰ไธ€ไธชๅฏ†็ ็”Ÿๆ•ˆใ€‚ + +#### TC-EDGE-04: Cookie SameSite ้ชŒ่ฏ + +> ๅฎŒๆ•ด็š„ HTTP/HTTPS cookie ๅฑžๆ€งๅฏนๆฏ”่ง ยง3.3 TC-ATK-06/07/07aใ€‚ + +```bash +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " 2>/dev/null | grep -i set-cookie +``` + +**้ข„ๆœŸ๏ผš** `access_token` โ†’ `SameSite=lax`๏ผŒ`csrf_token` โ†’ `SameSite=strict` + +#### TC-EDGE-05: HTTP ๆ—  max_age / HTTPS ๆœ‰ max_age + +```bash +# HTTP +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " 2>/dev/null \ + | grep "access_token=" | grep -oi "max-age=[0-9]*" || echo "NO max-age (HTTP session cookie)" + +# HTTPS +curl -s -D - -X POST $BASE/api/v1/auth/login/local \ + -H "X-Forwarded-Proto: https" \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " 2>/dev/null \ + | grep "access_token=" | grep -oi "max-age=[0-9]*" +``` + +**้ข„ๆœŸ๏ผš** HTTP ๆ—  `Max-Age`๏ผˆsession cookie๏ผŒๆต่งˆๅ™จๅ…ณ้—ญๅณๅคฑๆ•ˆ๏ผ‰๏ผŒHTTPS ๆœ‰ `Max-Age=604800`๏ผˆ7 ๅคฉ๏ผ‰ + +#### TC-EDGE-06: public ่ทฏๅพ„ trailing slash + +```bash +for path in /api/v1/auth/login/local/ /api/v1/auth/register/ \ + /api/v1/auth/logout/ /api/v1/auth/setup-status/; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)" +done +``` + +**้ข„ๆœŸ๏ผš** ๅ…จ้ƒจ 307๏ผˆredirect ๅŽปๆމ trailing slash๏ผ‰ๆˆ– 200/405๏ผŒไธๆ˜ฏ 401 + +### 7.5 ็บข้˜ŸๅฏนๆŠ—ๆต‹่ฏ• + +> ๆจกๆ‹Ÿๆ”ปๅ‡ป่€…่ง†่ง’๏ผŒ้ชŒ่ฏ้˜ฒ็บฟๆฒกๆœ‰ๅฏๅˆฉ็”จ็š„็ผ้š™ใ€‚ + +#### 7.5.1 ่ทฏๅพ„ๆททๆท†็ป•่ฟ‡ + +```bash +# ้€š่ฟ‡็ผ–็ /ๅŒๆ–œๆ /่ทฏๅพ„็ฉฟ่ถŠๅฐ่ฏ•็ป•่ฟ‡ AuthMiddleware ๅ…ฌๅผ€่ทฏๅพ„ๅˆคๆ–ญ +for path in \ + "//api/v1/auth/me" \ + "/api/v1/auth/login/local/../me" \ + "/api/v1/auth/login/local%2f..%2fme" \ + "/api/v1/auth/login/local/..%2Fme" \ + "/API/V1/AUTH/ME"; do + echo "$path: $(curl -s -w '%{http_code}' -o /dev/null $BASE$path)" +done +``` + +**้ข„ๆœŸ๏ผš** ๅ…จ้ƒจ 401 ๆˆ– 404ใ€‚ไธๅบ”ๆœ‰่ทฏๅพ„ๆททๆท†ๅฏผ่‡ด่ทณ่ฟ‡ auth ๆฃ€ๆŸฅใ€‚ + +#### 7.5.2 CSRF ๅฏนๆŠ—็Ÿฉ้˜ต + +```bash +# ็™ปๅฝ•ๆ‹ฟ cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " -c cookies.txt + +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') + +# Case 1: ๆœ‰ cookie ๆ—  header โ†’ 403 +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads -b cookies.txt \ + -H "Content-Type: application/json" -d '{"metadata":{}}' + +# Case 2: ๆœ‰ header ๆ—  cookie โ†’ 403๏ผˆๅˆ ้™ค cookie ไธญ็š„ csrf_token๏ผ‰ +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads \ + -b cookies.txt \ + -H "X-CSRF-Token: $CSRF" \ + -H "Content-Type: application/json" -d '{"metadata":{}}' + +# Case 3: header ๅ’Œ cookie ไธๅŒน้… โ†’ 403 +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads -b cookies.txt \ + -H "X-CSRF-Token: wrong-token" \ + -H "Content-Type: application/json" -d '{"metadata":{}}' + +# Case 4: ๆ—ง CSRF token๏ผˆ็™ปๅ‡บๅ†็™ปๅฝ•ๅŽ๏ผ‰ โ†’ ๆ—ง token ๅบ”ๅคฑๆ•ˆ +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " -c cookies.txt +# ็”จๆ—ง CSRF ๅ‘่ฏทๆฑ‚ +curl -s -w "%{http_code}" -o /dev/null \ + -X POST $BASE/api/threads -b cookies.txt \ + -H "X-CSRF-Token: $CSRF" \ + -H "Content-Type: application/json" -d '{"metadata":{}}' +``` + +**้ข„ๆœŸ๏ผš** Case 1-3 ๅ…จ้ƒจ 403ใ€‚Case 4 ๅบ” 403๏ผˆๆ—ง CSRF ไธŽๆ–ฐ cookie ไธๅŒน้…๏ผ‰ใ€‚ + +#### 7.5.3 Token Replay๏ผˆ็™ปๅ‡บๅŽๆ—ง token ้‡ๆ”พ๏ผ‰ + +```bash +# ็™ปๅฝ•๏ผŒไฟๅญ˜ cookie +curl -s -X POST $BASE/api/v1/auth/login/local \ + -d "username=admin@example.com&password=ๆญฃ็กฎๅฏ†็ " -c cookies.txt + +# ๆๅ– access_token ๅ€ผ +TOKEN=$(grep access_token cookies.txt | awk '{print $NF}') + +# ็™ปๅ‡บ +curl -s -X POST $BASE/api/v1/auth/logout -b cookies.txt + +# ๆ‰‹ๅทฅๆณจๅ…ฅๆ—ง token๏ผˆๆจกๆ‹Ÿๆ”ปๅ‡ป่€…็ชƒๅ–ไบ† token๏ผ‰ +curl -s -w "%{http_code}" -o /dev/null \ + $BASE/api/v1/auth/me --cookie "access_token=$TOKEN" +``` + +**้ข„ๆœŸ๏ผš** 200๏ผˆๅทฒ็Ÿฅ้™ๅˆถ๏ผš็™ปๅ‡บๅชๆธ…ๅฎขๆˆท็ซฏ cookie๏ผŒไธ bump `token_version`ใ€‚ๆ—ง token ๅœจ่ฟ‡ๆœŸๅ‰ไปๆœ‰ๆ•ˆ๏ผ‰ใ€‚ +**ๅฎ‰ๅ…จๅค‡ๆณจ๏ผš** ๅฆ‚้œ€ไธฅๆ ผ้˜ฒ้‡ๆ”พ๏ผŒ้œ€ๅœจ็™ปๅ‡บๆ—ถ `token_version += 1`ใ€‚ๅฝ“ๅ‰่ฎพ่ฎก้€‰ๆ‹ฉไธๅš๏ผŒๅ› ไธบๆˆๆœฌๆ˜ฏๆ‰€ๆœ‰่ฎพๅค‡็š„ session ๅ…จ้ƒจๅคฑๆ•ˆใ€‚ + +#### 7.5.4 ่ทจ็ซ™ๅผบๅˆถ็™ปๅ‡บ + +```bash +# ๆ”ปๅ‡ป่€…ไปŽ็ฌฌไธ‰ๆ–น็ซ™็‚น POST /logout๏ผˆๆ— ้œ€่ฎค่ฏใ€ๆ— ้œ€ CSRF๏ผ‰ +curl -s -X POST $BASE/api/v1/auth/logout -w "%{http_code}" +``` + +**้ข„ๆœŸ๏ผš** 200๏ผˆlogout ๆ˜ฏ public + CSRF ่ฑๅ…๏ผ‰ใ€‚ +**้ฃŽ้™ฉ่ฏ„ไผฐ๏ผš** ไฝŽโ€”โ€”ๅชๅฝฑๅ“ๅฏ็”จๆ€ง๏ผˆ่ขซๅผบๅˆถ็™ปๅ‡บ๏ผ‰๏ผŒไธๆณ„้œฒๆ•ฐๆฎใ€‚ๆต่งˆๅ™จ `SameSite=Lax` ้™ๅˆถไบ†็œŸๅฎž่ทจ็ซ™ๅœบๆ™ฏไธ‹ cookie ไธไผš่ขซๅธฆไธŠ๏ผŒๆ‰€ไปฅๅฎž้™…ไธŠ็ฌฌไธ‰ๆ–น็ซ™็‚น็š„ POST ไธไผšๆธ…้™ค็”จๆˆท cookieใ€‚ + +#### 7.5.5 Metadata ๆณจๅ…ฅๆ”ปๅ‡ป๏ผˆๆ‰€ๆœ‰ๆƒไผช้€ ๏ผ‰ + +```bash +# ๅฐ่ฏ•ๅœจๅˆ›ๅปบ thread ๆ—ถๆณจๅ…ฅๅ…ถไป–็”จๆˆท็š„ user_id +CSRF=$(grep csrf_token cookies.txt | awk '{print $NF}') +curl -s -X POST $BASE/api/threads \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: $CSRF" \ + -d '{"metadata":{"user_id":"victim-user-id"}}' | jq .metadata.user_id +``` + +**้ข„ๆœŸ๏ผš** ่ฟ”ๅ›ž็š„ `metadata.user_id` ๅบ”ไธบๅฝ“ๅ‰็™ปๅฝ•็”จๆˆท็š„ ID๏ผŒไธๆ˜ฏ่ฏทๆฑ‚ไธญๆณจๅ…ฅ็š„ `victim-user-id`ใ€‚ๆœๅŠก็ซฏๅบ”่ฆ†็›–ๅฎขๆˆท็ซฏๆไพ›็š„ `user_id`ใ€‚ + +#### 7.5.6 HTTP Method ๆŽขๆต‹ + +```bash +# HEAD/OPTIONS ไธๅบ”ๆณ„้œฒๅ—ไฟๆŠค่ต„ๆบไฟกๆฏ +for method in HEAD OPTIONS TRACE; do + echo "$method /api/models: $(curl -s -w '%{http_code}' -o /dev/null -X $method $BASE/api/models)" +done +``` + +**้ข„ๆœŸ๏ผš** HEAD/OPTIONS ่ฟ”ๅ›ž 401 ๆˆ– 405ใ€‚TRACE ๅบ”่ฟ”ๅ›ž 405ใ€‚ + +#### 7.5.7 Rate Limiter IP ็ปดๅบฆ็ผบ้™ท้ชŒ่ฏ + +```bash +# ้€š่ฟ‡ไธๅŒ็š„ X-Forwarded-For ็ป•่ฟ‡้™้€Ÿ๏ผˆ้ชŒ่ฏๆ˜ฏๅฆ็”จ client.host ่€Œ้ž header๏ผ‰ +for i in $(seq 1 6); do + curl -s -w "attempt $i: %{http_code}\n" -o /dev/null \ + -X POST $BASE/api/v1/auth/login/local \ + -H "X-Forwarded-For: 10.0.0.$i" \ + -d "username=admin@example.com&password=wrong" +done +``` + +**้ข„ๆœŸ๏ผš** ๅฆ‚ๆžœ rate limiter ๅŸบไบŽ `request.client.host`๏ผˆๅฎž้™… TCP ่ฟžๆŽฅ IP๏ผ‰๏ผŒๆ‰€ๆœ‰่ฏทๆฑ‚ๆฅ่‡ชๅŒไธ€ IP๏ผŒ็ฌฌ 6 ไธชๅบ”่ฟ”ๅ›ž 429ใ€‚X-Forwarded-For ไธๅบ”ๅฝฑๅ“้™้€Ÿๅˆคๆ–ญใ€‚ + +#### 7.5.8 Junk Cookie ็ฉฟ้€้ชŒ่ฏ + +```bash +# middleware ๅชๆฃ€ๆŸฅ cookie ๅญ˜ๅœจๆ€ง๏ผŒไธ้ชŒ่ฏ JWT +# ็กฎ่ฎค junk cookie ่ƒฝ่ฟ‡ middleware ไฝ†่ขซไธ‹ๆธธ @require_auth ๆ‹ฆๆˆช +curl -s -w "%{http_code}" $BASE/api/v1/auth/me \ + --cookie "access_token=not-a-jwt" +``` + +**้ข„ๆœŸ๏ผš** 401๏ผˆmiddleware ๆ”พ่กŒ๏ผŒ`get_current_user_from_request` ่งฃ็ ๅคฑ่ดฅ่ฟ”ๅ›ž 401๏ผ‰ใ€‚ +**ๅฎ‰ๅ…จๅค‡ๆณจ๏ผš** middleware ๆ˜ฏ presence-only ๆฃ€ๆŸฅ๏ผŒๆœ‰ๆ„่ฎพ่ฎกใ€‚ๅฎŒๆ•ด้ชŒ่ฏไบค็ป™ `@require_auth`ใ€‚ + +#### 7.5.9 ่ทฏ็”ฑ่ฆ†็›–ๅฎก่ฎก + +```bash +# ๅˆ—ๅ‡บๆ‰€ๆœ‰ๆณจๅ†Œ็š„่ทฏ็”ฑ๏ผŒๆฃ€ๆŸฅๅ“ชไบ›ๆฒกๆœ‰ @require_auth +cd backend && PYTHONPATH=. python3 -c " +from app.gateway.app import create_app +app = create_app() +public_prefixes = ['/health', '/docs', '/redoc', '/openapi.json', + '/api/v1/auth/login', '/api/v1/auth/register', + '/api/v1/auth/logout', '/api/v1/auth/setup-status'] +for route in app.routes: + path = getattr(route, 'path', '') + if not path or not path.startswith('/api'): + continue + is_public = any(path.startswith(p) for p in public_prefixes) + if not is_public: + print(f' {path}') +" 2>/dev/null +``` + +**้ข„ๆœŸ๏ผš** ๅˆ—ๅ‡บ็š„ๆ‰€ๆœ‰่ทฏ็”ฑ้ƒฝๅบ”็”ฑ AuthMiddleware๏ผˆcookie ๅญ˜ๅœจๆ€ง๏ผ‰+ `@require_auth`/`@require_permission`๏ผˆJWT ้ชŒ่ฏ๏ผ‰ๅŒๅฑ‚ไฟๆŠคใ€‚ๆฃ€ๆŸฅๆ˜ฏๅฆๆœ‰้—ๆผใ€‚ + +--- + +## ๅ…ซใ€ๅ›žๅฝ’ๆธ…ๅ• + +ๆฏๆฌก auth ็›ธๅ…ณไปฃ็ ๅ˜ๆ›ดๅŽๅฟ…้กป้€š่ฟ‡๏ผš + +```bash +# ๅ•ๅ…ƒๆต‹่ฏ•๏ผˆ168 ไธช๏ผ‰ +cd backend && PYTHONPATH=. uv run pytest \ + tests/test_auth.py \ + tests/test_auth_config.py \ + tests/test_auth_errors.py \ + tests/test_auth_type_system.py \ + tests/test_auth_middleware.py \ + tests/test_langgraph_auth.py \ + -v + +# ๆ ธๅฟƒๆŽฅๅฃๅ†’็ƒŸ +curl -s $BASE/health # 200 +curl -s $BASE/api/models # 401 (ๆ—  cookie) +curl -s -X POST $BASE/api/v1/auth/setup-status # 200 +curl -s $BASE/api/v1/auth/me -b cookies.txt # 200 (ๆœ‰ cookie) +``` diff --git a/backend/docs/AUTH_UPGRADE.md b/backend/docs/AUTH_UPGRADE.md new file mode 100644 index 000000000..344c488c4 --- /dev/null +++ b/backend/docs/AUTH_UPGRADE.md @@ -0,0 +1,129 @@ +# Authentication Upgrade Guide + +DeerFlow ๅ†…็ฝฎไบ†่ฎค่ฏๆจกๅ—ใ€‚ๆœฌๆ–‡ๆกฃ้ขๅ‘ไปŽๆ— ่ฎค่ฏ็‰ˆๆœฌๅ‡็บง็š„็”จๆˆทใ€‚ + +## ๆ ธๅฟƒๆฆ‚ๅฟต + +่ฎค่ฏๆจกๅ—้‡‡็”จ**ๅง‹็ปˆๅผบๅˆถ**็ญ–็•ฅ๏ผš + +- ้ฆ–ๆฌกๅฏๅŠจๆ—ถ่‡ชๅŠจๅˆ›ๅปบ admin ่ดฆๅท๏ผŒ้šๆœบๅฏ†็ ๆ‰“ๅฐๅˆฐๆŽงๅˆถๅฐๆ—ฅๅฟ— +- ่ฎค่ฏไปŽไธ€ๅผ€ๅง‹ๅฐฑๆ˜ฏๅผบๅˆถ็š„๏ผŒๆ— ็ซžไบ‰็ช—ๅฃ +- ๅކๅฒๅฏน่ฏ๏ผˆๅ‡็บงๅ‰ๅˆ›ๅปบ็š„ thread๏ผ‰่‡ชๅŠจ่ฟ็งปๅˆฐ admin ๅไธ‹ + +## ๅ‡็บงๆญฅ้ชค + +### 1. ๆ›ดๆ–ฐไปฃ็  + +```bash +git pull origin main +cd backend && make install +``` + +### 2. ้ฆ–ๆฌกๅฏๅŠจ + +```bash +make dev +``` + +ๆŽงๅˆถๅฐไผš่พ“ๅ‡บ๏ผš + +``` +============================================================ + Admin account created on first boot + Email: admin@deerflow.dev + Password: aB3xK9mN_pQ7rT2w + Change it after login: Settings โ†’ Account +============================================================ +``` + +ๅฆ‚ๆžœๆœช็™ปๅฝ•ๅฐฑ้‡ๅฏไบ†ๆœๅŠก๏ผŒไธ็”จๆ‹…ๅฟƒโ€”โ€”ๅช่ฆ setup ๆœชๅฎŒๆˆ๏ผŒๆฏๆฌกๅฏๅŠจ้ƒฝไผš้‡็ฝฎๅฏ†็ ๅนถ้‡ๆ–ฐๆ‰“ๅฐๅˆฐๆŽงๅˆถๅฐใ€‚ + +### 3. ็™ปๅฝ• + +่ฎฟ้—ฎ `http://localhost:2026/login`๏ผŒไฝฟ็”จๆŽงๅˆถๅฐ่พ“ๅ‡บ็š„้‚ฎ็ฎฑๅ’Œๅฏ†็ ็™ปๅฝ•ใ€‚ + +### 4. ไฟฎๆ”นๅฏ†็  + +็™ปๅฝ•ๅŽ่ฟ›ๅ…ฅ Settings โ†’ Account โ†’ Change Passwordใ€‚ + +### 5. ๆทปๅŠ ็”จๆˆท๏ผˆๅฏ้€‰๏ผ‰ + +ๅ…ถไป–็”จๆˆท้€š่ฟ‡ `/login` ้กต้ขๆณจๅ†Œ๏ผŒ่‡ชๅŠจ่Žทๅพ— **user** ่ง’่‰ฒใ€‚ๆฏไธช็”จๆˆทๅช่ƒฝ็œ‹ๅˆฐ่‡ชๅทฑ็š„ๅฏน่ฏใ€‚ + +## ๅฎ‰ๅ…จๆœบๅˆถ + +| ๆœบๅˆถ | ่ฏดๆ˜Ž | +|------|------| +| JWT HttpOnly Cookie | Token ไธๆšด้œฒ็ป™ JavaScript๏ผŒ้˜ฒๆญข XSS ็ชƒๅ– | +| CSRF Double Submit Cookie | ๆ‰€ๆœ‰ POST/PUT/DELETE ่ฏทๆฑ‚้œ€ๆบๅธฆ `X-CSRF-Token` | +| bcrypt ๅฏ†็ ๅ“ˆๅธŒ | ๅฏ†็ ไธไปฅๆ˜Žๆ–‡ๅญ˜ๅ‚จ | +| ๅคš็งŸๆˆท้š”็ฆป | ็”จๆˆทๅช่ƒฝ่ฎฟ้—ฎ่‡ชๅทฑ็š„ thread | +| HTTPS ่‡ช้€‚ๅบ” | ๆฃ€ๆต‹ `x-forwarded-proto`๏ผŒ่‡ชๅŠจ่ฎพ็ฝฎ `Secure` cookie ๆ ‡ๅฟ— | + +## ๅธธ่งๆ“ไฝœ + +### ๅฟ˜่ฎฐๅฏ†็  + +```bash +cd backend + +# ้‡็ฝฎ admin ๅฏ†็  +python -m app.gateway.auth.reset_admin + +# ้‡็ฝฎๆŒ‡ๅฎš็”จๆˆทๅฏ†็  +python -m app.gateway.auth.reset_admin --email user@example.com +``` + +ไผš่พ“ๅ‡บๆ–ฐ็š„้šๆœบๅฏ†็ ใ€‚ + +### ๅฎŒๅ…จ้‡็ฝฎ + +ๅˆ ้™ค็”จๆˆทๆ•ฐๆฎๅบ“๏ผŒ้‡ๅฏๅŽ่‡ชๅŠจๅˆ›ๅปบๆ–ฐ admin๏ผš + +```bash +rm -f backend/.deer-flow/users.db +# ้‡ๅฏๆœๅŠก๏ผŒๆŽงๅˆถๅฐ่พ“ๅ‡บๆ–ฐๅฏ†็  +``` + +## ๆ•ฐๆฎๅญ˜ๅ‚จ + +| ๆ–‡ไปถ | ๅ†…ๅฎน | +|------|------| +| `.deer-flow/users.db` | SQLite ็”จๆˆทๆ•ฐๆฎๅบ“๏ผˆๅฏ†็ ๅ“ˆๅธŒใ€่ง’่‰ฒ๏ผ‰ | +| `.env` ไธญ็š„ `AUTH_JWT_SECRET` | JWT ็ญพๅๅฏ†้’ฅ๏ผˆๆœช่ฎพ็ฝฎๆ—ถ่‡ชๅŠจ็”Ÿๆˆไธดๆ—ถๅฏ†้’ฅ๏ผŒ้‡ๅฏๅŽ session ๅคฑๆ•ˆ๏ผ‰ | + +### ็”Ÿไบง็Žฏๅขƒๅปบ่ฎฎ + +```bash +# ็”ŸๆˆๆŒไน…ๅŒ– JWT ๅฏ†้’ฅ๏ผŒ้ฟๅ…้‡ๅฏๅŽๆ‰€ๆœ‰็”จๆˆท้œ€้‡ๆ–ฐ็™ปๅฝ• +python -c "import secrets; print(secrets.token_urlsafe(32))" +# ๅฐ†่พ“ๅ‡บๆทปๅŠ ๅˆฐ .env๏ผš +# AUTH_JWT_SECRET=<็”Ÿๆˆ็š„ๅฏ†้’ฅ> +``` + +## API ็ซฏ็‚น + +| ็ซฏ็‚น | ๆ–นๆณ• | ่ฏดๆ˜Ž | +|------|------|------| +| `/api/v1/auth/login/local` | POST | ้‚ฎ็ฎฑๅฏ†็ ็™ปๅฝ•๏ผˆOAuth2 form๏ผ‰ | +| `/api/v1/auth/register` | POST | ๆณจๅ†Œๆ–ฐ็”จๆˆท๏ผˆuser ่ง’่‰ฒ๏ผ‰ | +| `/api/v1/auth/logout` | POST | ็™ปๅ‡บ๏ผˆๆธ…้™ค cookie๏ผ‰ | +| `/api/v1/auth/me` | GET | ่Žทๅ–ๅฝ“ๅ‰็”จๆˆทไฟกๆฏ | +| `/api/v1/auth/change-password` | POST | ไฟฎๆ”นๅฏ†็  | +| `/api/v1/auth/setup-status` | GET | ๆฃ€ๆŸฅ admin ๆ˜ฏๅฆๅญ˜ๅœจ | + +## ๅ…ผๅฎนๆ€ง + +- **ๆ ‡ๅ‡†ๆจกๅผ**๏ผˆ`make dev`๏ผ‰๏ผšๅฎŒๅ…จๅ…ผๅฎน๏ผŒadmin ่‡ชๅŠจๅˆ›ๅปบ +- **Gateway ๆจกๅผ**๏ผˆ`make dev-pro`๏ผ‰๏ผšๅฎŒๅ…จๅ…ผๅฎน +- **Docker ้ƒจ็ฝฒ**๏ผšๅฎŒๅ…จๅ…ผๅฎน๏ผŒ`.deer-flow/users.db` ้œ€ๆŒไน…ๅŒ–ๅทๆŒ‚่ฝฝ +- **IM ๆธ ้“**๏ผˆFeishu/Slack/Telegram๏ผ‰๏ผš้€š่ฟ‡ LangGraph SDK ้€šไฟก๏ผŒไธ็ป่ฟ‡่ฎค่ฏๅฑ‚ +- **DeerFlowClient**๏ผˆๅตŒๅ…ฅๅผ๏ผ‰๏ผšไธ็ป่ฟ‡ HTTP๏ผŒไธๅ—่ฎค่ฏๅฝฑๅ“ + +## ๆ•…้šœๆŽ’ๆŸฅ + +| ็—‡็Šถ | ๅŽŸๅ›  | ่งฃๅ†ณ | +|------|------|------| +| ๅฏๅŠจๅŽๆฒก็œ‹ๅˆฐๅฏ†็  | admin ๅทฒๅญ˜ๅœจ๏ผˆ้ž้ฆ–ๆฌกๅฏๅŠจ๏ผ‰ | ็”จ `reset_admin` ้‡็ฝฎ๏ผŒๆˆ–ๅˆ  `users.db` | +| ็™ปๅฝ•ๅŽ POST ่ฟ”ๅ›ž 403 | CSRF token ็ผบๅคฑ | ็กฎ่ฎคๅ‰็ซฏๅทฒๆ›ดๆ–ฐ | +| ้‡ๅฏๅŽ้œ€่ฆ้‡ๆ–ฐ็™ปๅฝ• | `AUTH_JWT_SECRET` ๆœชๆŒไน…ๅŒ– | ๅœจ `.env` ไธญ่ฎพ็ฝฎๅ›บๅฎšๅฏ†้’ฅ | diff --git a/backend/docs/AUTO_TITLE_GENERATION.md b/backend/docs/AUTO_TITLE_GENERATION.md index 2fd220ea5..27644b2ea 100644 --- a/backend/docs/AUTO_TITLE_GENERATION.md +++ b/backend/docs/AUTO_TITLE_GENERATION.md @@ -248,7 +248,7 @@ def after_agent(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | N - [`packages/harness/deerflow/agents/thread_state.py`](../packages/harness/deerflow/agents/thread_state.py) - ThreadState ๅฎšไน‰ - [`packages/harness/deerflow/agents/middlewares/title_middleware.py`](../packages/harness/deerflow/agents/middlewares/title_middleware.py) - TitleMiddleware ๅฎž็Žฐ - [`packages/harness/deerflow/config/title_config.py`](../packages/harness/deerflow/config/title_config.py) - ้…็ฝฎ็ฎก็† -- [`config.yaml`](../config.yaml) - ้…็ฝฎๆ–‡ไปถ +- [`config.yaml`](../../config.example.yaml) - ้…็ฝฎๆ–‡ไปถ - [`packages/harness/deerflow/agents/lead_agent/agent.py`](../packages/harness/deerflow/agents/lead_agent/agent.py) - Middleware ๆณจๅ†Œ ## ๅ‚่€ƒ่ต„ๆ–™ diff --git a/backend/docs/TITLE_GENERATION_IMPLEMENTATION.md b/backend/docs/TITLE_GENERATION_IMPLEMENTATION.md index e4ed673f5..07a026e79 100644 --- a/backend/docs/TITLE_GENERATION_IMPLEMENTATION.md +++ b/backend/docs/TITLE_GENERATION_IMPLEMENTATION.md @@ -30,7 +30,7 @@ ### 2. ้…็ฝฎๆ–‡ไปถ -#### [`config.yaml`](../config.yaml) +#### [`config.yaml`](../../config.example.yaml) - โœ… ๆทปๅŠ  title ้…็ฝฎๆฎต๏ผš ```yaml title: @@ -51,7 +51,7 @@ title: - โœ… ๆ•…้šœๆŽ’ๆŸฅๆŒ‡ๅ— - โœ… State vs Metadata ๅฏนๆฏ” -#### [`BACKEND_TODO.md`](../BACKEND_TODO.md) +#### [`TODO.md`](TODO.md) - โœ… ๆทปๅŠ ๅŠŸ่ƒฝๅฎŒๆˆ่ฎฐๅฝ• ### 4. ๆต‹่ฏ• diff --git a/backend/docs/rfc-grep-glob-tools.md b/backend/docs/rfc-grep-glob-tools.md new file mode 100644 index 000000000..f4defca98 --- /dev/null +++ b/backend/docs/rfc-grep-glob-tools.md @@ -0,0 +1,446 @@ +# [RFC] ๅœจ DeerFlow ไธญๅขžๅŠ  `grep` ไธŽ `glob` ๆ–‡ไปถๆœ็ดขๅทฅๅ…ท + +## Summary + +ๆˆ‘่ฎคไธบ่ฟ™ไธชๆ–นๅ‘ๆ˜ฏๅฏน็š„๏ผŒ่€Œไธ”ๅ€ผๅพ—ๅšใ€‚ + +ๅฆ‚ๆžœ DeerFlow ๆƒณๆ›ดๆŽฅ่ฟ‘ Claude Code ่ฟ™็ฑป coding agent ็š„ๅฎž้™…ๅทฅไฝœๆต๏ผŒไป…ๆœ‰ `ls` / `read_file` / `write_file` / `str_replace` ่ฟ˜ไธๅคŸใ€‚ๆจกๅž‹ๅœจ่ฟ›ๅ…ฅไฟฎๆ”นๅ‰๏ผŒ้€šๅธธ่ฟ˜้œ€่ฆไธค็ฑป่ƒฝๅŠ›๏ผš + +- `glob`: ๅฟซ้€ŸๆŒ‰่ทฏๅพ„ๆจกๅผๆ‰พๆ–‡ไปถ +- `grep`: ๅฟซ้€ŸๆŒ‰ๅ†…ๅฎนๆจกๅผๆ‰พๅ€™้€‰ไฝ็ฝฎ + +่ฟ™ไธค็ฑปๅทฅๅ…ท็š„ไปทๅ€ผ๏ผŒไธๆ˜ฏโ€œๅŠŸ่ƒฝไธŠ bash ไนŸ่ƒฝๅšโ€๏ผŒ่€Œๆ˜ฏๅฎƒไปฌ่ƒฝไปฅๆ›ดไฝŽ token ๆˆๆœฌใ€ๆ›ดๅผบ็บฆๆŸใ€ๆ›ด็จณๅฎš็š„่พ“ๅ‡บๆ ผๅผ๏ผŒๆ›ฟไปฃๆจกๅž‹้ข‘็น่ตฐ `bash find` / `bash grep` / `rg` ็š„ไน ๆƒฏใ€‚ + +ไฝ†ๅ‰ๆๆ˜ฏๅฎž็Žฐๆ–นๅผ่ฆๅฏน๏ผš**ๅฎƒไปฌๅบ”่ฏฅๆ˜ฏๅช่ฏปใ€็ป“ๆž„ๅŒ–ใ€ๅ—้™ใ€ๅฏๅฎก่ฎก็š„ๅŽŸ็”Ÿๅทฅๅ…ท๏ผŒ่€Œไธๆ˜ฏๅฏน shell ๅ‘ฝไปค็š„็ฎ€ๅ•ๅŒ…่ฃ…ใ€‚** + +## Problem + +ๅฝ“ๅ‰ DeerFlow ็š„ๆ–‡ไปถๅทฅๅ…ทๅฑ‚ไธป่ฆ่ฆ†็›–๏ผš + +- `ls`: ๆต่งˆ็›ฎๅฝ•็ป“ๆž„ +- `read_file`: ่ฏปๅ–ๆ–‡ไปถๅ†…ๅฎน +- `write_file`: ๅ†™ๆ–‡ไปถ +- `str_replace`: ๅšๅฑ€้ƒจๅญ—็ฌฆไธฒๆ›ฟๆข +- `bash`: ๅ…œๅบ•ๆ‰ง่กŒๅ‘ฝไปค + +่ฟ™ๅฅ—่ƒฝๅŠ›่ƒฝๅฎŒๆˆไปปๅŠก๏ผŒไฝ†ๅœจไปฃ็ ๅบ“ๆŽข็ดข้˜ถๆฎตๆ•ˆ็އไธ้ซ˜ใ€‚ + +ๅ…ธๅž‹้—ฎ้ข˜๏ผš + +1. ๆจกๅž‹ๆƒณๆ‰พ โ€œๆ‰€ๆœ‰ `*.tsx` ็š„ page ๆ–‡ไปถโ€ ๆ—ถ๏ผŒๅช่ƒฝๅๅค `ls` ๅคšๅฑ‚็›ฎๅฝ•๏ผŒๆˆ–่€…้€€ๅ›ž `bash find` +2. ๆจกๅž‹ๆƒณๆ‰พ โ€œๆŸไธช symbol / ๆ–‡ๆกˆ / ้…็ฝฎ้”ฎๅœจๅ“ช้‡Œๅ‡บ็Žฐโ€ ๆ—ถ๏ผŒๅช่ƒฝ้€ๆ–‡ไปถ `read_file`๏ผŒๆˆ–่€…้€€ๅ›ž `bash grep` / `rg` +3. ไธ€ๆ—ฆ้€€ๅ›ž `bash`๏ผŒๅทฅๅ…ท่ฐƒ็”จๅฐฑๅคฑๅŽป็ป“ๆž„ๅŒ–่พ“ๅ‡บ๏ผŒ็ป“ๆžœไนŸๆ›ด้šพๅš่ฃๅ‰ชใ€ๅˆ†้กตใ€ๅฎก่ฎกๅ’Œ่ทจ sandbox ไธ€่‡ดๅŒ– +4. ๅฏนๆฒกๆœ‰ๅผ€ๅฏ host bash ็š„ๆœฌๅœฐๆจกๅผ๏ผŒ`bash` ็”š่‡ณๅฏ่ƒฝไธๅฏ็”จ๏ผŒๆญคๆ—ถ็ผบๅฐ‘่ถณๅคŸๅผบ็š„ๅช่ฏปๆฃ€็ดข่ƒฝๅŠ› + +็ป“่ฎบ๏ผšDeerFlow ็Žฐๅœจ็ผบ็š„ไธๆ˜ฏโ€œๅ†ๅคšไธ€ไธช shell ๅ‘ฝไปคโ€๏ผŒ่€Œๆ˜ฏ**ๆ–‡ไปถ็ณป็ปŸๆฃ€็ดขๅฑ‚**ใ€‚ + +## Goals + +- ไธบ agent ๆไพ›็จณๅฎš็š„่ทฏๅพ„ๆœ็ดขๅ’Œๅ†…ๅฎนๆœ็ดข่ƒฝๅŠ› +- ๅ‡ๅฐ‘ๅฏน `bash` ็š„ไพ่ต–๏ผŒ็‰นๅˆซๆ˜ฏๅœจไป“ๅบ“ๆŽข็ดข้˜ถๆฎต +- ไฟๆŒไธŽ็Žฐๆœ‰ sandbox ๅฎ‰ๅ…จๆจกๅž‹ไธ€่‡ด +- ่พ“ๅ‡บๆ ผๅผ็ป“ๆž„ๅŒ–๏ผŒไพฟไบŽๆจกๅž‹ๅŽ็ปญไธฒ่” `read_file` / `str_replace` +- ่ฎฉๆœฌๅœฐ sandboxใ€ๅฎนๅ™จ sandboxใ€ๆœชๆฅ MCP ๆ–‡ไปถ็ณป็ปŸๅทฅๅ…ท้ƒฝ่ƒฝ้ตๅฎˆๅŒไธ€่ฏญไน‰ + +## Non-Goals + +- ไธๅš้€š็”จ shell ๅ…ผๅฎนๅฑ‚ +- ไธๆšด้œฒๅฎŒๆ•ด grep/find/rg CLI ่ฏญๆณ• +- ไธๅœจ็ฌฌไธ€็‰ˆๆ”ฏๆŒไบŒ่ฟ›ๅˆถๆฃ€็ดขใ€ๅคๆ‚ PCRE ็‰นๆ€งใ€ไธŠไธ‹ๆ–‡็ช—ๅฃ้ซ˜ไบฎๆธฒๆŸ“็ญ‰้‡ๅŠŸ่ƒฝ +- ไธๆŠŠๅฎƒๅšๆˆโ€œไปปๆ„็ฃ็›˜ๆœ็ดขโ€๏ผŒไป็„ถๅชๅ…่ฎธๅœจ DeerFlow ๅทฒๆŽˆๆƒ็š„่ทฏๅพ„ๅ†…ๆ‰ง่กŒ + +## Why This Is Worth Doing + +ๅ‚่€ƒ Claude Code ่ฟ™ไธ€็ฑป agent ็š„่ฎพ่ฎกๆ€่ทฏ๏ผŒ`glob` ๅ’Œ `grep` ็š„ๆ ธๅฟƒไปทๅ€ผไธๆ˜ฏๆ–ฐ่ƒฝๅŠ›ๆœฌ่บซ๏ผŒ่€Œๆ˜ฏๆŠŠโ€œๆŽข็ดขไปฃ็ ๅบ“โ€็š„ๅธธ่งๅŠจไฝœไปŽๅผ€ๆ”พๅผ shell ้™ๅˆฐๅ—ๆŽงๅทฅๅ…ทๅฑ‚ใ€‚ + +่ฟ™ๆ ทๆœ‰ๅ‡ ไธช็›ดๆŽฅๆ”ถ็›Š๏ผš + +1. **ๆ›ดไฝŽ็š„ๆจกๅž‹่ดŸๆ‹…** + ๆจกๅž‹ไธ้œ€่ฆ่‡ชๅทฑๆ‹ผ `find`, `grep`, `rg`, `xargs`, quoting ็ญ‰ๅ‘ฝไปค็ป†่Š‚ใ€‚ + +2. **ๆ›ด็จณๅฎš็š„่ทจ็Žฏๅขƒ่กŒไธบ** + ๆœฌๅœฐใ€Dockerใ€AIO sandbox ไธๅฟ…ไพ่ต–ๅฎนๅ™จ้‡Œๆ˜ฏๅฆ่ฃ…ไบ† `rg`๏ผŒไนŸไธไผšๅ› ไธบ shell ๅทฎๅผ‚ๅฏผ่‡ด่กŒไธบๆผ‚็งปใ€‚ + +3. **ๆ›ดๅผบ็š„ๅฎ‰ๅ…จไธŽๅฎก่ฎก** + ่ฐƒ็”จๅ‚ๆ•ฐๅฐฑๆ˜ฏโ€œๆœ็ดขไป€ไนˆใ€ๅœจๅ“ชๆœใ€ๆœ€ๅคš่ฟ”ๅ›žๅคšๅฐ‘โ€๏ผŒๅคฉ็„ถๆฏ”ไปปๆ„ๅ‘ฝไปคๆ›ดๅฎนๆ˜“ๅฎก่ฎกๅ’Œ้™ๆตใ€‚ + +4. **ๆ›ดๅฅฝ็š„ token ๆ•ˆ็އ** + `grep` ่ฟ”ๅ›ž็š„ๆ˜ฏๅ‘ฝไธญๆ‘˜่ฆ่€Œไธๆ˜ฏๆ•ดๆฎตๆ–‡ไปถ๏ผŒๆจกๅž‹ๅชๅฏนๅฐ‘ๆ•ฐๅ€™้€‰่ทฏๅพ„ๅ†่ฐƒ็”จ `read_file`ใ€‚ + +5. **ๅฏน `tool_search` ๅ‹ๅฅฝ** + ๅฝ“ DeerFlow ๆŒ็ปญๆ‰ฉๅฑ•ๅทฅๅ…ท้›†ๆ—ถ๏ผŒ`grep` / `glob` ไผšๆˆไธบ้žๅธธ้ซ˜้ข‘็š„ๅŸบ็ก€ๅทฅๅ…ท๏ผŒๅ€ผๅพ—ไฟ็•™ไธบ built-in๏ผŒ่€Œไธๆ˜ฏ่ฎฉๆจกๅž‹ๆ€ปๆ˜ฏ้€€ๅ›ž้€š็”จ bashใ€‚ + +## Proposal + +ๅขžๅŠ ไธคไธช built-in sandbox tools๏ผš + +- `glob` +- `grep` + +ๆŽจ่็ปง็ปญๆ”พๅœจ๏ผš + +- `backend/packages/harness/deerflow/sandbox/tools.py` + +ๅนถๅœจ `config.example.yaml` ไธญ้ป˜่ฎคๅŠ ๅ…ฅ `file:read` ็ป„ใ€‚ + +### 1. `glob` ๅทฅๅ…ท + +็”จ้€”๏ผšๆŒ‰่ทฏๅพ„ๆจกๅผๆŸฅๆ‰พๆ–‡ไปถๆˆ–็›ฎๅฝ•ใ€‚ + +ๅปบ่ฎฎ schema๏ผš + +```python +@tool("glob", parse_docstring=True) +def glob_tool( + runtime: ToolRuntime[ContextT, ThreadState], + description: str, + pattern: str, + path: str, + include_dirs: bool = False, + max_results: int = 200, +) -> str: + ... +``` + +ๅ‚ๆ•ฐ่ฏญไน‰๏ผš + +- `description`: ไธŽ็Žฐๆœ‰ๅทฅๅ…ทไฟๆŒไธ€่‡ด +- `pattern`: glob ๆจกๅผ๏ผŒไพ‹ๅฆ‚ `**/*.py`ใ€`src/**/test_*.ts` +- `path`: ๆœ็ดขๆ น็›ฎๅฝ•๏ผŒๅฟ…้กปๆ˜ฏ็ปๅฏน่ทฏๅพ„ +- `include_dirs`: ๆ˜ฏๅฆ่ฟ”ๅ›ž็›ฎๅฝ• +- `max_results`: ๆœ€ๅคง่ฟ”ๅ›žๆกๆ•ฐ๏ผŒ้˜ฒๆญขไธ€ๆฌกๆ€งๆ‰“็ˆ†ไธŠไธ‹ๆ–‡ + +ๅปบ่ฎฎ่ฟ”ๅ›žๆ ผๅผ๏ผš + +```text +Found 3 paths under /mnt/user-data/workspace +1. /mnt/user-data/workspace/backend/app.py +2. /mnt/user-data/workspace/backend/tests/test_app.py +3. /mnt/user-data/workspace/scripts/build.py +``` + +ๅฆ‚ๆžœๅŽ็ปญๆƒณๆ›ด้€‚ๅˆๅ‰็ซฏๆถˆ่ดน๏ผŒไนŸๅฏไปฅๆ”นๆˆ JSON ๅญ—็ฌฆไธฒ๏ผ›ไฝ†็ฌฌไธ€็‰ˆไธบไบ†ๅ…ผๅฎน็Žฐๆœ‰ๅทฅๅ…ท้ฃŽๆ ผ๏ผŒ่ฟ”ๅ›žๅฏ่ฏปๆ–‡ๆœฌๅณๅฏใ€‚ + +### 2. `grep` ๅทฅๅ…ท + +็”จ้€”๏ผšๆŒ‰ๅ†…ๅฎนๆจกๅผๆœ็ดขๆ–‡ไปถ๏ผŒ่ฟ”ๅ›žๅ‘ฝไธญไฝ็ฝฎๆ‘˜่ฆใ€‚ + +ๅปบ่ฎฎ schema๏ผš + +```python +@tool("grep", parse_docstring=True) +def grep_tool( + runtime: ToolRuntime[ContextT, ThreadState], + description: str, + pattern: str, + path: str, + glob: str | None = None, + literal: bool = False, + case_sensitive: bool = False, + max_results: int = 100, +) -> str: + ... +``` + +ๅ‚ๆ•ฐ่ฏญไน‰๏ผš + +- `pattern`: ๆœ็ดข่ฏๆˆ–ๆญฃๅˆ™ +- `path`: ๆœ็ดขๆ น็›ฎๅฝ•๏ผŒๅฟ…้กปๆ˜ฏ็ปๅฏน่ทฏๅพ„ +- `glob`: ๅฏ้€‰่ทฏๅพ„่ฟ‡ๆปค๏ผŒไพ‹ๅฆ‚ `**/*.py` +- `literal`: ไธบ `True` ๆ—ถๆŒ‰ๆ™ฎ้€šๅญ—็ฌฆไธฒๅŒน้…๏ผŒไธ่งฃ้‡Šไธบๆญฃๅˆ™ +- `case_sensitive`: ๆ˜ฏๅฆๅคงๅฐๅ†™ๆ•ๆ„Ÿ +- `max_results`: ๆœ€ๅคง่ฟ”ๅ›žๅ‘ฝไธญๆ•ฐ๏ผŒไธๆ˜ฏๆ–‡ไปถๆ•ฐ + +ๅปบ่ฎฎ่ฟ”ๅ›žๆ ผๅผ๏ผš + +```text +Found 4 matches under /mnt/user-data/workspace +/mnt/user-data/workspace/backend/config.py:12: TOOL_GROUPS = [...] +/mnt/user-data/workspace/backend/config.py:48: def load_tool_config(...): +/mnt/user-data/workspace/backend/tools.py:91: "tool_groups" +/mnt/user-data/workspace/backend/tests/test_config.py:22: assert "tool_groups" in data +``` + +็ฌฌไธ€็‰ˆๅปบ่ฎฎๅช่ฟ”ๅ›ž๏ผš + +- ๆ–‡ไปถ่ทฏๅพ„ +- ่กŒๅท +- ๅ‘ฝไธญ่กŒๆ‘˜่ฆ + +ไธ่ฟ”ๅ›žไธŠไธ‹ๆ–‡ๅ—๏ผŒ้ฟๅ…็ป“ๆžœ่ฟ‡ๅคงใ€‚ๆจกๅž‹ๅฆ‚ๆžœ้œ€่ฆไธŠไธ‹ๆ–‡๏ผŒๅ†่ฐƒ็”จ `read_file(path, start_line, end_line)`ใ€‚ + +## Design Principles + +### A. ไธๅš shell wrapper + +ไธๅปบ่ฎฎๆŠŠ `grep` ๅฎž็Žฐไธบ๏ผš + +```python +subprocess.run("grep ...") +``` + +ไนŸไธๅปบ่ฎฎๅœจๅฎนๅ™จ้‡Œ็›ดๆŽฅๆ‹ผ `find` / `rg` ๅ‘ฝไปคใ€‚ + +ๅŽŸๅ› ๏ผš + +- ไผšๅผ•ๅ…ฅ shell quoting ๅ’Œๆณจๅ…ฅ้ข +- ไผšไพ่ต–ไธๅŒ sandbox ๅ†…้•œๅƒๆ˜ฏๅฆๅฎ‰่ฃ…ๅŒไธ€ๅฅ—ๅ‘ฝไปค +- Windows / macOS / Linux ่กŒไธบไธไธ€่‡ด +- ๅพˆ้šพ็จณๅฎšๆŽงๅˆถ่พ“ๅ‡บๆกๆ•ฐไธŽๆ ผๅผ + +ๆญฃ็กฎๆ–นๅ‘ๆ˜ฏ๏ผš + +- `glob` ไฝฟ็”จ Python ๆ ‡ๅ‡†ๅบ“่ทฏๅพ„้ๅކ +- `grep` ไฝฟ็”จ Python ้€ๆ–‡ไปถๆ‰ซๆ +- ่พ“ๅ‡บ็”ฑ DeerFlow ่‡ชๅทฑๆ ผๅผๅŒ– + +ๅฆ‚ๆžœๆœชๆฅไธบไบ†ๆ€ง่ƒฝ่€ƒ่™‘่ฆไผ˜ๅ…ˆ่ฐƒ็”จ `rg`๏ผŒไนŸๅบ”่ฏฅๅฐ่ฃ…ๅœจ provider ๅ†…้ƒจ๏ผŒๅนถไฟ่ฏๅค–้ƒจ่ฏญไน‰ไธๅ˜๏ผŒ่€Œไธๆ˜ฏๆŠŠ CLI ๆšด้œฒ็ป™ๆจกๅž‹ใ€‚ + +### B. ็ปง็ปญๆฒฟ็”จ DeerFlow ็š„่ทฏๅพ„ๆƒ้™ๆจกๅž‹ + +่ฟ™ไธคไธชๅทฅๅ…ทๅฟ…้กปๅค็”จๅฝ“ๅ‰ `ls` / `read_file` ็š„่ทฏๅพ„ๆ ก้ชŒ้€ป่พ‘๏ผš + +- ๆœฌๅœฐๆจกๅผ่ตฐ `validate_local_tool_path(..., read_only=True)` +- ๆ”ฏๆŒ `/mnt/skills/...` +- ๆ”ฏๆŒ `/mnt/acp-workspace/...` +- ๆ”ฏๆŒ thread workspace / uploads / outputs ็š„่™šๆ‹Ÿ่ทฏๅพ„่งฃๆž +- ๆ˜Ž็กฎๆ‹’็ป่ถŠๆƒ่ทฏๅพ„ไธŽ path traversal + +ไนŸๅฐฑๆ˜ฏ่ฏด๏ผŒๅฎƒไปฌๅฑžไบŽ **file:read**๏ผŒไธๆ˜ฏ `bash` ็š„ๆ›ฟไปฃ่ถŠๆƒๅ…ฅๅฃใ€‚ + +### C. ็ป“ๆžœๅฟ…้กป็กฌ้™ๅˆถ + +ๆฒกๆœ‰็กฌ้™ๅˆถ็š„ `glob` / `grep` ๅพˆๅฎนๆ˜“็‚ธไธŠไธ‹ๆ–‡ใ€‚ + +ๅปบ่ฎฎ็ฌฌไธ€็‰ˆ่‡ณๅฐ‘้™ๅˆถ๏ผš + +- `glob.max_results` ้ป˜่ฎค 200๏ผŒๆœ€ๅคง 1000 +- `grep.max_results` ้ป˜่ฎค 100๏ผŒๆœ€ๅคง 500 +- ๅ•่กŒๆ‘˜่ฆๆœ€ๅคง้•ฟๅบฆ๏ผŒไพ‹ๅฆ‚ 200 ๅญ—็ฌฆ +- ไบŒ่ฟ›ๅˆถๆ–‡ไปถ่ทณ่ฟ‡ +- ่ถ…ๅคงๆ–‡ไปถ่ทณ่ฟ‡๏ผŒไพ‹ๅฆ‚ๅ•ๆ–‡ไปถๅคงไบŽ 1 MB ๆˆ–ๆŒ‰้…็ฝฎๆŽงๅˆถ + +ๆญคๅค–๏ผŒๅ‘ฝไธญๆ•ฐ่ถ…่ฟ‡้˜ˆๅ€ผๆ—ถๅบ”่ฟ”ๅ›ž๏ผš + +- ๅทฒๅฑ•็คบ็š„ๆกๆ•ฐ +- ่ขซๆˆชๆ–ญ็š„ไบ‹ๅฎž +- ๅปบ่ฎฎ็”จๆˆท็ผฉๅฐๆœ็ดข่Œƒๅ›ด + +ไพ‹ๅฆ‚๏ผš + +```text +Found more than 100 matches, showing first 100. Narrow the path or add a glob filter. +``` + +### D. ๅทฅๅ…ท่ฏญไน‰่ฆๅฝผๆญคไบ’่กฅ + +ๆŽจ่ๆจกๅž‹ๅทฅไฝœๆตๅบ”่ฏฅๆ˜ฏ๏ผš + +1. `glob` ๆ‰พๅ€™้€‰ๆ–‡ไปถ +2. `grep` ๆ‰พๅ€™้€‰ไฝ็ฝฎ +3. `read_file` ่ฏปๅฑ€้ƒจไธŠไธ‹ๆ–‡ +4. `str_replace` / `write_file` ๆ‰ง่กŒไฟฎๆ”น + +่ฟ™ๆ ทๅทฅๅ…ท่พน็•Œๆธ…ๆ™ฐ๏ผŒไนŸๆ›ดๅˆฉไบŽ prompt ไธญๆ•™ๆจกๅž‹ๅฝขๆˆ็จณๅฎšไน ๆƒฏใ€‚ + +## Implementation Approach + +## Option A: ็›ดๆŽฅๅœจ `sandbox/tools.py` ๅฎž็Žฐ็ฌฌไธ€็‰ˆ + +่ฟ™ๆ˜ฏๆˆ‘ๆŽจ่็š„่ตทๆญฅๆ–นๆกˆใ€‚ + +ๅšๆณ•๏ผš + +- ๅœจ `sandbox/tools.py` ๆ–ฐๅขž `glob_tool` ไธŽ `grep_tool` +- ๅœจ local sandbox ๅœบๆ™ฏ็›ดๆŽฅไฝฟ็”จ Python ๆ–‡ไปถ็ณป็ปŸ API +- ๅœจ้ž local sandbox ๅœบๆ™ฏ๏ผŒไผ˜ๅ…ˆไนŸ้€š่ฟ‡ DeerFlow ่‡ชๅทฑๆŽงๅˆถ็š„่ทฏๅพ„่ฎฟ้—ฎๅฑ‚ๅฎž็Žฐ + +ไผ˜็‚น๏ผš + +- ๆ”นๅŠจๅฐ +- ่ƒฝๅฐฝๅฟซ้ชŒ่ฏ agent ๆ•ˆๆžœ +- ไธ้œ€่ฆๅ…ˆๆ”น `Sandbox` ๆŠฝ่ฑก + +็ผบ็‚น๏ผš + +- `tools.py` ไผš็ปง็ปญๅ˜่ƒ– +- ๅฆ‚ๆžœๆœชๆฅๆƒณๅœจ provider ไพงๅšๆ€ง่ƒฝไผ˜ๅŒ–๏ผŒ้œ€่ฆๅ†ๆŠฝ่ฑกไธ€ๆฌก + +## Option B: ๅ…ˆๆ‰ฉๅฑ• `Sandbox` ๆŠฝ่ฑก + +ไพ‹ๅฆ‚ๆ–ฐๅขž๏ผš + +```python +class Sandbox(ABC): + def glob(self, path: str, pattern: str, include_dirs: bool = False, max_results: int = 200) -> list[str]: + ... + + def grep( + self, + path: str, + pattern: str, + *, + glob: str | None = None, + literal: bool = False, + case_sensitive: bool = False, + max_results: int = 100, + ) -> list[GrepMatch]: + ... +``` + +ไผ˜็‚น๏ผš + +- ๆŠฝ่ฑกๆ›ดๅนฒๅ‡€ +- ๅฎนๅ™จ / ่ฟœ็จ‹ sandbox ๅฏไปฅๅ„่‡ชไผ˜ๅŒ– + +็ผบ็‚น๏ผš + +- ้ฆ–ๆฌกๅผ•ๅ…ฅๆˆๆœฌๆ›ด้ซ˜ +- ้œ€่ฆๅŒๆญฅๆ”นๆ‰€ๆœ‰ sandbox provider + +็ป“่ฎบ๏ผš + +**็ฌฌไธ€็‰ˆๅปบ่ฎฎ่ตฐ Option A๏ผŒ็ญ‰ๅทฅๅ…ทไปทๅ€ผ้ชŒ่ฏๅŽๅ†ไธ‹ๆฒ‰ๅˆฐ `Sandbox` ๆŠฝ่ฑกๅฑ‚ใ€‚** + +## Detailed Behavior + +### `glob` ่กŒไธบ + +- ่พ“ๅ…ฅๆ น็›ฎๅฝ•ไธๅญ˜ๅœจ๏ผš่ฟ”ๅ›žๆธ…ๆ™ฐ้”™่ฏฏ +- ๆ น่ทฏๅพ„ไธๆ˜ฏ็›ฎๅฝ•๏ผš่ฟ”ๅ›žๆธ…ๆ™ฐ้”™่ฏฏ +- ๆจกๅผ้žๆณ•๏ผš่ฟ”ๅ›žๆธ…ๆ™ฐ้”™่ฏฏ +- ็ป“ๆžœไธบ็ฉบ๏ผš่ฟ”ๅ›ž `No files matched` +- ้ป˜่ฎคๅฟฝ็•ฅ้กนๅบ”ๅฐฝ้‡ไธŽๅฝ“ๅ‰ `list_dir` ๅฏน้ฝ๏ผŒไพ‹ๅฆ‚๏ผš + - `.git` + - `node_modules` + - `__pycache__` + - `.venv` + - ๆž„ๅปบไบง็‰ฉ็›ฎๅฝ• + +่ฟ™้‡Œๅปบ่ฎฎๆŠฝไธ€ไธชๅ…ฑไบซ ignore ้›†๏ผŒ้ฟๅ… `ls` ไธŽ `glob` ็ป“ๆžœ้ฃŽๆ ผไธไธ€่‡ดใ€‚ + +### `grep` ่กŒไธบ + +- ้ป˜่ฎคๅชๆ‰ซๆๆ–‡ๆœฌๆ–‡ไปถ +- ๆฃ€ๆต‹ๅˆฐไบŒ่ฟ›ๅˆถๆ–‡ไปถ็›ดๆŽฅ่ทณ่ฟ‡ +- ๅฏน่ถ…ๅคงๆ–‡ไปถ็›ดๆŽฅ่ทณ่ฟ‡ๆˆ–ๅชๆ‰ซๅ‰ N KB +- regex ็ผ–่ฏ‘ๅคฑ่ดฅๆ—ถ่ฟ”ๅ›žๅ‚ๆ•ฐ้”™่ฏฏ +- ่พ“ๅ‡บไธญ็š„่ทฏๅพ„็ปง็ปญไฝฟ็”จ่™šๆ‹Ÿ่ทฏๅพ„๏ผŒ่€Œไธๆ˜ฏๆšด้œฒๅฎฟไธป็œŸๅฎž่ทฏๅพ„ +- ๅปบ่ฎฎ้ป˜่ฎคๆŒ‰ๆ–‡ไปถ่ทฏๅพ„ใ€่กŒๅทๆŽ’ๅบ๏ผŒไฟๆŒ็จณๅฎš่พ“ๅ‡บ + +## Prompting Guidance + +ๅฆ‚ๆžœๅผ•ๅ…ฅ่ฟ™ไธคไธชๅทฅๅ…ท๏ผŒๅปบ่ฎฎๅŒๆญฅๆ›ดๆ–ฐ็ณป็ปŸๆ็คบไธญ็š„ๆ–‡ไปถๆ“ไฝœๅปบ่ฎฎ๏ผš + +- ๆŸฅๆ‰พๆ–‡ไปถๅๆจกๅผๆ—ถไผ˜ๅ…ˆ็”จ `glob` +- ๆŸฅๆ‰พไปฃ็ ็ฌฆๅทใ€้…็ฝฎ้กนใ€ๆ–‡ๆกˆๆ—ถไผ˜ๅ…ˆ็”จ `grep` +- ๅชๆœ‰ๅœจๅทฅๅ…ทไธ่ถณไปฅๅฎŒๆˆ็›ฎๆ ‡ๆ—ถๆ‰้€€ๅ›ž `bash` + +ๅฆๅˆ™ๆจกๅž‹ไปไผšไน ๆƒฏๆ€งๅ…ˆ่ฐƒ็”จ `bash`ใ€‚ + +## Risks + +### 1. ไธŽ `bash` ่ƒฝๅŠ›้‡ๅ  + +่ฟ™ๆ˜ฏไบ‹ๅฎž๏ผŒไฝ†ไธๆ˜ฏ้—ฎ้ข˜ใ€‚ + +`ls` ๅ’Œ `read_file` ไนŸ้ƒฝ่ƒฝ่ขซ `bash` ๆ›ฟไปฃ๏ผŒไฝ†ๆˆ‘ไปฌไป็„ถไฟ็•™ๅฎƒไปฌ๏ผŒๅ› ไธบ็ป“ๆž„ๅŒ–ๅทฅๅ…ทๆ›ด้€‚ๅˆ agentใ€‚ + +### 2. ๆ€ง่ƒฝ้—ฎ้ข˜ + +ๅœจๅคงไป“ๅบ“ไธŠ๏ผŒ็บฏ Python `grep` ๅฏ่ƒฝๆฏ” `rg` ๆ…ขใ€‚ + +็ผ“่งฃๆ–นๅผ๏ผš + +- ็ฌฌไธ€็‰ˆๅ…ˆๅŠ ็ป“ๆžœไธŠ้™ๅ’Œๆ–‡ไปถๅคงๅฐไธŠ้™ +- ่ทฏๅพ„ไธŠๅผบๅˆถ่ฆๆฑ‚ root path +- ๆไพ› `glob` ่ฟ‡ๆปค็ผฉๅฐๆ‰ซๆ่Œƒๅ›ด +- ๅŽ็ปญๅฆ‚ๆœ‰ๅฟ…่ฆ๏ผŒๅœจ provider ๅ†…้ƒจๅš `rg` ไผ˜ๅŒ–๏ผŒไฝ†ไฟๆŒๅŒไธ€ schema + +### 3. ๅฟฝ็•ฅ่ง„ๅˆ™ไธไธ€่‡ด + +ๅฆ‚ๆžœ `ls` ่ƒฝ็œ‹ๅˆฐ็š„่ทฏๅพ„๏ผŒ`glob` ๅด็œ‹ไธๅˆฐ๏ผŒๆจกๅž‹ไผšๅ›ฐๆƒ‘ใ€‚ + +็ผ“่งฃๆ–นๅผ๏ผš + +- ็ปŸไธ€ ignore ่ง„ๅˆ™ +- ๅœจๆ–‡ๆกฃ้‡Œๆ˜Ž็กฎโ€œ้ป˜่ฎค่ทณ่ฟ‡ๅธธ่งไพ่ต–ๅ’Œๆž„ๅปบ็›ฎๅฝ•โ€ + +### 4. ๆญฃๅˆ™ๆœ็ดข่ฟ‡ไบŽๅคๆ‚ + +ๅฆ‚ๆžœ็ฌฌไธ€็‰ˆๅฐฑๆ”ฏๆŒๅคง้‡ grep ๆ–น่จ€๏ผŒ่พน็•Œไผšๅพˆไนฑใ€‚ + +็ผ“่งฃๆ–นๅผ๏ผš + +- ็ฌฌไธ€็‰ˆๅชๆ”ฏๆŒ Python `re` +- ๅนถๆไพ› `literal=True` ็š„็ฎ€ๅ•ๆจกๅผ + +## Alternatives Considered + +### A. ไธๅขžๅŠ ๅทฅๅ…ท๏ผŒๅฎŒๅ…จไพ่ต– `bash` + +ไธๆŽจ่ใ€‚ + +่ฟ™ไผš่ฎฉ DeerFlow ๅœจไปฃ็ ๆŽข็ดขไฝ“้ชŒไธŠๆŒ็ปญ่ฝๅŽ๏ผŒไนŸๅ‰Šๅผฑๆ—  bash ๆˆ–ๅ—้™ bash ๅœบๆ™ฏไธ‹็š„่ƒฝๅŠ›ใ€‚ + +### B. ๅชๅŠ  `glob`๏ผŒไธๅŠ  `grep` + +ไธๆŽจ่ใ€‚ + +ๅช่งฃๅ†ณโ€œๆ‰พๆ–‡ไปถโ€๏ผŒๆฒกๆœ‰่งฃๅ†ณโ€œๆ‰พไฝ็ฝฎโ€ใ€‚ๆจกๅž‹ๆœ€็ปˆ่ฟ˜ๆ˜ฏไผš้€€ๅ›ž `bash grep`ใ€‚ + +### C. ๅชๅŠ  `grep`๏ผŒไธๅŠ  `glob` + +ไนŸไธๆŽจ่ใ€‚ + +`grep` ็ผบๅฐ‘่ทฏๅพ„ๆจกๅผ่ฟ‡ๆปคๆ—ถ๏ผŒๆ‰ซๆ่Œƒๅ›ด็ปๅธธๅคชๅคง๏ผ›`glob` ๆ˜ฏๅฎƒ็š„ๅคฉ็„ถๅ‰็ฝฎๅทฅๅ…ทใ€‚ + +### D. ็›ดๆŽฅๆŽฅๅ…ฅ MCP filesystem server ็š„ๆœ็ดข่ƒฝๅŠ› + +็ŸญๆœŸไธๆŽจ่ไฝœไธบไธป่ทฏๅพ„ใ€‚ + +MCP ๅฏไปฅๆ˜ฏ่กฅๅ……๏ผŒไฝ† `glob` / `grep` ไฝœไธบ DeerFlow ็š„ๅŸบ็ก€ coding tool๏ผŒๆœ€ๅฅฝไป็„ถๆ˜ฏ built-in๏ผŒ่ฟ™ๆ ทๆ‰่ƒฝๅœจ้ป˜่ฎคๅฎ‰่ฃ…ไธญ็จณๅฎšๅฏ็”จใ€‚ + +## Acceptance Criteria + +- `config.example.yaml` ไธญๅฏ้ป˜่ฎคๅฏ็”จ `glob` ไธŽ `grep` +- ไธคไธชๅทฅๅ…ทๅฝ’ๅฑž `file:read` ็ป„ +- ๆœฌๅœฐ sandbox ไธ‹ไธฅๆ ผ้ตๅฎˆ็Žฐๆœ‰่ทฏๅพ„ๆƒ้™ +- ่พ“ๅ‡บไธๆณ„้œฒๅฎฟไธปๆœบ็œŸๅฎž่ทฏๅพ„ +- ๅคง็ป“ๆžœ้›†ไผš่ขซๆˆชๆ–ญๅนถๆ˜Ž็กฎๆ็คบ +- ๆจกๅž‹ๅฏไปฅ้€š่ฟ‡ `glob -> grep -> read_file -> str_replace` ๅฎŒๆˆๅ…ธๅž‹ๆ”น็ ๆต +- ๅœจ็ฆ็”จ host bash ็š„ๆœฌๅœฐๆจกๅผไธ‹๏ผŒไป“ๅบ“ๆŽข็ดข่ƒฝๅŠ›ๆ˜Žๆ˜พๆๅ‡ + +## Rollout Plan + +1. ๅœจ `sandbox/tools.py` ไธญๅฎž็Žฐ `glob_tool` ไธŽ `grep_tool` +2. ๆŠฝๅ–ไธŽ `list_dir` ไธ€่‡ด็š„ ignore ่ง„ๅˆ™๏ผŒ้ฟๅ…่กŒไธบๆผ‚็งป +3. ๅœจ `config.example.yaml` ้ป˜่ฎคๅŠ ๅ…ฅๅทฅๅ…ท้…็ฝฎ +4. ไธบๆœฌๅœฐ่ทฏๅพ„ๆ ก้ชŒใ€่™šๆ‹Ÿ่ทฏๅพ„ๆ˜ ๅฐ„ใ€็ป“ๆžœๆˆชๆ–ญใ€ไบŒ่ฟ›ๅˆถ่ทณ่ฟ‡่กฅๆต‹่ฏ• +5. ๆ›ดๆ–ฐ README / backend docs / prompt guidance +6. ๆ”ถ้›†ๅฎž้™… agent ่ฐƒ็”จๆ•ฐๆฎ๏ผŒๅ†ๅ†ณๅฎšๆ˜ฏๅฆไธ‹ๆฒ‰ๅˆฐ `Sandbox` ๆŠฝ่ฑก + +## Suggested Config + +```yaml +tools: + - name: glob + group: file:read + use: deerflow.sandbox.tools:glob_tool + + - name: grep + group: file:read + use: deerflow.sandbox.tools:grep_tool +``` + +## Final Recommendation + +็ป“่ฎบๆ˜ฏ๏ผš**ๅฏไปฅๅŠ ๏ผŒ่€Œไธ”ๅบ”่ฏฅๅŠ ใ€‚** + +ไฝ†ๆˆ‘ไผšๆ˜Ž็กฎๅกไธ‰ไธช่พน็•Œ๏ผš + +1. `grep` / `glob` ๅฟ…้กปๆ˜ฏ built-in ็š„ๅช่ฏป็ป“ๆž„ๅŒ–ๅทฅๅ…ท +2. ็ฌฌไธ€็‰ˆไธ่ฆๅš shell wrapper๏ผŒไธ่ฆๆŠŠ CLI ๆ–น่จ€็›ดๆŽฅๆšด้œฒ็ป™ๆจกๅž‹ +3. ๅ…ˆๅœจ `sandbox/tools.py` ้ชŒ่ฏไปทๅ€ผ๏ผŒๅ†่€ƒ่™‘ๆ˜ฏๅฆไธ‹ๆฒ‰ๅˆฐ `Sandbox` provider ๆŠฝ่ฑก + +ๅฆ‚ๆžœๆŒ‰่ฟ™ไธชๆ–นๅ‘ๅš๏ผŒๅฎƒไผšๆ˜Žๆ˜พๆๅ‡ DeerFlow ๅœจ coding / repo exploration ๅœบๆ™ฏไธ‹็š„ๅฏ็”จๆ€ง๏ผŒ่€Œไธ”้ฃŽ้™ฉๅฏๆŽงใ€‚ diff --git a/backend/langgraph.json b/backend/langgraph.json index 74f5c691d..28588c9f8 100644 --- a/backend/langgraph.json +++ b/backend/langgraph.json @@ -8,6 +8,9 @@ "graphs": { "lead_agent": "deerflow.agents:make_lead_agent" }, + "auth": { + "path": "./app/gateway/langgraph_auth.py:auth" + }, "checkpointer": { "path": "./packages/harness/deerflow/agents/checkpointer/async_provider.py:make_checkpointer" } diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 0ce6ff899..07d3749b9 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -8,6 +8,14 @@ from deerflow.subagents import get_available_subagent_names logger = logging.getLogger(__name__) +def _get_enabled_skills(): + try: + return list(load_skills(enabled_only=True)) + except Exception: + logger.exception("Failed to load enabled skills for prompt injection") + return [] + + def _build_subagent_section(max_concurrent: int) -> str: """Build the subagent system prompt section with dynamic concurrency limit. @@ -386,7 +394,7 @@ def get_skills_prompt_section(available_skills: set[str] | None = None) -> str: Returns the ... block listing all enabled skills, suitable for injection into any agent's system prompt. """ - skills = load_skills(enabled_only=True) + skills = _get_enabled_skills() try: from deerflow.config import get_app_config @@ -450,7 +458,7 @@ def get_deferred_tools_prompt_section() -> str: if not get_app_config().tool_search.enabled: return "" - except FileNotFoundError: + except Exception: return "" registry = get_deferred_registry() diff --git a/backend/packages/harness/deerflow/agents/memory/prompt.py b/backend/packages/harness/deerflow/agents/memory/prompt.py index e0c04b77e..47b35e2ae 100644 --- a/backend/packages/harness/deerflow/agents/memory/prompt.py +++ b/backend/packages/harness/deerflow/agents/memory/prompt.py @@ -246,6 +246,10 @@ def format_memory_for_injection(memory_data: dict[str, Any], max_tokens: int = 2 if earlier.get("summary"): history_sections.append(f"Earlier: {earlier['summary']}") + background = history_data.get("longTermBackground", {}) + if background.get("summary"): + history_sections.append(f"Background: {background['summary']}") + if history_sections: sections.append("History:\n" + "\n".join(f"- {s}" for s in history_sections)) diff --git a/backend/packages/harness/deerflow/agents/memory/queue.py b/backend/packages/harness/deerflow/agents/memory/queue.py index 6d777a67e..d78c643f8 100644 --- a/backend/packages/harness/deerflow/agents/memory/queue.py +++ b/backend/packages/harness/deerflow/agents/memory/queue.py @@ -21,6 +21,7 @@ class ConversationContext: timestamp: datetime = field(default_factory=datetime.utcnow) agent_name: str | None = None correction_detected: bool = False + reinforcement_detected: bool = False class MemoryUpdateQueue: @@ -44,6 +45,7 @@ class MemoryUpdateQueue: messages: list[Any], agent_name: str | None = None, correction_detected: bool = False, + reinforcement_detected: bool = False, ) -> None: """Add a conversation to the update queue. @@ -52,6 +54,7 @@ class MemoryUpdateQueue: messages: The conversation messages. agent_name: If provided, memory is stored per-agent. If None, uses global memory. correction_detected: Whether recent turns include an explicit correction signal. + reinforcement_detected: Whether recent turns include a positive reinforcement signal. """ config = get_memory_config() if not config.enabled: @@ -63,11 +66,13 @@ class MemoryUpdateQueue: None, ) merged_correction_detected = correction_detected or (existing_context.correction_detected if existing_context is not None else False) + merged_reinforcement_detected = reinforcement_detected or (existing_context.reinforcement_detected if existing_context is not None else False) context = ConversationContext( thread_id=thread_id, messages=messages, agent_name=agent_name, correction_detected=merged_correction_detected, + reinforcement_detected=merged_reinforcement_detected, ) # Check if this thread already has a pending update @@ -130,6 +135,7 @@ class MemoryUpdateQueue: thread_id=context.thread_id, agent_name=context.agent_name, correction_detected=context.correction_detected, + reinforcement_detected=context.reinforcement_detected, ) if success: logger.info("Memory updated successfully for thread %s", context.thread_id) diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index c59749d7b..5f459b47a 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -246,7 +246,7 @@ def _fact_content_key(content: Any) -> str | None: stripped = content.strip() if not stripped: return None - return stripped + return stripped.casefold() class MemoryUpdater: @@ -272,6 +272,7 @@ class MemoryUpdater: thread_id: str | None = None, agent_name: str | None = None, correction_detected: bool = False, + reinforcement_detected: bool = False, ) -> bool: """Update memory based on conversation messages. @@ -280,6 +281,7 @@ class MemoryUpdater: thread_id: Optional thread ID for tracking source. agent_name: If provided, updates per-agent memory. If None, updates global memory. correction_detected: Whether recent turns include an explicit correction signal. + reinforcement_detected: Whether recent turns include a positive reinforcement signal. Returns: True if update was successful, False otherwise. @@ -310,6 +312,14 @@ class MemoryUpdater: "and record the correct approach as a fact with category " '"correction" and confidence >= 0.95 when appropriate.' ) + if reinforcement_detected: + reinforcement_hint = ( + "IMPORTANT: Positive reinforcement signals were detected in this conversation. " + "The user explicitly confirmed the agent's approach was correct or helpful. " + "Record the confirmed approach, style, or preference as a fact with category " + '"preference" or "behavior" and confidence >= 0.9 when appropriate.' + ) + correction_hint = (correction_hint + "\n" + reinforcement_hint).strip() if correction_hint else reinforcement_hint prompt = MEMORY_UPDATE_PROMPT.format( current_memory=json.dumps(current_memory, indent=2), @@ -441,6 +451,7 @@ def update_memory_from_conversation( thread_id: str | None = None, agent_name: str | None = None, correction_detected: bool = False, + reinforcement_detected: bool = False, ) -> bool: """Convenience function to update memory from a conversation. @@ -449,9 +460,10 @@ def update_memory_from_conversation( thread_id: Optional thread ID. agent_name: If provided, updates per-agent memory. If None, updates global memory. correction_detected: Whether recent turns include an explicit correction signal. + reinforcement_detected: Whether recent turns include a positive reinforcement signal. Returns: True if successful, False otherwise. """ updater = MemoryUpdater() - return updater.update_memory(messages, thread_id, agent_name, correction_detected) + return updater.update_memory(messages, thread_id, agent_name, correction_detected, reinforcement_detected) diff --git a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py index 5f68a3ce6..8bc4cfd43 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py @@ -182,6 +182,23 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]): return None, False + @staticmethod + def _append_text(content: str | list | None, text: str) -> str | list: + """Append *text* to AIMessage content, handling str, list, and None. + + When content is a list of content blocks (e.g. Anthropic thinking mode), + we append a new ``{"type": "text", ...}`` block instead of concatenating + a string to a list, which would raise ``TypeError``. + """ + if content is None: + return text + if isinstance(content, list): + return [*content, {"type": "text", "text": f"\n\n{text}"}] + if isinstance(content, str): + return content + f"\n\n{text}" + # Fallback: coerce unexpected types to str to avoid TypeError + return str(content) + f"\n\n{text}" + def _apply(self, state: AgentState, runtime: Runtime) -> dict | None: warning, hard_stop = self._track_and_check(state, runtime) @@ -192,7 +209,7 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]): stripped_msg = last_msg.model_copy( update={ "tool_calls": [], - "content": (last_msg.content or "") + f"\n\n{_HARD_STOP_MSG}", + "content": self._append_text(last_msg.content, _HARD_STOP_MSG), } ) return {"messages": [stripped_msg]} diff --git a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py index 6215a2957..5e8ca6344 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/memory_middleware.py @@ -29,6 +29,22 @@ _CORRECTION_PATTERNS = ( re.compile(r"ๆ”น็”จ"), ) +_REINFORCEMENT_PATTERNS = ( + re.compile(r"\byes[,.]?\s+(?:exactly|perfect|that(?:'s| is) (?:right|correct|it))\b", re.IGNORECASE), + re.compile(r"\bperfect(?:[.!?]|$)", re.IGNORECASE), + re.compile(r"\bexactly\s+(?:right|correct)\b", re.IGNORECASE), + re.compile(r"\bthat(?:'s| is)\s+(?:exactly\s+)?(?:right|correct|what i (?:wanted|needed|meant))\b", re.IGNORECASE), + re.compile(r"\bkeep\s+(?:doing\s+)?that\b", re.IGNORECASE), + re.compile(r"\bjust\s+(?:like\s+)?(?:that|this)\b", re.IGNORECASE), + re.compile(r"\bthis is (?:great|helpful)\b(?:[.!?]|$)", re.IGNORECASE), + re.compile(r"\bthis is what i wanted\b(?:[.!?]|$)", re.IGNORECASE), + re.compile(r"ๅฏน[๏ผŒ,]?\s*ๅฐฑๆ˜ฏ่ฟ™ๆ ท(?:[ใ€‚๏ผ๏ผŸ!?.]|$)"), + re.compile(r"ๅฎŒๅ…จๆญฃ็กฎ(?:[ใ€‚๏ผ๏ผŸ!?.]|$)"), + re.compile(r"(?:ๅฏน[๏ผŒ,]?\s*)?ๅฐฑๆ˜ฏ่ฟ™ไธชๆ„ๆ€(?:[ใ€‚๏ผ๏ผŸ!?.]|$)"), + re.compile(r"ๆญฃๆ˜ฏๆˆ‘ๆƒณ่ฆ็š„(?:[ใ€‚๏ผ๏ผŸ!?.]|$)"), + re.compile(r"็ปง็ปญไฟๆŒ(?:[ใ€‚๏ผ๏ผŸ!?.]|$)"), +) + class MemoryMiddlewareState(AgentState): """Compatible with the `ThreadState` schema.""" @@ -132,6 +148,29 @@ def detect_correction(messages: list[Any]) -> bool: return False +def detect_reinforcement(messages: list[Any]) -> bool: + """Detect explicit positive reinforcement signals in recent conversation turns. + + Complements detect_correction() by identifying when the user confirms the + agent's approach was correct. This allows the memory system to record what + worked well, not just what went wrong. + + The queue keeps only one pending context per thread, so callers pass the + latest filtered message list. Checking only recent user turns keeps signal + detection conservative while avoiding stale signals from long histories. + """ + recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"] + + for msg in recent_user_msgs: + content = _extract_message_text(msg).strip() + if not content: + continue + if any(pattern.search(content) for pattern in _REINFORCEMENT_PATTERNS): + return True + + return False + + class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): """Middleware that queues conversation for memory update after agent execution. @@ -196,12 +235,14 @@ class MemoryMiddleware(AgentMiddleware[MemoryMiddlewareState]): # Queue the filtered conversation for memory update correction_detected = detect_correction(filtered_messages) + reinforcement_detected = not correction_detected and detect_reinforcement(filtered_messages) queue = get_memory_queue() queue.add( thread_id=thread_id, messages=filtered_messages, agent_name=self._agent_name, correction_detected=correction_detected, + reinforcement_detected=reinforcement_detected, ) return None diff --git a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index 20cb02f68..42f465f01 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -101,44 +101,33 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return user_msg if user_msg else "New Conversation" def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None: - """Synchronously generate a title. Returns state update or None.""" + """Generate a local fallback title without blocking on an LLM call.""" if not self._should_generate_title(state): return None - prompt, user_msg = self._build_title_prompt(state) - config = get_title_config() - model = create_chat_model(name=config.model_name, thinking_enabled=False) - - try: - response = model.invoke(prompt) - title = self._parse_title(response.content) - if not title: - title = self._fallback_title(user_msg) - except Exception: - logger.exception("Failed to generate title (sync)") - title = self._fallback_title(user_msg) - - return {"title": title} + _, user_msg = self._build_title_prompt(state) + return {"title": self._fallback_title(user_msg)} async def _agenerate_title_result(self, state: TitleMiddlewareState) -> dict | None: - """Asynchronously generate a title. Returns state update or None.""" + """Generate a title asynchronously and fall back locally on failure.""" if not self._should_generate_title(state): return None - prompt, user_msg = self._build_title_prompt(state) config = get_title_config() - model = create_chat_model(name=config.model_name, thinking_enabled=False) + prompt, user_msg = self._build_title_prompt(state) try: + if config.model_name: + model = create_chat_model(name=config.model_name, thinking_enabled=False) + else: + model = create_chat_model(thinking_enabled=False) response = await model.ainvoke(prompt) title = self._parse_title(response.content) - if not title: - title = self._fallback_title(user_msg) + if title: + return {"title": title} except Exception: - logger.exception("Failed to generate title (async)") - title = self._fallback_title(user_msg) - - return {"title": title} + logger.debug("Failed to generate async title; falling back to local title", exc_info=True) + return {"title": self._fallback_title(user_msg)} @override def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: diff --git a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py index c3acd86cc..52be28bfb 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py @@ -138,6 +138,6 @@ def build_subagent_runtime_middlewares(*, lazy_init: bool = True) -> list[AgentM """Middlewares shared by subagent runtime before subagent-only middlewares.""" return _build_runtime_middlewares( include_uploads=False, - include_dangling_tool_call_patch=False, + include_dangling_tool_call_patch=True, lazy_init=lazy_init, ) diff --git a/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py index d9cb5f8b0..78c9a7b7b 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py @@ -10,10 +10,52 @@ from langchain_core.messages import HumanMessage from langgraph.runtime import Runtime from deerflow.config.paths import Paths, get_paths +from deerflow.utils.file_conversion import extract_outline logger = logging.getLogger(__name__) +_OUTLINE_PREVIEW_LINES = 5 + + +def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]: + """Return the document outline and fallback preview for *file_path*. + + Looks for a sibling ``.md`` file produced by the upload conversion + pipeline. + + Returns: + (outline, preview) where: + - outline: list of ``{title, line}`` dicts (plus optional sentinel). + Empty when no headings are found or no .md exists. + - preview: first few non-empty lines of the .md, used as a content + anchor when outline is empty so the agent has some context. + Empty when outline is non-empty (no fallback needed). + """ + md_path = file_path.with_suffix(".md") + if not md_path.is_file(): + return [], [] + + outline = extract_outline(md_path) + if outline: + logger.debug("Extracted %d outline entries from %s", len(outline), file_path.name) + return outline, [] + + # outline is empty โ€” read the first few non-empty lines as a content preview + preview: list[str] = [] + try: + with md_path.open(encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if stripped: + preview.append(stripped) + if len(preview) >= _OUTLINE_PREVIEW_LINES: + break + except Exception: + logger.debug("Failed to read preview lines from %s", md_path, exc_info=True) + return [], preview + + class UploadsMiddlewareState(AgentState): """State schema for uploads middleware.""" @@ -39,12 +81,38 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): super().__init__() self._paths = Paths(base_dir) if base_dir else get_paths() + def _format_file_entry(self, file: dict, lines: list[str]) -> None: + """Append a single file entry (name, size, path, optional outline) to lines.""" + size_kb = file["size"] / 1024 + size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB" + lines.append(f"- {file['filename']} ({size_str})") + lines.append(f" Path: {file['path']}") + outline = file.get("outline") or [] + if outline: + truncated = outline[-1].get("truncated", False) + visible = [e for e in outline if not e.get("truncated")] + lines.append(" Document outline (use `read_file` with line ranges to read sections):") + for entry in visible: + lines.append(f" L{entry['line']}: {entry['title']}") + if truncated: + lines.append(f" ... (showing first {len(visible)} headings; use `read_file` to explore further)") + else: + preview = file.get("outline_preview") or [] + if preview: + lines.append(" No structural headings detected. Document begins with:") + for text in preview: + lines.append(f" > {text}") + lines.append(" Use `grep` to search for keywords (e.g. `grep(pattern='keyword', path='/mnt/user-data/uploads/')`).") + lines.append("") + def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str: """Create a formatted message listing uploaded files. Args: new_files: Files uploaded in the current message. historical_files: Files uploaded in previous messages. + Each file dict may contain an optional ``outline`` key โ€” a list of + ``{title, line}`` dicts extracted from the converted Markdown file. Returns: Formatted string inside tags. @@ -55,25 +123,24 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): lines.append("") if new_files: for file in new_files: - size_kb = file["size"] / 1024 - size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB" - lines.append(f"- {file['filename']} ({size_str})") - lines.append(f" Path: {file['path']}") - lines.append("") + self._format_file_entry(file, lines) else: lines.append("(empty)") + lines.append("") if historical_files: lines.append("The following files were uploaded in previous messages and are still available:") lines.append("") for file in historical_files: - size_kb = file["size"] / 1024 - size_str = f"{size_kb:.1f} KB" if size_kb < 1024 else f"{size_kb / 1024:.1f} MB" - lines.append(f"- {file['filename']} ({size_str})") - lines.append(f" Path: {file['path']}") - lines.append("") + self._format_file_entry(file, lines) - lines.append("You can read these files using the `read_file` tool with the paths shown above.") + lines.append("To work with these files:") + lines.append("- Read from the file first โ€” use the outline line numbers and `read_file` to locate relevant sections.") + lines.append("- Use `grep` to search for keywords when you are not sure which section to look at") + lines.append(" (e.g. `grep(pattern='revenue', path='/mnt/user-data/uploads/')`).") + lines.append("- Use `glob` to find files by name pattern") + lines.append(" (e.g. `glob(pattern='**/*.md', path='/mnt/user-data/uploads/')`).") + lines.append("- Only fall back to web search if the file content is clearly insufficient to answer the question.") lines.append("") return "\n".join(lines) @@ -147,6 +214,13 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): # Resolve uploads directory for existence checks thread_id = (runtime.context or {}).get("thread_id") + if thread_id is None: + try: + from langgraph.config import get_config + + thread_id = get_config().get("configurable", {}).get("thread_id") + except RuntimeError: + pass # get_config() raises outside a runnable context (e.g. unit tests) uploads_dir = self._paths.sandbox_uploads_dir(thread_id) if thread_id else None # Get newly uploaded files from the current message's additional_kwargs.files @@ -159,15 +233,26 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): for file_path in sorted(uploads_dir.iterdir()): if file_path.is_file() and file_path.name not in new_filenames: stat = file_path.stat() + outline, preview = _extract_outline_for_file(file_path) historical_files.append( { "filename": file_path.name, "size": stat.st_size, "path": f"/mnt/user-data/uploads/{file_path.name}", "extension": file_path.suffix, + "outline": outline, + "outline_preview": preview, } ) + # Attach outlines to new files as well + if uploads_dir: + for file in new_files: + phys_path = uploads_dir / file["filename"] + outline, preview = _extract_outline_for_file(phys_path) + file["outline"] = outline + file["outline_preview"] = preview + if not new_files and not historical_files: return None diff --git a/backend/packages/harness/deerflow/client.py b/backend/packages/harness/deerflow/client.py index 5357e8f2a..27ebe537e 100644 --- a/backend/packages/harness/deerflow/client.py +++ b/backend/packages/harness/deerflow/client.py @@ -117,6 +117,7 @@ class DeerFlowClient: subagent_enabled: bool = False, plan_mode: bool = False, agent_name: str | None = None, + available_skills: set[str] | None = None, middlewares: Sequence[AgentMiddleware] | None = None, ): """Initialize the client. @@ -133,6 +134,7 @@ class DeerFlowClient: subagent_enabled: Enable subagent delegation. plan_mode: Enable TodoList middleware for plan mode. agent_name: Name of the agent to use. + available_skills: Optional set of skill names to make available. If None (default), all scanned skills are available. middlewares: Optional list of custom middlewares to inject into the agent. """ if config_path is not None: @@ -148,6 +150,7 @@ class DeerFlowClient: self._subagent_enabled = subagent_enabled self._plan_mode = plan_mode self._agent_name = agent_name + self._available_skills = set(available_skills) if available_skills is not None else None self._middlewares = list(middlewares) if middlewares else [] # Lazy agent โ€” created on first call, recreated when config changes. @@ -208,6 +211,8 @@ class DeerFlowClient: cfg.get("thinking_enabled"), cfg.get("is_plan_mode"), cfg.get("subagent_enabled"), + self._agent_name, + frozenset(self._available_skills) if self._available_skills is not None else None, ) if self._agent is not None and self._agent_config_key == key: @@ -226,6 +231,7 @@ class DeerFlowClient: subagent_enabled=subagent_enabled, max_concurrent_subagents=max_concurrent_subagents, agent_name=self._agent_name, + available_skills=self._available_skills, ), "state_schema": ThreadState, } diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py index 7f8784492..b6041f7ed 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py @@ -7,6 +7,7 @@ import uuid from agent_sandbox import Sandbox as AioSandboxClient from deerflow.sandbox.sandbox import Sandbox +from deerflow.sandbox.search import GrepMatch, path_matches, should_ignore_path, truncate_line logger = logging.getLogger(__name__) @@ -135,6 +136,86 @@ class AioSandbox(Sandbox): logger.error(f"Failed to write file in sandbox: {e}") raise + def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: + if not include_dirs: + result = self._client.file.find_files(path=path, glob=pattern) + files = result.data.files if result.data and result.data.files else [] + filtered = [file_path for file_path in files if not should_ignore_path(file_path)] + truncated = len(filtered) > max_results + return filtered[:max_results], truncated + + result = self._client.file.list_path(path=path, recursive=True, show_hidden=False) + entries = result.data.files if result.data and result.data.files else [] + matches: list[str] = [] + root_path = path.rstrip("/") or "/" + root_prefix = root_path if root_path == "/" else f"{root_path}/" + for entry in entries: + if entry.path != root_path and not entry.path.startswith(root_prefix): + continue + if should_ignore_path(entry.path): + continue + rel_path = entry.path[len(root_path) :].lstrip("/") + if path_matches(pattern, rel_path): + matches.append(entry.path) + if len(matches) >= max_results: + return matches, True + return matches, False + + def grep( + self, + path: str, + pattern: str, + *, + glob: str | None = None, + literal: bool = False, + case_sensitive: bool = False, + max_results: int = 100, + ) -> tuple[list[GrepMatch], bool]: + import re as _re + + regex_source = _re.escape(pattern) if literal else pattern + # Validate the pattern locally so an invalid regex raises re.error + # (caught by grep_tool's except re.error handler) rather than a + # generic remote API error. + _re.compile(regex_source, 0 if case_sensitive else _re.IGNORECASE) + regex = regex_source if case_sensitive else f"(?i){regex_source}" + + if glob is not None: + find_result = self._client.file.find_files(path=path, glob=glob) + candidate_paths = find_result.data.files if find_result.data and find_result.data.files else [] + else: + list_result = self._client.file.list_path(path=path, recursive=True, show_hidden=False) + entries = list_result.data.files if list_result.data and list_result.data.files else [] + candidate_paths = [entry.path for entry in entries if not entry.is_directory] + + matches: list[GrepMatch] = [] + truncated = False + + for file_path in candidate_paths: + if should_ignore_path(file_path): + continue + + search_result = self._client.file.search_in_file(file=file_path, regex=regex) + data = search_result.data + if data is None: + continue + + line_numbers = data.line_numbers or [] + matched_lines = data.matches or [] + for line_number, line in zip(line_numbers, matched_lines): + matches.append( + GrepMatch( + path=file_path, + line_number=line_number if isinstance(line_number, int) else 0, + line=truncate_line(line), + ) + ) + if len(matches) >= max_results: + truncated = True + return matches, truncated + + return matches, truncated + def update_file(self, path: str, content: bytes) -> None: """Update a file with binary content in the sandbox. diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index d034ffc4c..cd2336237 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -1,5 +1,6 @@ import logging import os +from contextvars import ContextVar from pathlib import Path from typing import Any, Self @@ -10,15 +11,15 @@ from pydantic import BaseModel, ConfigDict, Field from deerflow.config.acp_config import load_acp_config_from_dict from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict from deerflow.config.extensions_config import ExtensionsConfig -from deerflow.config.guardrails_config import load_guardrails_config_from_dict -from deerflow.config.memory_config import load_memory_config_from_dict +from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict +from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict from deerflow.config.model_config import ModelConfig from deerflow.config.sandbox_config import SandboxConfig from deerflow.config.skills_config import SkillsConfig from deerflow.config.stream_bridge_config import StreamBridgeConfig, load_stream_bridge_config_from_dict -from deerflow.config.subagents_config import load_subagents_config_from_dict -from deerflow.config.summarization_config import load_summarization_config_from_dict -from deerflow.config.title_config import load_title_config_from_dict +from deerflow.config.subagents_config import SubagentsAppConfig, load_subagents_config_from_dict +from deerflow.config.summarization_config import SummarizationConfig, load_summarization_config_from_dict +from deerflow.config.title_config import TitleConfig, load_title_config_from_dict from deerflow.config.token_usage_config import TokenUsageConfig from deerflow.config.tool_config import ToolConfig, ToolGroupConfig from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict @@ -28,6 +29,13 @@ load_dotenv() logger = logging.getLogger(__name__) +def _default_config_candidates() -> tuple[Path, ...]: + """Return deterministic config.yaml locations without relying on cwd.""" + backend_dir = Path(__file__).resolve().parents[4] + repo_root = backend_dir.parent + return (backend_dir / "config.yaml", repo_root / "config.yaml") + + class AppConfig(BaseModel): """Config for the DeerFlow application""" @@ -40,6 +48,11 @@ class AppConfig(BaseModel): skills: SkillsConfig = Field(default_factory=SkillsConfig, description="Skills configuration") extensions: ExtensionsConfig = Field(default_factory=ExtensionsConfig, description="Extensions configuration (MCP servers and skills state)") tool_search: ToolSearchConfig = Field(default_factory=ToolSearchConfig, description="Tool search / deferred loading configuration") + title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration") + summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration") + memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration") + subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration") + guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration") model_config = ConfigDict(extra="allow", frozen=False) checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") @@ -51,7 +64,7 @@ class AppConfig(BaseModel): Priority: 1. If provided `config_path` argument, use it. 2. If provided `DEER_FLOW_CONFIG_PATH` environment variable, use it. - 3. Otherwise, first check the `config.yaml` in the current directory, then fallback to `config.yaml` in the parent directory. + 3. Otherwise, search deterministic backend/repository-root defaults from `_default_config_candidates()`. """ if config_path: path = Path(config_path) @@ -64,14 +77,10 @@ class AppConfig(BaseModel): raise FileNotFoundError(f"Config file specified by environment variable `DEER_FLOW_CONFIG_PATH` not found at {path}") return path else: - # Check if the config.yaml is in the current directory - path = Path(os.getcwd()) / "config.yaml" - if not path.exists(): - # Check if the config.yaml is in the parent directory of CWD - path = Path(os.getcwd()).parent / "config.yaml" - if not path.exists(): - raise FileNotFoundError("`config.yaml` file not found at the current directory nor its parent directory") - return path + for path in _default_config_candidates(): + if path.exists(): + return path + raise FileNotFoundError("`config.yaml` file not found at the default backend or repository root locations") @classmethod def from_file(cls, config_path: str | None = None) -> Self: @@ -244,6 +253,8 @@ _app_config: AppConfig | None = None _app_config_path: Path | None = None _app_config_mtime: float | None = None _app_config_is_custom = False +_current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None) +_current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=()) def _get_config_mtime(config_path: Path) -> float | None: @@ -276,6 +287,10 @@ def get_app_config() -> AppConfig: """ global _app_config, _app_config_path, _app_config_mtime + runtime_override = _current_app_config.get() + if runtime_override is not None: + return runtime_override + if _app_config is not None and _app_config_is_custom: return _app_config @@ -337,3 +352,26 @@ def set_app_config(config: AppConfig) -> None: _app_config_path = None _app_config_mtime = None _app_config_is_custom = True + + +def peek_current_app_config() -> AppConfig | None: + """Return the runtime-scoped AppConfig override, if one is active.""" + return _current_app_config.get() + + +def push_current_app_config(config: AppConfig) -> None: + """Push a runtime-scoped AppConfig override for the current execution context.""" + stack = _current_app_config_stack.get() + _current_app_config_stack.set(stack + (_current_app_config.get(),)) + _current_app_config.set(config) + + +def pop_current_app_config() -> None: + """Pop the latest runtime-scoped AppConfig override for the current execution context.""" + stack = _current_app_config_stack.get() + if not stack: + _current_app_config.set(None) + return + previous = stack[-1] + _current_app_config_stack.set(stack[:-1]) + _current_app_config.set(previous) diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index 281e12179..e7a48d166 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -80,6 +80,12 @@ class ExtensionsConfig(BaseModel): Args: config_path: Optional path to extensions config file. + Resolution order: + 1. If provided `config_path` argument, use it. + 2. If provided `DEER_FLOW_EXTENSIONS_CONFIG_PATH` environment variable, use it. + 3. Otherwise, search backend/repository-root defaults for + `extensions_config.json`, then legacy `mcp_config.json`. + Returns: Path to the extensions config file if found, otherwise None. """ @@ -94,24 +100,16 @@ class ExtensionsConfig(BaseModel): raise FileNotFoundError(f"Extensions config file specified by environment variable `DEER_FLOW_EXTENSIONS_CONFIG_PATH` not found at {path}") return path else: - # Check if the extensions_config.json is in the current directory - path = Path(os.getcwd()) / "extensions_config.json" - if path.exists(): - return path - - # Check if the extensions_config.json is in the parent directory of CWD - path = Path(os.getcwd()).parent / "extensions_config.json" - if path.exists(): - return path - - # Backward compatibility: check for mcp_config.json - path = Path(os.getcwd()) / "mcp_config.json" - if path.exists(): - return path - - path = Path(os.getcwd()).parent / "mcp_config.json" - if path.exists(): - return path + backend_dir = Path(__file__).resolve().parents[4] + repo_root = backend_dir.parent + for path in ( + backend_dir / "extensions_config.json", + repo_root / "extensions_config.json", + backend_dir / "mcp_config.json", + repo_root / "mcp_config.json", + ): + if path.exists(): + return path # Extensions are optional, so return None if not found return None diff --git a/backend/packages/harness/deerflow/config/paths.py b/backend/packages/harness/deerflow/config/paths.py index 8b9cbc24c..2d5661e63 100644 --- a/backend/packages/harness/deerflow/config/paths.py +++ b/backend/packages/harness/deerflow/config/paths.py @@ -9,6 +9,12 @@ VIRTUAL_PATH_PREFIX = "/mnt/user-data" _SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$") +def _default_local_base_dir() -> Path: + """Return the repo-local DeerFlow state directory without relying on cwd.""" + backend_dir = Path(__file__).resolve().parents[4] + return backend_dir / ".deer-flow" + + def _validate_thread_id(thread_id: str) -> str: """Validate a thread ID before using it in filesystem paths.""" if not _SAFE_THREAD_ID_RE.match(thread_id): @@ -67,8 +73,7 @@ class Paths: BaseDir resolution (in priority order): 1. Constructor argument `base_dir` 2. DEER_FLOW_HOME environment variable - 3. Local dev fallback: cwd/.deer-flow (when cwd is the backend/ dir) - 4. Default: $HOME/.deer-flow + 3. Repo-local fallback derived from this module path: `{backend_dir}/.deer-flow` """ def __init__(self, base_dir: str | Path | None = None) -> None: @@ -104,11 +109,7 @@ class Paths: if env_home := os.getenv("DEER_FLOW_HOME"): return Path(env_home).resolve() - cwd = Path.cwd() - if cwd.name == "backend" or (cwd / "pyproject.toml").exists(): - return cwd / ".deer-flow" - - return Path.home() / ".deer-flow" + return _default_local_base_dir() @property def memory_file(self) -> Path: diff --git a/backend/packages/harness/deerflow/config/skills_config.py b/backend/packages/harness/deerflow/config/skills_config.py index 225aecbb9..31a6ca902 100644 --- a/backend/packages/harness/deerflow/config/skills_config.py +++ b/backend/packages/harness/deerflow/config/skills_config.py @@ -3,6 +3,11 @@ from pathlib import Path from pydantic import BaseModel, Field +def _default_repo_root() -> Path: + """Resolve the repo root without relying on the current working directory.""" + return Path(__file__).resolve().parents[5] + + class SkillsConfig(BaseModel): """Configuration for skills system""" @@ -26,8 +31,8 @@ class SkillsConfig(BaseModel): # Use configured path (can be absolute or relative) path = Path(self.path) if not path.is_absolute(): - # If relative, resolve from current working directory - path = Path.cwd() / path + # If relative, resolve from the repo root for deterministic behavior. + path = _default_repo_root() / path return path.resolve() else: # Default: ../skills relative to backend directory diff --git a/backend/packages/harness/deerflow/config/subagents_config.py b/backend/packages/harness/deerflow/config/subagents_config.py index 2611fe8fb..f2c650709 100644 --- a/backend/packages/harness/deerflow/config/subagents_config.py +++ b/backend/packages/harness/deerflow/config/subagents_config.py @@ -15,6 +15,11 @@ class SubagentOverrideConfig(BaseModel): ge=1, description="Timeout in seconds for this subagent (None = use global default)", ) + max_turns: int | None = Field( + default=None, + ge=1, + description="Maximum turns for this subagent (None = use global or builtin default)", + ) class SubagentsAppConfig(BaseModel): @@ -25,6 +30,11 @@ class SubagentsAppConfig(BaseModel): ge=1, description="Default timeout in seconds for all subagents (default: 900 = 15 minutes)", ) + max_turns: int | None = Field( + default=None, + ge=1, + description="Optional default max-turn override for all subagents (None = keep builtin defaults)", + ) agents: dict[str, SubagentOverrideConfig] = Field( default_factory=dict, description="Per-agent configuration overrides keyed by agent name", @@ -44,6 +54,15 @@ class SubagentsAppConfig(BaseModel): return override.timeout_seconds return self.timeout_seconds + def get_max_turns_for(self, agent_name: str, builtin_default: int) -> int: + """Get the effective max_turns for a specific agent.""" + override = self.agents.get(agent_name) + if override is not None and override.max_turns is not None: + return override.max_turns + if self.max_turns is not None: + return self.max_turns + return builtin_default + _subagents_config: SubagentsAppConfig = SubagentsAppConfig() @@ -58,8 +77,26 @@ def load_subagents_config_from_dict(config_dict: dict) -> None: global _subagents_config _subagents_config = SubagentsAppConfig(**config_dict) - overrides_summary = {name: f"{override.timeout_seconds}s" for name, override in _subagents_config.agents.items() if override.timeout_seconds is not None} + overrides_summary = {} + for name, override in _subagents_config.agents.items(): + parts = [] + if override.timeout_seconds is not None: + parts.append(f"timeout={override.timeout_seconds}s") + if override.max_turns is not None: + parts.append(f"max_turns={override.max_turns}") + if parts: + overrides_summary[name] = ", ".join(parts) + if overrides_summary: - logger.info(f"Subagents config loaded: default timeout={_subagents_config.timeout_seconds}s, per-agent overrides={overrides_summary}") + logger.info( + "Subagents config loaded: default timeout=%ss, default max_turns=%s, per-agent overrides=%s", + _subagents_config.timeout_seconds, + _subagents_config.max_turns, + overrides_summary, + ) else: - logger.info(f"Subagents config loaded: default timeout={_subagents_config.timeout_seconds}s, no per-agent overrides") + logger.info( + "Subagents config loaded: default timeout=%ss, default max_turns=%s, no per-agent overrides", + _subagents_config.timeout_seconds, + _subagents_config.max_turns, + ) diff --git a/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py b/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py index 5056e6ca3..45aff1349 100644 --- a/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py +++ b/backend/packages/harness/deerflow/runtime/stream_bridge/memory.py @@ -25,6 +25,7 @@ class MemoryStreamBridge(StreamBridge): self._maxsize = queue_maxsize self._queues: dict[str, asyncio.Queue[StreamEvent]] = {} self._counters: dict[str, int] = {} + self._dropped_counts: dict[str, int] = {} # -- helpers --------------------------------------------------------------- @@ -32,6 +33,7 @@ class MemoryStreamBridge(StreamBridge): if run_id not in self._queues: self._queues[run_id] = asyncio.Queue(maxsize=self._maxsize) self._counters[run_id] = 0 + self._dropped_counts[run_id] = 0 return self._queues[run_id] def _next_id(self, run_id: str) -> str: @@ -48,14 +50,41 @@ class MemoryStreamBridge(StreamBridge): try: await asyncio.wait_for(queue.put(entry), timeout=_PUBLISH_TIMEOUT) except TimeoutError: - logger.warning("Stream bridge queue full for run %s โ€” dropping event %s", run_id, event) + self._dropped_counts[run_id] = self._dropped_counts.get(run_id, 0) + 1 + logger.warning( + "Stream bridge queue full for run %s โ€” dropping event %s (total dropped: %d)", + run_id, + event, + self._dropped_counts[run_id], + ) async def publish_end(self, run_id: str) -> None: queue = self._get_or_create_queue(run_id) - try: - await asyncio.wait_for(queue.put(END_SENTINEL), timeout=_PUBLISH_TIMEOUT) - except TimeoutError: - logger.warning("Stream bridge queue full for run %s โ€” dropping END sentinel", run_id) + + # END sentinel is critical โ€” it is the only signal that allows + # subscribers to terminate. If the queue is full we evict the + # oldest *regular* events to make room rather than dropping END, + # which would cause the SSE connection to hang forever and leak + # the queue/counter resources for this run_id. + if queue.full(): + evicted = 0 + while queue.full(): + try: + queue.get_nowait() + evicted += 1 + except asyncio.QueueEmpty: + break # pragma: no cover โ€“ defensive + if evicted: + logger.warning( + "Stream bridge queue full for run %s โ€” evicted %d event(s) to guarantee END sentinel delivery", + run_id, + evicted, + ) + + # After eviction the queue is guaranteed to have space, so a + # simple non-blocking put is safe. We still use put() (which + # blocks until space is available) as a defensive measure. + await queue.put(END_SENTINEL) async def subscribe( self, @@ -84,7 +113,18 @@ class MemoryStreamBridge(StreamBridge): await asyncio.sleep(delay) self._queues.pop(run_id, None) self._counters.pop(run_id, None) + self._dropped_counts.pop(run_id, None) async def close(self) -> None: self._queues.clear() self._counters.clear() + self._dropped_counts.clear() + + def dropped_count(self, run_id: str) -> int: + """Return the number of events dropped for *run_id*.""" + return self._dropped_counts.get(run_id, 0) + + @property + def dropped_total(self) -> int: + """Return the total number of events dropped across all runs.""" + return sum(self._dropped_counts.values()) diff --git a/backend/packages/harness/deerflow/sandbox/local/list_dir.py b/backend/packages/harness/deerflow/sandbox/local/list_dir.py index 4c41cbf8a..b1031d340 100644 --- a/backend/packages/harness/deerflow/sandbox/local/list_dir.py +++ b/backend/packages/harness/deerflow/sandbox/local/list_dir.py @@ -1,72 +1,6 @@ -import fnmatch from pathlib import Path -IGNORE_PATTERNS = [ - # Version Control - ".git", - ".svn", - ".hg", - ".bzr", - # Dependencies - "node_modules", - "__pycache__", - ".venv", - "venv", - ".env", - "env", - ".tox", - ".nox", - ".eggs", - "*.egg-info", - "site-packages", - # Build outputs - "dist", - "build", - ".next", - ".nuxt", - ".output", - ".turbo", - "target", - "out", - # IDE & Editor - ".idea", - ".vscode", - "*.swp", - "*.swo", - "*~", - ".project", - ".classpath", - ".settings", - # OS generated - ".DS_Store", - "Thumbs.db", - "desktop.ini", - "*.lnk", - # Logs & temp files - "*.log", - "*.tmp", - "*.temp", - "*.bak", - "*.cache", - ".cache", - "logs", - # Coverage & test artifacts - ".coverage", - "coverage", - ".nyc_output", - "htmlcov", - ".pytest_cache", - ".mypy_cache", - ".ruff_cache", -] - - -def _should_ignore(name: str) -> bool: - """Check if a file/directory name matches any ignore pattern.""" - for pattern in IGNORE_PATTERNS: - if fnmatch.fnmatch(name, pattern): - return True - return False +from deerflow.sandbox.search import should_ignore_name def list_dir(path: str, max_depth: int = 2) -> list[str]: @@ -95,7 +29,7 @@ def list_dir(path: str, max_depth: int = 2) -> list[str]: try: for item in current_path.iterdir(): - if _should_ignore(item.name): + if should_ignore_name(item.name): continue post_fix = "/" if item.is_dir() else "" diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py index bf5cd4017..83129724b 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox.py @@ -1,11 +1,23 @@ +import errno import ntpath import os import shutil import subprocess +from dataclasses import dataclass from pathlib import Path from deerflow.sandbox.local.list_dir import list_dir from deerflow.sandbox.sandbox import Sandbox +from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches + + +@dataclass(frozen=True) +class PathMapping: + """A path mapping from a container path to a local path with optional read-only flag.""" + + container_path: str + local_path: str + read_only: bool = False class LocalSandbox(Sandbox): @@ -39,17 +51,42 @@ class LocalSandbox(Sandbox): return None - def __init__(self, id: str, path_mappings: dict[str, str] | None = None): + def __init__(self, id: str, path_mappings: list[PathMapping] | None = None): """ Initialize local sandbox with optional path mappings. Args: id: Sandbox identifier - path_mappings: Dictionary mapping container paths to local paths - Example: {"/mnt/skills": "/absolute/path/to/skills"} + path_mappings: List of path mappings with optional read-only flag. + Skills directory is read-only by default. """ super().__init__(id) - self.path_mappings = path_mappings or {} + self.path_mappings = path_mappings or [] + + def _is_read_only_path(self, resolved_path: str) -> bool: + """Check if a resolved path is under a read-only mount. + + When multiple mappings match (nested mounts), prefer the most specific + mapping (i.e. the one whose local_path is the longest prefix of the + resolved path), similar to how ``_resolve_path`` handles container paths. + """ + resolved = str(Path(resolved_path).resolve()) + + best_mapping: PathMapping | None = None + best_prefix_len = -1 + + for mapping in self.path_mappings: + local_resolved = str(Path(mapping.local_path).resolve()) + if resolved == local_resolved or resolved.startswith(local_resolved + os.sep): + prefix_len = len(local_resolved) + if prefix_len > best_prefix_len: + best_prefix_len = prefix_len + best_mapping = mapping + + if best_mapping is None: + return False + + return best_mapping.read_only def _resolve_path(self, path: str) -> str: """ @@ -64,7 +101,9 @@ class LocalSandbox(Sandbox): path_str = str(path) # Try each mapping (longest prefix first for more specific matches) - for container_path, local_path in sorted(self.path_mappings.items(), key=lambda x: len(x[0]), reverse=True): + for mapping in sorted(self.path_mappings, key=lambda m: len(m.container_path), reverse=True): + container_path = mapping.container_path + local_path = mapping.local_path if path_str == container_path or path_str.startswith(container_path + "/"): # Replace the container path prefix with local path relative = path_str[len(container_path) :].lstrip("/") @@ -84,15 +123,16 @@ class LocalSandbox(Sandbox): Returns: Container path if mapping exists, otherwise original path """ - path_str = str(Path(path).resolve()) + normalized_path = path.replace("\\", "/") + path_str = str(Path(normalized_path).resolve()) # Try each mapping (longest local path first for more specific matches) - for container_path, local_path in sorted(self.path_mappings.items(), key=lambda x: len(x[1]), reverse=True): - local_path_resolved = str(Path(local_path).resolve()) - if path_str.startswith(local_path_resolved): + for mapping in sorted(self.path_mappings, key=lambda m: len(m.local_path), reverse=True): + local_path_resolved = str(Path(mapping.local_path).resolve()) + if path_str == local_path_resolved or path_str.startswith(local_path_resolved + "/"): # Replace the local path prefix with container path relative = path_str[len(local_path_resolved) :].lstrip("/") - resolved = f"{container_path}/{relative}" if relative else container_path + resolved = f"{mapping.container_path}/{relative}" if relative else mapping.container_path return resolved # No mapping found, return original path @@ -111,7 +151,7 @@ class LocalSandbox(Sandbox): import re # Sort mappings by local path length (longest first) for correct prefix matching - sorted_mappings = sorted(self.path_mappings.items(), key=lambda x: len(x[1]), reverse=True) + sorted_mappings = sorted(self.path_mappings, key=lambda m: len(m.local_path), reverse=True) if not sorted_mappings: return output @@ -119,12 +159,11 @@ class LocalSandbox(Sandbox): # Create pattern that matches absolute paths # Match paths like /Users/... or other absolute paths result = output - for container_path, local_path in sorted_mappings: - local_path_resolved = str(Path(local_path).resolve()) + for mapping in sorted_mappings: # Escape the local path for use in regex - escaped_local = re.escape(local_path_resolved) - # Match the local path followed by optional path components - pattern = re.compile(escaped_local + r"(?:/[^\s\"';&|<>()]*)?") + escaped_local = re.escape(str(Path(mapping.local_path).resolve())) + # Match the local path followed by optional path components with either separator + pattern = re.compile(escaped_local + r"(?:[/\\][^\s\"';&|<>()]*)?") def replace_match(match: re.Match) -> str: matched_path = match.group(0) @@ -147,7 +186,7 @@ class LocalSandbox(Sandbox): import re # Sort mappings by length (longest first) for correct prefix matching - sorted_mappings = sorted(self.path_mappings.items(), key=lambda x: len(x[0]), reverse=True) + sorted_mappings = sorted(self.path_mappings, key=lambda m: len(m.container_path), reverse=True) # Build regex pattern to match all container paths # Match container path followed by optional path components @@ -157,7 +196,7 @@ class LocalSandbox(Sandbox): # Create pattern that matches any of the container paths. # The lookahead (?=/|$|...) ensures we only match at a path-segment boundary, # preventing /mnt/skills from matching inside /mnt/skills-extra. - patterns = [re.escape(container_path) + r"(?=/|$|[\s\"';&|<>()])(?:/[^\s\"';&|<>()]*)?" for container_path, _ in sorted_mappings] + patterns = [re.escape(m.container_path) + r"(?=/|$|[\s\"';&|<>()])(?:/[^\s\"';&|<>()]*)?" for m in sorted_mappings] pattern = re.compile("|".join(f"({p})" for p in patterns)) def replace_match(match: re.Match) -> str: @@ -248,6 +287,8 @@ class LocalSandbox(Sandbox): def write_file(self, path: str, content: str, append: bool = False) -> None: resolved_path = self._resolve_path(path) + if self._is_read_only_path(resolved_path): + raise OSError(errno.EROFS, "Read-only file system", path) try: dir_path = os.path.dirname(resolved_path) if dir_path: @@ -259,8 +300,43 @@ class LocalSandbox(Sandbox): # Re-raise with the original path for clearer error messages, hiding internal resolved paths raise type(e)(e.errno, e.strerror, path) from None + def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: + resolved_path = Path(self._resolve_path(path)) + matches, truncated = find_glob_matches(resolved_path, pattern, include_dirs=include_dirs, max_results=max_results) + return [self._reverse_resolve_path(match) for match in matches], truncated + + def grep( + self, + path: str, + pattern: str, + *, + glob: str | None = None, + literal: bool = False, + case_sensitive: bool = False, + max_results: int = 100, + ) -> tuple[list[GrepMatch], bool]: + resolved_path = Path(self._resolve_path(path)) + matches, truncated = find_grep_matches( + resolved_path, + pattern, + glob_pattern=glob, + literal=literal, + case_sensitive=case_sensitive, + max_results=max_results, + ) + return [ + GrepMatch( + path=self._reverse_resolve_path(match.path), + line_number=match.line_number, + line=match.line, + ) + for match in matches + ], truncated + def update_file(self, path: str, content: bytes) -> None: resolved_path = self._resolve_path(path) + if self._is_read_only_path(resolved_path): + raise OSError(errno.EROFS, "Read-only file system", path) try: dir_path = os.path.dirname(resolved_path) if dir_path: diff --git a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py index 625f80f02..ec4693036 100644 --- a/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py +++ b/backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py @@ -1,6 +1,7 @@ import logging +from pathlib import Path -from deerflow.sandbox.local.local_sandbox import LocalSandbox +from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping from deerflow.sandbox.sandbox import Sandbox from deerflow.sandbox.sandbox_provider import SandboxProvider @@ -14,16 +15,17 @@ class LocalSandboxProvider(SandboxProvider): """Initialize the local sandbox provider with path mappings.""" self._path_mappings = self._setup_path_mappings() - def _setup_path_mappings(self) -> dict[str, str]: + def _setup_path_mappings(self) -> list[PathMapping]: """ Setup path mappings for local sandbox. - Maps container paths to actual local paths, including skills directory. + Maps container paths to actual local paths, including skills directory + and any custom mounts configured in config.yaml. Returns: - Dictionary of path mappings + List of path mappings """ - mappings = {} + mappings: list[PathMapping] = [] # Map skills container path to local skills directory try: @@ -35,10 +37,63 @@ class LocalSandboxProvider(SandboxProvider): # Only add mapping if skills directory exists if skills_path.exists(): - mappings[container_path] = str(skills_path) + mappings.append( + PathMapping( + container_path=container_path, + local_path=str(skills_path), + read_only=True, # Skills directory is always read-only + ) + ) + + # Map custom mounts from sandbox config + _RESERVED_CONTAINER_PREFIXES = [container_path, "/mnt/acp-workspace", "/mnt/user-data"] + sandbox_config = config.sandbox + if sandbox_config and sandbox_config.mounts: + for mount in sandbox_config.mounts: + host_path = Path(mount.host_path) + container_path = mount.container_path.rstrip("/") or "/" + + if not host_path.is_absolute(): + logger.warning( + "Mount host_path must be absolute, skipping: %s -> %s", + mount.host_path, + mount.container_path, + ) + continue + + if not container_path.startswith("/"): + logger.warning( + "Mount container_path must be absolute, skipping: %s -> %s", + mount.host_path, + mount.container_path, + ) + continue + + # Reject mounts that conflict with reserved container paths + if any(container_path == p or container_path.startswith(p + "/") for p in _RESERVED_CONTAINER_PREFIXES): + logger.warning( + "Mount container_path conflicts with reserved prefix, skipping: %s", + mount.container_path, + ) + continue + # Ensure the host path exists before adding mapping + if host_path.exists(): + mappings.append( + PathMapping( + container_path=container_path, + local_path=str(host_path.resolve()), + read_only=mount.read_only, + ) + ) + else: + logger.warning( + "Mount host_path does not exist, skipping: %s -> %s", + mount.host_path, + mount.container_path, + ) except Exception as e: # Log but don't fail if config loading fails - logger.warning("Could not setup skills path mapping: %s", e, exc_info=True) + logger.warning("Could not setup path mappings: %s", e, exc_info=True) return mappings diff --git a/backend/packages/harness/deerflow/sandbox/sandbox.py b/backend/packages/harness/deerflow/sandbox/sandbox.py index 57cab4be6..dc567b503 100644 --- a/backend/packages/harness/deerflow/sandbox/sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/sandbox.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod +from deerflow.sandbox.search import GrepMatch + class Sandbox(ABC): """Abstract base class for sandbox environments""" @@ -61,6 +63,25 @@ class Sandbox(ABC): """ pass + @abstractmethod + def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: + """Find paths that match a glob pattern under a root directory.""" + pass + + @abstractmethod + def grep( + self, + path: str, + pattern: str, + *, + glob: str | None = None, + literal: bool = False, + case_sensitive: bool = False, + max_results: int = 100, + ) -> tuple[list[GrepMatch], bool]: + """Search for matches inside text files under a directory.""" + pass + @abstractmethod def update_file(self, path: str, content: bytes) -> None: """Update a file with binary content. diff --git a/backend/packages/harness/deerflow/sandbox/search.py b/backend/packages/harness/deerflow/sandbox/search.py new file mode 100644 index 000000000..a85938870 --- /dev/null +++ b/backend/packages/harness/deerflow/sandbox/search.py @@ -0,0 +1,210 @@ +import fnmatch +import os +import re +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +IGNORE_PATTERNS = [ + ".git", + ".svn", + ".hg", + ".bzr", + "node_modules", + "__pycache__", + ".venv", + "venv", + ".env", + "env", + ".tox", + ".nox", + ".eggs", + "*.egg-info", + "site-packages", + "dist", + "build", + ".next", + ".nuxt", + ".output", + ".turbo", + "target", + "out", + ".idea", + ".vscode", + "*.swp", + "*.swo", + "*~", + ".project", + ".classpath", + ".settings", + ".DS_Store", + "Thumbs.db", + "desktop.ini", + "*.lnk", + "*.log", + "*.tmp", + "*.temp", + "*.bak", + "*.cache", + ".cache", + "logs", + ".coverage", + "coverage", + ".nyc_output", + "htmlcov", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", +] + +DEFAULT_MAX_FILE_SIZE_BYTES = 1_000_000 +DEFAULT_LINE_SUMMARY_LENGTH = 200 + + +@dataclass(frozen=True) +class GrepMatch: + path: str + line_number: int + line: str + + +def should_ignore_name(name: str) -> bool: + for pattern in IGNORE_PATTERNS: + if fnmatch.fnmatch(name, pattern): + return True + return False + + +def should_ignore_path(path: str) -> bool: + return any(should_ignore_name(segment) for segment in path.replace("\\", "/").split("/") if segment) + + +def path_matches(pattern: str, rel_path: str) -> bool: + path = PurePosixPath(rel_path) + if path.match(pattern): + return True + if pattern.startswith("**/"): + return path.match(pattern[3:]) + return False + + +def truncate_line(line: str, max_chars: int = DEFAULT_LINE_SUMMARY_LENGTH) -> str: + line = line.rstrip("\n\r") + if len(line) <= max_chars: + return line + return line[: max_chars - 3] + "..." + + +def is_binary_file(path: Path, sample_size: int = 8192) -> bool: + try: + with path.open("rb") as handle: + return b"\0" in handle.read(sample_size) + except OSError: + return True + + +def find_glob_matches(root: Path, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: + matches: list[str] = [] + truncated = False + root = root.resolve() + + if not root.exists(): + raise FileNotFoundError(root) + if not root.is_dir(): + raise NotADirectoryError(root) + + for current_root, dirs, files in os.walk(root): + dirs[:] = [name for name in dirs if not should_ignore_name(name)] + # root is already resolved; os.walk builds current_root by joining under root, + # so relative_to() works without an extra stat()/resolve() per directory. + rel_dir = Path(current_root).relative_to(root) + + if include_dirs: + for name in dirs: + rel_path = (rel_dir / name).as_posix() + if path_matches(pattern, rel_path): + matches.append(str(Path(current_root) / name)) + if len(matches) >= max_results: + truncated = True + return matches, truncated + + for name in files: + if should_ignore_name(name): + continue + rel_path = (rel_dir / name).as_posix() + if path_matches(pattern, rel_path): + matches.append(str(Path(current_root) / name)) + if len(matches) >= max_results: + truncated = True + return matches, truncated + + return matches, truncated + + +def find_grep_matches( + root: Path, + pattern: str, + *, + glob_pattern: str | None = None, + literal: bool = False, + case_sensitive: bool = False, + max_results: int = 100, + max_file_size: int = DEFAULT_MAX_FILE_SIZE_BYTES, + line_summary_length: int = DEFAULT_LINE_SUMMARY_LENGTH, +) -> tuple[list[GrepMatch], bool]: + matches: list[GrepMatch] = [] + truncated = False + root = root.resolve() + + if not root.exists(): + raise FileNotFoundError(root) + if not root.is_dir(): + raise NotADirectoryError(root) + + regex_source = re.escape(pattern) if literal else pattern + flags = 0 if case_sensitive else re.IGNORECASE + regex = re.compile(regex_source, flags) + + # Skip lines longer than this to prevent ReDoS on minified / no-newline files. + _max_line_chars = line_summary_length * 10 + + for current_root, dirs, files in os.walk(root): + dirs[:] = [name for name in dirs if not should_ignore_name(name)] + rel_dir = Path(current_root).relative_to(root) + + for name in files: + if should_ignore_name(name): + continue + + candidate_path = Path(current_root) / name + rel_path = (rel_dir / name).as_posix() + + if glob_pattern is not None and not path_matches(glob_pattern, rel_path): + continue + + try: + if candidate_path.is_symlink(): + continue + file_path = candidate_path.resolve() + if not file_path.is_relative_to(root): + continue + if file_path.stat().st_size > max_file_size or is_binary_file(file_path): + continue + with file_path.open(encoding="utf-8", errors="replace") as handle: + for line_number, line in enumerate(handle, start=1): + if len(line) > _max_line_chars: + continue + if regex.search(line): + matches.append( + GrepMatch( + path=str(file_path), + line_number=line_number, + line=truncate_line(line, line_summary_length), + ) + ) + if len(matches) >= max_results: + truncated = True + return matches, truncated + except OSError: + continue + + return matches, truncated diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 40d176a26..b52131ff4 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -7,6 +7,7 @@ from langchain.tools import ToolRuntime, tool from langgraph.typing import ContextT from deerflow.agents.thread_state import ThreadDataState, ThreadState +from deerflow.config import get_app_config from deerflow.config.paths import VIRTUAL_PATH_PREFIX from deerflow.sandbox.exceptions import ( SandboxError, @@ -16,6 +17,7 @@ from deerflow.sandbox.exceptions import ( from deerflow.sandbox.file_operation_lock import get_file_operation_lock from deerflow.sandbox.sandbox import Sandbox from deerflow.sandbox.sandbox_provider import get_sandbox_provider +from deerflow.sandbox.search import GrepMatch from deerflow.sandbox.security import LOCAL_HOST_BASH_DISABLED_MESSAGE, is_host_bash_allowed _ABSOLUTE_PATH_PATTERN = re.compile(r"(?()]+)") @@ -31,6 +33,10 @@ _LOCAL_BASH_SYSTEM_PATH_PREFIXES = ( _DEFAULT_SKILLS_CONTAINER_PATH = "/mnt/skills" _ACP_WORKSPACE_VIRTUAL_PATH = "/mnt/acp-workspace" +_DEFAULT_GLOB_MAX_RESULTS = 200 +_MAX_GLOB_MAX_RESULTS = 1000 +_DEFAULT_GREP_MAX_RESULTS = 100 +_MAX_GREP_MAX_RESULTS = 500 def _get_skills_container_path() -> str: @@ -113,6 +119,54 @@ def _is_acp_workspace_path(path: str) -> bool: return path == _ACP_WORKSPACE_VIRTUAL_PATH or path.startswith(f"{_ACP_WORKSPACE_VIRTUAL_PATH}/") +def _get_custom_mounts(): + """Get custom volume mounts from sandbox config. + + Result is cached after the first successful config load. If config loading + fails an empty list is returned *without* caching so that a later call can + pick up the real value once the config is available. + """ + cached = getattr(_get_custom_mounts, "_cached", None) + if cached is not None: + return cached + try: + from pathlib import Path + + from deerflow.config import get_app_config + + config = get_app_config() + mounts = [] + if config.sandbox and config.sandbox.mounts: + # Only include mounts whose host_path exists, consistent with + # LocalSandboxProvider._setup_path_mappings() which also filters + # by host_path.exists(). + mounts = [m for m in config.sandbox.mounts if Path(m.host_path).exists()] + _get_custom_mounts._cached = mounts # type: ignore[attr-defined] + return mounts + except Exception: + # If config loading fails, return an empty list without caching so that + # a later call can retry once the config is available. + return [] + + +def _is_custom_mount_path(path: str) -> bool: + """Check if path is under a custom mount container_path.""" + for mount in _get_custom_mounts(): + if path == mount.container_path or path.startswith(f"{mount.container_path}/"): + return True + return False + + +def _get_custom_mount_for_path(path: str): + """Get the mount config matching this path (longest prefix first).""" + best = None + for mount in _get_custom_mounts(): + if path == mount.container_path or path.startswith(f"{mount.container_path}/"): + if best is None or len(mount.container_path) > len(best.container_path): + best = mount + return best + + def _extract_thread_id_from_thread_data(thread_data: "ThreadDataState | None") -> str | None: """Extract thread_id from thread_data by inspecting workspace_path. @@ -245,16 +299,84 @@ def _get_mcp_allowed_paths() -> list[str]: return allowed_paths +def _get_tool_config_int(name: str, key: str, default: int) -> int: + try: + tool_config = get_app_config().get_tool_config(name) + if tool_config is not None and key in tool_config.model_extra: + value = tool_config.model_extra.get(key) + if isinstance(value, int): + return value + except Exception: + pass + return default + + +def _clamp_max_results(value: int, *, default: int, upper_bound: int) -> int: + if value <= 0: + return default + return min(value, upper_bound) + + +def _resolve_max_results(name: str, requested: int, *, default: int, upper_bound: int) -> int: + requested_max_results = _clamp_max_results(requested, default=default, upper_bound=upper_bound) + configured_max_results = _clamp_max_results( + _get_tool_config_int(name, "max_results", default), + default=default, + upper_bound=upper_bound, + ) + return min(requested_max_results, configured_max_results) + + +def _resolve_local_read_path(path: str, thread_data: ThreadDataState) -> str: + validate_local_tool_path(path, thread_data, read_only=True) + if _is_skills_path(path): + return _resolve_skills_path(path) + if _is_acp_workspace_path(path): + return _resolve_acp_workspace_path(path, _extract_thread_id_from_thread_data(thread_data)) + return _resolve_and_validate_user_data_path(path, thread_data) + + +def _format_glob_results(root_path: str, matches: list[str], truncated: bool) -> str: + if not matches: + return f"No files matched under {root_path}" + + lines = [f"Found {len(matches)} paths under {root_path}"] + if truncated: + lines[0] += f" (showing first {len(matches)})" + lines.extend(f"{index}. {path}" for index, path in enumerate(matches, start=1)) + if truncated: + lines.append("Results truncated. Narrow the path or pattern to see fewer matches.") + return "\n".join(lines) + + +def _format_grep_results(root_path: str, matches: list[GrepMatch], truncated: bool) -> str: + if not matches: + return f"No matches found under {root_path}" + + lines = [f"Found {len(matches)} matches under {root_path}"] + if truncated: + lines[0] += f" (showing first {len(matches)})" + lines.extend(f"{match.path}:{match.line_number}: {match.line}" for match in matches) + if truncated: + lines.append("Results truncated. Narrow the path or add a glob filter.") + return "\n".join(lines) + + def _path_variants(path: str) -> set[str]: return {path, path.replace("\\", "/"), path.replace("/", "\\")} +def _path_separator_for_style(path: str) -> str: + return "\\" if "\\" in path and "/" not in path else "/" + + def _join_path_preserving_style(base: str, relative: str) -> str: if not relative: return base - if "/" in base and "\\" not in base: - return f"{base.rstrip('/')}/{relative}" - return str(Path(base) / relative) + separator = _path_separator_for_style(base) + normalized_relative = relative.replace("\\" if separator == "/" else "/", separator).lstrip("/\\") + stripped_base = base.rstrip("/\\") + return f"{stripped_base}{separator}{normalized_relative}" def _sanitize_error(error: Exception, runtime: "ToolRuntime[ContextT, ThreadState] | None" = None) -> str: @@ -299,7 +421,10 @@ def replace_virtual_path(path: str, thread_data: ThreadDataState | None) -> str: return actual_base if path.startswith(f"{virtual_base}/"): rest = path[len(virtual_base) :].lstrip("/") - return _join_path_preserving_style(actual_base, rest) + result = _join_path_preserving_style(actual_base, rest) + if path.endswith("/") and not result.endswith(("/", "\\")): + result += _path_separator_for_style(actual_base) + return result return path @@ -379,6 +504,8 @@ def mask_local_paths_in_output(output: str, thread_data: ThreadDataState | None) result = pattern.sub(replace_acp, result) + # Custom mount host paths are masked by LocalSandbox._reverse_resolve_paths_in_output() + # Mask user-data host paths if thread_data is None: return result @@ -427,6 +554,7 @@ def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *, - ``/mnt/user-data/*`` โ€” always allowed (read + write) - ``/mnt/skills/*`` โ€” allowed only when *read_only* is True - ``/mnt/acp-workspace/*`` โ€” allowed only when *read_only* is True + - Custom mount paths (from config.yaml) โ€” respects per-mount ``read_only`` flag Args: path: The virtual path to validate. @@ -458,7 +586,14 @@ def validate_local_tool_path(path: str, thread_data: ThreadDataState | None, *, if path.startswith(f"{VIRTUAL_PATH_PREFIX}/"): return - raise PermissionError(f"Only paths under {VIRTUAL_PATH_PREFIX}/, {_get_skills_container_path()}/, or {_ACP_WORKSPACE_VIRTUAL_PATH}/ are allowed") + # Custom mount paths โ€” respect read_only config + if _is_custom_mount_path(path): + mount = _get_custom_mount_for_path(path) + if mount and mount.read_only and not read_only: + raise PermissionError(f"Write access to read-only mount is not allowed: {path}") + return + + raise PermissionError(f"Only paths under {VIRTUAL_PATH_PREFIX}/, {_get_skills_container_path()}/, {_ACP_WORKSPACE_VIRTUAL_PATH}/, or configured mount paths are allowed") def _validate_resolved_user_data_path(resolved: Path, thread_data: ThreadDataState) -> None: @@ -508,9 +643,10 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState boundary and must not be treated as isolation from the host filesystem. In local mode, commands must use virtual paths under /mnt/user-data for - user data access. Skills paths under /mnt/skills and ACP workspace paths - under /mnt/acp-workspace are allowed (path-traversal checks only; write - prevention for bash commands is not enforced here). + user data access. Skills paths under /mnt/skills, ACP workspace paths + under /mnt/acp-workspace, and custom mount container paths (configured in + config.yaml) are allowed (path-traversal checks only; write prevention + for bash commands is not enforced here). A small allowlist of common system path prefixes is kept for executable and device references (e.g. /bin/sh, /dev/null). """ @@ -545,6 +681,11 @@ def validate_local_bash_command_paths(command: str, thread_data: ThreadDataState _reject_path_traversal(absolute_path) continue + # Allow custom mount container paths + if _is_custom_mount_path(absolute_path): + _reject_path_traversal(absolute_path) + continue + if any(absolute_path == prefix.rstrip("/") or absolute_path.startswith(prefix) for prefix in _LOCAL_BASH_SYSTEM_PATH_PREFIXES): continue @@ -589,6 +730,8 @@ def replace_virtual_paths_in_command(command: str, thread_data: ThreadDataState result = acp_pattern.sub(replace_acp_match, result) + # Custom mount paths are resolved by LocalSandbox._resolve_paths_in_command() + # Replace user-data paths if VIRTUAL_PATH_PREFIX in result and thread_data is not None: pattern = re.compile(rf"{re.escape(VIRTUAL_PATH_PREFIX)}(/[^\s\"';&|<>()]*)?") @@ -666,7 +809,8 @@ def sandbox_from_runtime(runtime: ToolRuntime[ContextT, ThreadState] | None = No if sandbox is None: raise SandboxNotFoundError(f"Sandbox with ID '{sandbox_id}' not found", sandbox_id=sandbox_id) - runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for downstream use + if runtime.context is not None: + runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for downstream use return sandbox @@ -701,7 +845,8 @@ def ensure_sandbox_initialized(runtime: ToolRuntime[ContextT, ThreadState] | Non if sandbox_id is not None: sandbox = get_sandbox_provider().get(sandbox_id) if sandbox is not None: - runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for releasing in after_agent + if runtime.context is not None: + runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for releasing in after_agent return sandbox # Sandbox was released, fall through to acquire new one @@ -723,7 +868,8 @@ def ensure_sandbox_initialized(runtime: ToolRuntime[ContextT, ThreadState] | Non if sandbox is None: raise SandboxNotFoundError("Sandbox not found after acquisition", sandbox_id=sandbox_id) - runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for releasing in after_agent + if runtime.context is not None: + runtime.context["sandbox_id"] = sandbox_id # Ensure sandbox_id is in context for releasing in after_agent return sandbox @@ -885,8 +1031,9 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path: path = _resolve_skills_path(path) elif _is_acp_workspace_path(path): path = _resolve_acp_workspace_path(path, _extract_thread_id_from_thread_data(thread_data)) - else: + elif not _is_custom_mount_path(path): path = _resolve_and_validate_user_data_path(path, thread_data) + # Custom mount paths are resolved by LocalSandbox._resolve_path() children = sandbox.list_dir(path) if not children: return "(empty)" @@ -901,6 +1048,126 @@ def ls_tool(runtime: ToolRuntime[ContextT, ThreadState], description: str, path: return f"Error: Unexpected error listing directory: {_sanitize_error(e, runtime)}" +@tool("glob", parse_docstring=True) +def glob_tool( + runtime: ToolRuntime[ContextT, ThreadState], + description: str, + pattern: str, + path: str, + include_dirs: bool = False, + max_results: int = _DEFAULT_GLOB_MAX_RESULTS, +) -> str: + """Find files or directories that match a glob pattern under a root directory. + + Args: + description: Explain why you are searching for these paths in short words. ALWAYS PROVIDE THIS PARAMETER FIRST. + pattern: The glob pattern to match relative to the root path, for example `**/*.py`. + path: The **absolute** root directory to search under. + include_dirs: Whether matching directories should also be returned. Default is False. + max_results: Maximum number of paths to return. Default is 200. + """ + try: + sandbox = ensure_sandbox_initialized(runtime) + ensure_thread_directories_exist(runtime) + requested_path = path + effective_max_results = _resolve_max_results( + "glob", + max_results, + default=_DEFAULT_GLOB_MAX_RESULTS, + upper_bound=_MAX_GLOB_MAX_RESULTS, + ) + thread_data = None + if is_local_sandbox(runtime): + thread_data = get_thread_data(runtime) + if thread_data is None: + raise SandboxRuntimeError("Thread data not available for local sandbox") + path = _resolve_local_read_path(path, thread_data) + matches, truncated = sandbox.glob(path, pattern, include_dirs=include_dirs, max_results=effective_max_results) + if thread_data is not None: + matches = [mask_local_paths_in_output(match, thread_data) for match in matches] + return _format_glob_results(requested_path, matches, truncated) + except SandboxError as e: + return f"Error: {e}" + except FileNotFoundError: + return f"Error: Directory not found: {requested_path}" + except NotADirectoryError: + return f"Error: Path is not a directory: {requested_path}" + except PermissionError: + return f"Error: Permission denied: {requested_path}" + except Exception as e: + return f"Error: Unexpected error searching paths: {_sanitize_error(e, runtime)}" + + +@tool("grep", parse_docstring=True) +def grep_tool( + runtime: ToolRuntime[ContextT, ThreadState], + description: str, + pattern: str, + path: str, + glob: str | None = None, + literal: bool = False, + case_sensitive: bool = False, + max_results: int = _DEFAULT_GREP_MAX_RESULTS, +) -> str: + """Search for matching lines inside text files under a root directory. + + Args: + description: Explain why you are searching file contents in short words. ALWAYS PROVIDE THIS PARAMETER FIRST. + pattern: The string or regex pattern to search for. + path: The **absolute** root directory to search under. + glob: Optional glob filter for candidate files, for example `**/*.py`. + literal: Whether to treat `pattern` as a plain string. Default is False. + case_sensitive: Whether matching is case-sensitive. Default is False. + max_results: Maximum number of matching lines to return. Default is 100. + """ + try: + sandbox = ensure_sandbox_initialized(runtime) + ensure_thread_directories_exist(runtime) + requested_path = path + effective_max_results = _resolve_max_results( + "grep", + max_results, + default=_DEFAULT_GREP_MAX_RESULTS, + upper_bound=_MAX_GREP_MAX_RESULTS, + ) + thread_data = None + if is_local_sandbox(runtime): + thread_data = get_thread_data(runtime) + if thread_data is None: + raise SandboxRuntimeError("Thread data not available for local sandbox") + path = _resolve_local_read_path(path, thread_data) + matches, truncated = sandbox.grep( + path, + pattern, + glob=glob, + literal=literal, + case_sensitive=case_sensitive, + max_results=effective_max_results, + ) + if thread_data is not None: + matches = [ + GrepMatch( + path=mask_local_paths_in_output(match.path, thread_data), + line_number=match.line_number, + line=match.line, + ) + for match in matches + ] + return _format_grep_results(requested_path, matches, truncated) + except SandboxError as e: + return f"Error: {e}" + except FileNotFoundError: + return f"Error: Directory not found: {requested_path}" + except NotADirectoryError: + return f"Error: Path is not a directory: {requested_path}" + except re.error as e: + return f"Error: Invalid regex pattern: {e}" + except PermissionError: + return f"Error: Permission denied: {requested_path}" + except Exception as e: + return f"Error: Unexpected error searching file contents: {_sanitize_error(e, runtime)}" + + @tool("read_file", parse_docstring=True) def read_file_tool( runtime: ToolRuntime[ContextT, ThreadState], @@ -928,8 +1195,9 @@ def read_file_tool( path = _resolve_skills_path(path) elif _is_acp_workspace_path(path): path = _resolve_acp_workspace_path(path, _extract_thread_id_from_thread_data(thread_data)) - else: + elif not _is_custom_mount_path(path): path = _resolve_and_validate_user_data_path(path, thread_data) + # Custom mount paths are resolved by LocalSandbox._resolve_path() content = sandbox.read_file(path) if not content: return "(empty)" @@ -977,7 +1245,9 @@ def write_file_tool( if is_local_sandbox(runtime): thread_data = get_thread_data(runtime) validate_local_tool_path(path, thread_data) - path = _resolve_and_validate_user_data_path(path, thread_data) + if not _is_custom_mount_path(path): + path = _resolve_and_validate_user_data_path(path, thread_data) + # Custom mount paths are resolved by LocalSandbox._resolve_path() with get_file_operation_lock(sandbox, path): sandbox.write_file(path, content, append) return "OK" @@ -1019,7 +1289,9 @@ def str_replace_tool( if is_local_sandbox(runtime): thread_data = get_thread_data(runtime) validate_local_tool_path(path, thread_data) - path = _resolve_and_validate_user_data_path(path, thread_data) + if not _is_custom_mount_path(path): + path = _resolve_and_validate_user_data_path(path, thread_data) + # Custom mount paths are resolved by LocalSandbox._resolve_path() with get_file_operation_lock(sandbox, path): content = sandbox.read_file(path) if not content: diff --git a/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py b/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py index 409594efa..094ec65e7 100644 --- a/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py +++ b/backend/packages/harness/deerflow/subagents/builtins/bash_agent.py @@ -43,5 +43,5 @@ You have access to the sandbox environment: tools=["bash", "ls", "read_file", "write_file", "str_replace"], # Sandbox tools only disallowed_tools=["task", "ask_clarification", "present_files"], model="inherit", - max_turns=30, + max_turns=60, ) diff --git a/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py b/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py index 45f1b9fa2..d09d1a00b 100644 --- a/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py +++ b/backend/packages/harness/deerflow/subagents/builtins/general_purpose.py @@ -44,5 +44,5 @@ You have access to the same sandbox environment as the parent agent: tools=None, # Inherit all tools from parent disallowed_tools=["task", "ask_clarification", "present_files"], # Prevent nesting and clarification model="inherit", - max_turns=50, + max_turns=100, ) diff --git a/backend/packages/harness/deerflow/subagents/registry.py b/backend/packages/harness/deerflow/subagents/registry.py index 61da0e453..0192ee7da 100644 --- a/backend/packages/harness/deerflow/subagents/registry.py +++ b/backend/packages/harness/deerflow/subagents/registry.py @@ -28,9 +28,27 @@ def get_subagent_config(name: str) -> SubagentConfig | None: app_config = get_subagents_app_config() effective_timeout = app_config.get_timeout_for(name) + effective_max_turns = app_config.get_max_turns_for(name, config.max_turns) + + overrides = {} if effective_timeout != config.timeout_seconds: - logger.debug(f"Subagent '{name}': timeout overridden by config.yaml ({config.timeout_seconds}s -> {effective_timeout}s)") - config = replace(config, timeout_seconds=effective_timeout) + logger.debug( + "Subagent '%s': timeout overridden by config.yaml (%ss -> %ss)", + name, + config.timeout_seconds, + effective_timeout, + ) + overrides["timeout_seconds"] = effective_timeout + if effective_max_turns != config.max_turns: + logger.debug( + "Subagent '%s': max_turns overridden by config.yaml (%s -> %s)", + name, + config.max_turns, + effective_max_turns, + ) + overrides["max_turns"] = effective_max_turns + if overrides: + config = replace(config, **overrides) return config diff --git a/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py b/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py index b64b77550..baf7f8ff5 100644 --- a/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/invoke_acp_agent_tool.py @@ -57,6 +57,42 @@ def _build_mcp_servers() -> dict[str, dict[str, Any]]: return build_servers_config(ExtensionsConfig.from_file()) +def _build_acp_mcp_servers() -> list[dict[str, Any]]: + """Build ACP ``mcpServers`` payload for ``new_session``. + + The ACP client expects a list of server objects, while DeerFlow's MCP helper + returns a name -> config mapping for the LangChain MCP adapter. This helper + converts the enabled servers into the ACP wire format. + """ + from deerflow.config.extensions_config import ExtensionsConfig + + extensions_config = ExtensionsConfig.from_file() + enabled_servers = extensions_config.get_enabled_mcp_servers() + + mcp_servers: list[dict[str, Any]] = [] + for name, server_config in enabled_servers.items(): + transport_type = server_config.type or "stdio" + payload: dict[str, Any] = {"name": name, "type": transport_type} + + if transport_type == "stdio": + if not server_config.command: + raise ValueError(f"MCP server '{name}' with stdio transport requires 'command' field") + payload["command"] = server_config.command + payload["args"] = server_config.args + payload["env"] = [{"name": key, "value": value} for key, value in server_config.env.items()] + elif transport_type in ("http", "sse"): + if not server_config.url: + raise ValueError(f"MCP server '{name}' with {transport_type} transport requires 'url' field") + payload["url"] = server_config.url + payload["headers"] = [{"name": key, "value": value} for key, value in server_config.headers.items()] + else: + raise ValueError(f"MCP server '{name}' has unsupported transport type: {transport_type}") + + mcp_servers.append(payload) + + return mcp_servers + + def _build_permission_response(options: list[Any], *, auto_approve: bool) -> Any: """Build an ACP permission response. @@ -173,7 +209,15 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool: cmd = agent_config.command args = agent_config.args or [] physical_cwd = _get_work_dir(thread_id) - mcp_servers = _build_mcp_servers() + try: + mcp_servers = _build_acp_mcp_servers() + except ValueError as exc: + logger.warning( + "Invalid MCP server configuration for ACP agent '%s'; continuing without MCP servers: %s", + agent, + exc, + ) + mcp_servers = [] agent_env: dict[str, str] | None = None if agent_config.env: agent_env = {k: (os.environ.get(v[1:], "") if v.startswith("$") else v) for k, v in agent_config.env.items()} diff --git a/backend/packages/harness/deerflow/utils/file_conversion.py b/backend/packages/harness/deerflow/utils/file_conversion.py index 45cdf1210..68755b675 100644 --- a/backend/packages/harness/deerflow/utils/file_conversion.py +++ b/backend/packages/harness/deerflow/utils/file_conversion.py @@ -1,10 +1,22 @@ """File conversion utilities. -Converts document files (PDF, PPT, Excel, Word) to Markdown using markitdown. +Converts document files (PDF, PPT, Excel, Word) to Markdown. + +PDF conversion strategy (auto mode): + 1. Try pymupdf4llm if installed โ€” better heading detection, faster on most files. + 2. If output is suspiciously short (< _MIN_CHARS_PER_PAGE chars/page, or < 200 chars + total when page count is unavailable), treat as image-based and fall back to MarkItDown. + 3. If pymupdf4llm is not installed, use MarkItDown directly (existing behaviour). + +Large files (> ASYNC_THRESHOLD_BYTES) are converted in a thread pool via +asyncio.to_thread() to avoid blocking the event loop (fixes #1569). + No FastAPI or HTTP dependencies โ€” pure utility functions. """ +import asyncio import logging +import re from pathlib import Path logger = logging.getLogger(__name__) @@ -20,28 +32,278 @@ CONVERTIBLE_EXTENSIONS = { ".docx", } +# Files larger than this threshold are converted in a background thread. +# Small files complete in < 1s synchronously; spawning a thread adds unnecessary +# scheduling overhead for them. +_ASYNC_THRESHOLD_BYTES = 1 * 1024 * 1024 # 1 MB + +# If pymupdf4llm produces fewer characters *per page* than this threshold, +# the PDF is likely image-based or encrypted โ€” fall back to MarkItDown. +# Rationale: normal text PDFs yield 200-2000 chars/page; image-based PDFs +# yield close to 0. 50 chars/page gives a wide safety margin. +# Falls back to absolute 200-char check when page count is unavailable. +_MIN_CHARS_PER_PAGE = 50 + + +def _pymupdf_output_too_sparse(text: str, file_path: Path) -> bool: + """Return True if pymupdf4llm output is suspiciously short (image-based PDF). + + Uses chars-per-page rather than an absolute threshold so that both short + documents (few pages, few chars) and long documents (many pages, many chars) + are handled correctly. + """ + chars = len(text.strip()) + doc = None + pages: int | None = None + try: + import pymupdf + + doc = pymupdf.open(str(file_path)) + pages = len(doc) + except Exception: + pass + finally: + if doc is not None: + try: + doc.close() + except Exception: + pass + if pages is not None and pages > 0: + return (chars / pages) < _MIN_CHARS_PER_PAGE + # Fallback: absolute threshold when page count is unavailable + return chars < 200 + + +def _convert_pdf_with_pymupdf4llm(file_path: Path) -> str | None: + """Attempt PDF conversion with pymupdf4llm. + + Returns the markdown text, or None if pymupdf4llm is not installed or + if conversion fails (e.g. encrypted/corrupt PDF). + """ + try: + import pymupdf4llm + except ImportError: + return None + + try: + return pymupdf4llm.to_markdown(str(file_path)) + except Exception: + logger.exception("pymupdf4llm failed to convert %s; falling back to MarkItDown", file_path.name) + return None + + +def _convert_with_markitdown(file_path: Path) -> str: + """Convert any supported file to markdown text using MarkItDown.""" + from markitdown import MarkItDown + + md = MarkItDown() + return md.convert(str(file_path)).text_content + + +def _do_convert(file_path: Path, pdf_converter: str) -> str: + """Synchronous conversion โ€” called directly or via asyncio.to_thread. + + Args: + file_path: Path to the file. + pdf_converter: "auto" | "pymupdf4llm" | "markitdown" + """ + is_pdf = file_path.suffix.lower() == ".pdf" + + if is_pdf and pdf_converter != "markitdown": + # Try pymupdf4llm first (auto or explicit) + pymupdf_text = _convert_pdf_with_pymupdf4llm(file_path) + + if pymupdf_text is not None: + # pymupdf4llm is installed + if pdf_converter == "pymupdf4llm": + # Explicit โ€” use as-is regardless of output length + return pymupdf_text + # auto mode: fall back if output looks like a failed parse. + # Use chars-per-page to distinguish image-based PDFs (near 0) from + # legitimately short documents. + if not _pymupdf_output_too_sparse(pymupdf_text, file_path): + return pymupdf_text + logger.warning( + "pymupdf4llm produced only %d chars for %s (likely image-based PDF); falling back to MarkItDown", + len(pymupdf_text.strip()), + file_path.name, + ) + # pymupdf4llm not installed or fallback triggered โ†’ use MarkItDown + + return _convert_with_markitdown(file_path) + async def convert_file_to_markdown(file_path: Path) -> Path | None: - """Convert a file to markdown using markitdown. + """Convert a supported document file to Markdown. + + PDF files are handled with a two-converter strategy (see module docstring). + Large files (> 1 MB) are offloaded to a thread pool to avoid blocking the + event loop. Args: file_path: Path to the file to convert. Returns: - Path to the markdown file if conversion was successful, None otherwise. + Path to the generated .md file, or None if conversion failed. """ try: - from markitdown import MarkItDown + pdf_converter = _get_pdf_converter() + file_size = file_path.stat().st_size - md = MarkItDown() - result = md.convert(str(file_path)) + if file_size > _ASYNC_THRESHOLD_BYTES: + text = await asyncio.to_thread(_do_convert, file_path, pdf_converter) + else: + text = _do_convert(file_path, pdf_converter) - # Save as .md file with same name md_path = file_path.with_suffix(".md") - md_path.write_text(result.text_content, encoding="utf-8") + md_path.write_text(text, encoding="utf-8") - logger.info(f"Converted {file_path.name} to markdown: {md_path.name}") + logger.info("Converted %s to markdown: %s (%d chars)", file_path.name, md_path.name, len(text)) return md_path except Exception as e: - logger.error(f"Failed to convert {file_path.name} to markdown: {e}") + logger.error("Failed to convert %s to markdown: %s", file_path.name, e) return None + + +# Regex for bold-only lines that look like section headings. +# Targets SEC filing structural headings that pymupdf4llm renders as **bold** +# rather than # Markdown headings (because they use same font size as body text, +# distinguished only by bold+caps formatting). +# +# Pattern requires ALL of: +# 1. Entire line is a single **...** block (no surrounding prose) +# 2. Starts with a recognised structural keyword: +# - ITEM / PART / SECTION (with optional number/letter after) +# - SCHEDULE, EXHIBIT, APPENDIX, ANNEX, CHAPTER +# All-caps addresses, boilerplate ("CURRENT REPORT", "SIGNATURES", +# "WASHINGTON, DC 20549") do NOT start with these keywords and are excluded. +# +# Chinese headings (็ฌฌไธ‰่Š‚...) are already captured as standard # headings +# by pymupdf4llm, so they don't need this pattern. +_BOLD_HEADING_RE = re.compile(r"^\*\*((ITEM|PART|SECTION|SCHEDULE|EXHIBIT|APPENDIX|ANNEX|CHAPTER)\b[A-Z0-9 .,\-]*)\*\*\s*$") + +# Regex for split-bold headings produced by pymupdf4llm when a heading spans +# multiple text spans in the PDF (e.g. section number and title are separate spans). +# Matches lines like: **1** **Introduction** or **3.2** **Multi-Head Attention** +# Requirements: +# 1. Entire line consists only of **...** blocks separated by whitespace (no prose) +# 2. First block is a section number (digits and dots, e.g. "1", "3.2", "A.1") +# 3. Second block must not be purely numeric/punctuation โ€” excludes financial table +# headers like **2023** **2022** **2021** while allowing non-ASCII titles such as +# **1** **ๆฆ‚่ฟฐ** or accented words (negative lookahead instead of [A-Za-z]) +# 4. At most two additional blocks (four total) with [^*]+ (no * inside) to keep +# the regex linear and avoid ReDoS on attacker-controlled content +_SPLIT_BOLD_HEADING_RE = re.compile(r"^\*\*[\dA-Z][\d\.]*\*\*\s+\*\*(?!\d[\d\s.,\-โ€“โ€”/:()%]*\*\*)[^*]+\*\*(?:\s+\*\*[^*]+\*\*){0,2}\s*$") + +# Maximum number of outline entries injected into the agent context. +# Keeps prompt size bounded even for very long documents. +MAX_OUTLINE_ENTRIES = 50 + +_ALLOWED_PDF_CONVERTERS = {"auto", "pymupdf4llm", "markitdown"} + + +def _clean_bold_title(raw: str) -> str: + """Normalise a title string that may contain pymupdf4llm bold artefacts. + + pymupdf4llm sometimes emits adjacent bold spans as ``**A** **B**`` instead + of a single ``**A B**`` block. This helper merges those fragments and then + strips the outermost ``**...**`` wrapper so the caller gets plain text. + + Examples:: + + "**Overview**" โ†’ "Overview" + "**UNITED STATES** **SECURITIES**" โ†’ "UNITED STATES SECURITIES" + "plain text" โ†’ "plain text" (unchanged) + """ + # Merge adjacent bold spans: "** **" โ†’ " " + merged = re.sub(r"\*\*\s*\*\*", " ", raw).strip() + # Strip outermost **...** if the whole string is wrapped + if m := re.fullmatch(r"\*\*(.+?)\*\*", merged, re.DOTALL): + return m.group(1).strip() + return merged + + +def extract_outline(md_path: Path) -> list[dict]: + """Extract document outline (headings) from a Markdown file. + + Recognises three heading styles produced by pymupdf4llm: + + 1. Standard Markdown headings: lines starting with one or more '#'. + Inline ``**...**`` wrappers and adjacent bold spans (``** **``) are + cleaned so the title is plain text. + + 2. Bold-only structural headings: ``**ITEM 1. BUSINESS**``, ``**PART II**``, + etc. SEC filings use bold+caps for section headings with the same font + size as body text, so pymupdf4llm cannot promote them to # headings. + + 3. Split-bold headings: ``**1** **Introduction**``, ``**3.2** **Attention**``. + pymupdf4llm emits these when the section number and title text are + separate spans in the underlying PDF (common in academic papers). + + Args: + md_path: Path to the .md file. + + Returns: + List of dicts with keys: title (str), line (int, 1-based). + When the outline is truncated at MAX_OUTLINE_ENTRIES, a sentinel entry + ``{"truncated": True}`` is appended as the last element so callers can + render a "showing first N headings" hint without re-scanning the file. + Returns an empty list if the file cannot be read or has no headings. + """ + outline: list[dict] = [] + try: + with md_path.open(encoding="utf-8") as f: + for lineno, line in enumerate(f, 1): + stripped = line.strip() + if not stripped: + continue + + # Style 1: standard Markdown heading + if stripped.startswith("#"): + title = _clean_bold_title(stripped.lstrip("#").strip()) + if title: + outline.append({"title": title, "line": lineno}) + + # Style 2: single bold block with SEC structural keyword + elif m := _BOLD_HEADING_RE.match(stripped): + title = m.group(1).strip() + if title: + outline.append({"title": title, "line": lineno}) + + # Style 3: split-bold heading โ€” **** **** + # Regex already enforces max 4 blocks and non-numeric second block. + elif _SPLIT_BOLD_HEADING_RE.match(stripped): + title = " ".join(re.findall(r"\*\*([^*]+)\*\*", stripped)) + if title: + outline.append({"title": title, "line": lineno}) + + if len(outline) >= MAX_OUTLINE_ENTRIES: + outline.append({"truncated": True}) + break + except Exception: + return [] + + return outline + + +def _get_pdf_converter() -> str: + """Read pdf_converter setting from app config, defaulting to 'auto'. + + Normalizes the value to lowercase and validates it against the allowed set + so that values like 'AUTO' or 'MarkItDown' from config.yaml don't silently + fall through to unexpected behaviour. + """ + try: + from deerflow.config.app_config import get_app_config + + cfg = get_app_config() + uploads_cfg = getattr(cfg, "uploads", None) + if uploads_cfg is not None: + raw = str(getattr(uploads_cfg, "pdf_converter", "auto")).strip().lower() + if raw not in _ALLOWED_PDF_CONVERTERS: + logger.warning("Invalid pdf_converter value %r; falling back to 'auto'", raw) + return "auto" + return raw + except Exception: + pass + return "auto" diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index c0c37e3d2..636714f84 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -9,16 +9,17 @@ dependencies = [ "dotenv>=0.9.9", "httpx>=0.28.0", "kubernetes>=30.0.0", - "langchain>=1.2.3", + "langchain>=1.2.3,<1.2.10", "langchain-anthropic>=1.3.4", "langchain-deepseek>=1.0.1", "langchain-mcp-adapters>=0.1.0", "langchain-openai>=1.1.7", "langfuse>=3.4.1", "langgraph>=1.0.6,<1.0.10", + "langgraph-prebuilt>=1.0.6,<1.0.9", "langgraph-api>=0.7.0,<0.8.0", "langgraph-cli>=0.4.14", - "langgraph-runtime-inmem>=0.22.1", + "langgraph-runtime-inmem>=0.22.1,<0.27.0", "markdownify>=1.2.2", "markitdown[all,xlsx]>=0.0.1a2", "pydantic>=2.12.5", @@ -34,6 +35,9 @@ dependencies = [ "langgraph-sdk>=0.1.51", ] +[project.optional-dependencies] +pymupdf = ["pymupdf4llm>=0.0.17"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c94b46825..c4e3251ab 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -16,6 +16,10 @@ dependencies = [ "python-telegram-bot>=21.0", "langgraph-sdk>=0.1.51", "markdown-to-mrkdwn>=0.3.1", + "wecom-aibot-python-sdk>=0.1.6", + "bcrypt>=4.0.0", + "pyjwt>=2.9.0", + "email-validator>=2.0.0", ] [dependency-groups] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 000000000..d73a6925f --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,506 @@ +"""Tests for authentication module: JWT, password hashing, AuthContext, and authz decorators.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from app.gateway.auth import create_access_token, decode_token, hash_password, verify_password +from app.gateway.auth.models import User +from app.gateway.authz import ( + AuthContext, + Permissions, + get_auth_context, + require_auth, + require_permission, +) + +# โ”€โ”€ Password Hashing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_hash_password_and_verify(): + """Hashing and verification round-trip.""" + password = "s3cr3tP@ssw0rd!" + hashed = hash_password(password) + assert hashed != password + assert verify_password(password, hashed) is True + assert verify_password("wrongpassword", hashed) is False + + +def test_hash_password_different_each_time(): + """bcrypt generates unique salts, so same password has different hashes.""" + password = "testpassword" + h1 = hash_password(password) + h2 = hash_password(password) + assert h1 != h2 # Different salts + # But both verify correctly + assert verify_password(password, h1) is True + assert verify_password(password, h2) is True + + +def test_verify_password_rejects_empty(): + """Empty password should not verify.""" + hashed = hash_password("nonempty") + assert verify_password("", hashed) is False + + +# โ”€โ”€ JWT โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_create_and_decode_token(): + """JWT creation and decoding round-trip.""" + user_id = str(uuid4()) + # Set a valid JWT secret for this test + import os + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(user_id) + assert isinstance(token, str) + + payload = decode_token(token) + assert payload is not None + assert payload.sub == user_id + + +def test_decode_token_expired(): + """Expired token returns TokenError.EXPIRED.""" + from app.gateway.auth.errors import TokenError + + user_id = str(uuid4()) + # Create token that expires immediately + token = create_access_token(user_id, expires_delta=timedelta(seconds=-1)) + payload = decode_token(token) + assert payload == TokenError.EXPIRED + + +def test_decode_token_invalid(): + """Invalid token returns TokenError.""" + from app.gateway.auth.errors import TokenError + + assert isinstance(decode_token("not.a.valid.token"), TokenError) + assert isinstance(decode_token(""), TokenError) + assert isinstance(decode_token("completely-wrong"), TokenError) + + +def test_create_token_custom_expiry(): + """Custom expiry is respected.""" + user_id = str(uuid4()) + token = create_access_token(user_id, expires_delta=timedelta(hours=1)) + payload = decode_token(token) + assert payload is not None + assert payload.sub == user_id + + +# โ”€โ”€ AuthContext โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_auth_context_unauthenticated(): + """AuthContext with no user.""" + ctx = AuthContext(user=None, permissions=[]) + assert ctx.is_authenticated is False + assert ctx.has_permission("threads", "read") is False + + +def test_auth_context_authenticated_no_perms(): + """AuthContext with user but no permissions.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + ctx = AuthContext(user=user, permissions=[]) + assert ctx.is_authenticated is True + assert ctx.has_permission("threads", "read") is False + + +def test_auth_context_has_permission(): + """AuthContext permission checking.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + perms = [Permissions.THREADS_READ, Permissions.THREADS_WRITE] + ctx = AuthContext(user=user, permissions=perms) + assert ctx.has_permission("threads", "read") is True + assert ctx.has_permission("threads", "write") is True + assert ctx.has_permission("threads", "delete") is False + assert ctx.has_permission("runs", "read") is False + + +def test_auth_context_require_user_raises(): + """require_user raises 401 when not authenticated.""" + ctx = AuthContext(user=None, permissions=[]) + with pytest.raises(HTTPException) as exc_info: + ctx.require_user() + assert exc_info.value.status_code == 401 + + +def test_auth_context_require_user_returns_user(): + """require_user returns user when authenticated.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + ctx = AuthContext(user=user, permissions=[]) + returned = ctx.require_user() + assert returned == user + + +# โ”€โ”€ get_auth_context helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_get_auth_context_not_set(): + """get_auth_context returns None when auth not set on request.""" + mock_request = MagicMock() + # Make getattr return None (simulating attribute not set) + mock_request.state = MagicMock() + del mock_request.state.auth + assert get_auth_context(mock_request) is None + + +def test_get_auth_context_set(): + """get_auth_context returns the AuthContext from request.""" + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + ctx = AuthContext(user=user, permissions=[Permissions.THREADS_READ]) + + mock_request = MagicMock() + mock_request.state.auth = ctx + + assert get_auth_context(mock_request) == ctx + + +# โ”€โ”€ require_auth decorator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_require_auth_sets_auth_context(): + """require_auth sets auth context on request from cookie.""" + from fastapi import Request + + app = FastAPI() + + @app.get("/test") + @require_auth + async def endpoint(request: Request): + ctx = get_auth_context(request) + return {"authenticated": ctx.is_authenticated} + + with TestClient(app) as client: + # No cookie โ†’ anonymous + response = client.get("/test") + assert response.status_code == 200 + assert response.json()["authenticated"] is False + + +def test_require_auth_requires_request_param(): + """require_auth raises ValueError if request parameter is missing.""" + import asyncio + + @require_auth + async def bad_endpoint(): # Missing `request` parameter + pass + + with pytest.raises(ValueError, match="require_auth decorator requires 'request' parameter"): + asyncio.run(bad_endpoint()) + + +# โ”€โ”€ require_permission decorator โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_require_permission_requires_auth(): + """require_permission raises 401 when not authenticated.""" + from fastapi import Request + + app = FastAPI() + + @app.get("/test") + @require_permission("threads", "read") + async def endpoint(request: Request): + return {"ok": True} + + with TestClient(app) as client: + response = client.get("/test") + assert response.status_code == 401 + assert "Authentication required" in response.json()["detail"] + + +def test_require_permission_denies_wrong_permission(): + """User without required permission gets 403.""" + from fastapi import Request + + app = FastAPI() + user = User(id=uuid4(), email="test@example.com", password_hash="hash") + + @app.get("/test") + @require_permission("threads", "delete") + async def endpoint(request: Request): + return {"ok": True} + + mock_auth = AuthContext(user=user, permissions=[Permissions.THREADS_READ]) + + with patch("app.gateway.authz._authenticate", return_value=mock_auth): + with TestClient(app) as client: + response = client.get("/test") + assert response.status_code == 403 + assert "Permission denied" in response.json()["detail"] + + +# โ”€โ”€ Weak JWT secret warning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +# โ”€โ”€ User Model Fields โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_user_model_has_needs_setup_default_false(): + """New users default to needs_setup=False.""" + user = User(email="test@example.com", password_hash="hash") + assert user.needs_setup is False + + +def test_user_model_has_token_version_default_zero(): + """New users default to token_version=0.""" + user = User(email="test@example.com", password_hash="hash") + assert user.token_version == 0 + + +def test_user_model_needs_setup_true(): + """Auto-created admin has needs_setup=True.""" + user = User(email="admin@example.com", password_hash="hash", needs_setup=True) + assert user.needs_setup is True + + +def test_sqlite_round_trip_new_fields(): + """needs_setup and token_version survive create โ†’ read round-trip.""" + import asyncio + import os + import tempfile + from pathlib import Path + + from app.gateway.auth.repositories import sqlite as sqlite_mod + + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test_users.db") + old_path = sqlite_mod._resolved_db_path + old_init = sqlite_mod._table_initialized + sqlite_mod._resolved_db_path = Path(db_path) + sqlite_mod._table_initialized = False + try: + repo = sqlite_mod.SQLiteUserRepository() + user = User( + email="setup@test.com", + password_hash="fakehash", + system_role="admin", + needs_setup=True, + token_version=3, + ) + created = asyncio.run(repo.create_user(user)) + assert created.needs_setup is True + assert created.token_version == 3 + + fetched = asyncio.run(repo.get_user_by_email("setup@test.com")) + assert fetched is not None + assert fetched.needs_setup is True + assert fetched.token_version == 3 + + fetched.needs_setup = False + fetched.token_version = 4 + asyncio.run(repo.update_user(fetched)) + refetched = asyncio.run(repo.get_user_by_id(str(fetched.id))) + assert refetched.needs_setup is False + assert refetched.token_version == 4 + finally: + sqlite_mod._resolved_db_path = old_path + sqlite_mod._table_initialized = old_init + + +# โ”€โ”€ Token Versioning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_jwt_encodes_ver(): + """JWT payload includes ver field.""" + import os + + from app.gateway.auth.errors import TokenError + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(str(uuid4()), token_version=3) + payload = decode_token(token) + assert not isinstance(payload, TokenError) + assert payload.ver == 3 + + +def test_jwt_default_ver_zero(): + """JWT ver defaults to 0.""" + import os + + from app.gateway.auth.errors import TokenError + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(str(uuid4())) + payload = decode_token(token) + assert not isinstance(payload, TokenError) + assert payload.ver == 0 + + +def test_token_version_mismatch_rejects(): + """Token with stale ver is rejected by get_current_user_from_request.""" + import asyncio + import os + + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + + user_id = str(uuid4()) + token = create_access_token(user_id, token_version=0) + + mock_user = User(id=user_id, email="test@example.com", password_hash="hash", token_version=1) + + mock_request = MagicMock() + mock_request.cookies = {"access_token": token} + + with patch("app.gateway.deps.get_local_provider") as mock_provider_fn: + mock_provider = MagicMock() + mock_provider.get_user = AsyncMock(return_value=mock_user) + mock_provider_fn.return_value = mock_provider + + from app.gateway.deps import get_current_user_from_request + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + assert "revoked" in str(exc_info.value.detail).lower() + + +# โ”€โ”€ change-password extension โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_change_password_request_accepts_new_email(): + """ChangePasswordRequest model accepts optional new_email.""" + from app.gateway.routers.auth import ChangePasswordRequest + + req = ChangePasswordRequest( + current_password="old", + new_password="newpassword", + new_email="new@example.com", + ) + assert req.new_email == "new@example.com" + + +def test_change_password_request_new_email_optional(): + """ChangePasswordRequest model works without new_email.""" + from app.gateway.routers.auth import ChangePasswordRequest + + req = ChangePasswordRequest(current_password="old", new_password="newpassword") + assert req.new_email is None + + +def test_login_response_includes_needs_setup(): + """LoginResponse includes needs_setup field.""" + from app.gateway.routers.auth import LoginResponse + + resp = LoginResponse(expires_in=3600, needs_setup=True) + assert resp.needs_setup is True + resp2 = LoginResponse(expires_in=3600) + assert resp2.needs_setup is False + + +# โ”€โ”€ Rate Limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_rate_limiter_allows_under_limit(): + """Requests under the limit are allowed.""" + from app.gateway.routers.auth import _check_rate_limit, _login_attempts + + _login_attempts.clear() + _check_rate_limit("192.168.1.1") # Should not raise + + +def test_rate_limiter_blocks_after_max_failures(): + """IP is blocked after 5 consecutive failures.""" + from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure + + _login_attempts.clear() + ip = "10.0.0.1" + for _ in range(5): + _record_login_failure(ip) + with pytest.raises(HTTPException) as exc_info: + _check_rate_limit(ip) + assert exc_info.value.status_code == 429 + + +def test_rate_limiter_resets_on_success(): + """Successful login clears the failure counter.""" + from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure, _record_login_success + + _login_attempts.clear() + ip = "10.0.0.2" + for _ in range(4): + _record_login_failure(ip) + _record_login_success(ip) + _check_rate_limit(ip) # Should not raise + + +# โ”€โ”€ Client IP extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_get_client_ip_direct_connection(): + """Without nginx (no X-Real-IP), falls back to request.client.host.""" + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "203.0.113.42" + req.headers = {} + assert _get_client_ip(req) == "203.0.113.42" + + +def test_get_client_ip_uses_x_real_ip(): + """X-Real-IP (set by nginx) is used when present.""" + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "10.0.0.1" # uvicorn may have replaced this with XFF[0] + req.headers = {"x-real-ip": "203.0.113.42"} + assert _get_client_ip(req) == "203.0.113.42" + + +def test_get_client_ip_xff_ignored(): + """X-Forwarded-For is never used; only X-Real-IP matters.""" + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "10.0.0.1" + req.headers = {"x-forwarded-for": "10.0.0.1, 198.51.100.5", "x-real-ip": "198.51.100.5"} + assert _get_client_ip(req) == "198.51.100.5" + + +def test_get_client_ip_no_real_ip_fallback(): + """No X-Real-IP โ†’ falls back to client.host (direct connection).""" + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "127.0.0.1" + req.headers = {} + assert _get_client_ip(req) == "127.0.0.1" + + +def test_get_client_ip_x_real_ip_always_preferred(): + """X-Real-IP is always preferred over client.host regardless of IP.""" + from app.gateway.routers.auth import _get_client_ip + + req = MagicMock() + req.client.host = "203.0.113.99" + req.headers = {"x-real-ip": "198.51.100.7"} + assert _get_client_ip(req) == "198.51.100.7" + + +# โ”€โ”€ Weak JWT secret warning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_missing_jwt_secret_generates_ephemeral(monkeypatch, caplog): + """get_auth_config() auto-generates an ephemeral secret when AUTH_JWT_SECRET is unset.""" + import logging + + import app.gateway.auth.config as config_module + + config_module._auth_config = None + monkeypatch.delenv("AUTH_JWT_SECRET", raising=False) + + with caplog.at_level(logging.WARNING): + config = config_module.get_auth_config() + + assert config.jwt_secret # non-empty ephemeral secret + assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) + + # Cleanup + config_module._auth_config = None diff --git a/backend/tests/test_auth_config.py b/backend/tests/test_auth_config.py new file mode 100644 index 000000000..21b8bd81b --- /dev/null +++ b/backend/tests/test_auth_config.py @@ -0,0 +1,54 @@ +"""Tests for AuthConfig typed configuration.""" + +import os +from unittest.mock import patch + +import pytest + +from app.gateway.auth.config import AuthConfig + + +def test_auth_config_defaults(): + config = AuthConfig(jwt_secret="test-secret-key-123") + assert config.token_expiry_days == 7 + + +def test_auth_config_token_expiry_range(): + AuthConfig(jwt_secret="s", token_expiry_days=1) + AuthConfig(jwt_secret="s", token_expiry_days=30) + with pytest.raises(Exception): + AuthConfig(jwt_secret="s", token_expiry_days=0) + with pytest.raises(Exception): + AuthConfig(jwt_secret="s", token_expiry_days=31) + + +def test_auth_config_from_env(): + env = {"AUTH_JWT_SECRET": "test-jwt-secret-from-env"} + with patch.dict(os.environ, env, clear=False): + import app.gateway.auth.config as cfg + + old = cfg._auth_config + cfg._auth_config = None + try: + config = cfg.get_auth_config() + assert config.jwt_secret == "test-jwt-secret-from-env" + finally: + cfg._auth_config = old + + +def test_auth_config_missing_secret_generates_ephemeral(caplog): + import logging + + import app.gateway.auth.config as cfg + + old = cfg._auth_config + cfg._auth_config = None + try: + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("AUTH_JWT_SECRET", None) + with caplog.at_level(logging.WARNING): + config = cfg.get_auth_config() + assert config.jwt_secret + assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) + finally: + cfg._auth_config = old diff --git a/backend/tests/test_auth_errors.py b/backend/tests/test_auth_errors.py new file mode 100644 index 000000000..b3b46c75f --- /dev/null +++ b/backend/tests/test_auth_errors.py @@ -0,0 +1,75 @@ +"""Tests for auth error types and typed decode_token.""" + +from datetime import UTC, datetime, timedelta + +import jwt as pyjwt + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError +from app.gateway.auth.jwt import create_access_token, decode_token + + +def test_auth_error_code_values(): + assert AuthErrorCode.INVALID_CREDENTIALS == "invalid_credentials" + assert AuthErrorCode.TOKEN_EXPIRED == "token_expired" + assert AuthErrorCode.NOT_AUTHENTICATED == "not_authenticated" + + +def test_token_error_values(): + assert TokenError.EXPIRED == "expired" + assert TokenError.INVALID_SIGNATURE == "invalid_signature" + assert TokenError.MALFORMED == "malformed" + + +def test_auth_error_response_serialization(): + err = AuthErrorResponse( + code=AuthErrorCode.TOKEN_EXPIRED, + message="Token has expired", + ) + d = err.model_dump() + assert d == {"code": "token_expired", "message": "Token has expired"} + + +def test_auth_error_response_from_dict(): + d = {"code": "invalid_credentials", "message": "Wrong password"} + err = AuthErrorResponse(**d) + assert err.code == AuthErrorCode.INVALID_CREDENTIALS + + +# โ”€โ”€ decode_token typed failure tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_TEST_SECRET = "test-secret-for-jwt-decode-token-tests" + + +def _setup_config(): + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + + +def test_decode_token_returns_token_error_on_expired(): + _setup_config() + expired_payload = {"sub": "user-1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired_payload, _TEST_SECRET, algorithm="HS256") + result = decode_token(token) + assert result == TokenError.EXPIRED + + +def test_decode_token_returns_token_error_on_bad_signature(): + _setup_config() + payload = {"sub": "user-1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-secret", algorithm="HS256") + result = decode_token(token) + assert result == TokenError.INVALID_SIGNATURE + + +def test_decode_token_returns_token_error_on_malformed(): + _setup_config() + result = decode_token("not-a-jwt") + assert result == TokenError.MALFORMED + + +def test_decode_token_returns_payload_on_valid(): + _setup_config() + token = create_access_token("user-123") + result = decode_token(token) + assert not isinstance(result, TokenError) + assert result.sub == "user-123" diff --git a/backend/tests/test_auth_middleware.py b/backend/tests/test_auth_middleware.py new file mode 100644 index 000000000..64f8604f0 --- /dev/null +++ b/backend/tests/test_auth_middleware.py @@ -0,0 +1,216 @@ +"""Tests for the global AuthMiddleware (fail-closed safety net).""" + +import pytest +from starlette.testclient import TestClient + +from app.gateway.auth_middleware import AuthMiddleware, _is_public + +# โ”€โ”€ _is_public unit tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@pytest.mark.parametrize( + "path", + [ + "/health", + "/health/", + "/docs", + "/docs/", + "/redoc", + "/openapi.json", + "/api/v1/auth/login/local", + "/api/v1/auth/register", + "/api/v1/auth/logout", + "/api/v1/auth/setup-status", + ], +) +def test_public_paths(path: str): + assert _is_public(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "/api/models", + "/api/mcp/config", + "/api/memory", + "/api/skills", + "/api/threads/123", + "/api/threads/123/uploads", + "/api/agents", + "/api/channels", + "/api/runs/stream", + "/api/threads/123/runs", + "/api/v1/auth/me", + "/api/v1/auth/change-password", + ], +) +def test_protected_paths(path: str): + assert _is_public(path) is False + + +# โ”€โ”€ Trailing slash / normalization edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +@pytest.mark.parametrize( + "path", + [ + "/api/v1/auth/login/local/", + "/api/v1/auth/register/", + "/api/v1/auth/logout/", + "/api/v1/auth/setup-status/", + ], +) +def test_public_auth_paths_with_trailing_slash(path: str): + assert _is_public(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "/api/models/", + "/api/v1/auth/me/", + "/api/v1/auth/change-password/", + ], +) +def test_protected_paths_with_trailing_slash(path: str): + assert _is_public(path) is False + + +def test_unknown_api_path_is_protected(): + """Fail-closed: any new /api/* path is protected by default.""" + assert _is_public("/api/new-feature") is False + assert _is_public("/api/v2/something") is False + assert _is_public("/api/v1/auth/new-endpoint") is False + + +# โ”€โ”€ Middleware integration tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _make_app(): + """Create a minimal FastAPI app with AuthMiddleware for testing.""" + from fastapi import FastAPI + + app = FastAPI() + app.add_middleware(AuthMiddleware) + + @app.get("/health") + async def health(): + return {"status": "ok"} + + @app.get("/api/v1/auth/me") + async def auth_me(): + return {"id": "1", "email": "test@test.com"} + + @app.get("/api/v1/auth/setup-status") + async def setup_status(): + return {"needs_setup": False} + + @app.get("/api/models") + async def models_get(): + return {"models": []} + + @app.put("/api/mcp/config") + async def mcp_put(): + return {"ok": True} + + @app.delete("/api/threads/abc") + async def thread_delete(): + return {"ok": True} + + @app.patch("/api/threads/abc") + async def thread_patch(): + return {"ok": True} + + @app.post("/api/threads/abc/runs/stream") + async def stream(): + return {"ok": True} + + @app.get("/api/future-endpoint") + async def future(): + return {"ok": True} + + return app + + +@pytest.fixture +def client(): + return TestClient(_make_app()) + + +def test_public_path_no_cookie(client): + res = client.get("/health") + assert res.status_code == 200 + + +def test_public_auth_path_no_cookie(client): + """Public auth endpoints (login/register) pass without cookie.""" + res = client.get("/api/v1/auth/setup-status") + assert res.status_code == 200 + + +def test_protected_auth_path_no_cookie(client): + """/auth/me requires cookie even though it's under /api/v1/auth/.""" + res = client.get("/api/v1/auth/me") + assert res.status_code == 401 + + +def test_protected_path_no_cookie_returns_401(client): + res = client.get("/api/models") + assert res.status_code == 401 + body = res.json() + assert body["detail"]["code"] == "not_authenticated" + + +def test_protected_path_with_cookie_passes(client): + res = client.get("/api/models", cookies={"access_token": "some-token"}) + assert res.status_code == 200 + + +def test_protected_post_no_cookie_returns_401(client): + res = client.post("/api/threads/abc/runs/stream") + assert res.status_code == 401 + + +# โ”€โ”€ Method matrix: PUT/DELETE/PATCH also protected โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_protected_put_no_cookie(client): + res = client.put("/api/mcp/config") + assert res.status_code == 401 + + +def test_protected_delete_no_cookie(client): + res = client.delete("/api/threads/abc") + assert res.status_code == 401 + + +def test_protected_patch_no_cookie(client): + res = client.patch("/api/threads/abc") + assert res.status_code == 401 + + +def test_put_with_cookie_passes(client): + client.cookies.set("access_token", "tok") + res = client.put("/api/mcp/config") + assert res.status_code == 200 + + +def test_delete_with_cookie_passes(client): + client.cookies.set("access_token", "tok") + res = client.delete("/api/threads/abc") + assert res.status_code == 200 + + +# โ”€โ”€ Fail-closed: unknown future endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_unknown_endpoint_no_cookie_returns_401(client): + """Any new /api/* endpoint is blocked by default without cookie.""" + res = client.get("/api/future-endpoint") + assert res.status_code == 401 + + +def test_unknown_endpoint_with_cookie_passes(client): + client.cookies.set("access_token", "tok") + res = client.get("/api/future-endpoint") + assert res.status_code == 200 diff --git a/backend/tests/test_auth_type_system.py b/backend/tests/test_auth_type_system.py new file mode 100644 index 000000000..18b4542d0 --- /dev/null +++ b/backend/tests/test_auth_type_system.py @@ -0,0 +1,675 @@ +"""Tests for auth type system hardening. + +Covers structured error responses, typed decode_token callers, +CSRF middleware path matching, config-driven cookie security, +and unhappy paths / edge cases for all auth boundaries. +""" + +import os +import secrets +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import jwt as pyjwt +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from pydantic import ValidationError + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError +from app.gateway.auth.jwt import decode_token +from app.gateway.csrf_middleware import ( + CSRF_COOKIE_NAME, + CSRF_HEADER_NAME, + CSRFMiddleware, + is_auth_endpoint, + should_check_csrf, +) + +# โ”€โ”€ Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_TEST_SECRET = "test-secret-for-auth-type-system-tests-min32" + + +def _setup_config(): + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + + +# โ”€โ”€ CSRF Middleware Path Matching โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class _FakeRequest: + """Minimal request mock for CSRF path matching tests.""" + + def __init__(self, path: str, method: str = "POST"): + self.method = method + + class _URL: + def __init__(self, p): + self.path = p + + self.url = _URL(path) + self.cookies = {} + self.headers = {} + + +def test_csrf_exempts_login_local(): + """login/local (actual route) should be exempt from CSRF.""" + req = _FakeRequest("/api/v1/auth/login/local") + assert is_auth_endpoint(req) is True + + +def test_csrf_exempts_login_local_trailing_slash(): + """Trailing slash should also be exempt.""" + req = _FakeRequest("/api/v1/auth/login/local/") + assert is_auth_endpoint(req) is True + + +def test_csrf_exempts_logout(): + req = _FakeRequest("/api/v1/auth/logout") + assert is_auth_endpoint(req) is True + + +def test_csrf_exempts_register(): + req = _FakeRequest("/api/v1/auth/register") + assert is_auth_endpoint(req) is True + + +def test_csrf_does_not_exempt_old_login_path(): + """Old /api/v1/auth/login (without /local) should NOT be exempt.""" + req = _FakeRequest("/api/v1/auth/login") + assert is_auth_endpoint(req) is False + + +def test_csrf_does_not_exempt_me(): + req = _FakeRequest("/api/v1/auth/me") + assert is_auth_endpoint(req) is False + + +def test_csrf_skips_get_requests(): + req = _FakeRequest("/api/v1/auth/me", method="GET") + assert should_check_csrf(req) is False + + +def test_csrf_checks_post_to_protected(): + req = _FakeRequest("/api/v1/some/endpoint", method="POST") + assert should_check_csrf(req) is True + + +# โ”€โ”€ Structured Error Response Format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_auth_error_response_has_code_and_message(): + """All auth errors should have structured {code, message} format.""" + err = AuthErrorResponse( + code=AuthErrorCode.INVALID_CREDENTIALS, + message="Wrong password", + ) + d = err.model_dump() + assert "code" in d + assert "message" in d + assert d["code"] == "invalid_credentials" + + +def test_auth_error_response_all_codes_serializable(): + """Every AuthErrorCode should be serializable in AuthErrorResponse.""" + for code in AuthErrorCode: + err = AuthErrorResponse(code=code, message=f"Test {code.value}") + d = err.model_dump() + assert d["code"] == code.value + + +# โ”€โ”€ decode_token Caller Pattern โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_decode_token_expired_maps_to_token_expired_code(): + """TokenError.EXPIRED should map to AuthErrorCode.TOKEN_EXPIRED.""" + _setup_config() + from datetime import UTC, datetime, timedelta + + import jwt as pyjwt + + expired = {"sub": "u1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired, _TEST_SECRET, algorithm="HS256") + result = decode_token(token) + assert result == TokenError.EXPIRED + + # Verify the mapping pattern used in route handlers + code = AuthErrorCode.TOKEN_EXPIRED if result == TokenError.EXPIRED else AuthErrorCode.TOKEN_INVALID + assert code == AuthErrorCode.TOKEN_EXPIRED + + +def test_decode_token_invalid_sig_maps_to_token_invalid_code(): + """TokenError.INVALID_SIGNATURE should map to AuthErrorCode.TOKEN_INVALID.""" + _setup_config() + from datetime import UTC, datetime, timedelta + + import jwt as pyjwt + + payload = {"sub": "u1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-key", algorithm="HS256") + result = decode_token(token) + assert result == TokenError.INVALID_SIGNATURE + + code = AuthErrorCode.TOKEN_EXPIRED if result == TokenError.EXPIRED else AuthErrorCode.TOKEN_INVALID + assert code == AuthErrorCode.TOKEN_INVALID + + +def test_decode_token_malformed_maps_to_token_invalid_code(): + """TokenError.MALFORMED should map to AuthErrorCode.TOKEN_INVALID.""" + _setup_config() + result = decode_token("garbage") + assert result == TokenError.MALFORMED + + code = AuthErrorCode.TOKEN_EXPIRED if result == TokenError.EXPIRED else AuthErrorCode.TOKEN_INVALID + assert code == AuthErrorCode.TOKEN_INVALID + + +# โ”€โ”€ Login Response Format โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_login_response_model_has_no_access_token(): + """LoginResponse should NOT contain access_token field (RFC-001).""" + from app.gateway.routers.auth import LoginResponse + + resp = LoginResponse(expires_in=604800) + d = resp.model_dump() + assert "access_token" not in d + assert "expires_in" in d + assert d["expires_in"] == 604800 + + +def test_login_response_model_fields(): + """LoginResponse has expires_in and needs_setup.""" + from app.gateway.routers.auth import LoginResponse + + fields = set(LoginResponse.model_fields.keys()) + assert fields == {"expires_in", "needs_setup"} + + +# โ”€โ”€ AuthConfig in Route โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_auth_config_token_expiry_used_in_login_response(): + """LoginResponse.expires_in should come from config.token_expiry_days.""" + from app.gateway.routers.auth import LoginResponse + + expected_seconds = 14 * 24 * 3600 + resp = LoginResponse(expires_in=expected_seconds) + assert resp.expires_in == expected_seconds + + +# โ”€โ”€ UserResponse Type Preservation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_user_response_system_role_literal(): + """UserResponse.system_role should only accept 'admin' or 'user'.""" + from app.gateway.auth.models import UserResponse + + # Valid roles + resp = UserResponse(id="1", email="a@b.com", system_role="admin") + assert resp.system_role == "admin" + + resp = UserResponse(id="1", email="a@b.com", system_role="user") + assert resp.system_role == "user" + + +def test_user_response_rejects_invalid_role(): + """UserResponse should reject invalid system_role values.""" + from app.gateway.auth.models import UserResponse + + with pytest.raises(ValidationError): + UserResponse(id="1", email="a@b.com", system_role="superadmin") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# UNHAPPY PATHS / EDGE CASES +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + +# โ”€โ”€ get_current_user structured 401 responses โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_get_current_user_no_cookie_returns_not_authenticated(): + """No cookie โ†’ 401 with code=not_authenticated.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + mock_request = type("MockRequest", (), {"cookies": {}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "not_authenticated" + + +def test_get_current_user_expired_token_returns_token_expired(): + """Expired token โ†’ 401 with code=token_expired.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + _setup_config() + expired = {"sub": "u1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired, _TEST_SECRET, algorithm="HS256") + + mock_request = type("MockRequest", (), {"cookies": {"access_token": token}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "token_expired" + + +def test_get_current_user_invalid_token_returns_token_invalid(): + """Bad signature โ†’ 401 with code=token_invalid.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + _setup_config() + payload = {"sub": "u1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-secret", algorithm="HS256") + + mock_request = type("MockRequest", (), {"cookies": {"access_token": token}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "token_invalid" + + +def test_get_current_user_malformed_token_returns_token_invalid(): + """Garbage token โ†’ 401 with code=token_invalid.""" + import asyncio + + from fastapi import HTTPException + + from app.gateway.deps import get_current_user_from_request + + _setup_config() + mock_request = type("MockRequest", (), {"cookies": {"access_token": "not-a-jwt"}})() + with pytest.raises(HTTPException) as exc_info: + asyncio.run(get_current_user_from_request(mock_request)) + assert exc_info.value.status_code == 401 + detail = exc_info.value.detail + assert detail["code"] == "token_invalid" + + +# โ”€โ”€ decode_token edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_decode_token_empty_string_returns_malformed(): + _setup_config() + result = decode_token("") + assert result == TokenError.MALFORMED + + +def test_decode_token_whitespace_returns_malformed(): + _setup_config() + result = decode_token(" ") + assert result == TokenError.MALFORMED + + +# โ”€โ”€ AuthConfig validation edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_auth_config_missing_jwt_secret_raises(): + """AuthConfig requires jwt_secret โ€” no default allowed.""" + with pytest.raises(ValidationError): + AuthConfig() + + +def test_auth_config_token_expiry_zero_raises(): + """token_expiry_days must be >= 1.""" + with pytest.raises(ValidationError): + AuthConfig(jwt_secret="secret", token_expiry_days=0) + + +def test_auth_config_token_expiry_31_raises(): + """token_expiry_days must be <= 30.""" + with pytest.raises(ValidationError): + AuthConfig(jwt_secret="secret", token_expiry_days=31) + + +def test_auth_config_token_expiry_boundary_1_ok(): + config = AuthConfig(jwt_secret="secret", token_expiry_days=1) + assert config.token_expiry_days == 1 + + +def test_auth_config_token_expiry_boundary_30_ok(): + config = AuthConfig(jwt_secret="secret", token_expiry_days=30) + assert config.token_expiry_days == 30 + + +def test_get_auth_config_missing_env_var_generates_ephemeral(caplog): + """get_auth_config() auto-generates ephemeral secret when AUTH_JWT_SECRET is unset.""" + import logging + + import app.gateway.auth.config as cfg + + old = cfg._auth_config + cfg._auth_config = None + try: + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("AUTH_JWT_SECRET", None) + with caplog.at_level(logging.WARNING): + config = cfg.get_auth_config() + assert config.jwt_secret + assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) + finally: + cfg._auth_config = old + + +# โ”€โ”€ CSRF middleware integration (unhappy paths) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _make_csrf_app(): + """Create a minimal FastAPI app with CSRFMiddleware for testing.""" + from fastapi import HTTPException as _HTTPException + from fastapi.responses import JSONResponse as _JSONResponse + + app = FastAPI() + + @app.exception_handler(_HTTPException) + async def _http_exc_handler(request, exc): + return _JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + app.add_middleware(CSRFMiddleware) + + @app.post("/api/v1/test/protected") + async def protected(): + return {"ok": True} + + @app.post("/api/v1/auth/login/local") + async def login(): + return {"ok": True} + + @app.get("/api/v1/test/read") + async def read_endpoint(): + return {"ok": True} + + return app + + +def test_csrf_middleware_blocks_post_without_token(): + """POST to protected endpoint without CSRF token โ†’ 403 with structured detail.""" + client = TestClient(_make_csrf_app()) + resp = client.post("/api/v1/test/protected") + assert resp.status_code == 403 + assert "CSRF" in resp.json()["detail"] + assert "missing" in resp.json()["detail"].lower() + + +def test_csrf_middleware_blocks_post_with_mismatched_token(): + """POST with mismatched CSRF cookie/header โ†’ 403 with mismatch detail.""" + client = TestClient(_make_csrf_app()) + client.cookies.set(CSRF_COOKIE_NAME, "token-a") + resp = client.post( + "/api/v1/test/protected", + headers={CSRF_HEADER_NAME: "token-b"}, + ) + assert resp.status_code == 403 + assert "mismatch" in resp.json()["detail"].lower() + + +def test_csrf_middleware_allows_post_with_matching_token(): + """POST with matching CSRF cookie/header โ†’ 200.""" + client = TestClient(_make_csrf_app()) + token = secrets.token_urlsafe(64) + client.cookies.set(CSRF_COOKIE_NAME, token) + resp = client.post( + "/api/v1/test/protected", + headers={CSRF_HEADER_NAME: token}, + ) + assert resp.status_code == 200 + + +def test_csrf_middleware_allows_get_without_token(): + """GET requests bypass CSRF check.""" + client = TestClient(_make_csrf_app()) + resp = client.get("/api/v1/test/read") + assert resp.status_code == 200 + + +def test_csrf_middleware_exempts_login_local(): + """POST to login/local is exempt from CSRF (no token yet).""" + client = TestClient(_make_csrf_app()) + resp = client.post("/api/v1/auth/login/local") + assert resp.status_code == 200 + + +def test_csrf_middleware_sets_cookie_on_auth_endpoint(): + """Auth endpoints should receive a CSRF cookie in response.""" + client = TestClient(_make_csrf_app()) + resp = client.post("/api/v1/auth/login/local") + assert CSRF_COOKIE_NAME in resp.cookies + + +# โ”€โ”€ UserResponse edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_user_response_missing_required_fields(): + """UserResponse with missing fields โ†’ ValidationError.""" + from app.gateway.auth.models import UserResponse + + with pytest.raises(ValidationError): + UserResponse(id="1") # missing email, system_role + + with pytest.raises(ValidationError): + UserResponse(id="1", email="a@b.com") # missing system_role + + +def test_user_response_empty_string_role_rejected(): + """Empty string is not a valid role.""" + from app.gateway.auth.models import UserResponse + + with pytest.raises(ValidationError): + UserResponse(id="1", email="a@b.com", system_role="") + + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# HTTP-LEVEL API CONTRACT TESTS +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + + +def _make_auth_app(): + """Create FastAPI app with auth routes for contract testing.""" + from app.gateway.app import create_app + + return create_app() + + +def _get_auth_client(): + """Get TestClient for auth API contract tests.""" + return TestClient(_make_auth_app()) + + +def test_api_auth_me_no_cookie_returns_structured_401(): + """/api/v1/auth/me without cookie โ†’ 401 with {code: 'not_authenticated'}.""" + _setup_config() + client = _get_auth_client() + resp = client.get("/api/v1/auth/me") + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "not_authenticated" + assert "message" in body["detail"] + + +def test_api_auth_me_expired_token_returns_structured_401(): + """/api/v1/auth/me with expired token โ†’ 401 with {code: 'token_expired'}.""" + _setup_config() + expired = {"sub": "u1", "exp": datetime.now(UTC) - timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(expired, _TEST_SECRET, algorithm="HS256") + + client = _get_auth_client() + client.cookies.set("access_token", token) + resp = client.get("/api/v1/auth/me") + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "token_expired" + + +def test_api_auth_me_invalid_sig_returns_structured_401(): + """/api/v1/auth/me with bad signature โ†’ 401 with {code: 'token_invalid'}.""" + _setup_config() + payload = {"sub": "u1", "exp": datetime.now(UTC) + timedelta(hours=1), "iat": datetime.now(UTC)} + token = pyjwt.encode(payload, "wrong-key", algorithm="HS256") + + client = _get_auth_client() + client.cookies.set("access_token", token) + resp = client.get("/api/v1/auth/me") + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "token_invalid" + + +def test_api_login_bad_credentials_returns_structured_401(): + """Login with wrong password โ†’ 401 with {code: 'invalid_credentials'}.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/login/local", + data={"username": "nonexistent@test.com", "password": "wrongpassword"}, + ) + assert resp.status_code == 401 + body = resp.json() + assert body["detail"]["code"] == "invalid_credentials" + + +def test_api_login_success_no_token_in_body(): + """Successful login โ†’ response body has expires_in but NOT access_token.""" + _setup_config() + client = _get_auth_client() + # Register first + client.post( + "/api/v1/auth/register", + json={"email": "contract-test@test.com", "password": "securepassword123"}, + ) + # Login + resp = client.post( + "/api/v1/auth/login/local", + data={"username": "contract-test@test.com", "password": "securepassword123"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert "expires_in" in body + assert "access_token" not in body + # Token should be in cookie, not body + assert "access_token" in resp.cookies + + +def test_api_register_duplicate_returns_structured_400(): + """Register with duplicate email โ†’ 400 with {code: 'email_already_exists'}.""" + _setup_config() + client = _get_auth_client() + email = "dup-contract-test@test.com" + # First register + client.post("/api/v1/auth/register", json={"email": email, "password": "password123"}) + # Duplicate + resp = client.post("/api/v1/auth/register", json={"email": email, "password": "password456"}) + assert resp.status_code == 400 + body = resp.json() + assert body["detail"]["code"] == "email_already_exists" + + +# โ”€โ”€ Cookie security: HTTP vs HTTPS โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _unique_email(prefix: str) -> str: + return f"{prefix}-{secrets.token_hex(4)}@test.com" + + +def _get_set_cookie_headers(resp) -> list[str]: + """Extract all set-cookie header values from a TestClient response.""" + return [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"] + + +def test_register_http_cookie_httponly_true_secure_false(): + """HTTP register โ†’ access_token cookie is httponly=True, secure=False, no max_age.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("http-cookie"), "password": "password123"}, + ) + assert resp.status_code == 201 + cookie_header = resp.headers.get("set-cookie", "") + assert "access_token=" in cookie_header + assert "httponly" in cookie_header.lower() + assert "secure" not in cookie_header.lower().replace("samesite", "") + + +def test_register_https_cookie_httponly_true_secure_true(): + """HTTPS register (x-forwarded-proto) โ†’ access_token cookie is httponly=True, secure=True, has max_age.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("https-cookie"), "password": "password123"}, + headers={"x-forwarded-proto": "https"}, + ) + assert resp.status_code == 201 + cookie_header = resp.headers.get("set-cookie", "") + assert "access_token=" in cookie_header + assert "httponly" in cookie_header.lower() + assert "secure" in cookie_header.lower() + assert "max-age" in cookie_header.lower() + + +def test_login_https_sets_secure_cookie(): + """HTTPS login โ†’ access_token cookie has secure flag.""" + _setup_config() + client = _get_auth_client() + email = _unique_email("https-login") + client.post("/api/v1/auth/register", json={"email": email, "password": "password123"}) + resp = client.post( + "/api/v1/auth/login/local", + data={"username": email, "password": "password123"}, + headers={"x-forwarded-proto": "https"}, + ) + assert resp.status_code == 200 + cookie_header = resp.headers.get("set-cookie", "") + assert "access_token=" in cookie_header + assert "httponly" in cookie_header.lower() + assert "secure" in cookie_header.lower() + + +def test_csrf_cookie_secure_on_https(): + """HTTPS register โ†’ csrf_token cookie has secure flag but NOT httponly.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("csrf-https"), "password": "password123"}, + headers={"x-forwarded-proto": "https"}, + ) + assert resp.status_code == 201 + csrf_cookies = [h for h in _get_set_cookie_headers(resp) if "csrf_token=" in h] + assert csrf_cookies, "csrf_token cookie not set on HTTPS register" + csrf_header = csrf_cookies[0] + assert "secure" in csrf_header.lower() + assert "httponly" not in csrf_header.lower() + + +def test_csrf_cookie_not_secure_on_http(): + """HTTP register โ†’ csrf_token cookie does NOT have secure flag.""" + _setup_config() + client = _get_auth_client() + resp = client.post( + "/api/v1/auth/register", + json={"email": _unique_email("csrf-http"), "password": "password123"}, + ) + assert resp.status_code == 201 + csrf_cookies = [h for h in _get_set_cookie_headers(resp) if "csrf_token=" in h] + assert csrf_cookies, "csrf_token cookie not set on HTTP register" + csrf_header = csrf_cookies[0] + assert "secure" not in csrf_header.lower().replace("samesite", "") diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index c48137c76..aaa5997b9 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -7,12 +7,12 @@ import json import tempfile from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.channels.base import Channel -from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage +from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment from app.channels.store import ChannelStore @@ -1718,6 +1718,159 @@ class TestFeishuChannel: _run(go()) +class TestWeComChannel: + def test_publish_ws_inbound_starts_stream_and_publishes_message(self, monkeypatch): + from app.channels.wecom import WeComChannel + + async def go(): + bus = MessageBus() + bus.publish_inbound = AsyncMock() + channel = WeComChannel(bus, config={}) + channel._ws_client = SimpleNamespace(reply_stream=AsyncMock()) + + monkeypatch.setitem( + __import__("sys").modules, + "aibot", + SimpleNamespace(generate_req_id=lambda prefix: "stream-1"), + ) + + frame = { + "body": { + "msgid": "msg-1", + "from": {"userid": "user-1"}, + "aibotid": "bot-1", + "chattype": "single", + } + } + files = [{"type": "image", "url": "https://example.com/image.png"}] + + await channel._publish_ws_inbound(frame, "hello", files=files) + + channel._ws_client.reply_stream.assert_awaited_once_with(frame, "stream-1", "Working on it...", False) + bus.publish_inbound.assert_awaited_once() + + inbound = bus.publish_inbound.await_args.args[0] + assert inbound.channel_name == "wecom" + assert inbound.chat_id == "user-1" + assert inbound.user_id == "user-1" + assert inbound.text == "hello" + assert inbound.thread_ts == "msg-1" + assert inbound.topic_id == "user-1" + assert inbound.files == files + assert inbound.metadata == {"aibotid": "bot-1", "chattype": "single"} + assert channel._ws_frames["msg-1"] is frame + assert channel._ws_stream_ids["msg-1"] == "stream-1" + + _run(go()) + + def test_publish_ws_inbound_uses_configured_working_message(self, monkeypatch): + from app.channels.wecom import WeComChannel + + async def go(): + bus = MessageBus() + bus.publish_inbound = AsyncMock() + channel = WeComChannel(bus, config={"working_message": "Please wait..."}) + channel._ws_client = SimpleNamespace(reply_stream=AsyncMock()) + channel._working_message = "Please wait..." + + monkeypatch.setitem( + __import__("sys").modules, + "aibot", + SimpleNamespace(generate_req_id=lambda prefix: "stream-1"), + ) + + frame = { + "body": { + "msgid": "msg-1", + "from": {"userid": "user-1"}, + } + } + + await channel._publish_ws_inbound(frame, "hello") + + channel._ws_client.reply_stream.assert_awaited_once_with(frame, "stream-1", "Please wait...", False) + + _run(go()) + + def test_on_outbound_sends_attachment_before_clearing_context(self, tmp_path): + from app.channels.wecom import WeComChannel + + async def go(): + bus = MessageBus() + channel = WeComChannel(bus, config={}) + + frame = {"body": {"msgid": "msg-1"}} + ws_client = SimpleNamespace( + reply_stream=AsyncMock(), + reply=AsyncMock(), + ) + channel._ws_client = ws_client + channel._ws_frames["msg-1"] = frame + channel._ws_stream_ids["msg-1"] = "stream-1" + channel._upload_media_ws = AsyncMock(return_value="media-1") + + attachment_path = tmp_path / "image.png" + attachment_path.write_bytes(b"png") + attachment = ResolvedAttachment( + virtual_path="/mnt/user-data/outputs/image.png", + actual_path=attachment_path, + filename="image.png", + mime_type="image/png", + size=attachment_path.stat().st_size, + is_image=True, + ) + + msg = OutboundMessage( + channel_name="wecom", + chat_id="user-1", + thread_id="thread-1", + text="done", + attachments=[attachment], + is_final=True, + thread_ts="msg-1", + ) + + await channel._on_outbound(msg) + + ws_client.reply_stream.assert_awaited_once_with(frame, "stream-1", "done", True) + channel._upload_media_ws.assert_awaited_once_with( + media_type="image", + filename="image.png", + path=str(attachment_path), + size=attachment.size, + ) + ws_client.reply.assert_awaited_once_with(frame, {"image": {"media_id": "media-1"}, "msgtype": "image"}) + assert "msg-1" not in channel._ws_frames + assert "msg-1" not in channel._ws_stream_ids + + _run(go()) + + def test_send_falls_back_to_send_message_without_thread_context(self): + from app.channels.wecom import WeComChannel + + async def go(): + bus = MessageBus() + channel = WeComChannel(bus, config={}) + channel._ws_client = SimpleNamespace(send_message=AsyncMock()) + + msg = OutboundMessage( + channel_name="wecom", + chat_id="user-1", + thread_id="thread-1", + text="hello", + thread_ts=None, + ) + + await channel.send(msg) + + channel._ws_client.send_message.assert_awaited_once_with( + "user-1", + {"msgtype": "markdown", "markdown": {"content": "hello"}}, + ) + + _run(go()) + + class TestChannelService: def test_get_status_no_channels(self): from app.channels.service import ChannelService @@ -1835,6 +1988,47 @@ class TestSlackSendRetry: _run(go()) + +class TestSlackAllowedUsers: + def test_numeric_allowed_users_match_string_event_user_id(self): + from app.channels.slack import SlackChannel + + bus = MessageBus() + bus.publish_inbound = AsyncMock() + channel = SlackChannel( + bus=bus, + config={"allowed_users": [123456]}, + ) + channel._loop = MagicMock() + channel._loop.is_running.return_value = True + channel._add_reaction = MagicMock() + channel._send_running_reply = MagicMock() + + event = { + "user": "123456", + "text": "hello from slack", + "channel": "C123", + "ts": "1710000000.000100", + } + + def submit_coro(coro, loop): + coro.close() + return MagicMock() + + with patch( + "app.channels.slack.asyncio.run_coroutine_threadsafe", + side_effect=submit_coro, + ) as submit: + channel._handle_message_event(event) + + channel._add_reaction.assert_called_once_with("C123", "1710000000.000100", "eyes") + channel._send_running_reply.assert_called_once_with("C123", "1710000000.000100") + submit.assert_called_once() + inbound = bus.publish_inbound.call_args.args[0] + assert inbound.user_id == "123456" + assert inbound.chat_id == "C123" + assert inbound.text == "hello from slack" + def test_raises_after_all_retries_exhausted(self): from app.channels.slack import SlackChannel @@ -1854,6 +2048,20 @@ class TestSlackSendRetry: _run(go()) + def test_raises_runtime_error_when_no_attempts_configured(self): + from app.channels.slack import SlackChannel + + async def go(): + bus = MessageBus() + ch = SlackChannel(bus=bus, config={"bot_token": "xoxb-test", "app_token": "xapp-test"}) + ch._web_client = MagicMock() + + msg = OutboundMessage(channel_name="slack", chat_id="C123", thread_id="t1", text="hello") + with pytest.raises(RuntimeError, match="without an exception"): + await ch.send(msg, _max_retries=0) + + _run(go()) + # --------------------------------------------------------------------------- # Telegram send retry tests @@ -1912,6 +2120,36 @@ class TestTelegramSendRetry: _run(go()) + def test_raises_runtime_error_when_no_attempts_configured(self): + from app.channels.telegram import TelegramChannel + + async def go(): + bus = MessageBus() + ch = TelegramChannel(bus=bus, config={"bot_token": "test-token"}) + ch._application = MagicMock() + + msg = OutboundMessage(channel_name="telegram", chat_id="12345", thread_id="t1", text="hello") + with pytest.raises(RuntimeError, match="without an exception"): + await ch.send(msg, _max_retries=0) + + _run(go()) + + +class TestFeishuSendRetry: + def test_raises_runtime_error_when_no_attempts_configured(self): + from app.channels.feishu import FeishuChannel + + async def go(): + bus = MessageBus() + ch = FeishuChannel(bus=bus, config={"app_id": "id", "app_secret": "secret"}) + ch._api_client = MagicMock() + + msg = OutboundMessage(channel_name="feishu", chat_id="chat", thread_id="t1", text="hello") + with pytest.raises(RuntimeError, match="without an exception"): + await ch.send(msg, _max_retries=0) + + _run(go()) + # --------------------------------------------------------------------------- # Telegram private-chat thread context tests diff --git a/backend/tests/test_client.py b/backend/tests/test_client.py index b1912937d..e78c0ea26 100644 --- a/backend/tests/test_client.py +++ b/backend/tests/test_client.py @@ -59,18 +59,20 @@ class TestClientInit: assert client._subagent_enabled is False assert client._plan_mode is False assert client._agent_name is None + assert client._available_skills is None assert client._checkpointer is None assert client._agent is None def test_custom_params(self, mock_app_config): mock_middleware = MagicMock() with patch("deerflow.client.get_app_config", return_value=mock_app_config): - c = DeerFlowClient(model_name="gpt-4", thinking_enabled=False, subagent_enabled=True, plan_mode=True, agent_name="test-agent", middlewares=[mock_middleware]) + c = DeerFlowClient(model_name="gpt-4", thinking_enabled=False, subagent_enabled=True, plan_mode=True, agent_name="test-agent", available_skills={"skill1", "skill2"}, middlewares=[mock_middleware]) assert c._model_name == "gpt-4" assert c._thinking_enabled is False assert c._subagent_enabled is True assert c._plan_mode is True assert c._agent_name == "test-agent" + assert c._available_skills == {"skill1", "skill2"} assert c._middlewares == [mock_middleware] def test_invalid_agent_name(self, mock_app_config): @@ -394,8 +396,10 @@ class TestEnsureAgent: patch("deerflow.client._build_middlewares", return_value=[]) as mock_build_middlewares, patch("deerflow.client.apply_prompt_template", return_value="prompt") as mock_apply_prompt, patch.object(client, "_get_tools", return_value=[]), + patch("deerflow.agents.checkpointer.get_checkpointer", return_value=MagicMock()), ): client._agent_name = "custom-agent" + client._available_skills = {"test_skill"} client._ensure_agent(config) assert client._agent is mock_agent @@ -404,6 +408,7 @@ class TestEnsureAgent: assert mock_build_middlewares.call_args.kwargs.get("agent_name") == "custom-agent" mock_apply_prompt.assert_called_once() assert mock_apply_prompt.call_args.kwargs.get("agent_name") == "custom-agent" + assert mock_apply_prompt.call_args.kwargs.get("available_skills") == {"test_skill"} def test_uses_default_checkpointer_when_available(self, client): mock_agent = MagicMock() @@ -441,6 +446,7 @@ class TestEnsureAgent: patch("deerflow.client._build_middlewares", side_effect=fake_build_middlewares), patch("deerflow.client.apply_prompt_template", return_value="prompt"), patch.object(client, "_get_tools", return_value=[]), + patch("deerflow.agents.checkpointer.get_checkpointer", return_value=MagicMock()), ): client._ensure_agent(config) @@ -469,7 +475,7 @@ class TestEnsureAgent: """_ensure_agent does not recreate if config key unchanged.""" mock_agent = MagicMock() client._agent = mock_agent - client._agent_config_key = (None, True, False, False) + client._agent_config_key = (None, True, False, False, None, None) config = client._get_runnable_config("t1") client._ensure_agent(config) @@ -1276,6 +1282,7 @@ class TestScenarioAgentRecreation: patch("deerflow.client._build_middlewares", return_value=[]), patch("deerflow.client.apply_prompt_template", return_value="prompt"), patch.object(client, "_get_tools", return_value=[]), + patch("deerflow.agents.checkpointer.get_checkpointer", return_value=MagicMock()), ): client._ensure_agent(config_a) first_agent = client._agent @@ -1303,6 +1310,7 @@ class TestScenarioAgentRecreation: patch("deerflow.client._build_middlewares", return_value=[]), patch("deerflow.client.apply_prompt_template", return_value="prompt"), patch.object(client, "_get_tools", return_value=[]), + patch("deerflow.agents.checkpointer.get_checkpointer", return_value=MagicMock()), ): client._ensure_agent(config) client._ensure_agent(config) @@ -1327,6 +1335,7 @@ class TestScenarioAgentRecreation: patch("deerflow.client._build_middlewares", return_value=[]), patch("deerflow.client.apply_prompt_template", return_value="prompt"), patch.object(client, "_get_tools", return_value=[]), + patch("deerflow.agents.checkpointer.get_checkpointer", return_value=MagicMock()), ): client._ensure_agent(config) client.reset_agent() diff --git a/backend/tests/test_custom_agent.py b/backend/tests/test_custom_agent.py index c97cb4789..9b5e7bb28 100644 --- a/backend/tests/test_custom_agent.py +++ b/backend/tests/test_custom_agent.py @@ -439,6 +439,15 @@ class TestAgentsAPI: assert "agent-one" in names assert "agent-two" in names + def test_list_agents_includes_soul(self, agent_client): + agent_client.post("/api/agents", json={"name": "soul-agent", "soul": "My soul content"}) + + response = agent_client.get("/api/agents") + assert response.status_code == 200 + agents = response.json()["agents"] + soul_agent = next(a for a in agents if a["name"] == "soul-agent") + assert soul_agent["soul"] == "My soul content" + def test_get_agent(self, agent_client): agent_client.post("/api/agents", json={"name": "test-agent", "soul": "Hello world"}) diff --git a/backend/tests/test_ensure_admin.py b/backend/tests/test_ensure_admin.py new file mode 100644 index 000000000..cf6448bcd --- /dev/null +++ b/backend/tests/test_ensure_admin.py @@ -0,0 +1,214 @@ +"""Tests for _ensure_admin_user() in app.py. + +Covers: first-boot admin creation, auto-reset on needs_setup=True, +no-op on needs_setup=False, migration, and edge cases. +""" + +import asyncio +import os +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-ensure-admin-testing-min-32") + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.models import User + +_JWT_SECRET = "test-secret-key-ensure-admin-testing-min-32" + + +@pytest.fixture(autouse=True) +def _setup_auth_config(): + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + yield + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + + +def _make_app_stub(store=None): + """Minimal app-like object with state.store.""" + app = SimpleNamespace() + app.state = SimpleNamespace() + app.state.store = store + return app + + +def _make_provider(user_count=0, admin_user=None): + p = AsyncMock() + p.count_users = AsyncMock(return_value=user_count) + p.create_user = AsyncMock( + side_effect=lambda **kw: User( + email=kw["email"], + password_hash="hashed", + system_role=kw.get("system_role", "user"), + needs_setup=kw.get("needs_setup", False), + ) + ) + p.get_user_by_email = AsyncMock(return_value=admin_user) + p.update_user = AsyncMock(side_effect=lambda u: u) + return p + + +# โ”€โ”€ First boot: no users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_first_boot_creates_admin(): + """count_users==0 โ†’ create admin with needs_setup=True.""" + provider = _make_provider(user_count=0) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.create_user.assert_called_once() + call_kwargs = provider.create_user.call_args[1] + assert call_kwargs["email"] == "admin@deerflow.dev" + assert call_kwargs["system_role"] == "admin" + assert call_kwargs["needs_setup"] is True + assert len(call_kwargs["password"]) > 10 # random password generated + + +def test_first_boot_triggers_migration_if_store_present(): + """First boot with store โ†’ _migrate_orphaned_threads called.""" + provider = _make_provider(user_count=0) + store = AsyncMock() + store.asearch = AsyncMock(return_value=[]) + app = _make_app_stub(store=store) + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + store.asearch.assert_called_once() + + +def test_first_boot_no_store_skips_migration(): + """First boot without store โ†’ no crash, migration skipped.""" + provider = _make_provider(user_count=0) + app = _make_app_stub(store=None) + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.create_user.assert_called_once() + + +# โ”€โ”€ Subsequent boot: needs_setup=True โ†’ auto-reset โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_needs_setup_true_resets_password(): + """Existing admin with needs_setup=True โ†’ password reset + token_version bumped.""" + admin = User( + email="admin@deerflow.dev", + password_hash="old-hash", + system_role="admin", + needs_setup=True, + token_version=0, + created_at=datetime.now(UTC) - timedelta(seconds=30), + ) + provider = _make_provider(user_count=1, admin_user=admin) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + # Password was reset + provider.update_user.assert_called_once() + updated = provider.update_user.call_args[0][0] + assert updated.password_hash == "new-hash" + assert updated.token_version == 1 + + +def test_needs_setup_true_consecutive_resets_increment_version(): + """Two boots with needs_setup=True โ†’ token_version increments each time.""" + admin = User( + email="admin@deerflow.dev", + password_hash="hash", + system_role="admin", + needs_setup=True, + token_version=3, + created_at=datetime.now(UTC) - timedelta(seconds=30), + ) + provider = _make_provider(user_count=1, admin_user=admin) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + updated = provider.update_user.call_args[0][0] + assert updated.token_version == 4 + + +# โ”€โ”€ Subsequent boot: needs_setup=False โ†’ no-op โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_needs_setup_false_no_reset(): + """Admin with needs_setup=False โ†’ no password reset, no update.""" + admin = User( + email="admin@deerflow.dev", + password_hash="stable-hash", + system_role="admin", + needs_setup=False, + token_version=2, + ) + provider = _make_provider(user_count=1, admin_user=admin) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.update_user.assert_not_called() + assert admin.password_hash == "stable-hash" + assert admin.token_version == 2 + + +# โ”€โ”€ Edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_no_admin_email_found_no_crash(): + """Users exist but no admin@deerflow.dev โ†’ no crash, no reset.""" + provider = _make_provider(user_count=3, admin_user=None) + app = _make_app_stub() + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + from app.gateway.app import _ensure_admin_user + + asyncio.run(_ensure_admin_user(app)) + + provider.update_user.assert_not_called() + provider.create_user.assert_not_called() + + +def test_migration_failure_is_non_fatal(): + """_migrate_orphaned_threads exception is caught and logged.""" + provider = _make_provider(user_count=0) + store = AsyncMock() + store.asearch = AsyncMock(side_effect=RuntimeError("store crashed")) + app = _make_app_stub(store=store) + + with patch("app.gateway.deps.get_local_provider", return_value=provider): + with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"): + from app.gateway.app import _ensure_admin_user + + # Should not raise + asyncio.run(_ensure_admin_user(app)) + + provider.create_user.assert_called_once() diff --git a/backend/tests/test_file_conversion.py b/backend/tests/test_file_conversion.py new file mode 100644 index 000000000..42abd3b4a --- /dev/null +++ b/backend/tests/test_file_conversion.py @@ -0,0 +1,459 @@ +"""Tests for file_conversion utilities (PR1: pymupdf4llm + asyncio.to_thread; PR2: extract_outline).""" + +from __future__ import annotations + +import asyncio +import sys +from types import ModuleType +from unittest.mock import MagicMock, patch + +from deerflow.utils.file_conversion import ( + _ASYNC_THRESHOLD_BYTES, + _MIN_CHARS_PER_PAGE, + MAX_OUTLINE_ENTRIES, + _do_convert, + _pymupdf_output_too_sparse, + convert_file_to_markdown, + extract_outline, +) + + +def _make_pymupdf_mock(page_count: int) -> ModuleType: + """Return a fake *pymupdf* module whose ``open()`` reports *page_count* pages.""" + mock_doc = MagicMock() + mock_doc.__len__ = MagicMock(return_value=page_count) + fake_pymupdf = ModuleType("pymupdf") + fake_pymupdf.open = MagicMock(return_value=mock_doc) # type: ignore[attr-defined] + return fake_pymupdf + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +# --------------------------------------------------------------------------- +# _pymupdf_output_too_sparse +# --------------------------------------------------------------------------- + + +class TestPymupdfOutputTooSparse: + """Check the chars-per-page sparsity heuristic.""" + + def test_dense_text_pdf_not_sparse(self, tmp_path): + """Normal text PDF: many chars per page โ†’ not sparse.""" + pdf = tmp_path / "dense.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + # 10 pages ร— 10 000 chars โ†’ 1000/page โ‰ซ threshold + with patch.dict(sys.modules, {"pymupdf": _make_pymupdf_mock(page_count=10)}): + result = _pymupdf_output_too_sparse("x" * 10_000, pdf) + assert result is False + + def test_image_based_pdf_is_sparse(self, tmp_path): + """Image-based PDF: near-zero chars per page โ†’ sparse.""" + pdf = tmp_path / "image.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + # 612 chars / 31 pages โ‰ˆ 19.7/page < _MIN_CHARS_PER_PAGE (50) + with patch.dict(sys.modules, {"pymupdf": _make_pymupdf_mock(page_count=31)}): + result = _pymupdf_output_too_sparse("x" * 612, pdf) + assert result is True + + def test_fallback_when_pymupdf_unavailable(self, tmp_path): + """When pymupdf is not installed, fall back to absolute 200-char threshold.""" + pdf = tmp_path / "broken.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + # Remove pymupdf from sys.modules so the `import pymupdf` inside the + # function raises ImportError, triggering the absolute-threshold fallback. + with patch.dict(sys.modules, {"pymupdf": None}): + sparse = _pymupdf_output_too_sparse("x" * 100, pdf) + not_sparse = _pymupdf_output_too_sparse("x" * 300, pdf) + + assert sparse is True + assert not_sparse is False + + def test_exactly_at_threshold_is_not_sparse(self, tmp_path): + """Chars-per-page == threshold is treated as NOT sparse (boundary inclusive).""" + pdf = tmp_path / "boundary.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + # 2 pages ร— _MIN_CHARS_PER_PAGE chars = exactly at threshold + with patch.dict(sys.modules, {"pymupdf": _make_pymupdf_mock(page_count=2)}): + result = _pymupdf_output_too_sparse("x" * (_MIN_CHARS_PER_PAGE * 2), pdf) + assert result is False + + +# --------------------------------------------------------------------------- +# _do_convert โ€” routing logic +# --------------------------------------------------------------------------- + + +class TestDoConvert: + """Verify that _do_convert routes to the right sub-converter.""" + + def test_non_pdf_always_uses_markitdown(self, tmp_path): + """DOCX / XLSX / PPTX always go through MarkItDown regardless of setting.""" + docx = tmp_path / "report.docx" + docx.write_bytes(b"PK fake docx") + + with patch( + "deerflow.utils.file_conversion._convert_with_markitdown", + return_value="# Markdown from MarkItDown", + ) as mock_md: + result = _do_convert(docx, "auto") + + mock_md.assert_called_once_with(docx) + assert result == "# Markdown from MarkItDown" + + def test_pdf_auto_uses_pymupdf4llm_when_dense(self, tmp_path): + """auto mode: use pymupdf4llm output when it's dense enough.""" + pdf = tmp_path / "report.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + dense_text = "# Heading\n" + "word " * 2000 # clearly dense + + with ( + patch( + "deerflow.utils.file_conversion._convert_pdf_with_pymupdf4llm", + return_value=dense_text, + ), + patch( + "deerflow.utils.file_conversion._pymupdf_output_too_sparse", + return_value=False, + ), + patch("deerflow.utils.file_conversion._convert_with_markitdown") as mock_md, + ): + result = _do_convert(pdf, "auto") + + mock_md.assert_not_called() + assert result == dense_text + + def test_pdf_auto_falls_back_when_sparse(self, tmp_path): + """auto mode: fall back to MarkItDown when pymupdf4llm output is sparse.""" + pdf = tmp_path / "scanned.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + with ( + patch( + "deerflow.utils.file_conversion._convert_pdf_with_pymupdf4llm", + return_value="x" * 612, # 19.7 chars/page for 31-page doc + ), + patch( + "deerflow.utils.file_conversion._pymupdf_output_too_sparse", + return_value=True, + ), + patch( + "deerflow.utils.file_conversion._convert_with_markitdown", + return_value="OCR result via MarkItDown", + ) as mock_md, + ): + result = _do_convert(pdf, "auto") + + mock_md.assert_called_once_with(pdf) + assert result == "OCR result via MarkItDown" + + def test_pdf_explicit_pymupdf4llm_skips_sparsity_check(self, tmp_path): + """'pymupdf4llm' mode: use output as-is even if sparse.""" + pdf = tmp_path / "explicit.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + sparse_text = "x" * 10 # very short + + with ( + patch( + "deerflow.utils.file_conversion._convert_pdf_with_pymupdf4llm", + return_value=sparse_text, + ), + patch("deerflow.utils.file_conversion._convert_with_markitdown") as mock_md, + ): + result = _do_convert(pdf, "pymupdf4llm") + + mock_md.assert_not_called() + assert result == sparse_text + + def test_pdf_explicit_markitdown_skips_pymupdf4llm(self, tmp_path): + """'markitdown' mode: never attempt pymupdf4llm.""" + pdf = tmp_path / "force_md.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + with ( + patch("deerflow.utils.file_conversion._convert_pdf_with_pymupdf4llm") as mock_pymu, + patch( + "deerflow.utils.file_conversion._convert_with_markitdown", + return_value="MarkItDown result", + ), + ): + result = _do_convert(pdf, "markitdown") + + mock_pymu.assert_not_called() + assert result == "MarkItDown result" + + def test_pdf_auto_falls_back_when_pymupdf4llm_not_installed(self, tmp_path): + """auto mode: if pymupdf4llm is not installed, use MarkItDown directly.""" + pdf = tmp_path / "no_pymupdf.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + with ( + patch( + "deerflow.utils.file_conversion._convert_pdf_with_pymupdf4llm", + return_value=None, # None signals not installed + ), + patch( + "deerflow.utils.file_conversion._convert_with_markitdown", + return_value="MarkItDown fallback", + ) as mock_md, + ): + result = _do_convert(pdf, "auto") + + mock_md.assert_called_once_with(pdf) + assert result == "MarkItDown fallback" + + +# --------------------------------------------------------------------------- +# convert_file_to_markdown โ€” async + file writing +# --------------------------------------------------------------------------- + + +class TestConvertFileToMarkdown: + def test_small_file_runs_synchronously(self, tmp_path): + """Small files (< 1 MB) are converted in the event loop thread.""" + pdf = tmp_path / "small.pdf" + pdf.write_bytes(b"%PDF-1.4 " + b"x" * 100) # well under 1 MB + + with ( + patch("deerflow.utils.file_conversion._get_pdf_converter", return_value="auto"), + patch( + "deerflow.utils.file_conversion._do_convert", + return_value="# Small PDF", + ) as mock_convert, + patch("asyncio.to_thread") as mock_thread, + ): + md_path = _run(convert_file_to_markdown(pdf)) + + # asyncio.to_thread must NOT have been called + mock_thread.assert_not_called() + mock_convert.assert_called_once() + assert md_path == pdf.with_suffix(".md") + assert md_path.read_text() == "# Small PDF" + + def test_large_file_offloaded_to_thread(self, tmp_path): + """Large files (> 1 MB) are offloaded via asyncio.to_thread.""" + pdf = tmp_path / "large.pdf" + # Write slightly more than the threshold + pdf.write_bytes(b"%PDF-1.4 " + b"x" * (_ASYNC_THRESHOLD_BYTES + 1)) + + async def fake_to_thread(fn, *args, **kwargs): + return fn(*args, **kwargs) + + with ( + patch("deerflow.utils.file_conversion._get_pdf_converter", return_value="auto"), + patch( + "deerflow.utils.file_conversion._do_convert", + return_value="# Large PDF", + ), + patch("asyncio.to_thread", side_effect=fake_to_thread) as mock_thread, + ): + md_path = _run(convert_file_to_markdown(pdf)) + + mock_thread.assert_called_once() + assert md_path == pdf.with_suffix(".md") + assert md_path.read_text() == "# Large PDF" + + def test_returns_none_on_conversion_error(self, tmp_path): + """If conversion raises, return None without propagating the exception.""" + pdf = tmp_path / "broken.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + + with ( + patch("deerflow.utils.file_conversion._get_pdf_converter", return_value="auto"), + patch( + "deerflow.utils.file_conversion._do_convert", + side_effect=RuntimeError("conversion failed"), + ), + ): + result = _run(convert_file_to_markdown(pdf)) + + assert result is None + + def test_writes_utf8_markdown_file(self, tmp_path): + """Generated .md file is written with UTF-8 encoding.""" + pdf = tmp_path / "report.pdf" + pdf.write_bytes(b"%PDF-1.4 fake") + chinese_content = "# ไธญๆ–‡ๆŠฅๅ‘Š\n\n่ฟ™ๆ˜ฏๆต‹่ฏ•ๅ†…ๅฎนใ€‚" + + with ( + patch("deerflow.utils.file_conversion._get_pdf_converter", return_value="auto"), + patch( + "deerflow.utils.file_conversion._do_convert", + return_value=chinese_content, + ), + ): + md_path = _run(convert_file_to_markdown(pdf)) + + assert md_path is not None + assert md_path.read_text(encoding="utf-8") == chinese_content + + +# --------------------------------------------------------------------------- +# extract_outline +# --------------------------------------------------------------------------- + + +class TestExtractOutline: + """Tests for extract_outline().""" + + def test_empty_file_returns_empty(self, tmp_path): + """Empty markdown file yields no outline entries.""" + md = tmp_path / "empty.md" + md.write_text("", encoding="utf-8") + assert extract_outline(md) == [] + + def test_missing_file_returns_empty(self, tmp_path): + """Non-existent path returns [] without raising.""" + assert extract_outline(tmp_path / "nonexistent.md") == [] + + def test_standard_markdown_headings(self, tmp_path): + """# / ## / ### headings are all recognised.""" + md = tmp_path / "doc.md" + md.write_text( + "# Chapter One\n\nSome text.\n\n## Section 1.1\n\nMore text.\n\n### Sub 1.1.1\n", + encoding="utf-8", + ) + outline = extract_outline(md) + assert len(outline) == 3 + assert outline[0] == {"title": "Chapter One", "line": 1} + assert outline[1] == {"title": "Section 1.1", "line": 5} + assert outline[2] == {"title": "Sub 1.1.1", "line": 9} + + def test_bold_sec_item_heading(self, tmp_path): + """**ITEM N. TITLE** lines in SEC filings are recognised.""" + md = tmp_path / "10k.md" + md.write_text( + "Cover page text.\n\n**ITEM 1. BUSINESS**\n\nBody.\n\n**ITEM 1A. RISK FACTORS**\n", + encoding="utf-8", + ) + outline = extract_outline(md) + assert len(outline) == 2 + assert outline[0] == {"title": "ITEM 1. BUSINESS", "line": 3} + assert outline[1] == {"title": "ITEM 1A. RISK FACTORS", "line": 7} + + def test_bold_part_heading(self, tmp_path): + """**PART I** / **PART II** headings are recognised.""" + md = tmp_path / "10k.md" + md.write_text("**PART I**\n\n**PART II**\n\n**PART III**\n", encoding="utf-8") + outline = extract_outline(md) + assert len(outline) == 3 + titles = [e["title"] for e in outline] + assert "PART I" in titles + assert "PART II" in titles + assert "PART III" in titles + + def test_sec_cover_page_boilerplate_excluded(self, tmp_path): + """Address lines and short cover boilerplate must NOT appear in outline.""" + md = tmp_path / "8k.md" + md.write_text( + "## **UNITED STATES SECURITIES AND EXCHANGE COMMISSION**\n\n**WASHINGTON, DC 20549**\n\n**CURRENT REPORT**\n\n**SIGNATURES**\n\n**TESLA, INC.**\n\n**ITEM 2.02. RESULTS OF OPERATIONS**\n", + encoding="utf-8", + ) + outline = extract_outline(md) + titles = [e["title"] for e in outline] + # Cover-page boilerplate should be excluded + assert "WASHINGTON, DC 20549" not in titles + assert "CURRENT REPORT" not in titles + assert "SIGNATURES" not in titles + assert "TESLA, INC." not in titles + # Real SEC heading must be included + assert "ITEM 2.02. RESULTS OF OPERATIONS" in titles + + def test_chinese_headings_via_standard_markdown(self, tmp_path): + """Chinese annual report headings emitted as # by pymupdf4llm are captured.""" + md = tmp_path / "annual.md" + md.write_text( + "# ็ฌฌไธ€่Š‚ ๅ…ฌๅธ็ฎ€ไป‹\n\nๅ†…ๅฎนใ€‚\n\n## ็ฌฌไธ‰่Š‚ ็ฎก็†ๅฑ‚่ฎจ่ฎบไธŽๅˆ†ๆž\n\nๅˆ†ๆžๅ†…ๅฎนใ€‚\n", + encoding="utf-8", + ) + outline = extract_outline(md) + assert len(outline) == 2 + assert outline[0]["title"] == "็ฌฌไธ€่Š‚ ๅ…ฌๅธ็ฎ€ไป‹" + assert outline[1]["title"] == "็ฌฌไธ‰่Š‚ ็ฎก็†ๅฑ‚่ฎจ่ฎบไธŽๅˆ†ๆž" + + def test_outline_capped_at_max_entries(self, tmp_path): + """When truncated, result has MAX_OUTLINE_ENTRIES real entries + 1 sentinel.""" + lines = [f"# Heading {i}" for i in range(MAX_OUTLINE_ENTRIES + 10)] + md = tmp_path / "long.md" + md.write_text("\n".join(lines), encoding="utf-8") + outline = extract_outline(md) + # Last entry is the truncation sentinel + assert outline[-1] == {"truncated": True} + # Visible entries are exactly MAX_OUTLINE_ENTRIES + visible = [e for e in outline if not e.get("truncated")] + assert len(visible) == MAX_OUTLINE_ENTRIES + + def test_no_truncation_sentinel_when_under_limit(self, tmp_path): + """Short documents produce no sentinel entry.""" + lines = [f"# Heading {i}" for i in range(5)] + md = tmp_path / "short.md" + md.write_text("\n".join(lines), encoding="utf-8") + outline = extract_outline(md) + assert len(outline) == 5 + assert not any(e.get("truncated") for e in outline) + + def test_blank_lines_and_whitespace_ignored(self, tmp_path): + """Blank lines between headings do not produce empty entries.""" + md = tmp_path / "spaced.md" + md.write_text("\n\n# Title One\n\n\n\n# Title Two\n\n", encoding="utf-8") + outline = extract_outline(md) + assert len(outline) == 2 + assert all(e["title"] for e in outline) + + def test_inline_bold_not_confused_with_heading(self, tmp_path): + """Mid-sentence bold text must not be mistaken for a heading.""" + md = tmp_path / "prose.md" + md.write_text( + "This sentence has **bold words** inside it.\n\nAnother with **MULTIPLE CAPS** inline.\n", + encoding="utf-8", + ) + outline = extract_outline(md) + assert outline == [] + + def test_split_bold_heading_academic_paper(self, tmp_path): + """**<num>** **<title>** lines from academic papers are recognised (Style 3).""" + md = tmp_path / "paper.md" + md.write_text( + "## **Attention Is All You Need**\n\n**1** **Introduction**\n\nBody text.\n\n**2** **Background**\n\nMore text.\n\n**3.1** **Encoder and Decoder Stacks**\n", + encoding="utf-8", + ) + outline = extract_outline(md) + titles = [e["title"] for e in outline] + assert "1 Introduction" in titles + assert "2 Background" in titles + assert "3.1 Encoder and Decoder Stacks" in titles + + def test_split_bold_year_columns_excluded(self, tmp_path): + """Financial table headers like **2023** **2022** **2021** are NOT headings.""" + md = tmp_path / "annual.md" + md.write_text( + "# Financial Summary\n\n**2023** **2022** **2021**\n\nRevenue 100 90 80\n", + encoding="utf-8", + ) + outline = extract_outline(md) + titles = [e["title"] for e in outline] + # Only the # heading should appear, not the year-column row + assert titles == ["Financial Summary"] + + def test_adjacent_bold_spans_merged_in_markdown_heading(self, tmp_path): + """** ** artefacts inside a # heading are merged into clean plain text.""" + md = tmp_path / "sec.md" + md.write_text( + "## **UNITED STATES** **SECURITIES AND EXCHANGE COMMISSION**\n\nBody text.\n", + encoding="utf-8", + ) + outline = extract_outline(md) + assert len(outline) == 1 + # Title must be clean โ€” no ** ** artefacts + assert outline[0]["title"] == "UNITED STATES SECURITIES AND EXCHANGE COMMISSION" diff --git a/backend/tests/test_invoke_acp_agent_tool.py b/backend/tests/test_invoke_acp_agent_tool.py index 6c36635c4..8063875cf 100644 --- a/backend/tests/test_invoke_acp_agent_tool.py +++ b/backend/tests/test_invoke_acp_agent_tool.py @@ -8,6 +8,7 @@ import pytest from deerflow.config.acp_config import ACPAgentConfig from deerflow.config.extensions_config import ExtensionsConfig, McpServerConfig, set_extensions_config from deerflow.tools.builtins.invoke_acp_agent_tool import ( + _build_acp_mcp_servers, _build_mcp_servers, _build_permission_response, _get_work_dir, @@ -42,6 +43,43 @@ def test_build_mcp_servers_filters_disabled_and_maps_transports(): set_extensions_config(ExtensionsConfig(mcp_servers={}, skills={})) +def test_build_acp_mcp_servers_formats_list_payload(): + set_extensions_config(ExtensionsConfig(mcp_servers={"stale": McpServerConfig(enabled=True, type="stdio", command="echo")}, skills={})) + fresh_config = ExtensionsConfig( + mcp_servers={ + "stdio": McpServerConfig(enabled=True, type="stdio", command="npx", args=["srv"], env={"FOO": "bar"}), + "http": McpServerConfig(enabled=True, type="http", url="https://example.com/mcp", headers={"Authorization": "Bearer token"}), + "disabled": McpServerConfig(enabled=False, type="stdio", command="echo"), + }, + skills={}, + ) + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr( + "deerflow.config.extensions_config.ExtensionsConfig.from_file", + classmethod(lambda cls: fresh_config), + ) + + try: + assert _build_acp_mcp_servers() == [ + { + "name": "stdio", + "type": "stdio", + "command": "npx", + "args": ["srv"], + "env": [{"name": "FOO", "value": "bar"}], + }, + { + "name": "http", + "type": "http", + "url": "https://example.com/mcp", + "headers": [{"name": "Authorization", "value": "Bearer token"}], + }, + ] + finally: + monkeypatch.undo() + set_extensions_config(ExtensionsConfig(mcp_servers={}, skills={})) + + def test_build_permission_response_prefers_allow_once(): response = _build_permission_response( [ @@ -251,9 +289,15 @@ async def test_invoke_acp_agent_uses_fixed_acp_workspace(monkeypatch, tmp_path): assert captured["spawn"] == {"cmd": "codex-acp", "args": ["--json"], "cwd": expected_cwd} assert captured["new_session"] == { "cwd": expected_cwd, - "mcp_servers": { - "github": {"transport": "stdio", "command": "npx", "args": ["github-mcp"]}, - }, + "mcp_servers": [ + { + "name": "github", + "type": "stdio", + "command": "npx", + "args": ["github-mcp"], + "env": [], + } + ], "model": "gpt-5-codex", } assert captured["prompt"] == { @@ -448,6 +492,94 @@ async def test_invoke_acp_agent_passes_env_to_spawn(monkeypatch, tmp_path): assert captured["env"] == {"OPENAI_API_KEY": "sk-from-env", "FOO": "bar"} +@pytest.mark.anyio +async def test_invoke_acp_agent_skips_invalid_mcp_servers(monkeypatch, tmp_path, caplog): + """Invalid MCP config should be logged and skipped instead of failing ACP invocation.""" + from deerflow.config import paths as paths_module + + monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path)) + monkeypatch.setattr( + "deerflow.tools.builtins.invoke_acp_agent_tool._build_acp_mcp_servers", + lambda: (_ for _ in ()).throw(ValueError("missing command")), + ) + + captured: dict[str, object] = {} + + class DummyClient: + def __init__(self) -> None: + self._chunks: list[str] = [] + + @property + def collected_text(self) -> str: + return "" + + async def session_update(self, session_id, update, **kwargs): + pass + + async def request_permission(self, options, session_id, tool_call, **kwargs): + raise AssertionError("should not be called") + + class DummyConn: + async def initialize(self, **kwargs): + pass + + async def new_session(self, **kwargs): + captured["new_session"] = kwargs + return SimpleNamespace(session_id="s1") + + async def prompt(self, **kwargs): + pass + + class DummyProcessContext: + def __init__(self, client, cmd, *args, env=None, cwd=None): + captured["spawn"] = {"cmd": cmd, "args": list(args), "env": env, "cwd": cwd} + + async def __aenter__(self): + return DummyConn(), object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + class DummyRequestError(Exception): + @staticmethod + def method_not_found(method): + return DummyRequestError(method) + + monkeypatch.setitem( + sys.modules, + "acp", + SimpleNamespace( + PROTOCOL_VERSION="2026-03-24", + Client=DummyClient, + RequestError=DummyRequestError, + spawn_agent_process=lambda client, cmd, *args, env=None, cwd: DummyProcessContext(client, cmd, *args, env=env, cwd=cwd), + text_block=lambda text: {"type": "text", "text": text}, + ), + ) + monkeypatch.setitem( + sys.modules, + "acp.schema", + SimpleNamespace( + ClientCapabilities=lambda: {}, + Implementation=lambda **kwargs: kwargs, + TextContentBlock=type("TextContentBlock", (), {"__init__": lambda self, text: setattr(self, "text", text)}), + ), + ) + + tool = build_invoke_acp_agent_tool({"codex": ACPAgentConfig(command="codex-acp", description="Codex CLI")}) + caplog.set_level("WARNING") + + try: + await tool.coroutine(agent="codex", prompt="Do something") + finally: + sys.modules.pop("acp", None) + sys.modules.pop("acp.schema", None) + + assert captured["new_session"]["mcp_servers"] == [] + assert "continuing without MCP servers" in caplog.text + assert "missing command" in caplog.text + + @pytest.mark.anyio async def test_invoke_acp_agent_passes_none_env_when_not_configured(monkeypatch, tmp_path): """When env is empty, None is passed to spawn_agent_process (subprocess inherits parent env).""" diff --git a/backend/tests/test_langgraph_auth.py b/backend/tests/test_langgraph_auth.py new file mode 100644 index 000000000..52d215751 --- /dev/null +++ b/backend/tests/test_langgraph_auth.py @@ -0,0 +1,312 @@ +"""Tests for LangGraph Server auth handler (langgraph_auth.py). + +Validates that the LangGraph auth layer enforces the same rules as Gateway: + cookie โ†’ JWT decode โ†’ DB lookup โ†’ token_version check โ†’ owner filter +""" + +import asyncio +import os +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-for-langgraph-auth-testing-min-32") + +from langgraph_sdk import Auth + +from app.gateway.auth.config import AuthConfig, set_auth_config +from app.gateway.auth.jwt import create_access_token, decode_token +from app.gateway.auth.models import User +from app.gateway.langgraph_auth import add_owner_filter, authenticate + +# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_JWT_SECRET = "test-secret-key-for-langgraph-auth-testing-min-32" + + +@pytest.fixture(autouse=True) +def _setup_auth_config(): + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + yield + set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET)) + + +def _req(cookies=None, method="GET", headers=None): + return SimpleNamespace(cookies=cookies or {}, method=method, headers=headers or {}) + + +def _user(user_id=None, token_version=0): + return User(email="test@example.com", password_hash="fakehash", system_role="user", id=user_id or uuid4(), token_version=token_version) + + +def _mock_provider(user=None): + p = AsyncMock() + p.get_user = AsyncMock(return_value=user) + return p + + +# โ”€โ”€ @auth.authenticate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_no_cookie_raises_401(): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req())) + assert exc.value.status_code == 401 + assert "Not authenticated" in str(exc.value.detail) + + +def test_invalid_jwt_raises_401(): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": "garbage"}))) + assert exc.value.status_code == 401 + assert "Token error" in str(exc.value.detail) + + +def test_expired_jwt_raises_401(): + token = create_access_token("user-1", expires_delta=timedelta(seconds=-1)) + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": token}))) + assert exc.value.status_code == 401 + + +def test_user_not_found_raises_401(): + token = create_access_token("ghost") + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(None)): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": token}))) + assert exc.value.status_code == 401 + assert "User not found" in str(exc.value.detail) + + +def test_token_version_mismatch_raises_401(): + user = _user(token_version=2) + token = create_access_token(str(user.id), token_version=1) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": token}))) + assert exc.value.status_code == 401 + assert "revoked" in str(exc.value.detail).lower() + + +def test_valid_token_returns_user_id(): + user = _user(token_version=0) + token = create_access_token(str(user.id), token_version=0) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + result = asyncio.run(authenticate(_req({"access_token": token}))) + assert result == str(user.id) + + +def test_valid_token_matching_version(): + user = _user(token_version=5) + token = create_access_token(str(user.id), token_version=5) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + result = asyncio.run(authenticate(_req({"access_token": token}))) + assert result == str(user.id) + + +# โ”€โ”€ @auth.authenticate edge cases โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_provider_exception_propagates(): + """Provider raises โ†’ should not be swallowed silently.""" + token = create_access_token("user-1") + p = AsyncMock() + p.get_user = AsyncMock(side_effect=RuntimeError("DB down")) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=p): + with pytest.raises(RuntimeError, match="DB down"): + asyncio.run(authenticate(_req({"access_token": token}))) + + +def test_jwt_missing_ver_defaults_to_zero(): + """JWT without 'ver' claim โ†’ decoded as ver=0, matches user with token_version=0.""" + import jwt as pyjwt + + uid = str(uuid4()) + raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256") + user = _user(user_id=uid, token_version=0) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + result = asyncio.run(authenticate(_req({"access_token": raw}))) + assert result == uid + + +def test_jwt_missing_ver_rejected_when_user_version_nonzero(): + """JWT without 'ver' (defaults 0) vs user with token_version=1 โ†’ 401.""" + import jwt as pyjwt + + uid = str(uuid4()) + raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256") + user = _user(user_id=uid, token_version=1) + with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": raw}))) + assert exc.value.status_code == 401 + + +def test_wrong_secret_raises_401(): + """Token signed with different secret โ†’ 401.""" + import jwt as pyjwt + + raw = pyjwt.encode({"sub": "user-1", "exp": 9999999999, "ver": 0}, "wrong-secret-that-is-long-enough-32chars!", algorithm="HS256") + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req({"access_token": raw}))) + assert exc.value.status_code == 401 + + +# โ”€โ”€ @auth.on (owner filter) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +class _FakeUser: + """Minimal BaseUser-compatible object without langgraph_api.config dependency.""" + + def __init__(self, identity: str): + self.identity = identity + self.is_authenticated = True + self.display_name = identity + + +def _make_ctx(user_id): + return Auth.types.AuthContext(resource="threads", action="create", user=_FakeUser(user_id), permissions=[]) + + +def test_filter_injects_user_id(): + value = {} + asyncio.run(add_owner_filter(_make_ctx("user-a"), value)) + assert value["metadata"]["user_id"] == "user-a" + + +def test_filter_preserves_existing_metadata(): + value = {"metadata": {"title": "hello"}} + asyncio.run(add_owner_filter(_make_ctx("user-a"), value)) + assert value["metadata"]["user_id"] == "user-a" + assert value["metadata"]["title"] == "hello" + + +def test_filter_returns_user_id_dict(): + result = asyncio.run(add_owner_filter(_make_ctx("user-x"), {})) + assert result == {"user_id": "user-x"} + + +def test_filter_read_write_consistency(): + value = {} + filter_dict = asyncio.run(add_owner_filter(_make_ctx("user-1"), value)) + assert value["metadata"]["user_id"] == filter_dict["user_id"] + + +def test_different_users_different_filters(): + f_a = asyncio.run(add_owner_filter(_make_ctx("a"), {})) + f_b = asyncio.run(add_owner_filter(_make_ctx("b"), {})) + assert f_a["user_id"] != f_b["user_id"] + + +def test_filter_overrides_conflicting_user_id(): + """If value already has a different user_id in metadata, it gets overwritten.""" + value = {"metadata": {"user_id": "attacker"}} + asyncio.run(add_owner_filter(_make_ctx("real-owner"), value)) + assert value["metadata"]["user_id"] == "real-owner" + + +def test_filter_with_empty_metadata(): + """Explicit empty metadata dict is fine.""" + value = {"metadata": {}} + result = asyncio.run(add_owner_filter(_make_ctx("user-z"), value)) + assert value["metadata"]["user_id"] == "user-z" + assert result == {"user_id": "user-z"} + + +# โ”€โ”€ Gateway parity โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_shared_jwt_secret(): + token = create_access_token("user-1", token_version=3) + payload = decode_token(token) + from app.gateway.auth.errors import TokenError + + assert not isinstance(payload, TokenError) + assert payload.sub == "user-1" + assert payload.ver == 3 + + +def test_langgraph_json_has_auth_path(): + import json + + config = json.loads((Path(__file__).parent.parent / "langgraph.json").read_text()) + assert "auth" in config + assert "langgraph_auth" in config["auth"]["path"] + + +def test_auth_handler_has_both_layers(): + from app.gateway.langgraph_auth import auth + + assert auth._authenticate_handler is not None + assert len(auth._global_handlers) == 1 + + +# โ”€โ”€ CSRF in LangGraph auth โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_csrf_get_no_check(): + """GET requests skip CSRF โ€” should proceed to JWT validation.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="GET"))) + # Rejected by missing cookie, NOT by CSRF + assert exc.value.status_code == 401 + assert "Not authenticated" in str(exc.value.detail) + + +def test_csrf_post_missing_token(): + """POST without CSRF token โ†’ 403.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="POST", cookies={"access_token": "some-jwt"}))) + assert exc.value.status_code == 403 + assert "CSRF token missing" in str(exc.value.detail) + + +def test_csrf_post_mismatched_token(): + """POST with mismatched CSRF tokens โ†’ 403.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run( + authenticate( + _req( + method="POST", + cookies={"access_token": "some-jwt", "csrf_token": "real-token"}, + headers={"x-csrf-token": "wrong-token"}, + ) + ) + ) + assert exc.value.status_code == 403 + assert "mismatch" in str(exc.value.detail) + + +def test_csrf_post_matching_token_proceeds_to_jwt(): + """POST with matching CSRF tokens passes CSRF check, then fails on JWT.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run( + authenticate( + _req( + method="POST", + cookies={"access_token": "garbage", "csrf_token": "same-token"}, + headers={"x-csrf-token": "same-token"}, + ) + ) + ) + # Past CSRF, rejected by JWT decode + assert exc.value.status_code == 401 + assert "Token error" in str(exc.value.detail) + + +def test_csrf_put_requires_token(): + """PUT also requires CSRF.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="PUT", cookies={"access_token": "jwt"}))) + assert exc.value.status_code == 403 + + +def test_csrf_delete_requires_token(): + """DELETE also requires CSRF.""" + with pytest.raises(Auth.exceptions.HTTPException) as exc: + asyncio.run(authenticate(_req(method="DELETE", cookies={"access_token": "jwt"}))) + assert exc.value.status_code == 403 diff --git a/backend/tests/test_local_sandbox_provider_mounts.py b/backend/tests/test_local_sandbox_provider_mounts.py new file mode 100644 index 000000000..0eb6d4654 --- /dev/null +++ b/backend/tests/test_local_sandbox_provider_mounts.py @@ -0,0 +1,388 @@ +import errno +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from deerflow.sandbox.local.local_sandbox import LocalSandbox, PathMapping +from deerflow.sandbox.local.local_sandbox_provider import LocalSandboxProvider + + +class TestPathMapping: + def test_path_mapping_dataclass(self): + mapping = PathMapping(container_path="/mnt/skills", local_path="/home/user/skills", read_only=True) + assert mapping.container_path == "/mnt/skills" + assert mapping.local_path == "/home/user/skills" + assert mapping.read_only is True + + def test_path_mapping_defaults_to_false(self): + mapping = PathMapping(container_path="/mnt/data", local_path="/home/user/data") + assert mapping.read_only is False + + +class TestLocalSandboxPathResolution: + def test_resolve_path_exact_match(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path="/home/user/skills"), + ], + ) + resolved = sandbox._resolve_path("/mnt/skills") + assert resolved == "/home/user/skills" + + def test_resolve_path_nested_path(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path="/home/user/skills"), + ], + ) + resolved = sandbox._resolve_path("/mnt/skills/agent/prompt.py") + assert resolved == "/home/user/skills/agent/prompt.py" + + def test_resolve_path_no_mapping(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path="/home/user/skills"), + ], + ) + resolved = sandbox._resolve_path("/mnt/other/file.txt") + assert resolved == "/mnt/other/file.txt" + + def test_resolve_path_longest_prefix_first(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path="/home/user/skills"), + PathMapping(container_path="/mnt", local_path="/var/mnt"), + ], + ) + resolved = sandbox._resolve_path("/mnt/skills/file.py") + # Should match /mnt/skills first (longer prefix) + assert resolved == "/home/user/skills/file.py" + + def test_reverse_resolve_path_exact_match(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path=str(skills_dir)), + ], + ) + resolved = sandbox._reverse_resolve_path(str(skills_dir)) + assert resolved == "/mnt/skills" + + def test_reverse_resolve_path_nested(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + file_path = skills_dir / "agent" / "prompt.py" + file_path.parent.mkdir() + file_path.write_text("test") + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path=str(skills_dir)), + ], + ) + resolved = sandbox._reverse_resolve_path(str(file_path)) + assert resolved == "/mnt/skills/agent/prompt.py" + + +class TestReadOnlyPath: + def test_is_read_only_true(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path="/home/user/skills", read_only=True), + ], + ) + assert sandbox._is_read_only_path("/home/user/skills/file.py") is True + + def test_is_read_only_false_for_writable(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path="/home/user/data", read_only=False), + ], + ) + assert sandbox._is_read_only_path("/home/user/data/file.txt") is False + + def test_is_read_only_false_for_unmapped_path(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path="/home/user/skills", read_only=True), + ], + ) + # Path not under any mapping + assert sandbox._is_read_only_path("/tmp/other/file.txt") is False + + def test_is_read_only_true_for_exact_match(self): + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path="/home/user/skills", read_only=True), + ], + ) + assert sandbox._is_read_only_path("/home/user/skills") is True + + def test_write_file_blocked_on_read_only(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path=str(skills_dir), read_only=True), + ], + ) + # Skills dir is read-only, write should be blocked + with pytest.raises(OSError) as exc_info: + sandbox.write_file("/mnt/skills/new_file.py", "content") + assert exc_info.value.errno == errno.EROFS + + def test_write_file_allowed_on_writable_mount(self, tmp_path): + data_dir = tmp_path / "data" + data_dir.mkdir() + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path=str(data_dir), read_only=False), + ], + ) + sandbox.write_file("/mnt/data/file.txt", "content") + assert (data_dir / "file.txt").read_text() == "content" + + def test_update_file_blocked_on_read_only(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + existing_file = skills_dir / "existing.py" + existing_file.write_bytes(b"original") + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path=str(skills_dir), read_only=True), + ], + ) + with pytest.raises(OSError) as exc_info: + sandbox.update_file("/mnt/skills/existing.py", b"updated") + assert exc_info.value.errno == errno.EROFS + + +class TestMultipleMounts: + def test_multiple_read_write_mounts(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + data_dir = tmp_path / "data" + data_dir.mkdir() + external_dir = tmp_path / "external" + external_dir.mkdir() + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/skills", local_path=str(skills_dir), read_only=True), + PathMapping(container_path="/mnt/data", local_path=str(data_dir), read_only=False), + PathMapping(container_path="/mnt/external", local_path=str(external_dir), read_only=True), + ], + ) + + # Skills is read-only + with pytest.raises(OSError): + sandbox.write_file("/mnt/skills/file.py", "content") + + # Data is writable + sandbox.write_file("/mnt/data/file.txt", "data content") + assert (data_dir / "file.txt").read_text() == "data content" + + # External is read-only + with pytest.raises(OSError): + sandbox.write_file("/mnt/external/file.txt", "content") + + def test_nested_mounts_writable_under_readonly(self, tmp_path): + """A writable mount nested under a read-only mount should allow writes.""" + ro_dir = tmp_path / "ro" + ro_dir.mkdir() + rw_dir = ro_dir / "writable" + rw_dir.mkdir() + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/repo", local_path=str(ro_dir), read_only=True), + PathMapping(container_path="/mnt/repo/writable", local_path=str(rw_dir), read_only=False), + ], + ) + + # Parent mount is read-only + with pytest.raises(OSError): + sandbox.write_file("/mnt/repo/file.txt", "content") + + # Nested writable mount should allow writes + sandbox.write_file("/mnt/repo/writable/file.txt", "content") + assert (rw_dir / "file.txt").read_text() == "content" + + def test_execute_command_path_replacement(self, tmp_path, monkeypatch): + data_dir = tmp_path / "data" + data_dir.mkdir() + test_file = data_dir / "test.txt" + test_file.write_text("hello") + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path=str(data_dir)), + ], + ) + + # Mock subprocess to capture the resolved command + captured = {} + original_run = __import__("subprocess").run + + def mock_run(*args, **kwargs): + if len(args) > 0: + captured["command"] = args[0] + return original_run(*args, **kwargs) + + monkeypatch.setattr("deerflow.sandbox.local.local_sandbox.subprocess.run", mock_run) + monkeypatch.setattr("deerflow.sandbox.local.local_sandbox.LocalSandbox._get_shell", lambda self: "/bin/sh") + + sandbox.execute_command("cat /mnt/data/test.txt") + # Verify the command received the resolved local path + assert str(data_dir) in captured.get("command", "") + + def test_reverse_resolve_path_does_not_match_partial_prefix(self, tmp_path): + foo_dir = tmp_path / "foo" + foo_dir.mkdir() + foobar_dir = tmp_path / "foobar" + foobar_dir.mkdir() + target = foobar_dir / "file.txt" + target.write_text("test") + + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/foo", local_path=str(foo_dir)), + ], + ) + + resolved = sandbox._reverse_resolve_path(str(target)) + assert resolved == str(target.resolve()) + + def test_reverse_resolve_paths_in_output_supports_backslash_separator(self, tmp_path): + mount_dir = tmp_path / "mount" + mount_dir.mkdir() + sandbox = LocalSandbox( + "test", + [ + PathMapping(container_path="/mnt/data", local_path=str(mount_dir)), + ], + ) + + output = f"Copied: {mount_dir}\\file.txt" + masked = sandbox._reverse_resolve_paths_in_output(output) + + assert "/mnt/data/file.txt" in masked + assert str(mount_dir) not in masked + + +class TestLocalSandboxProviderMounts: + def test_setup_path_mappings_uses_configured_skills_container_path_as_reserved_prefix(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + custom_dir = tmp_path / "custom" + custom_dir.mkdir() + + from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + + sandbox_config = SandboxConfig( + use="deerflow.sandbox.local:LocalSandboxProvider", + mounts=[ + VolumeMountConfig(host_path=str(custom_dir), container_path="/custom-skills/nested", read_only=False), + ], + ) + config = SimpleNamespace( + skills=SimpleNamespace(container_path="/custom-skills", get_skills_path=lambda: skills_dir), + sandbox=sandbox_config, + ) + + with patch("deerflow.config.get_app_config", return_value=config): + provider = LocalSandboxProvider() + + assert [m.container_path for m in provider._path_mappings] == ["/custom-skills"] + + def test_setup_path_mappings_skips_relative_host_path(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + + sandbox_config = SandboxConfig( + use="deerflow.sandbox.local:LocalSandboxProvider", + mounts=[ + VolumeMountConfig(host_path="relative/path", container_path="/mnt/data", read_only=False), + ], + ) + config = SimpleNamespace( + skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir), + sandbox=sandbox_config, + ) + + with patch("deerflow.config.get_app_config", return_value=config): + provider = LocalSandboxProvider() + + assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"] + + def test_setup_path_mappings_skips_non_absolute_container_path(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + custom_dir = tmp_path / "custom" + custom_dir.mkdir() + + from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + + sandbox_config = SandboxConfig( + use="deerflow.sandbox.local:LocalSandboxProvider", + mounts=[ + VolumeMountConfig(host_path=str(custom_dir), container_path="mnt/data", read_only=False), + ], + ) + config = SimpleNamespace( + skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir), + sandbox=sandbox_config, + ) + + with patch("deerflow.config.get_app_config", return_value=config): + provider = LocalSandboxProvider() + + assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills"] + + def test_setup_path_mappings_normalizes_container_path_trailing_slash(self, tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + custom_dir = tmp_path / "custom" + custom_dir.mkdir() + + from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + + sandbox_config = SandboxConfig( + use="deerflow.sandbox.local:LocalSandboxProvider", + mounts=[ + VolumeMountConfig(host_path=str(custom_dir), container_path="/mnt/data/", read_only=False), + ], + ) + config = SimpleNamespace( + skills=SimpleNamespace(container_path="/mnt/skills", get_skills_path=lambda: skills_dir), + sandbox=sandbox_config, + ) + + with patch("deerflow.config.get_app_config", return_value=config): + provider = LocalSandboxProvider() + + assert [m.container_path for m in provider._path_mappings] == ["/mnt/skills", "/mnt/data"] diff --git a/backend/tests/test_loop_detection_middleware.py b/backend/tests/test_loop_detection_middleware.py index 3bd0c3665..e037b8492 100644 --- a/backend/tests/test_loop_detection_middleware.py +++ b/backend/tests/test_loop_detection_middleware.py @@ -1,5 +1,6 @@ """Tests for LoopDetectionMiddleware.""" +import copy from unittest.mock import MagicMock from langchain_core.messages import AIMessage, HumanMessage, SystemMessage @@ -19,8 +20,13 @@ def _make_runtime(thread_id="test-thread"): def _make_state(tool_calls=None, content=""): - """Build a minimal AgentState dict with an AIMessage.""" - msg = AIMessage(content=content, tool_calls=tool_calls or []) + """Build a minimal AgentState dict with an AIMessage. + + Deep-copies *content* when it is mutable (e.g. list) so that + successive calls never share the same object reference. + """ + safe_content = copy.deepcopy(content) if isinstance(content, list) else content + msg = AIMessage(content=safe_content, tool_calls=tool_calls or []) return {"messages": [msg]} @@ -229,3 +235,114 @@ class TestLoopDetection: mw._apply(_make_state(tool_calls=call), runtime) assert "default" in mw._history + + +class TestAppendText: + """Unit tests for LoopDetectionMiddleware._append_text.""" + + def test_none_content_returns_text(self): + result = LoopDetectionMiddleware._append_text(None, "hello") + assert result == "hello" + + def test_str_content_concatenates(self): + result = LoopDetectionMiddleware._append_text("existing", "appended") + assert result == "existing\n\nappended" + + def test_empty_str_content_concatenates(self): + result = LoopDetectionMiddleware._append_text("", "appended") + assert result == "\n\nappended" + + def test_list_content_appends_text_block(self): + """List content (e.g. Anthropic thinking mode) should get a new text block.""" + content = [ + {"type": "thinking", "text": "Let me think..."}, + {"type": "text", "text": "Here is my answer"}, + ] + result = LoopDetectionMiddleware._append_text(content, "stop msg") + assert isinstance(result, list) + assert len(result) == 3 + assert result[0] == content[0] + assert result[1] == content[1] + assert result[2] == {"type": "text", "text": "\n\nstop msg"} + + def test_empty_list_content_appends_text_block(self): + result = LoopDetectionMiddleware._append_text([], "stop msg") + assert isinstance(result, list) + assert len(result) == 1 + assert result[0] == {"type": "text", "text": "\n\nstop msg"} + + def test_unexpected_type_coerced_to_str(self): + """Unexpected content types should be coerced to str as a fallback.""" + result = LoopDetectionMiddleware._append_text(42, "stop msg") + assert isinstance(result, str) + assert result == "42\n\nstop msg" + + def test_list_content_not_mutated_in_place(self): + """_append_text must not modify the original list.""" + original = [{"type": "text", "text": "hello"}] + result = LoopDetectionMiddleware._append_text(original, "appended") + assert len(original) == 1 # original unchanged + assert len(result) == 2 # new list has the appended block + + +class TestHardStopWithListContent: + """Regression tests: hard stop must not crash when AIMessage.content is a list.""" + + def test_hard_stop_with_list_content(self): + """Hard stop on list content should not raise TypeError (regression).""" + mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4) + runtime = _make_runtime() + call = [_bash_call("ls")] + + # Build state with list content (e.g. Anthropic thinking mode) + list_content = [ + {"type": "thinking", "text": "Let me think..."}, + {"type": "text", "text": "I'll run ls"}, + ] + + for _ in range(3): + mw._apply(_make_state(tool_calls=call, content=list_content), runtime) + + # Fourth call triggers hard stop โ€” must not raise TypeError + result = mw._apply(_make_state(tool_calls=call, content=list_content), runtime) + assert result is not None + msg = result["messages"][0] + assert isinstance(msg, AIMessage) + assert msg.tool_calls == [] + # Content should remain a list with the stop message appended + assert isinstance(msg.content, list) + assert len(msg.content) == 3 + assert msg.content[2]["type"] == "text" + assert _HARD_STOP_MSG in msg.content[2]["text"] + + def test_hard_stop_with_none_content(self): + """Hard stop on None content should produce a plain string.""" + mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4) + runtime = _make_runtime() + call = [_bash_call("ls")] + + for _ in range(3): + mw._apply(_make_state(tool_calls=call), runtime) + + # Fourth call with default empty-string content + result = mw._apply(_make_state(tool_calls=call), runtime) + assert result is not None + msg = result["messages"][0] + assert isinstance(msg.content, str) + assert _HARD_STOP_MSG in msg.content + + def test_hard_stop_with_str_content(self): + """Hard stop on str content should concatenate the stop message.""" + mw = LoopDetectionMiddleware(warn_threshold=2, hard_limit=4) + runtime = _make_runtime() + call = [_bash_call("ls")] + + for _ in range(3): + mw._apply(_make_state(tool_calls=call, content="thinking..."), runtime) + + result = mw._apply(_make_state(tool_calls=call, content="thinking..."), runtime) + assert result is not None + msg = result["messages"][0] + assert isinstance(msg.content, str) + assert msg.content.startswith("thinking...") + assert _HARD_STOP_MSG in msg.content diff --git a/backend/tests/test_memory_prompt_injection.py b/backend/tests/test_memory_prompt_injection.py index d33b69a92..7c3ad85c4 100644 --- a/backend/tests/test_memory_prompt_injection.py +++ b/backend/tests/test_memory_prompt_injection.py @@ -154,3 +154,22 @@ def test_format_memory_renders_correction_without_source_error_normally() -> Non assert "Use make dev for local development." in result assert "avoid:" not in result + + +def test_format_memory_includes_long_term_background() -> None: + """longTermBackground in history must be injected into the prompt.""" + memory_data = { + "user": {}, + "history": { + "recentMonths": {"summary": "Recent activity summary"}, + "earlierContext": {"summary": "Earlier context summary"}, + "longTermBackground": {"summary": "Core expertise in distributed systems"}, + }, + "facts": [], + } + + result = format_memory_for_injection(memory_data, max_tokens=2000) + + assert "Background: Core expertise in distributed systems" in result + assert "Recent: Recent activity summary" in result + assert "Earlier: Earlier context summary" in result diff --git a/backend/tests/test_memory_queue.py b/backend/tests/test_memory_queue.py index 6ef91a142..204f9d16e 100644 --- a/backend/tests/test_memory_queue.py +++ b/backend/tests/test_memory_queue.py @@ -47,4 +47,45 @@ def test_process_queue_forwards_correction_flag_to_updater() -> None: thread_id="thread-1", agent_name="lead_agent", correction_detected=True, + reinforcement_detected=False, + ) + + +def test_queue_add_preserves_existing_reinforcement_flag_for_same_thread() -> None: + queue = MemoryUpdateQueue() + + with ( + patch("deerflow.agents.memory.queue.get_memory_config", return_value=_memory_config(enabled=True)), + patch.object(queue, "_reset_timer"), + ): + queue.add(thread_id="thread-1", messages=["first"], reinforcement_detected=True) + queue.add(thread_id="thread-1", messages=["second"], reinforcement_detected=False) + + assert len(queue._queue) == 1 + assert queue._queue[0].messages == ["second"] + assert queue._queue[0].reinforcement_detected is True + + +def test_process_queue_forwards_reinforcement_flag_to_updater() -> None: + queue = MemoryUpdateQueue() + queue._queue = [ + ConversationContext( + thread_id="thread-1", + messages=["conversation"], + agent_name="lead_agent", + reinforcement_detected=True, + ) + ] + mock_updater = MagicMock() + mock_updater.update_memory.return_value = True + + with patch("deerflow.agents.memory.updater.MemoryUpdater", return_value=mock_updater): + queue._process_queue() + + mock_updater.update_memory.assert_called_once_with( + messages=["conversation"], + thread_id="thread-1", + agent_name="lead_agent", + correction_detected=False, + reinforcement_detected=True, ) diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index 6309cf9f6..48fdfd89e 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -619,3 +619,156 @@ class TestUpdateMemoryStructuredResponse: assert result is True prompt = model.invoke.call_args[0][0] assert "Explicit correction signals were detected" not in prompt + + +class TestFactDeduplicationCaseInsensitive: + """Tests that fact deduplication is case-insensitive.""" + + def test_duplicate_fact_different_case_not_stored(self): + updater = MemoryUpdater() + current_memory = _make_memory( + facts=[ + { + "id": "fact_1", + "content": "User prefers Python", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-01-01T00:00:00Z", + "source": "thread-a", + }, + ] + ) + # Same fact with different casing should be treated as duplicate + update_data = { + "factsToRemove": [], + "newFacts": [ + {"content": "user prefers python", "category": "preference", "confidence": 0.95}, + ], + } + + with patch( + "deerflow.agents.memory.updater.get_memory_config", + return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), + ): + result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") + + # Should still have only 1 fact (duplicate rejected) + assert len(result["facts"]) == 1 + assert result["facts"][0]["content"] == "User prefers Python" + + def test_unique_fact_different_case_and_content_stored(self): + updater = MemoryUpdater() + current_memory = _make_memory( + facts=[ + { + "id": "fact_1", + "content": "User prefers Python", + "category": "preference", + "confidence": 0.9, + "createdAt": "2026-01-01T00:00:00Z", + "source": "thread-a", + }, + ] + ) + update_data = { + "factsToRemove": [], + "newFacts": [ + {"content": "User prefers Go", "category": "preference", "confidence": 0.85}, + ], + } + + with patch( + "deerflow.agents.memory.updater.get_memory_config", + return_value=_memory_config(max_facts=100, fact_confidence_threshold=0.7), + ): + result = updater._apply_updates(current_memory, update_data, thread_id="thread-b") + + assert len(result["facts"]) == 2 + + +class TestReinforcementHint: + """Tests that reinforcement_detected injects the correct hint into the prompt.""" + + @staticmethod + def _make_mock_model(json_response: str): + model = MagicMock() + response = MagicMock() + response.content = f"```json\n{json_response}\n```" + model.invoke.return_value = response + return model + + def test_reinforcement_hint_injected_when_detected(self): + updater = MemoryUpdater() + valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' + model = self._make_mock_model(valid_json) + + with ( + patch.object(updater, "_get_model", return_value=model), + patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), + patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), + patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), + ): + msg = MagicMock() + msg.type = "human" + msg.content = "Yes, exactly! That's what I needed." + ai_msg = MagicMock() + ai_msg.type = "ai" + ai_msg.content = "Great to hear!" + ai_msg.tool_calls = [] + + result = updater.update_memory([msg, ai_msg], reinforcement_detected=True) + + assert result is True + prompt = model.invoke.call_args[0][0] + assert "Positive reinforcement signals were detected" in prompt + + def test_reinforcement_hint_absent_when_not_detected(self): + updater = MemoryUpdater() + valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' + model = self._make_mock_model(valid_json) + + with ( + patch.object(updater, "_get_model", return_value=model), + patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), + patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), + patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), + ): + msg = MagicMock() + msg.type = "human" + msg.content = "Tell me more." + ai_msg = MagicMock() + ai_msg.type = "ai" + ai_msg.content = "Sure." + ai_msg.tool_calls = [] + + result = updater.update_memory([msg, ai_msg], reinforcement_detected=False) + + assert result is True + prompt = model.invoke.call_args[0][0] + assert "Positive reinforcement signals were detected" not in prompt + + def test_both_hints_present_when_both_detected(self): + updater = MemoryUpdater() + valid_json = '{"user": {}, "history": {}, "newFacts": [], "factsToRemove": []}' + model = self._make_mock_model(valid_json) + + with ( + patch.object(updater, "_get_model", return_value=model), + patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(enabled=True)), + patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()), + patch("deerflow.agents.memory.updater.get_memory_storage", return_value=MagicMock(save=MagicMock(return_value=True))), + ): + msg = MagicMock() + msg.type = "human" + msg.content = "No wait, that's wrong. Actually yes, exactly right." + ai_msg = MagicMock() + ai_msg.type = "ai" + ai_msg.content = "Got it." + ai_msg.tool_calls = [] + + result = updater.update_memory([msg, ai_msg], correction_detected=True, reinforcement_detected=True) + + assert result is True + prompt = model.invoke.call_args[0][0] + assert "Explicit correction signals were detected" in prompt + assert "Positive reinforcement signals were detected" in prompt diff --git a/backend/tests/test_memory_upload_filtering.py b/backend/tests/test_memory_upload_filtering.py index 1ff0aa3b6..2e2308b61 100644 --- a/backend/tests/test_memory_upload_filtering.py +++ b/backend/tests/test_memory_upload_filtering.py @@ -10,7 +10,7 @@ persisting in long-term memory: from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from deerflow.agents.memory.updater import _strip_upload_mentions_from_memory -from deerflow.agents.middlewares.memory_middleware import _filter_messages_for_memory, detect_correction +from deerflow.agents.middlewares.memory_middleware import _filter_messages_for_memory, detect_correction, detect_reinforcement # --------------------------------------------------------------------------- # Helpers @@ -270,3 +270,73 @@ class TestStripUploadMentionsFromMemory: mem = {"user": {}, "history": {}, "facts": []} result = _strip_upload_mentions_from_memory(mem) assert result == {"user": {}, "history": {}, "facts": []} + + +# =========================================================================== +# detect_reinforcement +# =========================================================================== + + +class TestDetectReinforcement: + def test_detects_english_reinforcement_signal(self): + msgs = [ + _human("Can you summarise it in bullet points?"), + _ai("Here are the key points: ..."), + _human("Yes, exactly! That's what I needed."), + _ai("Glad it helped."), + ] + + assert detect_reinforcement(msgs) is True + + def test_detects_perfect_signal(self): + msgs = [ + _human("Write it more concisely."), + _ai("Here is the concise version."), + _human("Perfect."), + _ai("Great!"), + ] + + assert detect_reinforcement(msgs) is True + + def test_detects_chinese_reinforcement_signal(self): + msgs = [ + _human("ๅธฎๆˆ‘็”จ่ฆ็‚นๆฅๆ€ป็ป“"), + _ai("ๅฅฝ็š„๏ผŒ่ฆ็‚นๅฆ‚ไธ‹๏ผš..."), + _human("ๅฎŒๅ…จๆญฃ็กฎ๏ผŒๅฐฑๆ˜ฏ่ฟ™ไธชๆ„ๆ€"), + _ai("ๅพˆ้ซ˜ๅ…ด่ƒฝๅธฎๅˆฐไฝ "), + ] + + assert detect_reinforcement(msgs) is True + + def test_returns_false_without_signal(self): + msgs = [ + _human("What does this function do?"), + _ai("It processes the input data."), + _human("Can you show me an example?"), + ] + + assert detect_reinforcement(msgs) is False + + def test_only_checks_recent_messages(self): + # Reinforcement signal buried beyond the -6 window should not trigger + msgs = [ + _human("Yes, exactly right."), + _ai("Noted."), + _human("Let's discuss tests."), + _ai("Sure."), + _human("What about linting?"), + _ai("Use ruff."), + _human("And formatting?"), + _ai("Use make format."), + ] + + assert detect_reinforcement(msgs) is False + + def test_does_not_conflict_with_correction(self): + # A message can trigger correction but not reinforcement + msgs = [ + _human("That's wrong, try again."), + _ai("Corrected."), + ] + + assert detect_reinforcement(msgs) is False diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py new file mode 100644 index 000000000..6b6c686c4 --- /dev/null +++ b/backend/tests/test_sandbox_search_tools.py @@ -0,0 +1,393 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox +from deerflow.sandbox.local.local_sandbox import LocalSandbox +from deerflow.sandbox.search import GrepMatch, find_glob_matches, find_grep_matches +from deerflow.sandbox.tools import glob_tool, grep_tool + + +def _make_runtime(tmp_path): + workspace = tmp_path / "workspace" + uploads = tmp_path / "uploads" + outputs = tmp_path / "outputs" + workspace.mkdir() + uploads.mkdir() + outputs.mkdir() + return SimpleNamespace( + state={ + "sandbox": {"sandbox_id": "local"}, + "thread_data": { + "workspace_path": str(workspace), + "uploads_path": str(uploads), + "outputs_path": str(outputs), + }, + }, + context={"thread_id": "thread-1"}, + ) + + +def test_glob_tool_returns_virtual_paths_and_ignores_common_dirs(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "app.py").write_text("print('hi')\n", encoding="utf-8") + (workspace / "pkg").mkdir() + (workspace / "pkg" / "util.py").write_text("print('util')\n", encoding="utf-8") + (workspace / "node_modules").mkdir() + (workspace / "node_modules" / "skip.py").write_text("ignored\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = glob_tool.func( + runtime=runtime, + description="find python files", + pattern="**/*.py", + path="/mnt/user-data/workspace", + ) + + assert "/mnt/user-data/workspace/app.py" in result + assert "/mnt/user-data/workspace/pkg/util.py" in result + assert "node_modules" not in result + assert str(workspace) not in result + + +def test_glob_tool_supports_skills_virtual_paths(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + skills_dir = tmp_path / "skills" + (skills_dir / "public" / "demo").mkdir(parents=True) + (skills_dir / "public" / "demo" / "SKILL.md").write_text("# Demo\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + with ( + patch("deerflow.sandbox.tools._get_skills_container_path", return_value="/mnt/skills"), + patch("deerflow.sandbox.tools._get_skills_host_path", return_value=str(skills_dir)), + ): + result = glob_tool.func( + runtime=runtime, + description="find skills", + pattern="**/SKILL.md", + path="/mnt/skills", + ) + + assert "/mnt/skills/public/demo/SKILL.md" in result + assert str(skills_dir) not in result + + +def test_grep_tool_filters_by_glob_and_skips_binary_files(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "main.py").write_text("TODO = 'ship it'\nprint(TODO)\n", encoding="utf-8") + (workspace / "notes.txt").write_text("TODO in txt should be filtered\n", encoding="utf-8") + (workspace / "image.bin").write_bytes(b"\0binary TODO") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = grep_tool.func( + runtime=runtime, + description="find todo references", + pattern="TODO", + path="/mnt/user-data/workspace", + glob="**/*.py", + ) + + assert "/mnt/user-data/workspace/main.py:1: TODO = 'ship it'" in result + assert "notes.txt" not in result + assert "image.bin" not in result + assert str(workspace) not in result + + +def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "main.py").write_text("TODO one\nTODO two\nTODO three\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + # Prevent config.yaml tool config from overriding the caller-supplied max_results=2. + monkeypatch.setattr("deerflow.sandbox.tools.get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: None)) + + result = grep_tool.func( + runtime=runtime, + description="limit matches", + pattern="TODO", + path="/mnt/user-data/workspace", + max_results=2, + ) + + assert "Found 2 matches under /mnt/user-data/workspace (showing first 2)" in result + assert "TODO one" in result + assert "TODO two" in result + assert "TODO three" not in result + assert "Results truncated." in result + + +def test_glob_tool_include_dirs_filters_nested_ignored_paths(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "src").mkdir() + (workspace / "src" / "main.py").write_text("x\n", encoding="utf-8") + (workspace / "node_modules").mkdir() + (workspace / "node_modules" / "lib").mkdir() + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = glob_tool.func( + runtime=runtime, + description="find dirs", + pattern="**", + path="/mnt/user-data/workspace", + include_dirs=True, + ) + + assert "src" in result + assert "node_modules" not in result + + +def test_grep_tool_literal_mode(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "file.py").write_text("price = (a+b)\nresult = a+b\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + # literal=True should treat (a+b) as a plain string, not a regex group + result = grep_tool.func( + runtime=runtime, + description="literal search", + pattern="(a+b)", + path="/mnt/user-data/workspace", + literal=True, + ) + + assert "price = (a+b)" in result + assert "result = a+b" not in result + + +def test_grep_tool_case_sensitive(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "file.py").write_text("TODO: fix\ntodo: also fix\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = grep_tool.func( + runtime=runtime, + description="case sensitive search", + pattern="TODO", + path="/mnt/user-data/workspace", + case_sensitive=True, + ) + + assert "TODO: fix" in result + assert "todo: also fix" not in result + + +def test_grep_tool_invalid_regex_returns_error(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = grep_tool.func( + runtime=runtime, + description="bad pattern", + pattern="[invalid", + path="/mnt/user-data/workspace", + ) + + assert "Invalid regex pattern" in result + + +def test_aio_sandbox_glob_include_dirs_filters_nested_ignored(monkeypatch) -> None: + with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): + sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") + monkeypatch.setattr( + sandbox._client.file, + "list_path", + lambda **kwargs: SimpleNamespace( + data=SimpleNamespace( + files=[ + SimpleNamespace(name="src", path="/mnt/workspace/src"), + SimpleNamespace(name="node_modules", path="/mnt/workspace/node_modules"), + # child of node_modules โ€” should be filtered via should_ignore_path + SimpleNamespace(name="lib", path="/mnt/workspace/node_modules/lib"), + ] + ) + ), + ) + + matches, truncated = sandbox.glob("/mnt/workspace", "**", include_dirs=True) + + assert "/mnt/workspace/src" in matches + assert "/mnt/workspace/node_modules" not in matches + assert "/mnt/workspace/node_modules/lib" not in matches + assert truncated is False + + +def test_aio_sandbox_grep_invalid_regex_raises() -> None: + with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): + sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") + + import re + + try: + sandbox.grep("/mnt/workspace", "[invalid") + assert False, "Expected re.error" + except re.error: + pass + + +def test_aio_sandbox_glob_parses_json(monkeypatch) -> None: + with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): + sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") + monkeypatch.setattr( + sandbox._client.file, + "find_files", + lambda **kwargs: SimpleNamespace(data=SimpleNamespace(files=["/mnt/user-data/workspace/app.py", "/mnt/user-data/workspace/node_modules/skip.py"])), + ) + + matches, truncated = sandbox.glob("/mnt/user-data/workspace", "**/*.py") + + assert matches == ["/mnt/user-data/workspace/app.py"] + assert truncated is False + + +def test_aio_sandbox_grep_parses_json(monkeypatch) -> None: + with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): + sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") + monkeypatch.setattr( + sandbox._client.file, + "list_path", + lambda **kwargs: SimpleNamespace( + data=SimpleNamespace( + files=[ + SimpleNamespace( + name="app.py", + path="/mnt/user-data/workspace/app.py", + is_directory=False, + ) + ] + ) + ), + ) + monkeypatch.setattr( + sandbox._client.file, + "search_in_file", + lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True"])), + ) + + matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO") + + assert matches == [GrepMatch(path="/mnt/user-data/workspace/app.py", line_number=7, line="TODO = True")] + assert truncated is False + + +def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None: + file_path = tmp_path / "file.txt" + file_path.write_text("x\n", encoding="utf-8") + + try: + find_glob_matches(file_path, "**/*.py") + assert False, "Expected NotADirectoryError" + except NotADirectoryError: + pass + + +def test_find_grep_matches_raises_not_a_directory(tmp_path) -> None: + file_path = tmp_path / "file.txt" + file_path.write_text("TODO\n", encoding="utf-8") + + try: + find_grep_matches(file_path, "TODO") + assert False, "Expected NotADirectoryError" + except NotADirectoryError: + pass + + +def test_find_grep_matches_skips_symlink_outside_root(tmp_path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("TODO outside\n", encoding="utf-8") + (workspace / "outside-link.txt").symlink_to(outside) + + matches, truncated = find_grep_matches(workspace, "TODO") + + assert matches == [] + assert truncated is False + + +def test_glob_tool_honors_smaller_requested_max_results(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + workspace = tmp_path / "workspace" + (workspace / "a.py").write_text("print('a')\n", encoding="utf-8") + (workspace / "b.py").write_text("print('b')\n", encoding="utf-8") + (workspace / "c.py").write_text("print('c')\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + monkeypatch.setattr( + "deerflow.sandbox.tools.get_app_config", + lambda: SimpleNamespace(get_tool_config=lambda name: SimpleNamespace(model_extra={"max_results": 50})), + ) + + result = glob_tool.func( + runtime=runtime, + description="limit glob matches", + pattern="**/*.py", + path="/mnt/user-data/workspace", + max_results=2, + ) + + assert "Found 2 paths under /mnt/user-data/workspace (showing first 2)" in result + assert "Results truncated." in result + + +def test_aio_sandbox_glob_include_dirs_enforces_root_boundary(monkeypatch) -> None: + with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): + sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") + monkeypatch.setattr( + sandbox._client.file, + "list_path", + lambda **kwargs: SimpleNamespace( + data=SimpleNamespace( + files=[ + SimpleNamespace(name="src", path="/mnt/workspace/src"), + SimpleNamespace(name="src2", path="/mnt/workspace2/src2"), + ] + ) + ), + ) + + matches, truncated = sandbox.glob("/mnt/workspace", "**", include_dirs=True) + + assert matches == ["/mnt/workspace/src"] + assert truncated is False + + +def test_aio_sandbox_grep_skips_mismatched_line_number_payloads(monkeypatch) -> None: + with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): + sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") + monkeypatch.setattr( + sandbox._client.file, + "list_path", + lambda **kwargs: SimpleNamespace( + data=SimpleNamespace( + files=[ + SimpleNamespace( + name="app.py", + path="/mnt/user-data/workspace/app.py", + is_directory=False, + ) + ] + ) + ), + ) + monkeypatch.setattr( + sandbox._client.file, + "search_in_file", + lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True", "extra"])), + ) + + matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO") + + assert matches == [GrepMatch(path="/mnt/user-data/workspace/app.py", line_number=7, line="TODO = True")] + assert truncated is False diff --git a/backend/tests/test_sandbox_tools_security.py b/backend/tests/test_sandbox_tools_security.py index 02d1b27ce..268c5aada 100644 --- a/backend/tests/test_sandbox_tools_security.py +++ b/backend/tests/test_sandbox_tools_security.py @@ -8,7 +8,10 @@ import pytest from deerflow.sandbox.tools import ( VIRTUAL_PATH_PREFIX, _apply_cwd_prefix, + _get_custom_mount_for_path, + _get_custom_mounts, _is_acp_workspace_path, + _is_custom_mount_path, _is_skills_path, _reject_path_traversal, _resolve_acp_workspace_path, @@ -39,6 +42,53 @@ def test_replace_virtual_path_maps_virtual_root_and_subpaths() -> None: assert Path(replace_virtual_path("/mnt/user-data", _THREAD_DATA)).as_posix() == "/tmp/deer-flow/threads/t1/user-data" +def test_replace_virtual_path_preserves_trailing_slash() -> None: + """Trailing slash must survive virtual-to-actual path replacement. + + Regression: '/mnt/user-data/workspace/' was previously returned without + the trailing slash, causing string concatenations like + output_dir + 'file.txt' to produce a missing-separator path. + """ + result = replace_virtual_path("/mnt/user-data/workspace/", _THREAD_DATA) + assert result.endswith("/"), f"Expected trailing slash, got: {result!r}" + assert result == "/tmp/deer-flow/threads/t1/user-data/workspace/" + + +def test_replace_virtual_path_preserves_trailing_slash_windows_style() -> None: + """Trailing slash must be preserved as backslash when actual_base is Windows-style. + + If actual_base uses backslash separators, appending '/' would produce a + mixed-separator path. The separator must match the style of actual_base. + """ + win_thread_data = { + "workspace_path": r"C:\deer-flow\threads\t1\user-data\workspace", + "uploads_path": r"C:\deer-flow\threads\t1\user-data\uploads", + "outputs_path": r"C:\deer-flow\threads\t1\user-data\outputs", + } + result = replace_virtual_path("/mnt/user-data/workspace/", win_thread_data) + assert result.endswith("\\"), f"Expected trailing backslash for Windows path, got: {result!r}" + assert "/" not in result, f"Mixed separators in Windows path: {result!r}" + + +def test_replace_virtual_path_preserves_windows_style_for_nested_subdir_trailing_slash() -> None: + """Nested Windows-style subdirectories must keep backslashes throughout.""" + win_thread_data = { + "workspace_path": r"C:\deer-flow\threads\t1\user-data\workspace", + "uploads_path": r"C:\deer-flow\threads\t1\user-data\uploads", + "outputs_path": r"C:\deer-flow\threads\t1\user-data\outputs", + } + result = replace_virtual_path("/mnt/user-data/workspace/subdir/", win_thread_data) + assert result == "C:\\deer-flow\\threads\\t1\\user-data\\workspace\\subdir\\" + assert "/" not in result, f"Mixed separators in Windows path: {result!r}" + + +def test_replace_virtual_paths_in_command_preserves_trailing_slash() -> None: + """Trailing slash on a virtual path inside a command must be preserved.""" + cmd = """python -c "output_dir = '/mnt/user-data/workspace/'; print(output_dir + 'some_file.txt')\"""" + result = replace_virtual_paths_in_command(cmd, _THREAD_DATA) + assert "/tmp/deer-flow/threads/t1/user-data/workspace/" in result, f"Trailing slash lost in: {result!r}" + + # ---------- mask_local_paths_in_output ---------- @@ -96,6 +146,25 @@ def test_validate_local_tool_path_rejects_non_virtual_path() -> None: validate_local_tool_path("/Users/someone/config.yaml", _THREAD_DATA) +def test_validate_local_tool_path_rejects_non_virtual_path_mentions_configured_mounts() -> None: + with pytest.raises(PermissionError, match="configured mount paths"): + validate_local_tool_path("/Users/someone/config.yaml", _THREAD_DATA) + + +def test_validate_local_tool_path_prioritizes_user_data_before_custom_mounts() -> None: + from deerflow.config.sandbox_config import VolumeMountConfig + + mounts = [ + VolumeMountConfig(host_path="/tmp/host-user-data", container_path=VIRTUAL_PATH_PREFIX, read_only=False), + ] + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=mounts): + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/file.txt", _THREAD_DATA, read_only=True) + + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=mounts): + with pytest.raises(PermissionError, match="path traversal"): + validate_local_tool_path(f"{VIRTUAL_PATH_PREFIX}/workspace/../../etc/passwd", _THREAD_DATA, read_only=True) + + def test_validate_local_tool_path_rejects_bare_virtual_root() -> None: """The bare /mnt/user-data root without trailing slash is not a valid sub-path.""" with pytest.raises(PermissionError, match="Only paths under"): @@ -235,6 +304,22 @@ def test_validate_local_bash_command_paths_blocks_host_paths() -> None: validate_local_bash_command_paths("cat /etc/passwd", _THREAD_DATA) +def test_validate_local_bash_command_paths_allows_https_urls() -> None: + """URLs like https://github.com/... must not be flagged as unsafe absolute paths.""" + validate_local_bash_command_paths( + "cd /mnt/user-data/workspace && git clone https://github.com/CherryHQ/cherry-studio.git", + _THREAD_DATA, + ) + + +def test_validate_local_bash_command_paths_allows_http_urls() -> None: + """HTTP URLs must not be flagged as unsafe absolute paths.""" + validate_local_bash_command_paths( + "curl http://example.com/file.tar.gz -o /mnt/user-data/workspace/file.tar.gz", + _THREAD_DATA, + ) + + def test_validate_local_bash_command_paths_allows_virtual_and_system_paths() -> None: validate_local_bash_command_paths( "/bin/echo ok > /mnt/user-data/workspace/out.txt && cat /dev/null", @@ -567,6 +652,156 @@ def test_validate_local_bash_command_paths_allows_mcp_filesystem_paths() -> None validate_local_bash_command_paths("ls /mnt/d/workspace", _THREAD_DATA) +# ---------- Custom mount path tests ---------- + + +def _mock_custom_mounts(): + """Create mock VolumeMountConfig objects for testing.""" + from deerflow.config.sandbox_config import VolumeMountConfig + + return [ + VolumeMountConfig(host_path="/home/user/code-read", container_path="/mnt/code-read", read_only=True), + VolumeMountConfig(host_path="/home/user/data", container_path="/mnt/data", read_only=False), + ] + + +def test_is_custom_mount_path_recognises_configured_mounts() -> None: + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + assert _is_custom_mount_path("/mnt/code-read") is True + assert _is_custom_mount_path("/mnt/code-read/src/main.py") is True + assert _is_custom_mount_path("/mnt/data") is True + assert _is_custom_mount_path("/mnt/data/file.txt") is True + assert _is_custom_mount_path("/mnt/code-read-extra/foo") is False + assert _is_custom_mount_path("/mnt/other") is False + + +def test_get_custom_mount_for_path_returns_longest_prefix() -> None: + from deerflow.config.sandbox_config import VolumeMountConfig + + mounts = [ + VolumeMountConfig(host_path="/var/mnt", container_path="/mnt", read_only=False), + VolumeMountConfig(host_path="/home/user/code", container_path="/mnt/code", read_only=True), + ] + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=mounts): + mount = _get_custom_mount_for_path("/mnt/code/file.py") + assert mount is not None + assert mount.container_path == "/mnt/code" + + +def test_validate_local_tool_path_allows_custom_mount_read() -> None: + """read_file / ls should be able to access custom mount paths.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + validate_local_tool_path("/mnt/code-read/src/main.py", _THREAD_DATA, read_only=True) + validate_local_tool_path("/mnt/data/file.txt", _THREAD_DATA, read_only=True) + + +def test_validate_local_tool_path_blocks_read_only_mount_write() -> None: + """write_file / str_replace must NOT write to read-only custom mounts.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + with pytest.raises(PermissionError, match="Write access to read-only mount is not allowed"): + validate_local_tool_path("/mnt/code-read/src/main.py", _THREAD_DATA, read_only=False) + + +def test_validate_local_tool_path_allows_writable_mount_write() -> None: + """write_file / str_replace should succeed on writable custom mounts.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + validate_local_tool_path("/mnt/data/file.txt", _THREAD_DATA, read_only=False) + + +def test_validate_local_tool_path_blocks_traversal_in_custom_mount() -> None: + """Path traversal via .. in custom mount paths must be rejected.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + with pytest.raises(PermissionError, match="path traversal"): + validate_local_tool_path("/mnt/code-read/../../etc/passwd", _THREAD_DATA, read_only=True) + + +def test_validate_local_bash_command_paths_allows_custom_mount() -> None: + """bash commands referencing custom mount paths should be allowed.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + validate_local_bash_command_paths("cat /mnt/code-read/src/main.py", _THREAD_DATA) + validate_local_bash_command_paths("ls /mnt/data", _THREAD_DATA) + + +def test_validate_local_bash_command_paths_blocks_traversal_in_custom_mount() -> None: + """Bash commands with traversal in custom mount paths should be blocked.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + with pytest.raises(PermissionError, match="path traversal"): + validate_local_bash_command_paths("cat /mnt/code-read/../../etc/passwd", _THREAD_DATA) + + +def test_validate_local_bash_command_paths_still_blocks_non_mount_paths() -> None: + """Paths not matching any custom mount should still be blocked.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + with pytest.raises(PermissionError, match="Unsafe absolute paths"): + validate_local_bash_command_paths("cat /etc/shadow", _THREAD_DATA) + + +def test_get_custom_mounts_caching(monkeypatch, tmp_path) -> None: + """_get_custom_mounts should cache after first successful load.""" + # Clear any existing cache + if hasattr(_get_custom_mounts, "_cached"): + monkeypatch.delattr(_get_custom_mounts, "_cached") + + # Use real directories so host_path.exists() filtering passes + dir_a = tmp_path / "code-read" + dir_a.mkdir() + dir_b = tmp_path / "data" + dir_b.mkdir() + + from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + + mounts = [ + VolumeMountConfig(host_path=str(dir_a), container_path="/mnt/code-read", read_only=True), + VolumeMountConfig(host_path=str(dir_b), container_path="/mnt/data", read_only=False), + ] + mock_sandbox = SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider", mounts=mounts) + mock_config = SimpleNamespace(sandbox=mock_sandbox) + + with patch("deerflow.config.get_app_config", return_value=mock_config): + result = _get_custom_mounts() + assert len(result) == 2 + + # After caching, should return cached value even without mock + assert hasattr(_get_custom_mounts, "_cached") + assert len(_get_custom_mounts()) == 2 + + # Cleanup + monkeypatch.delattr(_get_custom_mounts, "_cached") + + +def test_get_custom_mounts_filters_nonexistent_host_path(monkeypatch, tmp_path) -> None: + """_get_custom_mounts should only return mounts whose host_path exists.""" + if hasattr(_get_custom_mounts, "_cached"): + monkeypatch.delattr(_get_custom_mounts, "_cached") + + from deerflow.config.sandbox_config import SandboxConfig, VolumeMountConfig + + existing_dir = tmp_path / "existing" + existing_dir.mkdir() + + mounts = [ + VolumeMountConfig(host_path=str(existing_dir), container_path="/mnt/existing", read_only=True), + VolumeMountConfig(host_path="/nonexistent/path/12345", container_path="/mnt/ghost", read_only=False), + ] + mock_sandbox = SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider", mounts=mounts) + mock_config = SimpleNamespace(sandbox=mock_sandbox) + + with patch("deerflow.config.get_app_config", return_value=mock_config): + result = _get_custom_mounts() + assert len(result) == 1 + assert result[0].container_path == "/mnt/existing" + + # Cleanup + monkeypatch.delattr(_get_custom_mounts, "_cached") + + +def test_get_custom_mount_for_path_boundary_no_false_prefix_match() -> None: + """_get_custom_mount_for_path must not match /mnt/code-read-extra for /mnt/code-read.""" + with patch("deerflow.sandbox.tools._get_custom_mounts", return_value=_mock_custom_mounts()): + mount = _get_custom_mount_for_path("/mnt/code-read-extra/foo") + assert mount is None + + def test_str_replace_parallel_updates_should_preserve_both_edits(monkeypatch) -> None: class SharedSandbox: def __init__(self) -> None: diff --git a/backend/tests/test_stream_bridge.py b/backend/tests/test_stream_bridge.py index 34d2e2811..f9aee4867 100644 --- a/backend/tests/test_stream_bridge.py +++ b/backend/tests/test_stream_bridge.py @@ -140,6 +140,193 @@ async def test_event_id_format(bridge: MemoryStreamBridge): assert re.match(r"^\d+-\d+$", event.id), f"Expected timestamp-seq format, got {event.id}" +# --------------------------------------------------------------------------- +# END sentinel guarantee tests +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_end_sentinel_delivered_when_queue_full(): + """END sentinel must always be delivered, even when the queue is completely full. + + This is the critical regression test for the bug where publish_end() + would silently drop the END sentinel when the queue was full, causing + subscribe() to hang forever and leaking resources. + """ + bridge = MemoryStreamBridge(queue_maxsize=2) + run_id = "run-end-full" + + # Fill the queue to capacity + await bridge.publish(run_id, "event-1", {"n": 1}) + await bridge.publish(run_id, "event-2", {"n": 2}) + assert bridge._queues[run_id].full() + + # publish_end should succeed by evicting old events + await bridge.publish_end(run_id) + + # Subscriber must receive END_SENTINEL + events = [] + async for entry in bridge.subscribe(run_id, heartbeat_interval=0.1): + events.append(entry) + if entry is END_SENTINEL: + break + + assert any(e is END_SENTINEL for e in events), "END sentinel was not delivered" + + +@pytest.mark.anyio +async def test_end_sentinel_evicts_oldest_events(): + """When queue is full, publish_end evicts the oldest events to make room.""" + bridge = MemoryStreamBridge(queue_maxsize=1) + run_id = "run-evict" + + # Fill queue with one event + await bridge.publish(run_id, "will-be-evicted", {}) + assert bridge._queues[run_id].full() + + # publish_end must succeed + await bridge.publish_end(run_id) + + # The only event we should get is END_SENTINEL (the regular event was evicted) + events = [] + async for entry in bridge.subscribe(run_id, heartbeat_interval=0.1): + events.append(entry) + if entry is END_SENTINEL: + break + + assert len(events) == 1 + assert events[0] is END_SENTINEL + + +@pytest.mark.anyio +async def test_end_sentinel_no_eviction_when_space_available(): + """When queue has space, publish_end should not evict anything.""" + bridge = MemoryStreamBridge(queue_maxsize=10) + run_id = "run-no-evict" + + await bridge.publish(run_id, "event-1", {"n": 1}) + await bridge.publish(run_id, "event-2", {"n": 2}) + await bridge.publish_end(run_id) + + events = [] + async for entry in bridge.subscribe(run_id, heartbeat_interval=0.1): + events.append(entry) + if entry is END_SENTINEL: + break + + # All events plus END should be present + assert len(events) == 3 + assert events[0].event == "event-1" + assert events[1].event == "event-2" + assert events[2] is END_SENTINEL + + +@pytest.mark.anyio +async def test_concurrent_tasks_end_sentinel(): + """Multiple concurrent producer/consumer pairs should all terminate properly. + + Simulates the production scenario where multiple runs share a single + bridge instance โ€” each must receive its own END sentinel. + """ + bridge = MemoryStreamBridge(queue_maxsize=4) + num_runs = 4 + + async def producer(run_id: str): + for i in range(10): # More events than queue capacity + await bridge.publish(run_id, f"event-{i}", {"i": i}) + await bridge.publish_end(run_id) + + async def consumer(run_id: str) -> list: + events = [] + async for entry in bridge.subscribe(run_id, heartbeat_interval=0.1): + events.append(entry) + if entry is END_SENTINEL: + return events + return events # pragma: no cover + + # Run producers and consumers concurrently + run_ids = [f"concurrent-{i}" for i in range(num_runs)] + producers = [producer(rid) for rid in run_ids] + consumers = [consumer(rid) for rid in run_ids] + + # Start consumers first, then producers + consumer_tasks = [asyncio.create_task(c) for c in consumers] + await asyncio.gather(*producers) + + results = await asyncio.wait_for( + asyncio.gather(*consumer_tasks), + timeout=10.0, + ) + + for i, events in enumerate(results): + assert events[-1] is END_SENTINEL, f"Run {run_ids[i]} did not receive END sentinel" + + +# --------------------------------------------------------------------------- +# Drop counter tests +# --------------------------------------------------------------------------- + + +@pytest.mark.anyio +async def test_dropped_count_tracking(): + """Dropped events should be tracked per run_id.""" + bridge = MemoryStreamBridge(queue_maxsize=1) + run_id = "run-drop-count" + + # Fill the queue + await bridge.publish(run_id, "first", {}) + + # This publish will time out and be dropped (we patch timeout to be instant) + # Instead, we verify the counter after publish_end eviction + await bridge.publish_end(run_id) + + # dropped_count tracks publish() drops, not publish_end evictions + assert bridge.dropped_count(run_id) == 0 + + # cleanup should also clear the counter + await bridge.cleanup(run_id) + assert bridge.dropped_count(run_id) == 0 + + +@pytest.mark.anyio +async def test_dropped_total(): + """dropped_total should sum across all runs.""" + bridge = MemoryStreamBridge(queue_maxsize=256) + + # No drops yet + assert bridge.dropped_total == 0 + + # Manually set some counts to verify the property + bridge._dropped_counts["run-a"] = 3 + bridge._dropped_counts["run-b"] = 7 + assert bridge.dropped_total == 10 + + +@pytest.mark.anyio +async def test_cleanup_clears_dropped_counts(): + """cleanup() should clear the dropped counter for the run.""" + bridge = MemoryStreamBridge(queue_maxsize=256) + run_id = "run-cleanup-drops" + + bridge._get_or_create_queue(run_id) + bridge._dropped_counts[run_id] = 5 + + await bridge.cleanup(run_id) + assert run_id not in bridge._dropped_counts + + +@pytest.mark.anyio +async def test_close_clears_dropped_counts(): + """close() should clear all dropped counters.""" + bridge = MemoryStreamBridge(queue_maxsize=256) + bridge._dropped_counts["run-x"] = 10 + bridge._dropped_counts["run-y"] = 20 + + await bridge.close() + assert bridge.dropped_total == 0 + assert len(bridge._dropped_counts) == 0 + + # --------------------------------------------------------------------------- # Factory tests # --------------------------------------------------------------------------- diff --git a/backend/tests/test_subagent_timeout_config.py b/backend/tests/test_subagent_timeout_config.py index 9edd971a0..50722cc97 100644 --- a/backend/tests/test_subagent_timeout_config.py +++ b/backend/tests/test_subagent_timeout_config.py @@ -1,8 +1,8 @@ -"""Tests for subagent timeout configuration. +"""Tests for subagent runtime configuration. Covers: - SubagentsAppConfig / SubagentOverrideConfig model validation and defaults -- get_timeout_for() resolution logic (global vs per-agent) +- get_timeout_for() / get_max_turns_for() resolution logic - load_subagents_config_from_dict() and get_subagents_app_config() singleton - registry.get_subagent_config() applies config overrides - registry.list_subagents() applies overrides for all agents @@ -24,9 +24,20 @@ from deerflow.subagents.config import SubagentConfig # --------------------------------------------------------------------------- -def _reset_subagents_config(timeout_seconds: int = 900, agents: dict | None = None) -> None: +def _reset_subagents_config( + timeout_seconds: int = 900, + *, + max_turns: int | None = None, + agents: dict | None = None, +) -> None: """Reset global subagents config to a known state.""" - load_subagents_config_from_dict({"timeout_seconds": timeout_seconds, "agents": agents or {}}) + load_subagents_config_from_dict( + { + "timeout_seconds": timeout_seconds, + "max_turns": max_turns, + "agents": agents or {}, + } + ) # --------------------------------------------------------------------------- @@ -38,22 +49,29 @@ class TestSubagentOverrideConfig: def test_default_is_none(self): override = SubagentOverrideConfig() assert override.timeout_seconds is None + assert override.max_turns is None def test_explicit_value(self): - override = SubagentOverrideConfig(timeout_seconds=300) + override = SubagentOverrideConfig(timeout_seconds=300, max_turns=42) assert override.timeout_seconds == 300 + assert override.max_turns == 42 def test_rejects_zero(self): with pytest.raises(ValueError): SubagentOverrideConfig(timeout_seconds=0) + with pytest.raises(ValueError): + SubagentOverrideConfig(max_turns=0) def test_rejects_negative(self): with pytest.raises(ValueError): SubagentOverrideConfig(timeout_seconds=-1) + with pytest.raises(ValueError): + SubagentOverrideConfig(max_turns=-1) def test_minimum_valid_value(self): - override = SubagentOverrideConfig(timeout_seconds=1) + override = SubagentOverrideConfig(timeout_seconds=1, max_turns=1) assert override.timeout_seconds == 1 + assert override.max_turns == 1 # --------------------------------------------------------------------------- @@ -66,66 +84,86 @@ class TestSubagentsAppConfigDefaults: config = SubagentsAppConfig() assert config.timeout_seconds == 900 + def test_default_max_turns_override_is_none(self): + config = SubagentsAppConfig() + assert config.max_turns is None + def test_default_agents_empty(self): config = SubagentsAppConfig() assert config.agents == {} - def test_custom_global_timeout(self): - config = SubagentsAppConfig(timeout_seconds=1800) + def test_custom_global_runtime_overrides(self): + config = SubagentsAppConfig(timeout_seconds=1800, max_turns=120) assert config.timeout_seconds == 1800 + assert config.max_turns == 120 def test_rejects_zero_timeout(self): with pytest.raises(ValueError): SubagentsAppConfig(timeout_seconds=0) + with pytest.raises(ValueError): + SubagentsAppConfig(max_turns=0) def test_rejects_negative_timeout(self): with pytest.raises(ValueError): SubagentsAppConfig(timeout_seconds=-60) + with pytest.raises(ValueError): + SubagentsAppConfig(max_turns=-60) # --------------------------------------------------------------------------- -# SubagentsAppConfig.get_timeout_for() +# SubagentsAppConfig resolution helpers # --------------------------------------------------------------------------- -class TestGetTimeoutFor: +class TestRuntimeResolution: def test_returns_global_default_when_no_override(self): config = SubagentsAppConfig(timeout_seconds=600) assert config.get_timeout_for("general-purpose") == 600 assert config.get_timeout_for("bash") == 600 assert config.get_timeout_for("unknown-agent") == 600 + assert config.get_max_turns_for("general-purpose", 100) == 100 + assert config.get_max_turns_for("bash", 60) == 60 def test_returns_per_agent_override_when_set(self): config = SubagentsAppConfig( timeout_seconds=900, - agents={"bash": SubagentOverrideConfig(timeout_seconds=300)}, + max_turns=120, + agents={"bash": SubagentOverrideConfig(timeout_seconds=300, max_turns=80)}, ) assert config.get_timeout_for("bash") == 300 + assert config.get_max_turns_for("bash", 60) == 80 def test_other_agents_still_use_global_default(self): config = SubagentsAppConfig( timeout_seconds=900, - agents={"bash": SubagentOverrideConfig(timeout_seconds=300)}, + max_turns=140, + agents={"bash": SubagentOverrideConfig(timeout_seconds=300, max_turns=80)}, ) assert config.get_timeout_for("general-purpose") == 900 + assert config.get_max_turns_for("general-purpose", 100) == 140 def test_agent_with_none_override_falls_back_to_global(self): config = SubagentsAppConfig( timeout_seconds=900, - agents={"general-purpose": SubagentOverrideConfig(timeout_seconds=None)}, + max_turns=150, + agents={"general-purpose": SubagentOverrideConfig(timeout_seconds=None, max_turns=None)}, ) assert config.get_timeout_for("general-purpose") == 900 + assert config.get_max_turns_for("general-purpose", 100) == 150 def test_multiple_per_agent_overrides(self): config = SubagentsAppConfig( timeout_seconds=900, + max_turns=120, agents={ - "general-purpose": SubagentOverrideConfig(timeout_seconds=1800), - "bash": SubagentOverrideConfig(timeout_seconds=120), + "general-purpose": SubagentOverrideConfig(timeout_seconds=1800, max_turns=200), + "bash": SubagentOverrideConfig(timeout_seconds=120, max_turns=80), }, ) assert config.get_timeout_for("general-purpose") == 1800 assert config.get_timeout_for("bash") == 120 + assert config.get_max_turns_for("general-purpose", 100) == 200 + assert config.get_max_turns_for("bash", 60) == 80 # --------------------------------------------------------------------------- @@ -139,54 +177,63 @@ class TestLoadSubagentsConfig: _reset_subagents_config() def test_load_global_timeout(self): - load_subagents_config_from_dict({"timeout_seconds": 300}) + load_subagents_config_from_dict({"timeout_seconds": 300, "max_turns": 120}) assert get_subagents_app_config().timeout_seconds == 300 + assert get_subagents_app_config().max_turns == 120 def test_load_with_per_agent_overrides(self): load_subagents_config_from_dict( { "timeout_seconds": 900, + "max_turns": 120, "agents": { - "general-purpose": {"timeout_seconds": 1800}, - "bash": {"timeout_seconds": 60}, + "general-purpose": {"timeout_seconds": 1800, "max_turns": 200}, + "bash": {"timeout_seconds": 60, "max_turns": 80}, }, } ) cfg = get_subagents_app_config() assert cfg.get_timeout_for("general-purpose") == 1800 assert cfg.get_timeout_for("bash") == 60 + assert cfg.get_max_turns_for("general-purpose", 100) == 200 + assert cfg.get_max_turns_for("bash", 60) == 80 def test_load_partial_override(self): load_subagents_config_from_dict( { "timeout_seconds": 600, - "agents": {"bash": {"timeout_seconds": 120}}, + "agents": {"bash": {"timeout_seconds": 120, "max_turns": 70}}, } ) cfg = get_subagents_app_config() assert cfg.get_timeout_for("general-purpose") == 600 assert cfg.get_timeout_for("bash") == 120 + assert cfg.get_max_turns_for("general-purpose", 100) == 100 + assert cfg.get_max_turns_for("bash", 60) == 70 def test_load_empty_dict_uses_defaults(self): load_subagents_config_from_dict({}) cfg = get_subagents_app_config() assert cfg.timeout_seconds == 900 + assert cfg.max_turns is None assert cfg.agents == {} def test_load_replaces_previous_config(self): - load_subagents_config_from_dict({"timeout_seconds": 100}) + load_subagents_config_from_dict({"timeout_seconds": 100, "max_turns": 90}) assert get_subagents_app_config().timeout_seconds == 100 + assert get_subagents_app_config().max_turns == 90 - load_subagents_config_from_dict({"timeout_seconds": 200}) + load_subagents_config_from_dict({"timeout_seconds": 200, "max_turns": 110}) assert get_subagents_app_config().timeout_seconds == 200 + assert get_subagents_app_config().max_turns == 110 def test_singleton_returns_same_instance_between_calls(self): - load_subagents_config_from_dict({"timeout_seconds": 777}) + load_subagents_config_from_dict({"timeout_seconds": 777, "max_turns": 123}) assert get_subagents_app_config() is get_subagents_app_config() # --------------------------------------------------------------------------- -# registry.get_subagent_config โ€“ timeout override applied +# registry.get_subagent_config โ€“ runtime overrides applied # --------------------------------------------------------------------------- @@ -211,25 +258,29 @@ class TestRegistryGetSubagentConfig: _reset_subagents_config(timeout_seconds=900) config = get_subagent_config("general-purpose") assert config.timeout_seconds == 900 + assert config.max_turns == 100 def test_global_timeout_override_applied(self): from deerflow.subagents.registry import get_subagent_config - _reset_subagents_config(timeout_seconds=1800) + _reset_subagents_config(timeout_seconds=1800, max_turns=140) config = get_subagent_config("general-purpose") assert config.timeout_seconds == 1800 + assert config.max_turns == 140 - def test_per_agent_timeout_override_applied(self): + def test_per_agent_runtime_override_applied(self): from deerflow.subagents.registry import get_subagent_config load_subagents_config_from_dict( { "timeout_seconds": 900, - "agents": {"bash": {"timeout_seconds": 120}}, + "max_turns": 120, + "agents": {"bash": {"timeout_seconds": 120, "max_turns": 80}}, } ) bash_config = get_subagent_config("bash") assert bash_config.timeout_seconds == 120 + assert bash_config.max_turns == 80 def test_per_agent_override_does_not_affect_other_agents(self): from deerflow.subagents.registry import get_subagent_config @@ -237,11 +288,13 @@ class TestRegistryGetSubagentConfig: load_subagents_config_from_dict( { "timeout_seconds": 900, - "agents": {"bash": {"timeout_seconds": 120}}, + "max_turns": 120, + "agents": {"bash": {"timeout_seconds": 120, "max_turns": 80}}, } ) gp_config = get_subagent_config("general-purpose") assert gp_config.timeout_seconds == 900 + assert gp_config.max_turns == 120 def test_builtin_config_object_is_not_mutated(self): """Registry must return a new object, leaving the builtin default intact.""" @@ -249,24 +302,27 @@ class TestRegistryGetSubagentConfig: from deerflow.subagents.registry import get_subagent_config original_timeout = BUILTIN_SUBAGENTS["bash"].timeout_seconds - load_subagents_config_from_dict({"timeout_seconds": 42}) + original_max_turns = BUILTIN_SUBAGENTS["bash"].max_turns + load_subagents_config_from_dict({"timeout_seconds": 42, "max_turns": 88}) returned = get_subagent_config("bash") assert returned.timeout_seconds == 42 + assert returned.max_turns == 88 assert BUILTIN_SUBAGENTS["bash"].timeout_seconds == original_timeout + assert BUILTIN_SUBAGENTS["bash"].max_turns == original_max_turns def test_config_preserves_other_fields(self): - """Applying timeout override must not change other SubagentConfig fields.""" + """Applying runtime overrides must not change other SubagentConfig fields.""" from deerflow.subagents.builtins import BUILTIN_SUBAGENTS from deerflow.subagents.registry import get_subagent_config - _reset_subagents_config(timeout_seconds=300) + _reset_subagents_config(timeout_seconds=300, max_turns=140) original = BUILTIN_SUBAGENTS["general-purpose"] overridden = get_subagent_config("general-purpose") assert overridden.name == original.name assert overridden.description == original.description - assert overridden.max_turns == original.max_turns + assert overridden.max_turns == 140 assert overridden.model == original.model assert overridden.tools == original.tools assert overridden.disallowed_tools == original.disallowed_tools @@ -291,9 +347,10 @@ class TestRegistryListSubagents: def test_all_returned_configs_get_global_override(self): from deerflow.subagents.registry import list_subagents - _reset_subagents_config(timeout_seconds=123) + _reset_subagents_config(timeout_seconds=123, max_turns=77) for cfg in list_subagents(): assert cfg.timeout_seconds == 123, f"{cfg.name} has wrong timeout" + assert cfg.max_turns == 77, f"{cfg.name} has wrong max_turns" def test_per_agent_overrides_reflected_in_list(self): from deerflow.subagents.registry import list_subagents @@ -301,15 +358,18 @@ class TestRegistryListSubagents: load_subagents_config_from_dict( { "timeout_seconds": 900, + "max_turns": 120, "agents": { - "general-purpose": {"timeout_seconds": 1800}, - "bash": {"timeout_seconds": 60}, + "general-purpose": {"timeout_seconds": 1800, "max_turns": 200}, + "bash": {"timeout_seconds": 60, "max_turns": 80}, }, } ) by_name = {cfg.name: cfg for cfg in list_subagents()} assert by_name["general-purpose"].timeout_seconds == 1800 assert by_name["bash"].timeout_seconds == 60 + assert by_name["general-purpose"].max_turns == 200 + assert by_name["bash"].max_turns == 80 # --------------------------------------------------------------------------- diff --git a/backend/tests/test_suggestions_router.py b/backend/tests/test_suggestions_router.py index 75237ee10..fee07dd44 100644 --- a/backend/tests/test_suggestions_router.py +++ b/backend/tests/test_suggestions_router.py @@ -1,5 +1,5 @@ import asyncio -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from app.gateway.routers import suggestions @@ -43,7 +43,7 @@ def test_generate_suggestions_parses_and_limits(monkeypatch): model_name=None, ) fake_model = MagicMock() - fake_model.invoke.return_value = MagicMock(content='```json\n["Q1", "Q2", "Q3", "Q4"]\n```') + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='```json\n["Q1", "Q2", "Q3", "Q4"]\n```')) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) result = asyncio.run(suggestions.generate_suggestions("t1", req)) @@ -61,7 +61,7 @@ def test_generate_suggestions_parses_list_block_content(monkeypatch): model_name=None, ) fake_model = MagicMock() - fake_model.invoke.return_value = MagicMock(content=[{"type": "text", "text": '```json\n["Q1", "Q2"]\n```'}]) + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content=[{"type": "text", "text": '```json\n["Q1", "Q2"]\n```'}])) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) result = asyncio.run(suggestions.generate_suggestions("t1", req)) @@ -79,7 +79,7 @@ def test_generate_suggestions_parses_output_text_block_content(monkeypatch): model_name=None, ) fake_model = MagicMock() - fake_model.invoke.return_value = MagicMock(content=[{"type": "output_text", "text": '```json\n["Q1", "Q2"]\n```'}]) + fake_model.ainvoke = AsyncMock(return_value=MagicMock(content=[{"type": "output_text", "text": '```json\n["Q1", "Q2"]\n```'}])) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) result = asyncio.run(suggestions.generate_suggestions("t1", req)) @@ -94,7 +94,7 @@ def test_generate_suggestions_returns_empty_on_model_error(monkeypatch): model_name=None, ) fake_model = MagicMock() - fake_model.invoke.side_effect = RuntimeError("boom") + fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("boom")) monkeypatch.setattr(suggestions, "create_chat_model", lambda **kwargs: fake_model) result = asyncio.run(suggestions.generate_suggestions("t1", req)) diff --git a/backend/tests/test_thread_runs_router.py b/backend/tests/test_thread_runs_router.py new file mode 100644 index 000000000..7f635cee7 --- /dev/null +++ b/backend/tests/test_thread_runs_router.py @@ -0,0 +1,111 @@ +"""Tests for thread_runs router with auth decorators. + +These tests verify that auth decorators properly enforce permission checks +on run endpoints. They follow the same pattern as test_threads_router.py. +""" + +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.gateway.auth.models import User +from app.gateway.authz import AuthContext +from app.gateway.routers.thread_runs import router + + +def test_create_run_requires_auth(): + """POST /{thread_id}/runs requires auth.""" + app = FastAPI() + app.include_router(router) + + with TestClient(app, raise_server_exceptions=False) as client: + response = client.post( + "/api/threads/test-thread/runs", + json={"assistant_id": "test"}, + ) + assert response.status_code == 401 + + +def test_create_run_with_auth(): + """POST /{thread_id}/runs with valid auth passes through.""" + app = FastAPI() + app.include_router(router) + + mock_user = User(id=uuid4(), email="test@example.com", password_hash="hash") + mock_auth = AuthContext( + user=mock_user, + permissions=["runs:create", "threads:read", "threads:write"], + ) + + # Mock the checkpointer and run_manager to avoid 503s + mock_checkpointer = MagicMock() + mock_run_manager = MagicMock() + mock_run_manager.list_by_thread = MagicMock(return_value=[]) + mock_stream_bridge = MagicMock() + + with patch("app.gateway.routers.thread_runs.get_checkpointer", return_value=mock_checkpointer): + with patch("app.gateway.routers.thread_runs.get_run_manager", return_value=mock_run_manager): + with patch("app.gateway.routers.thread_runs.get_stream_bridge", return_value=mock_stream_bridge): + with patch("app.gateway.authz._authenticate", return_value=mock_auth): + with TestClient(app, raise_server_exceptions=False) as client: + # Without a real checkpointer.setup, this will 500 - but the point is auth passed + response = client.post( + "/api/threads/test-thread/runs", + json={"assistant_id": "test"}, + ) + # Auth passed if we don't get 401 + assert response.status_code != 401 + + +def test_list_runs_requires_auth(): + """GET /{thread_id}/runs requires auth.""" + app = FastAPI() + app.include_router(router) + + with TestClient(app, raise_server_exceptions=False) as client: + response = client.get("/api/threads/test-thread/runs") + assert response.status_code == 401 + + +def test_list_runs_with_auth(): + """GET /{thread_id}/runs with auth passes through.""" + app = FastAPI() + app.include_router(router) + + mock_user = User(id=uuid4(), email="test@example.com", password_hash="hash") + mock_auth = AuthContext( + user=mock_user, + permissions=["runs:read", "threads:read"], + ) + + mock_run_manager = MagicMock() + mock_run_manager.list_by_thread = MagicMock(return_value=[]) + + with patch("app.gateway.routers.thread_runs.get_run_manager", return_value=mock_run_manager): + with patch("app.gateway.authz._authenticate", return_value=mock_auth): + with TestClient(app, raise_server_exceptions=False) as client: + response = client.get("/api/threads/test-thread/runs") + # Should not be 401 (may be 500 or other, but auth passed) + assert response.status_code != 401 + + +def test_get_run_requires_auth(): + """GET /{thread_id}/runs/{run_id} requires auth.""" + app = FastAPI() + app.include_router(router) + + with TestClient(app, raise_server_exceptions=False) as client: + response = client.get("/api/threads/test-thread/runs/run-123") + assert response.status_code == 401 + + +def test_cancel_run_requires_auth(): + """POST /{thread_id}/runs/{run_id}/cancel requires auth.""" + app = FastAPI() + app.include_router(router) + + with TestClient(app, raise_server_exceptions=False) as client: + response = client.post("/api/threads/test-thread/runs/run-123/cancel") + assert response.status_code == 401 diff --git a/backend/tests/test_threads_router.py b/backend/tests/test_threads_router.py index ad3abe4e9..2100e4525 100644 --- a/backend/tests/test_threads_router.py +++ b/backend/tests/test_threads_router.py @@ -49,20 +49,43 @@ def test_delete_thread_data_rejects_invalid_thread_id(tmp_path): def test_delete_thread_route_cleans_thread_directory(tmp_path): + """DELETE /{thread_id} requires auth + permission โ€” mock auth and store.""" + from unittest.mock import AsyncMock, MagicMock + + from app.gateway.authz import AuthContext + + tid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" paths = Paths(tmp_path) - thread_dir = paths.thread_dir("thread-route") - paths.sandbox_work_dir("thread-route").mkdir(parents=True, exist_ok=True) - (paths.sandbox_work_dir("thread-route") / "notes.txt").write_text("hello", encoding="utf-8") + thread_dir = paths.thread_dir(tid) + paths.sandbox_work_dir(tid).mkdir(parents=True, exist_ok=True) + (paths.sandbox_work_dir(tid) / "notes.txt").write_text("hello", encoding="utf-8") + + # Mock store item with .value attribute + mock_store = MagicMock() + mock_record = { + "thread_id": tid, + "metadata": {"user_id": "test-user-123"}, + } + mock_store_item = MagicMock() + mock_store_item.value = mock_record + mock_store.aget = AsyncMock(return_value=mock_store_item) + + mock_user = MagicMock() + mock_user.id = "test-user-123" + mock_auth = AuthContext(user=mock_user, permissions=["threads:read", "threads:write", "threads:delete"]) app = FastAPI() app.include_router(threads.router) with patch("app.gateway.routers.threads.get_paths", return_value=paths): - with TestClient(app) as client: - response = client.delete("/api/threads/thread-route") + with patch("app.gateway.routers.threads.get_store", return_value=mock_store): + with patch("app.gateway.routers.threads.get_checkpointer", return_value=MagicMock()): + with patch("app.gateway.authz._authenticate", return_value=mock_auth): + with TestClient(app) as client: + response = client.delete(f"/api/threads/{tid}") assert response.status_code == 200 - assert response.json() == {"success": True, "message": "Deleted local thread data for thread-route"} + assert response.json() == {"success": True, "message": f"Deleted local thread data for {tid}"} assert not thread_dir.exists() @@ -80,6 +103,7 @@ def test_delete_thread_route_rejects_invalid_thread_id(tmp_path): def test_delete_thread_route_returns_422_for_route_safe_invalid_id(tmp_path): + """DELETE /{thread_id} with non-UUID id โ€” FastAPI rejects at path validation.""" paths = Paths(tmp_path) app = FastAPI() @@ -90,7 +114,9 @@ def test_delete_thread_route_returns_422_for_route_safe_invalid_id(tmp_path): response = client.delete("/api/threads/thread.with.dot") assert response.status_code == 422 - assert "Invalid thread_id" in response.json()["detail"] + # FastAPI returns a list of validation errors for path parameter mismatch + detail = response.json()["detail"] + assert any("thread_id" in str(err) for err in detail) def test_delete_thread_data_returns_generic_500_error(tmp_path): diff --git a/backend/tests/test_title_middleware_core_logic.py b/backend/tests/test_title_middleware_core_logic.py index f2552e332..3b2b5926f 100644 --- a/backend/tests/test_title_middleware_core_logic.py +++ b/backend/tests/test_title_middleware_core_logic.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock from langchain_core.messages import AIMessage, HumanMessage +from deerflow.agents.middlewares import title_middleware as title_middleware_module from deerflow.agents.middlewares.title_middleware import TitleMiddleware from deerflow.config.title_config import TitleConfig, get_title_config, set_title_config @@ -73,37 +74,32 @@ class TestTitleMiddlewareCoreLogic: assert middleware._should_generate_title(state) is False - def test_generate_title_trims_quotes_and_respects_max_chars(self, monkeypatch): + def test_generate_title_uses_async_model_and_respects_max_chars(self, monkeypatch): _set_test_title_config(max_chars=12) middleware = TitleMiddleware() - fake_model = MagicMock() - fake_model.ainvoke = AsyncMock(return_value=MagicMock(content='"A very long generated title"')) - monkeypatch.setattr("deerflow.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model) + model = MagicMock() + model.ainvoke = AsyncMock(return_value=AIMessage(content="็Ÿญๆ ‡้ข˜")) + monkeypatch.setattr(title_middleware_module, "create_chat_model", MagicMock(return_value=model)) state = { "messages": [ - HumanMessage(content="่ฏทๅธฎๆˆ‘ๅ†™ไธ€ไธช่„šๆœฌ"), + HumanMessage(content="่ฏทๅธฎๆˆ‘ๅ†™ไธ€ไธชๅพˆ้•ฟๅพˆ้•ฟ็š„่„šๆœฌๆ ‡้ข˜"), AIMessage(content="ๅฅฝ็š„๏ผŒๅ…ˆ็กฎ่ฎค้œ€ๆฑ‚"), ] } result = asyncio.run(middleware._agenerate_title_result(state)) title = result["title"] - assert '"' not in title - assert "'" not in title - assert len(title) == 12 + assert title == "็Ÿญๆ ‡้ข˜" + title_middleware_module.create_chat_model.assert_called_once_with(thinking_enabled=False) + model.ainvoke.assert_awaited_once() - def test_generate_title_normalizes_structured_message_and_response_content(self, monkeypatch): + def test_generate_title_normalizes_structured_message_content(self, monkeypatch): _set_test_title_config(max_chars=20) middleware = TitleMiddleware() - fake_model = MagicMock() - fake_model.ainvoke = AsyncMock( - return_value=MagicMock(content=[{"type": "text", "text": '"็ป“ๆž„ๆ€ป็ป“"'}]), - ) - monkeypatch.setattr( - "deerflow.agents.middlewares.title_middleware.create_chat_model", - lambda **kwargs: fake_model, - ) + model = MagicMock() + model.ainvoke = AsyncMock(return_value=AIMessage(content="่ฏทๅธฎๆˆ‘ๆ€ป็ป“่ฟ™ๆฎตไปฃ็ ")) + monkeypatch.setattr(title_middleware_module, "create_chat_model", MagicMock(return_value=model)) state = { "messages": [ @@ -115,21 +111,14 @@ class TestTitleMiddlewareCoreLogic: result = asyncio.run(middleware._agenerate_title_result(state)) title = result["title"] - prompt = fake_model.ainvoke.await_args.args[0] - assert "่ฏทๅธฎๆˆ‘ๆ€ป็ป“่ฟ™ๆฎตไปฃ็ " in prompt - assert "ๅฅฝ็š„๏ผŒๅ…ˆ็œ‹็ป“ๆž„" in prompt - # Ensure structured message dict/JSON reprs are not leaking into the prompt. - assert "{'type':" not in prompt - assert "'type':" not in prompt - assert '"type":' not in prompt - assert title == "็ป“ๆž„ๆ€ป็ป“" + assert title == "่ฏทๅธฎๆˆ‘ๆ€ป็ป“่ฟ™ๆฎตไปฃ็ " - def test_generate_title_fallback_when_model_fails(self, monkeypatch): + def test_generate_title_fallback_for_long_message(self, monkeypatch): _set_test_title_config(max_chars=20) middleware = TitleMiddleware() - fake_model = MagicMock() - fake_model.ainvoke = AsyncMock(side_effect=RuntimeError("LLM unavailable")) - monkeypatch.setattr("deerflow.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model) + model = MagicMock() + model.ainvoke = AsyncMock(side_effect=RuntimeError("model unavailable")) + monkeypatch.setattr(title_middleware_module, "create_chat_model", MagicMock(return_value=model)) state = { "messages": [ @@ -164,13 +153,10 @@ class TestTitleMiddlewareCoreLogic: monkeypatch.setattr(middleware, "_generate_title_result", MagicMock(return_value=None)) assert middleware.after_model({"messages": []}, runtime=MagicMock()) is None - def test_sync_generate_title_with_model(self, monkeypatch): - """Sync path calls model.invoke and produces a title.""" + def test_sync_generate_title_uses_fallback_without_model(self): + """Sync path avoids LLM calls and derives a local fallback title.""" _set_test_title_config(max_chars=20) middleware = TitleMiddleware() - fake_model = MagicMock() - fake_model.invoke = MagicMock(return_value=MagicMock(content='"ๅŒๆญฅ็”Ÿๆˆ็š„ๆ ‡้ข˜"')) - monkeypatch.setattr("deerflow.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model) state = { "messages": [ @@ -179,22 +165,19 @@ class TestTitleMiddlewareCoreLogic: ] } result = middleware._generate_title_result(state) - assert result == {"title": "ๅŒๆญฅ็”Ÿๆˆ็š„ๆ ‡้ข˜"} - fake_model.invoke.assert_called_once() + assert result == {"title": "่ฏทๅธฎๆˆ‘ๅ†™ๆต‹่ฏ•"} - def test_empty_title_falls_back(self, monkeypatch): - """Empty model response triggers fallback title.""" + def test_sync_generate_title_respects_fallback_truncation(self): + """Sync fallback path still respects max_chars truncation rules.""" _set_test_title_config(max_chars=50) middleware = TitleMiddleware() - fake_model = MagicMock() - fake_model.invoke = MagicMock(return_value=MagicMock(content=" ")) - monkeypatch.setattr("deerflow.agents.middlewares.title_middleware.create_chat_model", lambda **kwargs: fake_model) state = { "messages": [ - HumanMessage(content="็ฉบๆ ‡้ข˜ๆต‹่ฏ•"), + HumanMessage(content="่ฟ™ๆ˜ฏไธ€ไธช้žๅธธ้•ฟ็š„้—ฎ้ข˜ๆ่ฟฐ๏ผŒ้œ€่ฆ่ขซๆˆชๆ–ญไปฅๅฝขๆˆfallbackๆ ‡้ข˜๏ผŒ่€Œไธ”่ฟ™้‡Œ็ปง็ปญ่กฅๅ……ๆ›ดๅคšไธŠไธ‹ๆ–‡๏ผŒ็กฎไฟ่ถ…่ฟ‡ๆœฌๅœฐfallbackๆˆชๆ–ญ้˜ˆๅ€ผ"), AIMessage(content="ๅ›žๅค"), ] } result = middleware._generate_title_result(state) - assert result["title"] == "็ฉบๆ ‡้ข˜ๆต‹่ฏ•" + assert result["title"].endswith("...") + assert result["title"].startswith("่ฟ™ๆ˜ฏไธ€ไธช้žๅธธ้•ฟ็š„้—ฎ้ข˜ๆ่ฟฐ") diff --git a/backend/tests/test_uploads_middleware_core_logic.py b/backend/tests/test_uploads_middleware_core_logic.py index e69f80978..72639fb09 100644 --- a/backend/tests/test_uploads_middleware_core_logic.py +++ b/backend/tests/test_uploads_middleware_core_logic.py @@ -289,6 +289,8 @@ class TestBeforeAgent: "size": 5, "path": "/mnt/user-data/uploads/notes.txt", "extension": ".txt", + "outline": [], + "outline_preview": [], } ] @@ -339,3 +341,130 @@ class TestBeforeAgent: result = mw.before_agent(self._state(msg), _runtime()) assert result["messages"][-1].id == "original-id-42" + + def test_outline_injected_when_md_file_exists(self, tmp_path): + """When a converted .md file exists alongside the upload, its outline is injected.""" + mw = _middleware(tmp_path) + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "report.pdf").write_bytes(b"%PDF fake") + # Simulate the .md produced by the conversion pipeline + (uploads_dir / "report.md").write_text( + "# PART I\n\n## ITEM 1. BUSINESS\n\nBody text.\n\n## ITEM 2. RISK\n", + encoding="utf-8", + ) + + msg = _human("summarise", files=[{"filename": "report.pdf", "size": 9, "path": "/mnt/user-data/uploads/report.pdf"}]) + result = mw.before_agent(self._state(msg), _runtime()) + + assert result is not None + content = result["messages"][-1].content + assert "Document outline" in content + assert "PART I" in content + assert "ITEM 1. BUSINESS" in content + assert "ITEM 2. RISK" in content + assert "read_file" in content + + def test_no_outline_when_no_md_file(self, tmp_path): + """Files without a sibling .md have no outline section.""" + mw = _middleware(tmp_path) + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "data.xlsx").write_bytes(b"fake-xlsx") + + msg = _human("analyse", files=[{"filename": "data.xlsx", "size": 9, "path": "/mnt/user-data/uploads/data.xlsx"}]) + result = mw.before_agent(self._state(msg), _runtime()) + + assert result is not None + content = result["messages"][-1].content + assert "Document outline" not in content + + def test_outline_truncation_hint_shown(self, tmp_path): + """When outline is truncated, a hint line is appended after the last visible entry.""" + from deerflow.utils.file_conversion import MAX_OUTLINE_ENTRIES + + mw = _middleware(tmp_path) + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "big.pdf").write_bytes(b"%PDF fake") + # Write MAX_OUTLINE_ENTRIES + 5 headings so truncation is triggered + headings = "\n".join(f"# Heading {i}" for i in range(MAX_OUTLINE_ENTRIES + 5)) + (uploads_dir / "big.md").write_text(headings, encoding="utf-8") + + msg = _human("read", files=[{"filename": "big.pdf", "size": 9, "path": "/mnt/user-data/uploads/big.pdf"}]) + result = mw.before_agent(self._state(msg), _runtime()) + + assert result is not None + content = result["messages"][-1].content + assert f"showing first {MAX_OUTLINE_ENTRIES} headings" in content + assert "use `read_file` to explore further" in content + + def test_no_truncation_hint_for_short_outline(self, tmp_path): + """Short outlines (under the cap) must not show a truncation hint.""" + mw = _middleware(tmp_path) + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "short.pdf").write_bytes(b"%PDF fake") + (uploads_dir / "short.md").write_text("# Intro\n\n# Conclusion\n", encoding="utf-8") + + msg = _human("read", files=[{"filename": "short.pdf", "size": 9, "path": "/mnt/user-data/uploads/short.pdf"}]) + result = mw.before_agent(self._state(msg), _runtime()) + + assert result is not None + content = result["messages"][-1].content + assert "showing first" not in content + + def test_historical_file_outline_injected(self, tmp_path): + """Outline is also shown for historical (previously uploaded) files.""" + mw = _middleware(tmp_path) + uploads_dir = _uploads_dir(tmp_path) + # Historical file with .md + (uploads_dir / "old_report.pdf").write_bytes(b"%PDF old") + (uploads_dir / "old_report.md").write_text( + "# Chapter 1\n\n# Chapter 2\n", + encoding="utf-8", + ) + # New file without .md + (uploads_dir / "new.txt").write_bytes(b"new") + + msg = _human("go", files=[{"filename": "new.txt", "size": 3, "path": "/mnt/user-data/uploads/new.txt"}]) + result = mw.before_agent(self._state(msg), _runtime()) + + assert result is not None + content = result["messages"][-1].content + assert "Chapter 1" in content + assert "Chapter 2" in content + + def test_fallback_preview_shown_when_outline_empty(self, tmp_path): + """When .md exists but has no headings, first lines are shown as a preview.""" + mw = _middleware(tmp_path) + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "report.pdf").write_bytes(b"%PDF fake") + # .md with no # headings โ€” plain prose only + (uploads_dir / "report.md").write_text( + "Annual Financial Report 2024\n\nThis document summarises key findings.\n\nRevenue grew by 12%.\n", + encoding="utf-8", + ) + + msg = _human("analyse", files=[{"filename": "report.pdf", "size": 9, "path": "/mnt/user-data/uploads/report.pdf"}]) + result = mw.before_agent(self._state(msg), _runtime()) + + assert result is not None + content = result["messages"][-1].content + # Outline section must NOT appear + assert "Document outline" not in content + # Preview lines must appear + assert "Annual Financial Report 2024" in content + assert "No structural headings detected" in content + # grep hint must appear + assert "grep" in content + + def test_fallback_grep_hint_shown_when_no_md_file(self, tmp_path): + """Files with no sibling .md still get the grep hint (outline is empty).""" + mw = _middleware(tmp_path) + uploads_dir = _uploads_dir(tmp_path) + (uploads_dir / "data.csv").write_bytes(b"a,b,c\n1,2,3\n") + + msg = _human("analyse", files=[{"filename": "data.csv", "size": 12, "path": "/mnt/user-data/uploads/data.csv"}]) + result = mw.before_agent(self._state(msg), _runtime()) + + assert result is not None + content = result["messages"][-1].content + assert "Document outline" not in content + assert "grep" in content diff --git a/backend/uv.lock b/backend/uv.lock index 73e4d9694..1fafbbdf4 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -325,6 +325,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -670,17 +736,21 @@ name = "deer-flow" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "bcrypt" }, { name = "deerflow-harness" }, + { name = "email-validator" }, { name = "fastapi" }, { name = "httpx" }, { name = "langgraph-sdk" }, { name = "lark-oapi" }, { name = "markdown-to-mrkdwn" }, + { name = "pyjwt" }, { name = "python-multipart" }, { name = "python-telegram-bot" }, { name = "slack-sdk" }, { name = "sse-starlette" }, { name = "uvicorn", extra = ["standard"] }, + { name = "wecom-aibot-python-sdk" }, ] [package.dev-dependencies] @@ -691,17 +761,21 @@ dev = [ [package.metadata] requires-dist = [ + { name = "bcrypt", specifier = ">=4.0.0" }, { name = "deerflow-harness", editable = "packages/harness" }, + { name = "email-validator", specifier = ">=2.0.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "langgraph-sdk", specifier = ">=0.1.51" }, { name = "lark-oapi", specifier = ">=1.4.0" }, { name = "markdown-to-mrkdwn", specifier = ">=0.3.1" }, + { name = "pyjwt", specifier = ">=2.9.0" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "slack-sdk", specifier = ">=3.33.0" }, { name = "sse-starlette", specifier = ">=2.1.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, + { name = "wecom-aibot-python-sdk", specifier = ">=0.1.6" }, ] [package.metadata.requires-dev] @@ -734,6 +808,7 @@ dependencies = [ { name = "langgraph-api" }, { name = "langgraph-checkpoint-sqlite" }, { name = "langgraph-cli" }, + { name = "langgraph-prebuilt" }, { name = "langgraph-runtime-inmem" }, { name = "langgraph-sdk" }, { name = "markdownify" }, @@ -745,6 +820,11 @@ dependencies = [ { name = "tiktoken" }, ] +[package.optional-dependencies] +pymupdf = [ + { name = "pymupdf4llm" }, +] + [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = ">=0.4.0" }, @@ -755,7 +835,7 @@ requires-dist = [ { name = "firecrawl-py", specifier = ">=1.15.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "kubernetes", specifier = ">=30.0.0" }, - { name = "langchain", specifier = ">=1.2.3" }, + { name = "langchain", specifier = ">=1.2.3,<1.2.10" }, { name = "langchain-anthropic", specifier = ">=1.3.4" }, { name = "langchain-deepseek", specifier = ">=1.0.1" }, { name = "langchain-google-genai", specifier = ">=4.2.1" }, @@ -766,16 +846,19 @@ requires-dist = [ { name = "langgraph-api", specifier = ">=0.7.0,<0.8.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.3" }, { name = "langgraph-cli", specifier = ">=0.4.14" }, - { name = "langgraph-runtime-inmem", specifier = ">=0.22.1" }, + { name = "langgraph-prebuilt", specifier = ">=1.0.6,<1.0.9" }, + { name = "langgraph-runtime-inmem", specifier = ">=0.22.1,<0.27.0" }, { name = "langgraph-sdk", specifier = ">=0.1.51" }, { name = "markdownify", specifier = ">=1.2.2" }, { name = "markitdown", extras = ["all", "xlsx"], specifier = ">=0.0.1a2" }, { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pymupdf4llm", marker = "extra == 'pymupdf'", specifier = ">=0.0.17" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "readabilipy", specifier = ">=0.3.0" }, { name = "tavily-python", specifier = ">=0.7.17" }, { name = "tiktoken", specifier = ">=0.8.0" }, ] +provides-extras = ["pymupdf"] [[package]] name = "defusedxml" @@ -795,6 +878,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -853,6 +945,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -2147,6 +2252,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "numpy" version = "2.4.1" @@ -2906,6 +3020,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2929,6 +3055,55 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pymupdf" +version = "1.27.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380, upload-time = "2026-03-20T09:47:58.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563, upload-time = "2026-03-19T12:35:42.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089, upload-time = "2026-03-20T09:44:16.982Z" }, + { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817, upload-time = "2026-03-20T09:44:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135, upload-time = "2026-03-20T09:44:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585, upload-time = "2026-03-20T09:45:05.393Z" }, + { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861, upload-time = "2026-03-20T09:45:21.353Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032, upload-time = "2026-03-20T09:45:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c6/e3e11c42f09b9c34ec332c0f37b817671b59ef4001895b854f0494092105/pymupdf-1.27.2.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6cebfbbdfd219ebdebf4d8e3914624b2e3d3a844c43f4f76935822dd9b13cc12", size = 24985299, upload-time = "2026-03-20T09:45:53.26Z" }, +] + +[[package]] +name = "pymupdf-layout" +version = "1.27.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "networkx" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "pymupdf" }, + { name = "pyyaml" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/dd/4a9769b17661c1ee1b5bdeac28c832c9c7cc1ef425eb2088b5b5bd982bcc/pymupdf_layout-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7b8f0d94d5675802c67e4af321214dcfce2de3d963926459dc6fc138607366cd", size = 15799842, upload-time = "2026-03-20T09:46:04.194Z" }, + { url = "https://files.pythonhosted.org/packages/ce/14/3ed13138449a002ab6957789019da5951fc8ba07ab8f1faf27a14c274717/pymupdf_layout-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bef82a3ff5c05212c806333153cece2b9d972eed173d2352f0c514bb3f1faf54", size = 15795217, upload-time = "2026-03-20T09:46:14.142Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/487a2b1422999113ecc8b117cf50e72915992d0a7ef247164989396cf8db/pymupdf_layout-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d610359e1eb8013124531431f3b8c77818070e7869500b92c9b25bd78ea7ef7f", size = 15805238, upload-time = "2026-03-20T09:46:23.676Z" }, + { url = "https://files.pythonhosted.org/packages/02/45/35c67a1b1956618f69674b9823cc78e96787de37fe22a2b217581a1770a9/pymupdf_layout-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df503eab9c28cfaadb847970f39093958e7a2ebf79fc47426dbd91b9f9064d6c", size = 15806267, upload-time = "2026-03-20T09:46:33.089Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/97fad0cd00869e934f7a130f251b21e3534ec0fcffaa3459286fbf3daf32/pymupdf_layout-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:efc66387833f085b9e9a77089c748c88c4c96485772d7dfe0139eaa6efc2f444", size = 15809705, upload-time = "2026-03-20T09:46:43.009Z" }, +] + +[[package]] +name = "pymupdf4llm" +version = "1.27.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pymupdf" }, + { name = "pymupdf-layout" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/e7/8b97bf223ea2fd72efd862af3210ae3aa2fb15b39b55767de9e0a2fd0985/pymupdf4llm-1.27.2.2.tar.gz", hash = "sha256:f95e113d434958f8c63393c836fe965ad398d1fc07e7807c0a627c9ec1946e9f", size = 72877, upload-time = "2026-03-20T09:48:01.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/fc/a4977b84f9a7e70aac4c9beed55d4693b985cef89fab7d49c896335bf158/pymupdf4llm-1.27.2.2-py3-none-any.whl", hash = "sha256:ec3bbceed21c6f86289155f29c557aa54ae1c8282c4a45d6de984f16fb4c90cb", size = 84294, upload-time = "2026-03-20T09:45:55.365Z" }, +] + [[package]] name = "pypdfium2" version = "5.3.0" @@ -3528,6 +3703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + [[package]] name = "tavily-python" version = "0.7.17" @@ -3884,6 +4068,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "wecom-aibot-python-sdk" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "certifi" }, + { name = "cryptography" }, + { name = "pyee" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/b4/df93b46006e5c1900703aefa59004e6d524a4e73ba56ae73bcce24ff4184/wecom_aibot_python_sdk-1.0.2.tar.gz", hash = "sha256:f8cd9920c0b6cb88bf8a50742fca1e834e5c49e06c3ae861d0f128672c17697b", size = 31706, upload-time = "2026-03-23T07:44:53.949Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/39/f2fab475f15d5bf596c4fa998ddd321b1400bcc6ae2e73d3e935db939379/wecom_aibot_python_sdk-1.0.2-py3-none-any.whl", hash = "sha256:03df207c72021157506647cd9f4ee51b865a7f37d3b5df7f7af1b1c7e677db84", size = 23228, upload-time = "2026-03-23T07:44:52.555Z" }, +] + [[package]] name = "wrapt" version = "1.17.3" diff --git a/backend/uv.toml b/backend/uv.toml new file mode 100644 index 000000000..7884c96f1 --- /dev/null +++ b/backend/uv.toml @@ -0,0 +1 @@ +index-url = "https://pypi.org/simple" diff --git a/config.example.yaml b/config.example.yaml index 813da9749..d6f382591 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -232,7 +232,6 @@ models: # supports_vision: true # supports_thinking: true - # Example: OpenRouter (OpenAI-compatible) # OpenRouter models use the same ChatOpenAI + base_url pattern as other OpenAI-compatible gateways. # - name: openrouter-gemini-2.5-flash @@ -325,6 +324,16 @@ tools: group: file:read use: deerflow.sandbox.tools:read_file_tool + - name: glob + group: file:read + use: deerflow.sandbox.tools:glob_tool + max_results: 200 + + - name: grep + group: file:read + use: deerflow.sandbox.tools:grep_tool + max_results: 100 + - name: write_file group: file:write use: deerflow.sandbox.tools:write_file_tool @@ -359,12 +368,27 @@ tool_search: # Option 1: Local Sandbox (Default) # Executes commands directly on the host machine +uploads: + # PDF-to-Markdown converter used when a PDF is uploaded. + # auto โ€” prefer pymupdf4llm when installed; fall back to MarkItDown for + # image-based or encrypted PDFs (recommended default). + # pymupdf4llm โ€” always use pymupdf4llm (must be installed: uv add pymupdf4llm). + # Better heading/table extraction; faster on most files. + # markitdown โ€” always use MarkItDown (original behaviour, no extra dependency). + pdf_converter: auto + sandbox: use: deerflow.sandbox.local:LocalSandboxProvider # Host bash execution is disabled by default because LocalSandboxProvider is # not a secure isolation boundary for shell access. Enable only for fully # trusted, single-user local workflows. allow_host_bash: false + # Optional: Mount additional host directories into the sandbox. + # Each mount maps a host path to a virtual container path accessible by the agent. + # mounts: + # - host_path: /home/user/my-project # Absolute path on the host machine + # container_path: /mnt/my-project # Virtual path inside the sandbox + # read_only: true # Whether the mount is read-only (default: false) # Tool output truncation limits (characters). # bash uses middle-truncation (head + tail) since errors can appear anywhere in the output. @@ -432,13 +456,17 @@ sandbox: # subagents: # # Default timeout in seconds for all subagents (default: 900 = 15 minutes) # timeout_seconds: 900 +# # Optional global max-turn override for all subagents +# # max_turns: 120 # -# # Optional per-agent timeout overrides +# # Optional per-agent overrides # agents: # general-purpose: # timeout_seconds: 1800 # 30 minutes for complex multi-step tasks +# max_turns: 160 # bash: # timeout_seconds: 300 # 5 minutes for quick command execution +# max_turns: 80 # ============================================================================ # ACP Agents Configuration @@ -662,6 +690,10 @@ checkpointer: # context: # thinking_enabled: true # subagent_enabled: true +# wecom: +# enabled: false +# bot_id: $WECOM_BOT_ID +# bot_secret: $WECOM_BOT_SECRET # ============================================================================ # Guardrails Configuration diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml index a77a957fa..da07ab873 100644 --- a/docker/docker-compose-dev.yaml +++ b/docker/docker-compose-dev.yaml @@ -19,8 +19,6 @@ services: # cluster via the K8s API. # Backend accesses sandboxes directly via host.docker.internal:{NodePort}. provisioner: - profiles: - - provisioner build: context: ./provisioner dockerfile: Dockerfile @@ -59,20 +57,25 @@ services: # โ”€โ”€ Reverse Proxy โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ # Routes API traffic to gateway/langgraph and (optionally) provisioner. - # Select nginx config via NGINX_CONF: - # - nginx.local.conf (default): no provisioner route (local/aio modes) - # - nginx.conf: includes provisioner route (provisioner mode) + # LANGGRAPH_UPSTREAM and LANGGRAPH_REWRITE control gateway vs standard + # routing (processed by envsubst at container start). nginx: image: nginx:alpine container_name: deer-flow-nginx ports: - "2026:2026" volumes: - - ./nginx/${NGINX_CONF:-nginx.conf}:/etc/nginx/nginx.conf:ro + - ./nginx/nginx.conf:/etc/nginx/nginx.conf.template:ro + environment: + - LANGGRAPH_UPSTREAM=${LANGGRAPH_UPSTREAM:-langgraph:2024} + - LANGGRAPH_REWRITE=${LANGGRAPH_REWRITE:-/} + command: > + sh -c "envsubst '$$LANGGRAPH_UPSTREAM $$LANGGRAPH_REWRITE' + < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf + && nginx -g 'daemon off;'" depends_on: - frontend - gateway - - langgraph networks: - deer-flow-dev restart: unless-stopped @@ -102,6 +105,7 @@ services: - CI=true - DEER_FLOW_INTERNAL_GATEWAY_BASE_URL=http://gateway:8001 - DEER_FLOW_INTERNAL_LANGGRAPH_BASE_URL=http://langgraph:2024 + - NEXT_PUBLIC_LANGGRAPH_BASE_URL=${NEXT_PUBLIC_LANGGRAPH_BASE_URL:-} env_file: - ../frontend/.env networks: @@ -113,6 +117,7 @@ services: build: context: ../ dockerfile: backend/Dockerfile + target: dev # cache_from disabled - requires manual setup: mkdir -p /tmp/docker-cache-gateway args: APT_MIRROR: ${APT_MIRROR:-} @@ -169,13 +174,14 @@ services: build: context: ../ dockerfile: backend/Dockerfile + target: dev # cache_from disabled - requires manual setup: mkdir -p /tmp/docker-cache-langgraph args: APT_MIRROR: ${APT_MIRROR:-} UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} container_name: deer-flow-langgraph - command: sh -c "cd backend && uv sync && uv run langgraph dev --no-browser --allow-blocking --host 0.0.0.0 --port 2024 --n-jobs-per-worker 10 > /app/logs/langgraph.log 2>&1" + command: sh -c "cd backend && uv sync && allow_blocking='' && if [ \"\${LANGGRAPH_ALLOW_BLOCKING:-0}\" = '1' ]; then allow_blocking='--allow-blocking'; fi && uv run langgraph dev --no-browser \${allow_blocking} --host 0.0.0.0 --port 2024 --n-jobs-per-worker \${LANGGRAPH_JOBS_PER_WORKER:-10} > /app/logs/langgraph.log 2>&1" volumes: - ../backend/:/app/backend/ # Preserve the .venv built during Docker image build โ€” mounting the full backend/ diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index b3974de9a..44923d5a1 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -29,11 +29,17 @@ services: ports: - "${PORT:-2026}:2026" volumes: - - ./nginx/${NGINX_CONF:-nginx.conf}:/etc/nginx/nginx.conf:ro + - ./nginx/nginx.conf:/etc/nginx/nginx.conf.template:ro + environment: + - LANGGRAPH_UPSTREAM=${LANGGRAPH_UPSTREAM:-langgraph:2024} + - LANGGRAPH_REWRITE=${LANGGRAPH_REWRITE:-/} + command: > + sh -c "envsubst '$$LANGGRAPH_UPSTREAM $$LANGGRAPH_REWRITE' + < /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf + && nginx -g 'daemon off;'" depends_on: - frontend - gateway - - langgraph networks: - deer-flow restart: unless-stopped @@ -52,6 +58,7 @@ services: - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} - DEER_FLOW_INTERNAL_GATEWAY_BASE_URL=http://gateway:8001 - DEER_FLOW_INTERNAL_LANGGRAPH_BASE_URL=http://langgraph:2024 + - NEXT_PUBLIC_LANGGRAPH_BASE_URL=${NEXT_PUBLIC_LANGGRAPH_BASE_URL:-} env_file: - ../frontend/.env networks: @@ -67,8 +74,10 @@ services: APT_MIRROR: ${APT_MIRROR:-} UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} + NODE_DIST_URL: ${NODE_DIST_URL:-} + NODE_VERSION: ${NODE_VERSION:-22.16.0} container_name: deer-flow-gateway - command: sh -c "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 2" + command: sh -c "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers ${GATEWAY_WORKERS:-4}" volumes: - ${DEER_FLOW_CONFIG_PATH}:/app/backend/config.yaml:ro - ${DEER_FLOW_EXTENSIONS_CONFIG_PATH}:/app/backend/extensions_config.json:ro @@ -118,8 +127,10 @@ services: APT_MIRROR: ${APT_MIRROR:-} UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} + NODE_DIST_URL: ${NODE_DIST_URL:-} + NODE_VERSION: ${NODE_VERSION:-22.16.0} container_name: deer-flow-langgraph - command: sh -c "cd /app/backend && uv run langgraph dev --no-browser --allow-blocking --no-reload --host 0.0.0.0 --port 2024 --n-jobs-per-worker 10" + command: sh -c 'cd /app/backend && allow_blocking_flag="" && if [ "${LANGGRAPH_ALLOW_BLOCKING:-0}" = "1" ]; then allow_blocking_flag="--allow-blocking"; fi && uv run langgraph dev --no-browser ${allow_blocking_flag} --no-reload --host 0.0.0.0 --port 2024 --n-jobs-per-worker ${LANGGRAPH_JOBS_PER_WORKER:-10}' volumes: - ${DEER_FLOW_CONFIG_PATH}:/app/backend/config.yaml:ro - ${DEER_FLOW_EXTENSIONS_CONFIG_PATH}:/app/backend/extensions_config.json:ro @@ -160,13 +171,12 @@ services: # โ”€โ”€ Sandbox Provisioner (optional, Kubernetes mode) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ provisioner: - profiles: - - provisioner build: context: ./provisioner dockerfile: Dockerfile args: APT_MIRROR: ${APT_MIRROR:-} + PIP_INDEX_URL: ${PIP_INDEX_URL:-} container_name: deer-flow-provisioner volumes: - ~/.kube/config:/root/.kube/config:ro diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index fdb5e0a50..75362f73f 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -18,21 +18,20 @@ http { resolver 127.0.0.11 valid=10s ipv6=off; # Upstream servers (using Docker service names) - # NOTE: add `resolve` so nginx re-resolves container IPs after restarts. - # Otherwise nginx may keep stale DNS results and proxy to the wrong container. + # NOTE: `zone` and `resolve` are nginx Plus-only features and are not + # available in the standard nginx:alpine image. Docker's internal DNS + # (127.0.0.11) handles service discovery; upstreams are resolved at + # nginx startup and remain valid for the lifetime of the deployment. upstream gateway { - zone gateway 64k; - server gateway:8001 resolve; + server gateway:8001; } upstream langgraph { - zone langgraph 64k; - server langgraph:2024 resolve; + server ${LANGGRAPH_UPSTREAM}; } upstream frontend { - zone frontend 64k; - server frontend:3000 resolve; + server frontend:3000; } # โ”€โ”€ Main server (path-based routing) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -58,9 +57,11 @@ http { } # LangGraph API routes - # Rewrites /api/langgraph/* to /* before proxying + # In standard mode: /api/langgraph/* โ†’ langgraph:2024 (rewrite to /*) + # In gateway mode: /api/langgraph/* โ†’ gateway:8001 (rewrite to /api/*) + # Controlled by LANGGRAPH_UPSTREAM and LANGGRAPH_REWRITE env vars. location /api/langgraph/ { - rewrite ^/api/langgraph/(.*) /$1 break; + rewrite ^/api/langgraph/(.*) ${LANGGRAPH_REWRITE}$1 break; proxy_pass http://langgraph; proxy_http_version 1.1; @@ -85,34 +86,6 @@ http { chunked_transfer_encoding on; } - # Experimental: Gateway-backed LangGraph-compatible API - # Frontend can opt-in via NEXT_PUBLIC_LANGGRAPH_BASE_URL=/api/langgraph-compat - location /api/langgraph-compat/ { - rewrite ^/api/langgraph-compat/(.*) /api/$1 break; - proxy_pass http://gateway; - proxy_http_version 1.1; - - # Headers - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Connection ''; - - # SSE/Streaming support - proxy_buffering off; - proxy_cache off; - proxy_set_header X-Accel-Buffering no; - - # Timeouts for long-running requests - proxy_connect_timeout 600s; - proxy_send_timeout 600s; - proxy_read_timeout 600s; - - # Chunked transfer encoding - chunked_transfer_encoding on; - } - # Custom API: Models endpoint location /api/models { proxy_pass http://gateway; @@ -217,6 +190,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # Auth API (gateway) + location /api/v1/auth/ { + proxy_pass http://gateway; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Health check endpoint (gateway) location /health { proxy_pass http://gateway; diff --git a/docker/nginx/nginx.local.conf b/docker/nginx/nginx.local.conf index f8dc5b609..29c10eb79 100644 --- a/docker/nginx/nginx.local.conf +++ b/docker/nginx/nginx.local.conf @@ -144,6 +144,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + # Auth API routes (served by gateway) + location /api/v1/auth/ { + proxy_pass http://gateway; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Custom API: Agents endpoint location /api/agents { proxy_pass http://gateway; @@ -179,8 +189,8 @@ http { } # API Documentation: Swagger UI - location /docs { - proxy_pass http://gateway; + location /api/docs { + proxy_pass http://gateway/docs ; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -189,8 +199,8 @@ http { } # API Documentation: ReDoc - location /redoc { - proxy_pass http://gateway; + location /api/redoc { + proxy_pass http://gateway/redoc; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/docker/provisioner/Dockerfile b/docker/provisioner/Dockerfile index 6f30e778d..96ef93156 100644 --- a/docker/provisioner/Dockerfile +++ b/docker/provisioner/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.12-slim-bookworm ARG APT_MIRROR +ARG PIP_INDEX_URL # Optionally override apt mirror for restricted networks (e.g. APT_MIRROR=mirrors.aliyun.com) RUN if [ -n "${APT_MIRROR}" ]; then \ @@ -15,6 +16,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Python dependencies RUN pip install --no-cache-dir \ + ${PIP_INDEX_URL:+--index-url "$PIP_INDEX_URL"} \ fastapi \ "uvicorn[standard]" \ kubernetes diff --git a/docs/superpowers/plans/2026-04-04-auth-permission-init.md b/docs/superpowers/plans/2026-04-04-auth-permission-init.md new file mode 100644 index 000000000..e32bbf394 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-auth-permission-init.md @@ -0,0 +1,1201 @@ +# Auth Permission Initialization โ€” Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the remaining auth module features defined in `docs/superpowers/specs/2026-04-04-auth-module-design.md` โ€” User model fields, token versioning, setup flow, rate limiting, thread migration. + +**Architecture:** Incremental backend-first approach. Each task adds one isolated feature with tests. DB schema changes come first (foundation), then JWT/auth logic, then API extensions, then frontend. Every backend change has a matching test. + +**Tech Stack:** Python 3.12, FastAPI, SQLite, PyJWT, bcrypt, Next.js 16, React 19, TypeScript + +--- + +## File Map + +| File | Responsibility | Tasks | +|------|---------------|-------| +| `backend/app/gateway/auth/models.py` | User model โ€” add `needs_setup`, `token_version` | 1 | +| `backend/app/gateway/auth/repositories/sqlite.py` | DDL + ALTER TABLE + WAL + updated queries | 1 | +| `backend/app/gateway/auth/jwt.py` | JWT payload โ€” add `ver` field | 2 | +| `backend/app/gateway/deps.py` | Token version check on decode | 2 | +| `backend/app/gateway/routers/auth.py` | change-password extension, rate limiting, login `needs_setup` | 3, 4 | +| `backend/app/gateway/app.py` | Thread migration in `_ensure_admin_user`, `needs_setup` logging | 5 | +| `backend/app/gateway/auth/reset_admin.py` | Set `needs_setup=True` + `token_version++` | 5 | +| `frontend/src/core/auth/types.ts` | AuthResult add `needs_setup` tag | 6 | +| `frontend/src/core/auth/server.ts` | SSR guard โ€” detect `needs_setup` | 6 | +| `frontend/src/app/workspace/layout.tsx` | Redirect to `/setup` | 6 | +| `frontend/src/app/(auth)/setup/page.tsx` | New setup page | 6 | +| `backend/tests/test_auth.py` | Tests for all backend changes | 1โ€“5 | + +--- + +### Task 1: User Model + DB Schema โ€” `needs_setup` and `token_version` + +**Files:** +- Modify: `backend/app/gateway/auth/models.py:15-28` +- Modify: `backend/app/gateway/auth/repositories/sqlite.py:41-64,81-112,140-151,178-189` +- Test: `backend/tests/test_auth.py` + +- [ ] **Step 1: Write failing test โ€” User model has new fields** + +Add to `backend/tests/test_auth.py` after the password hashing section: + +```python +# โ”€โ”€ User Model Fields โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_user_model_has_needs_setup_default_false(): + """New users default to needs_setup=False.""" + user = User(email="test@example.com", password_hash="hash") + assert user.needs_setup is False + +def test_user_model_has_token_version_default_zero(): + """New users default to token_version=0.""" + user = User(email="test@example.com", password_hash="hash") + assert user.token_version == 0 + +def test_user_model_needs_setup_true(): + """Auto-created admin has needs_setup=True.""" + user = User(email="admin@localhost", password_hash="hash", needs_setup=True) + assert user.needs_setup is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_user_model_has_needs_setup_default_false tests/test_auth.py::test_user_model_has_token_version_default_zero tests/test_auth.py::test_user_model_needs_setup_true -v` + +Expected: FAIL โ€” `User.__init__()` got unexpected keyword argument `needs_setup` + +- [ ] **Step 3: Add fields to User model** + +In `backend/app/gateway/auth/models.py`, add two fields to the `User` class after `oauth_id`: + +```python +class User(BaseModel): + """Internal user representation.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUID = Field(default_factory=uuid4, description="Primary key") + email: EmailStr = Field(..., description="Unique email address") + password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users") + system_role: Literal["admin", "user"] = Field(default="user") + created_at: datetime = Field(default_factory=_utc_now) + + # OAuth linkage (optional) + oauth_provider: str | None = Field(None, description="e.g. 'github', 'google'") + oauth_id: str | None = Field(None, description="User ID from OAuth provider") + + # Auth lifecycle + needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") + token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") +``` + +- [ ] **Step 4: Run model tests to verify they pass** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_user_model_has_needs_setup_default_false tests/test_auth.py::test_user_model_has_token_version_default_zero tests/test_auth.py::test_user_model_needs_setup_true -v` + +Expected: PASS + +- [ ] **Step 5: Update SQLite DDL โ€” CREATE TABLE + ALTER TABLE migration + WAL** + +In `backend/app/gateway/auth/repositories/sqlite.py`: + +1. Add WAL mode to `_get_connection()`: + +```python +def _get_connection() -> sqlite3.Connection: + """Get a SQLite connection for the users database.""" + db_path = _get_users_db_path() + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn +``` + +2. Update `_init_users_table()` to add new columns in CREATE TABLE and ALTER TABLE for existing DBs: + +```python +def _init_users_table(conn: sqlite3.Connection) -> None: + """Initialize the users table if it doesn't exist.""" + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT, + system_role TEXT NOT NULL DEFAULT 'user', + created_at REAL NOT NULL, + oauth_provider TEXT, + oauth_id TEXT, + needs_setup INTEGER NOT NULL DEFAULT 0, + token_version INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + # Add unique constraint for OAuth identity to prevent duplicate social logins + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oauth_identity + ON users(oauth_provider, oauth_id) + WHERE oauth_provider IS NOT NULL AND oauth_id IS NOT NULL + """ + ) + # Migrate existing databases: add new columns if missing + for col, default in [("needs_setup", "0"), ("token_version", "0")]: + try: + conn.execute(f"ALTER TABLE users ADD COLUMN {col} INTEGER NOT NULL DEFAULT {default}") + except sqlite3.OperationalError: + pass # Column already exists + conn.commit() +``` + +3. Update `_create_user_sync()` to include new fields: + +```python + def _create_user_sync(self, user: User) -> User: + """Synchronous user creation (runs in thread pool).""" + with _get_users_conn() as conn: + try: + conn.execute( + """ + INSERT INTO users (id, email, password_hash, system_role, created_at, + oauth_provider, oauth_id, needs_setup, token_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(user.id), + user.email, + user.password_hash, + user.system_role, + datetime.now(UTC).timestamp(), + user.oauth_provider, + user.oauth_id, + int(user.needs_setup), + user.token_version, + ), + ) + conn.commit() + except sqlite3.IntegrityError as e: + if "UNIQUE constraint failed: users.email" in str(e): + raise ValueError(f"Email already registered: {user.email}") from e + raise + return user +``` + +4. Update `_update_user_sync()` to include new fields: + +```python + def _update_user_sync(self, user: User) -> User: + with _get_users_conn() as conn: + conn.execute( + """UPDATE users SET email = ?, password_hash = ?, system_role = ?, + oauth_provider = ?, oauth_id = ?, needs_setup = ?, token_version = ? + WHERE id = ?""", + (user.email, user.password_hash, user.system_role, + user.oauth_provider, user.oauth_id, + int(user.needs_setup), user.token_version, str(user.id)), + ) + conn.commit() + return user +``` + +5. Update `_row_to_user()` to read new fields: + +```python + @staticmethod + def _row_to_user(row: dict[str, Any]) -> User: + """Convert a database row to a User model.""" + return User( + id=UUID(row["id"]), + email=row["email"], + password_hash=row["password_hash"], + system_role=row["system_role"], + created_at=datetime.fromtimestamp(row["created_at"], tz=UTC), + oauth_provider=row.get("oauth_provider"), + oauth_id=row.get("oauth_id"), + needs_setup=bool(row.get("needs_setup", 0)), + token_version=int(row.get("token_version", 0)), + ) +``` + +- [ ] **Step 6: Write DB round-trip test** + +Add to `backend/tests/test_auth.py`: + +```python +import asyncio +import tempfile +import os + +def test_sqlite_round_trip_new_fields(): + """needs_setup and token_version survive create โ†’ read round-trip.""" + from app.gateway.auth.repositories import sqlite as sqlite_mod + + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "test_users.db") + # Patch the DB path + old_path = sqlite_mod._resolved_db_path + old_init = sqlite_mod._table_initialized + sqlite_mod._resolved_db_path = Path(db_path) + sqlite_mod._table_initialized = False + try: + repo = sqlite_mod.SQLiteUserRepository() + user = User( + email="setup@test.com", + password_hash="fakehash", + system_role="admin", + needs_setup=True, + token_version=3, + ) + created = asyncio.run(repo.create_user(user)) + assert created.needs_setup is True + assert created.token_version == 3 + + fetched = asyncio.run(repo.get_user_by_email("setup@test.com")) + assert fetched is not None + assert fetched.needs_setup is True + assert fetched.token_version == 3 + + # Update + fetched.needs_setup = False + fetched.token_version = 4 + asyncio.run(repo.update_user(fetched)) + refetched = asyncio.run(repo.get_user_by_id(str(fetched.id))) + assert refetched.needs_setup is False + assert refetched.token_version == 4 + finally: + sqlite_mod._resolved_db_path = old_path + sqlite_mod._table_initialized = old_init +``` + +Add this import at the top of the test file if not present: `from pathlib import Path` + +- [ ] **Step 7: Run all Task 1 tests** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_user_model_has_needs_setup_default_false tests/test_auth.py::test_user_model_has_token_version_default_zero tests/test_auth.py::test_user_model_needs_setup_true tests/test_auth.py::test_sqlite_round_trip_new_fields -v` + +Expected: PASS (all 4) + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/gateway/auth/models.py backend/app/gateway/auth/repositories/sqlite.py backend/tests/test_auth.py +git commit -m "feat(auth): add needs_setup and token_version to User model + SQLite schema" +``` + +--- + +### Task 2: Token Invalidation โ€” JWT `ver` field + deps check + +**Files:** +- Modify: `backend/app/gateway/auth/jwt.py:12-35` +- Modify: `backend/app/gateway/deps.py:80-110` +- Test: `backend/tests/test_auth.py` + +- [ ] **Step 1: Write failing test โ€” JWT encodes ver** + +Add to `backend/tests/test_auth.py`: + +```python +# โ”€โ”€ Token Versioning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_jwt_encodes_ver(): + """JWT payload includes ver field.""" + import os + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(str(uuid4()), token_version=3) + payload = decode_token(token) + assert not isinstance(payload, TokenError) + assert payload.ver == 3 + +def test_jwt_default_ver_zero(): + """JWT ver defaults to 0.""" + import os + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + token = create_access_token(str(uuid4())) + payload = decode_token(token) + assert not isinstance(payload, TokenError) + assert payload.ver == 0 +``` + +Add `TokenError` to the existing import at line 11 of test_auth.py: + +```python +from app.gateway.auth import create_access_token, decode_token, hash_password, verify_password +from app.gateway.auth.errors import TokenError +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_jwt_encodes_ver tests/test_auth.py::test_jwt_default_ver_zero -v` + +Expected: FAIL โ€” `create_access_token() got unexpected keyword argument 'token_version'` + +- [ ] **Step 3: Update JWT module** + +In `backend/app/gateway/auth/jwt.py`: + +1. Add `ver` to `TokenPayload`: + +```python +class TokenPayload(BaseModel): + """JWT token payload.""" + + sub: str # user_id + exp: datetime + iat: datetime | None = None + ver: int = 0 # token_version โ€” must match User.token_version +``` + +2. Update `create_access_token()` to accept and encode `token_version`: + +```python +def create_access_token(user_id: str, expires_delta: timedelta | None = None, token_version: int = 0) -> str: + """Create a JWT access token. + + Args: + user_id: The user's UUID as string + expires_delta: Optional custom expiry, defaults to 7 days + token_version: User's current token_version for invalidation + + Returns: + Encoded JWT string + """ + config = get_auth_config() + expiry = expires_delta or timedelta(days=config.token_expiry_days) + + now = datetime.now(UTC) + payload = {"sub": user_id, "exp": now + expiry, "iat": now, "ver": token_version} + return jwt.encode(payload, config.jwt_secret, algorithm="HS256") +``` + +- [ ] **Step 4: Run JWT tests to verify they pass** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_jwt_encodes_ver tests/test_auth.py::test_jwt_default_ver_zero -v` + +Expected: PASS + +- [ ] **Step 5: Update deps.py โ€” check token_version on decode** + +In `backend/app/gateway/deps.py`, modify `get_current_user_from_request()` to compare versions. After the line `user = await provider.get_user(payload.sub)` and the null check, add: + +```python +async def get_current_user_from_request(request: Request): + """Get the current authenticated user from the request cookie. + + Raises HTTPException 401 if not authenticated. + """ + from app.gateway.auth import decode_token + from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse, TokenError, token_error_to_code + + access_token = request.cookies.get("access_token") + if not access_token: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.NOT_AUTHENTICATED, message="Not authenticated").model_dump(), + ) + + payload = decode_token(access_token) + if isinstance(payload, TokenError): + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=token_error_to_code(payload), message=f"Token error: {payload.value}").model_dump(), + ) + + provider = get_local_provider() + user = await provider.get_user(payload.sub) + if user is None: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.USER_NOT_FOUND, message="User not found").model_dump(), + ) + + # Token version mismatch โ†’ password was changed, token is stale + if user.token_version != payload.ver: + raise HTTPException( + status_code=401, + detail=AuthErrorResponse(code=AuthErrorCode.TOKEN_INVALID, message="Token revoked (password changed)").model_dump(), + ) + + return user +``` + +- [ ] **Step 6: Update all create_access_token call sites to pass token_version** + +In `backend/app/gateway/routers/auth.py`, update the two call sites: + +Login (line 95): +```python + token = create_access_token(str(user.id), token_version=user.token_version) +``` + +Register (line 116): +```python + token = create_access_token(str(user.id), token_version=user.token_version) +``` + +- [ ] **Step 7: Write test for token version mismatch rejection** + +Add to `backend/tests/test_auth.py`: + +```python +@pytest.mark.asyncio +async def test_token_version_mismatch_rejects(): + """Token with stale ver is rejected by get_current_user_from_request.""" + import os + os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" + + user_id = str(uuid4()) + # Create token with ver=0 + token = create_access_token(user_id, token_version=0) + + # Mock user with token_version=1 (password was changed) + mock_user = User(id=user_id, email="test@test.com", password_hash="hash", token_version=1) + + mock_request = MagicMock() + mock_request.cookies = {"access_token": token} + + with patch("app.gateway.deps.get_local_provider") as mock_provider_fn: + mock_provider = MagicMock() + mock_provider.get_user = MagicMock(return_value=mock_user) + mock_provider_fn.return_value = mock_provider + + from app.gateway.deps import get_current_user_from_request + with pytest.raises(HTTPException) as exc_info: + await get_current_user_from_request(mock_request) + assert exc_info.value.status_code == 401 + assert "revoked" in str(exc_info.value.detail).lower() +``` + +- [ ] **Step 8: Run all Task 2 tests** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_jwt_encodes_ver tests/test_auth.py::test_jwt_default_ver_zero tests/test_auth.py::test_token_version_mismatch_rejects -v` + +Expected: PASS (all 3) + +- [ ] **Step 9: Commit** + +```bash +git add backend/app/gateway/auth/jwt.py backend/app/gateway/deps.py backend/app/gateway/routers/auth.py backend/tests/test_auth.py +git commit -m "feat(auth): add token versioning โ€” JWT ver field + stale token rejection" +``` + +--- + +### Task 3: change-password Extension โ€” `new_email` + `needs_setup` + `token_version` + +**Files:** +- Modify: `backend/app/gateway/routers/auth.py:38-42,129-146` +- Test: `backend/tests/test_auth.py` + +- [ ] **Step 1: Write failing tests** + +Add to `backend/tests/test_auth.py`: + +```python +# โ”€โ”€ change-password extension โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_change_password_request_accepts_new_email(): + """ChangePasswordRequest model accepts optional new_email.""" + from app.gateway.routers.auth import ChangePasswordRequest + req = ChangePasswordRequest( + current_password="old", + new_password="newpassword", + new_email="new@example.com", + ) + assert req.new_email == "new@example.com" + +def test_change_password_request_new_email_optional(): + """ChangePasswordRequest model works without new_email.""" + from app.gateway.routers.auth import ChangePasswordRequest + req = ChangePasswordRequest(current_password="old", new_password="newpassword") + assert req.new_email is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_change_password_request_accepts_new_email tests/test_auth.py::test_change_password_request_new_email_optional -v` + +Expected: FAIL โ€” unexpected keyword argument `new_email` + +- [ ] **Step 3: Update ChangePasswordRequest model** + +In `backend/app/gateway/routers/auth.py`, update the model: + +```python +class ChangePasswordRequest(BaseModel): + """Request model for password change (also handles setup flow).""" + + current_password: str + new_password: str = Field(..., min_length=8) + new_email: EmailStr | None = None +``` + +- [ ] **Step 4: Update change_password endpoint** + +Replace the `change_password` function in `backend/app/gateway/routers/auth.py`: + +```python +@router.post("/change-password", response_model=MessageResponse) +async def change_password(request: Request, response: Response, body: ChangePasswordRequest): + """Change password for the currently authenticated user. + + Also handles the first-boot setup flow: + - If new_email is provided, updates email (checks uniqueness) + - If user.needs_setup is True and new_email is given, clears needs_setup + - Always increments token_version to invalidate old sessions + - Re-issues session cookie with new token_version + """ + from app.gateway.auth.password import hash_password_async, verify_password_async + + user = await get_current_user_from_request(request) + + if user.password_hash is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="OAuth users cannot change password").model_dump()) + + if not await verify_password_async(body.current_password, user.password_hash): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Current password is incorrect").model_dump()) + + # Update email if provided + if body.new_email is not None: + provider = get_local_provider() + existing = await provider.get_user_by_email(body.new_email) + if existing and str(existing.id) != str(user.id): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=AuthErrorResponse(code=AuthErrorCode.EMAIL_ALREADY_EXISTS, message="Email already in use").model_dump()) + user.email = body.new_email + + # Update password + bump version + user.password_hash = await hash_password_async(body.new_password) + user.token_version += 1 + + # Clear setup flag if this is the setup flow + if user.needs_setup and body.new_email is not None: + user.needs_setup = False + + await get_local_provider().update_user(user) + + # Re-issue cookie with new token_version + token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, token, request) + + return MessageResponse(message="Password changed successfully") +``` + +Note: add `Response` to the function signature (import already exists). + +- [ ] **Step 5: Update LoginResponse to include needs_setup** + +In `backend/app/gateway/routers/auth.py`, update the response model and the login endpoint: + +```python +class LoginResponse(BaseModel): + """Response model for login โ€” token only lives in HttpOnly cookie.""" + + expires_in: int # seconds + needs_setup: bool = False +``` + +Update the login endpoint return: + +```python + return LoginResponse( + expires_in=get_auth_config().token_expiry_days * 24 * 3600, + needs_setup=user.needs_setup, + ) +``` + +- [ ] **Step 6: Run model tests** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_change_password_request_accepts_new_email tests/test_auth.py::test_change_password_request_new_email_optional -v` + +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/gateway/routers/auth.py backend/tests/test_auth.py +git commit -m "feat(auth): extend change-password with new_email, token_version bump, and setup flow" +``` + +--- + +### Task 4: Login Rate Limiting + +**Files:** +- Modify: `backend/app/gateway/routers/auth.py:76-98` +- Test: `backend/tests/test_auth.py` + +- [ ] **Step 1: Write failing test** + +Add to `backend/tests/test_auth.py`: + +```python +# โ”€โ”€ Rate Limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_rate_limiter_allows_under_limit(): + """Requests under the limit are allowed.""" + from app.gateway.routers.auth import _check_rate_limit, _login_attempts + _login_attempts.clear() + # Should not raise + _check_rate_limit("192.168.1.1") + +def test_rate_limiter_blocks_after_max_failures(): + """IP is blocked after 5 consecutive failures.""" + import time + from app.gateway.routers.auth import _record_login_failure, _check_rate_limit, _login_attempts + _login_attempts.clear() + ip = "10.0.0.1" + for _ in range(5): + _record_login_failure(ip) + with pytest.raises(HTTPException) as exc_info: + _check_rate_limit(ip) + assert exc_info.value.status_code == 429 + +def test_rate_limiter_resets_on_success(): + """Successful login clears the failure counter.""" + from app.gateway.routers.auth import _record_login_failure, _record_login_success, _check_rate_limit, _login_attempts + _login_attempts.clear() + ip = "10.0.0.2" + for _ in range(4): + _record_login_failure(ip) + _record_login_success(ip) + # Should not raise โ€” counter was reset + _check_rate_limit(ip) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_rate_limiter_allows_under_limit tests/test_auth.py::test_rate_limiter_blocks_after_max_failures tests/test_auth.py::test_rate_limiter_resets_on_success -v` + +Expected: FAIL โ€” cannot import `_check_rate_limit` + +- [ ] **Step 3: Implement rate limiting** + +Add the following to `backend/app/gateway/routers/auth.py` after the `_set_session_cookie` helper (before endpoints): + +```python +# โ”€โ”€ Rate Limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +import time + +_MAX_LOGIN_ATTEMPTS = 5 +_LOCKOUT_SECONDS = 300 # 5 minutes + +# ip โ†’ (fail_count, lock_until_timestamp) +_login_attempts: dict[str, tuple[int, float]] = {} + + +def _check_rate_limit(ip: str) -> None: + """Raise 429 if the IP is currently locked out.""" + record = _login_attempts.get(ip) + if record is None: + return + fail_count, lock_until = record + if fail_count >= _MAX_LOGIN_ATTEMPTS and time.time() < lock_until: + raise HTTPException( + status_code=429, + detail="Too many login attempts. Try again later.", + ) + # Lockout expired โ€” clear + if fail_count >= _MAX_LOGIN_ATTEMPTS and time.time() >= lock_until: + del _login_attempts[ip] + + +def _record_login_failure(ip: str) -> None: + """Record a failed login attempt for the given IP.""" + record = _login_attempts.get(ip) + if record is None: + _login_attempts[ip] = (1, 0.0) + else: + new_count = record[0] + 1 + lock_until = time.time() + _LOCKOUT_SECONDS if new_count >= _MAX_LOGIN_ATTEMPTS else 0.0 + _login_attempts[ip] = (new_count, lock_until) + + +def _record_login_success(ip: str) -> None: + """Clear failure counter for the given IP on successful login.""" + _login_attempts.pop(ip, None) +``` + +- [ ] **Step 4: Wire rate limiting into login endpoint** + +Update the `login_local` function to call rate limiting. Add at the start of the function body: + +```python + client_ip = request.client.host if request.client else "unknown" + _check_rate_limit(client_ip) +``` + +After the `if user is None:` block, add `_record_login_failure(client_ip)` before the raise. After a successful login (before `return`), add `_record_login_success(client_ip)`. + +The full login function becomes: + +```python +@router.post("/login/local", response_model=LoginResponse) +async def login_local( + request: Request, + response: Response, + form_data: OAuth2PasswordRequestForm = Depends(), +): + """Local email/password login.""" + client_ip = request.client.host if request.client else "unknown" + _check_rate_limit(client_ip) + + user = await get_local_provider().authenticate({"email": form_data.username, "password": form_data.password}) + + if user is None: + _record_login_failure(client_ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(), + ) + + _record_login_success(client_ip) + token = create_access_token(str(user.id), token_version=user.token_version) + _set_session_cookie(response, token, request) + + return LoginResponse( + expires_in=get_auth_config().token_expiry_days * 24 * 3600, + needs_setup=user.needs_setup, + ) +``` + +- [ ] **Step 5: Run rate limiting tests** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_rate_limiter_allows_under_limit tests/test_auth.py::test_rate_limiter_blocks_after_max_failures tests/test_auth.py::test_rate_limiter_resets_on_success -v` + +Expected: PASS (all 3) + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/gateway/routers/auth.py backend/tests/test_auth.py +git commit -m "feat(auth): add IP-based login rate limiting (5 attempts, 5-min lockout)" +``` + +--- + +### Task 5: Thread Migration + `_ensure_admin_user` + `reset_admin` Updates + +**Files:** +- Modify: `backend/app/gateway/app.py:40-61` +- Modify: `backend/app/gateway/auth/reset_admin.py:34-36` +- Test: `backend/tests/test_auth.py` + +- [ ] **Step 1: Write failing test for admin creation with needs_setup** + +Add to `backend/tests/test_auth.py`: + +```python +# โ”€โ”€ Admin Bootstrap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@pytest.mark.asyncio +async def test_ensure_admin_sets_needs_setup(): + """_ensure_admin_user creates admin with needs_setup=True.""" + from unittest.mock import AsyncMock, patch + + mock_provider = MagicMock() + mock_provider.count_users = AsyncMock(return_value=0) + + created_user = None + async def capture_create(email, password, system_role): + nonlocal created_user + created_user = User(email=email, password_hash="hash", system_role=system_role, needs_setup=True) + return created_user + mock_provider.create_user = capture_create + + mock_app = MagicMock() + mock_app.state = MagicMock() + mock_app.state.store = None # No store โ€” skip thread migration + + with patch("app.gateway.app.get_local_provider", return_value=mock_provider): + from app.gateway.app import _ensure_admin_user + await _ensure_admin_user(mock_app) + + assert created_user is not None + assert created_user.needs_setup is True +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_ensure_admin_sets_needs_setup -v` + +Expected: FAIL โ€” `_ensure_admin_user` doesn't pass `needs_setup=True` + +- [ ] **Step 3: Update `_ensure_admin_user` in app.py** + +Replace the function in `backend/app/gateway/app.py`: + +```python +async def _ensure_admin_user(app: FastAPI) -> None: + """Auto-create the admin user on first boot if no users exist. + + Prints the generated password to stdout so the operator can log in. + On subsequent boots, warns if any user still needs setup. + """ + import secrets + + from app.gateway.deps import get_local_provider + + provider = get_local_provider() + user_count = await provider.count_users() + + if user_count == 0: + password = secrets.token_urlsafe(16) + admin = await provider.create_user(email="admin@localhost", password=password, system_role="admin") + + # Set needs_setup flag (create_user defaults to False, update it) + admin.needs_setup = True + await provider.update_user(admin) + + # Migrate orphaned threads (no user_id) to this admin + store = getattr(app.state, "store", None) + if store is not None: + await _migrate_orphaned_threads(store, str(admin.id)) + + logger.info("=" * 60) + logger.info(" Admin account created on first boot") + logger.info(" Email: %s", admin.email) + logger.info(" Password: %s", password) + logger.info(" Change it after login: Settings -> Account") + logger.info("=" * 60) + return + + # Check for users that still need setup + admin = await provider.get_user_by_email("admin@localhost") + if admin and admin.needs_setup: + logger.warning("Admin account still needs setup. Log in or use: python -m app.gateway.auth.reset_admin") + + +async def _migrate_orphaned_threads(store, admin_user_id: str) -> None: + """Migrate threads with no user_id to the given admin.""" + try: + migrated = 0 + results = await store.asearch(("threads",), limit=1000) + for item in results: + metadata = item.value.get("metadata", {}) if hasattr(item, "value") else {} + if not metadata.get("user_id"): + metadata["user_id"] = admin_user_id + if hasattr(item, "value"): + item.value["metadata"] = metadata + await store.aput(("threads",), item.key, item.value) + migrated += 1 + if migrated: + logger.info("Migrated %d orphaned thread(s) to admin", migrated) + except Exception: + logger.exception("Thread migration failed (non-fatal)") +``` + +- [ ] **Step 4: Update reset_admin.py** + +Replace the password reset section in `backend/app/gateway/auth/reset_admin.py`: + +```python + new_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(new_password) + user.token_version += 1 + user.needs_setup = True + asyncio.run(repo.update_user(user)) + + print(f"Password reset for: {user.email}") + print(f"New password: {new_password}") + print("Next login will require setup (new email + password).") +``` + +- [ ] **Step 5: Run admin bootstrap test** + +Run: `cd backend && PYTHONPATH=. uv run pytest tests/test_auth.py::test_ensure_admin_sets_needs_setup -v` + +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/gateway/app.py backend/app/gateway/auth/reset_admin.py backend/tests/test_auth.py +git commit -m "feat(auth): thread migration on first boot + reset_admin sets needs_setup + token_version" +``` + +--- + +### Task 6: Frontend โ€” Setup Page + SSR Guard + +**Files:** +- Modify: `frontend/src/core/auth/types.ts:15-19` +- Modify: `frontend/src/core/auth/server.ts:36-43` +- Modify: `frontend/src/app/workspace/layout.tsx:17-55` +- Create: `frontend/src/app/(auth)/setup/page.tsx` + +- [ ] **Step 1: Add `needs_setup` tag to AuthResult** + +In `frontend/src/core/auth/types.ts`, update the AuthResult type: + +```typescript +export type AuthResult = + | { tag: "authenticated"; user: User } + | { tag: "needs_setup"; user: User } + | { tag: "unauthenticated" } + | { tag: "gateway_unavailable" } + | { tag: "config_error"; message: string }; +``` + +- [ ] **Step 2: Update SSR guard to detect needs_setup** + +In `frontend/src/core/auth/server.ts`, update the `getServerSideUser()` function. After the successful `res.ok` block where user is parsed, add the needs_setup check: + +```typescript + if (res.ok) { + const parsed = userSchema.safeParse(await res.json()); + if (!parsed.success) { + console.error("[SSR auth] Malformed /auth/me response:", parsed.error); + return { tag: "gateway_unavailable" }; + } + // Check if user needs initial setup + if (parsed.data.needs_setup) { + return { tag: "needs_setup", user: parsed.data }; + } + return { tag: "authenticated", user: parsed.data }; + } +``` + +Also update `userSchema` in `types.ts` to include `needs_setup`: + +```typescript +export const userSchema = z.object({ + id: z.string(), + email: z.string().email(), + system_role: z.enum(["admin", "user"]), + needs_setup: z.boolean().optional().default(false), +}); +``` + +And update `UserResponse` in the backend `models.py` to include `needs_setup`: + +```python +class UserResponse(BaseModel): + """Response model for user info endpoint.""" + + id: str + email: str + system_role: Literal["admin", "user"] + needs_setup: bool = False +``` + +And update the `/me` endpoint in `backend/app/gateway/routers/auth.py`: + +```python +@router.get("/me", response_model=UserResponse) +async def get_me(request: Request): + """Get current authenticated user info.""" + user = await get_current_user_from_request(request) + return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup) +``` + +- [ ] **Step 3: Update workspace layout to handle needs_setup** + +In `frontend/src/app/workspace/layout.tsx`: + +```typescript + switch (result.tag) { + case "authenticated": + return ( + <AuthProvider initialUser={result.user}> + <WorkspaceContent>{children}</WorkspaceContent> + </AuthProvider> + ); + case "needs_setup": + redirect("/setup"); + case "unauthenticated": + redirect("/login"); + case "gateway_unavailable": + return ( + <div className="flex h-screen flex-col items-center justify-center gap-4"> + <p className="text-muted-foreground"> + Service temporarily unavailable. + </p> + <p className="text-muted-foreground text-xs"> + The backend may be restarting. Please wait a moment and try again. + </p> + <div className="flex gap-3"> + <Link + href="/workspace" + className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm" + > + Retry + </Link> + <Link + href="/api/v1/auth/logout" + className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm" + > + Logout & Reset + </Link> + </div> + </div> + ); + case "config_error": + throw new Error(result.message); + default: + assertNever(result); + } +``` + +- [ ] **Step 4: Create the setup page** + +Create `frontend/src/app/(auth)/setup/page.tsx`: + +```tsx +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getCsrfHeaders } from "@/core/api/fetcher"; +import { parseAuthError } from "@/core/auth/types"; + +export default function SetupPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSetup = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + try { + const res = await fetch("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getCsrfHeaders(), + }, + credentials: "include", + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + new_email: email || undefined, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + router.push("/workspace"); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + <div className="flex min-h-screen items-center justify-center"> + <div className="w-full max-w-sm space-y-6 p-6"> + <div className="text-center"> + <h1 className="font-serif text-3xl">DeerFlow</h1> + <p className="text-muted-foreground mt-2"> + Complete admin account setup + </p> + <p className="text-muted-foreground mt-1 text-xs"> + Set your real email and a new password. + </p> + </div> + <form onSubmit={handleSetup} className="space-y-4"> + <Input + type="email" + placeholder="Your email" + value={email} + onChange={(e) => setEmail(e.target.value)} + required + /> + <Input + type="password" + placeholder="Current password (from console log)" + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} + required + /> + <Input + type="password" + placeholder="New password" + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} + required + minLength={8} + /> + <Input + type="password" + placeholder="Confirm new password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error && <p className="text-sm text-red-500">{error}</p>} + <Button type="submit" className="w-full" disabled={loading}> + {loading ? "Setting up..." : "Complete Setup"} + </Button> + </form> + </div> + </div> + ); +} +``` + +- [ ] **Step 5: Run frontend type check** + +Run: `cd frontend && pnpm typecheck` + +Expected: PASS (no type errors) + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/core/auth/types.ts frontend/src/core/auth/server.ts frontend/src/app/workspace/layout.tsx frontend/src/app/\(auth\)/setup/page.tsx backend/app/gateway/auth/models.py backend/app/gateway/routers/auth.py +git commit -m "feat(auth): add setup page + SSR guard for needs_setup flow" +``` + +--- + +### Task 7: Full Regression โ€” Run All Tests + +- [ ] **Step 1: Run full backend test suite** + +Run: `cd backend && make test` + +Expected: All tests pass (including new tests from Tasks 1โ€“5) + +- [ ] **Step 2: Run frontend check** + +Run: `cd frontend && pnpm check` + +Expected: No lint or type errors + +- [ ] **Step 3: Fix any regressions** + +If any tests fail, diagnose and fix before proceeding. + +- [ ] **Step 4: Final commit (if fixes were needed)** + +```bash +git add -A +git commit -m "fix(auth): address test regressions from permission init changes" +``` diff --git a/docs/superpowers/specs/2026-04-04-auth-module-design.md b/docs/superpowers/specs/2026-04-04-auth-module-design.md new file mode 100644 index 000000000..88cd34527 --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-auth-module-design.md @@ -0,0 +1,228 @@ +# Auth Module Design Spec + +**Date:** 2026-04-04 +**Status:** Approved +**PR:** #1728 + +## Overview + +DeerFlow ๅ†…็ฝฎ่ฎค่ฏๆจกๅ—๏ผŒๅง‹็ปˆๅผบๅˆถ๏ผŒ้›ถ้…็ฝฎๅฏๅŠจใ€‚้ฆ–ๆฌกๅฏๅŠจ่‡ชๅŠจๅˆ›ๅปบ admin๏ผŒ็”จๆˆท้€š่ฟ‡ๆŽงๅˆถๅฐๆ—ฅๅฟ—่Žทๅ–ๅˆๅง‹ๅฏ†็ ๏ผŒ้ฆ–ๆฌก็™ปๅฝ•ๆ—ถ่ฎพ็ฝฎ็œŸๅฎž้‚ฎ็ฎฑๅ’Œๆ–ฐๅฏ†็ ใ€‚ + +## Design Decisions + +| ๅ†ณ็ญ– | ้€‰ๆ‹ฉ | ๆ‹’็ป็š„ๆ›ฟไปฃๆ–นๆกˆ | ็†็”ฑ | +|------|------|---------------|------| +| Auth ๆจกๅผ | ๅง‹็ปˆๅผบๅˆถ | ๆธ่ฟ›ๅผ / ๅฏๅ…ณ้—ญ | ๆ— ็ซžไบ‰็ช—ๅฃ๏ผŒๆ— ๆกไปถๅˆ†ๆ”ฏ | +| Admin ๅˆ›ๅปบ | ๅฏๅŠจๆ—ถ่‡ชๅŠจๅˆ›ๅปบ + ้šๆœบๅฏ†็  | Setup ้กต้ข่‡ชๆณจๅ†Œ / ็Žฏๅขƒๅ˜้‡ๆณจๅ…ฅ | ้›ถ้…็ฝฎ + ๆ— ็ซžไบ‰ | +| ๅฏ†็ ๅ‘็Žฐ | ๆŽงๅˆถๅฐๆ—ฅๅฟ— | ๅฏ†็ ๆ–‡ไปถ | ๆ— ๆ–‡ไปถๆƒ้™/ๅˆ ้™ค/gitignore ้—ฎ้ข˜ | +| ้ฆ–ๆฌก็™ปๅฝ• | ๅผบๅˆถ setup๏ผˆๆ”น้‚ฎ็ฎฑ + ๆ”นๅฏ†็ ๏ผ‰ | ๅฏ้€‰ไฟฎๆ”น | ้ฟๅ… admin@localhost ๆˆไธบๆฐธไน…่ดฆๅท | +| Setup ๆŽฅๅฃ | ๆ‰ฉๅฑ• change-password | ๆ–ฐๅขž /setup ็ซฏ็‚น | YAGNI๏ผŒๅŠŸ่ƒฝๅฎŒๅ…จ่ฆ†็›– | +| needs_setup ๅญ˜ๅ‚จ | DB ๅญ—ๆฎต | JWT payload / ๆ–‡ไปถ | Single source of truth | +| Auth ไฟๆŠค่Œƒๅ›ด | ๅ…จๅฑ€ middleware + allowlist | ้€่ทฏ็”ฑ่ฃ…้ฅฐๅ™จ | ๆผๅŠ ่ฃ…้ฅฐๅ™จ = ๆผๆดž | +| ๆณจๅ†Œ | ๅผ€ๆ”พ๏ผˆuser ่ง’่‰ฒ๏ผ‰ | ้ป˜่ฎคๅ…ณ้—ญ / ้‚€่ฏทๅˆถ | ๅ›ข้˜Ÿๅทฅๅ…ท๏ผŒไฝŽๆปฅ็”จ้ฃŽ้™ฉ | +| Token ๅคฑๆ•ˆ | password_version ๅญ—ๆฎต | blocklist / secret rotation | ๆœ€็ฎ€ๅฎž็Žฐ | + +## Data Model + +### User ่กจๅ˜ๆ›ด + +ๆ–ฐๅขžไธคไธชๅญ—ๆฎต๏ผš + +```sql +needs_setup BOOLEAN NOT NULL DEFAULT FALSE +token_version INTEGER NOT NULL DEFAULT 0 +``` + +- `needs_setup`: ่‡ชๅŠจๅˆ›ๅปบ็š„ admin ไธบ True๏ผŒๅฎŒๆˆ setup ๅŽ False +- `token_version`: ๆฏๆฌกๆ”นๅฏ†็  +1๏ผŒJWT ๆ ก้ชŒๆ—ถๆฏ”ๅฏน + +ๅทฒๆœ‰ๆ•ฐๆฎๅบ“ๅ‡็บง๏ผš`ALTER TABLE users ADD COLUMN ... DEFAULT ...`๏ผŒ็”จ try/except ๅ…ผๅฎนใ€‚ + +### JWT Payload + +```json +{ + "sub": "user_id", + "ver": 0, + "exp": "...", + "iat": "..." +} +``` + +`ver` ๅฏนๅบ” `User.token_version`๏ผŒdecode ๆ—ถๆŸฅ DB ๆฏ”ๅฏน๏ผŒไธๅŒน้…ๅˆ™ 401ใ€‚ + +## Startup Flow + +``` +Gateway lifespan ๅฏๅŠจ + โ†“ +count_users() == 0? + โ”œโ”€ YES โ†’ ๅˆ›ๅปบ admin@deerflow.dev (needs_setup=True, ้šๆœบๅฏ†็ ) + โ”‚ โ†’ ่ฟ็งปๆ—  user_id ็š„ thread ๅˆฐ admin + โ”‚ โ†’ ๆŽงๅˆถๅฐ่พ“ๅ‡บ้‚ฎ็ฎฑ + ๅฏ†็  + โ”œโ”€ NO, ๆœ‰ needs_setup=True ็š„็”จๆˆท โ†’ ๆ—ฅๅฟ—ๆ้†’ๅฎŒๆˆ่ฎพ็ฝฎๆˆ–็”จ reset_admin + โ””โ”€ NO, ๆ—  needs_setup โ†’ ๆญฃๅธธๅฏๅŠจ +``` + +### ๅคš่ฟ›็จ‹ๅฎ‰ๅ…จ + +- SQLite UNIQUE ็บฆๆŸๅค„็†็ซžไบ‰ +- ็ฌฌไบŒไธชๅฎžไพ‹ INSERT ๅคฑ่ดฅ โ†’ ๆ•่Žท ValueError โ†’ ้™้ป˜่ทณ่ฟ‡ + +### SQLite WAL ๆจกๅผ + +`_init_users_table()` ไธญๆ‰ง่กŒ `PRAGMA journal_mode=WAL`๏ผˆ้ฆ–ๆฌกๅปบ่กจๆ—ถ่ฎพ็ฝฎ๏ผŒDB ็บงๆŒไน…็”Ÿๆ•ˆ๏ผ‰ใ€‚ๅ…่ฎธๅนถๅ‘่ฏป + ๅ•ๅ†™ไธ้˜ปๅกž่ฏปใ€‚ + +## Auth Enforcement + +### ๅŒๅฑ‚ Auth ๆžถๆž„ + +**็ฌฌไธ€ๅฑ‚๏ผšๅ…จๅฑ€ AuthMiddleware**๏ผˆ`gateway/auth_middleware.py`๏ผŒfail-closed safety net๏ผ‰ + +```python +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + if _is_public(request.url.path): + return await call_next(request) + if not request.cookies.get("access_token"): + return JSONResponse(401, ...) + return await call_next(request) +``` + +ไป…ๆฃ€ๆŸฅ cookie ๆ˜ฏๅฆๅญ˜ๅœจ๏ผŒไธ้ชŒ่ฏ็ญพๅใ€‚ๆ–ฐ endpoint ๅณไฝฟๅฟ˜ๅŠ ่ฃ…้ฅฐๅ™จไนŸไธไผš่ฃธๅฅ”ใ€‚ + +**Public path ่ง„ๅˆ™๏ผš** +- ๅ‰็ผ€ๅŒน้…๏ผš`/health`, `/docs`, `/redoc`, `/openapi.json`, `/api/v1/auth/` +- ๅ…ถไป–ๆ‰€ๆœ‰่ทฏๅพ„้ป˜่ฎค้œ€่ฆ cookie + +**็ฌฌไบŒๅฑ‚๏ผš`@require_auth` + `@require_permission` ่ฃ…้ฅฐๅ™จ**๏ผˆ`gateway/authz.py`๏ผŒ็ป†็ฒ’ๅบฆๆŽงๅˆถ๏ผ‰ + +้ชŒ่ฏ JWT ็ญพๅใ€็”จๆˆทๅญ˜ๅœจๆ€งใ€token_versionใ€resource:action ๆƒ้™ใ€owner_checkใ€‚ + +ไธคๅฑ‚่Œ่ดฃไธๅŒใ€ไบ’ไธๆ›ฟไปฃใ€‚ + +### CSRF + +ๅง‹็ปˆๆ ก้ชŒ๏ผŒๆ— ๆกไปถใ€‚Auth endpoint๏ผˆlogin/register/logout๏ผ‰่ฑๅ…ใ€‚ + +## Login Flow + +``` +POST /api/v1/auth/login/local + โ†“ +้ชŒ่ฏๅฏ†็  โ†’ ๆˆๅŠŸ + โ†“ +ๆฃ€ๆŸฅ user.needs_setup + โ”œโ”€ True โ†’ ่ฟ”ๅ›ž {expires_in, needs_setup: true} + โ””โ”€ False โ†’ ่ฟ”ๅ›ž {expires_in} +``` + +ๅ‰็ซฏๆ นๆฎ `needs_setup` ๅ†ณๅฎš่ทณ่ฝฌ `/setup` ่ฟ˜ๆ˜ฏ `/workspace`ใ€‚ + +## Setup Flow + +``` +/setup ้กต้ข๏ผˆๅ‰็ซฏๆ–ฐๅขž๏ผ‰ + โ†“ +่กจๅ•๏ผš้‚ฎ็ฎฑ + ๆ–ฐๅฏ†็  + ็กฎ่ฎคๅฏ†็  + โ†“ +POST /api/v1/auth/change-password + body: {current_password, new_password, new_email} + โ†“ +ๅŽ็ซฏ๏ผšๆ›ดๆ–ฐ้‚ฎ็ฎฑ + ๅฏ†็  + token_version++ + needs_setup=False + โ†“ +ๅ‰็ซฏ๏ผš่ทณ่ฝฌ /workspace +``` + +### SSR Guard + +`getServerSideUser()` ่ฟ”ๅ›ž็š„ `AuthResult` ๅขžๅŠ  tag๏ผš + +```typescript +| { tag: "needs_setup"; user: User } +``` + +`workspace/layout.tsx`: `case "needs_setup": redirect("/setup")` + +### change-password ๆŽฅๅฃๆ‰ฉๅฑ• + +ๆ–ฐๅขžๅฏ้€‰ๅญ—ๆฎต `new_email: EmailStr | None`ใ€‚ๆœ‰ๅ€ผๆ—ถๅŒๆ—ถๆ›ดๆ–ฐ้‚ฎ็ฎฑ๏ผˆๆ ก้ชŒๅ”ฏไธ€ๆ€ง๏ผ‰ใ€‚ +ๅฆ‚ๆžœ `user.needs_setup == True` ไธ”ๆไพ›ไบ† `new_email`๏ผŒๆˆๅŠŸๅŽ่‡ชๅŠจ่ฎพ `needs_setup = False`ใ€‚ + +## Token Invalidation + +ๆ”นๅฏ†็ ๆ—ถ `token_version += 1`ใ€‚JWT encode ๆ—ถๅ†™ๅ…ฅ `ver`ใ€‚ + +ๆ ก้ชŒ้“พ่ทฏ๏ผš`decode_token` โ†’ `TokenPayload` ๅŒ…ๅซ `ver` โ†’ `get_current_user_from_request` ๆŸฅ DB โ†’ ๆฏ”ๅฏน `user.token_version != payload.ver` โ†’ 401ใ€‚ + +่งฆๅ‘ๅœบๆ™ฏ๏ผš +- ็”จๆˆทๆ”นๅฏ†็  +- admin ็”จ reset_admin CLI +- setup ๆต็จ‹ๅฎŒๆˆ + +## Rate Limiting + +็™ปๅฝ•็ซฏ็‚น IP ็บง้™้€Ÿ๏ผŒๅ†…ๅญ˜่ฎกๆ•ฐๅ™จ๏ผš + +```python +_login_attempts: dict[str, tuple[int, float]] = {} # ip โ†’ (fail_count, lock_until) +``` + +- ๅŒไธ€ IP ่ฟž็ปญ 5 ๆฌกๅคฑ่ดฅ โ†’ ้”ๅฎš 5 ๅˆ†้’Ÿ +- ๆˆๅŠŸ็™ปๅฝ• โ†’ ้‡็ฝฎ่ฎกๆ•ฐ +- ไธๅผ•ๅ…ฅ Redis๏ผŒ่ฟ›็จ‹ๅ†… dict ๅคŸ็”จ + +## Thread Migration + +ๅœจ `_ensure_admin_user` ไธญ๏ผŒๅˆ›ๅปบ admin ๅŽๅŒๆญฅๆ‰ง่กŒ๏ผš +- ๆ‰ซๆ Store ไธญๆ‰€ๆœ‰ thread +- `metadata.user_id` ไธบ็ฉบ โ†’ ๅ†™ๅ…ฅ admin ็š„ user_id +- ๆ—ฅๅฟ—่ฎฐๅฝ•่ฟ็งปๆ•ฐ้‡ + +ๅœจ lifespan ไธญๅŒๆญฅๆ‰ง่กŒ๏ผˆๆญคๆ—ถๆœชๆŽฅๅ—่ฏทๆฑ‚๏ผ‰๏ผŒไธๅญ˜ๅœจๅนถๅ‘้—ฎ้ข˜ใ€‚ + +## Password Recovery + +```bash +python -m app.gateway.auth.reset_admin [--email user@example.com] +``` + +่กŒไธบ๏ผš +- ็”Ÿๆˆ้šๆœบๅฏ†็  +- ๆ›ดๆ–ฐๅฏ†็  hash +- `token_version += 1`๏ผˆๆ—ง token ็ซ‹ๅณๅคฑๆ•ˆ๏ผ‰ +- `needs_setup = True`๏ผˆไธ‹ๆฌก็™ปๅฝ•่ตฐ setup ๆต็จ‹๏ผ‰ +- ๆ‰“ๅฐๆ–ฐๅฏ†็ ๅˆฐ stdout + +## File Changes Summary + +### Backend + +| ๆ–‡ไปถ | ๅ˜ๆ›ด | +|------|------| +| `auth/models.py` | User ๅŠ  `needs_setup`, `token_version` | +| `auth/jwt.py` | encode ๅŠ  `ver`๏ผŒTokenPayload ๅŠ  `ver` | +| `auth/repositories/sqlite.py` | DDL ๅŠ ๅˆ— + ALTER TABLE ๅ…ผๅฎน + WAL ๆจกๅผ | +| `gateway/app.py` | `_ensure_admin_user` + thread ่ฟ็งป + AuthMiddleware | +| `gateway/auth_middleware.py` | ๅ…จๅฑ€ auth middleware๏ผˆcookie ๅญ˜ๅœจๆ€งๆฃ€ๆŸฅ๏ผŒfail-closed safety net๏ผ‰ | +| `gateway/deps.py` | decode ๅŽๆ ก้ชŒ `token_version` | +| `gateway/routers/auth.py` | login ่ฟ”ๅ›ž `needs_setup`๏ผŒchange-password ๆ‰ฉๅฑ• `new_email` + `needs_setup` + `token_version` | +| `gateway/routers/auth.py` | ็™ปๅฝ•้™้€Ÿ | +| `auth/reset_admin.py` | ่ฎพ `needs_setup=True` + `token_version++` | + +### Frontend + +| ๆ–‡ไปถ | ๅ˜ๆ›ด | +|------|------| +| `core/auth/types.ts` | AuthResult ๅŠ  `needs_setup` tag | +| `core/auth/server.ts` | SSR guard ่ฟ”ๅ›ž `needs_setup` | +| `app/workspace/layout.tsx` | `needs_setup` โ†’ redirect `/setup` | +| `app/(auth)/setup/page.tsx` | ๆ–ฐ้กต้ข๏ผš้‚ฎ็ฎฑ + ๆ–ฐๅฏ†็ ่กจๅ• | + +## Deferred (Future Work) + +- OAuth ็™ปๅฝ•๏ผˆGitHub, Google๏ผ‰ +- ้‚ฎไปถ้ชŒ่ฏ / ๆ‰พๅ›žๅฏ†็  +- RBAC ็ป†็ฒ’ๅบฆๆƒ้™ +- CSRF token ๅฎšๆœŸ่ฝฎๆข +- PostgreSQL ็”จๆˆทๅญ˜ๅ‚จ diff --git a/frontend/next.config.js b/frontend/next.config.js index d769c14a5..3ce6cc877 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -10,12 +10,24 @@ function getInternalServiceURL(envKey, fallbackURL) { ? configured.replace(/\/+$/, "") : fallbackURL; } +import nextra from "nextra"; + +const withNextra = nextra({}); /** @type {import("next").NextConfig} */ const config = { + i18n: { + locales: ["en", "zh"], + defaultLocale: "en", + }, devIndicators: false, + allowedDevOrigins: process.env.NEXT_DEV_ALLOWED_ORIGINS + ? process.env.NEXT_DEV_ALLOWED_ORIGINS.split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [], async rewrites() { - const rewrites = []; + const beforeFiles = []; const langgraphURL = getInternalServiceURL( "DEER_FLOW_INTERNAL_LANGGRAPH_BASE_URL", "http://127.0.0.1:2024", @@ -26,29 +38,49 @@ const config = { ); if (!process.env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) { - rewrites.push({ + beforeFiles.push({ source: "/api/langgraph", destination: langgraphURL, }); - rewrites.push({ + beforeFiles.push({ source: "/api/langgraph/:path*", destination: `${langgraphURL}/:path*`, }); } + // Auth endpoints: explicit v1/auth prefix only (deny-by-default) + beforeFiles.push({ + source: "/api/v1/auth/:path*", + destination: `${gatewayURL}/api/v1/auth/:path*`, + }); + + // LangGraph-compat: handled by route handler at /api/langgraph-compat/[...path] + // with allowlist, header sanitization, and timeout โ€” no rewrite needed. + if (!process.env.NEXT_PUBLIC_BACKEND_BASE_URL) { - rewrites.push({ - source: "/api/agents", - destination: `${gatewayURL}/api/agents`, - }); - rewrites.push({ - source: "/api/agents/:path*", - destination: `${gatewayURL}/api/agents/:path*`, - }); + // Explicit gateway API prefixes (deny-by-default, no catch-all) + const GATEWAY_PREFIXES = [ + "agents", + "models", + "threads", + "memory", + "skills", + "mcp", + ]; + for (const prefix of GATEWAY_PREFIXES) { + beforeFiles.push({ + source: `/api/${prefix}`, + destination: `${gatewayURL}/api/${prefix}`, + }); + beforeFiles.push({ + source: `/api/${prefix}/:path*`, + destination: `${gatewayURL}/api/${prefix}/:path*`, + }); + } } - return rewrites; + return { beforeFiles, afterFiles: [], fallback: [] }; }, }; -export default config; +export default withNextra(config); diff --git a/frontend/package.json b/frontend/package.json index 33bfe683a..83f69b4e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -69,6 +69,8 @@ "nanoid": "^5.1.6", "next": "^16.1.7", "next-themes": "^0.4.6", + "nextra": "^4.6.1", + "nextra-theme-docs": "^4.6.1", "nuxt-og-image": "^5.1.13", "ogl": "^1.0.11", "react": "^19.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f409b9697..e317aaa64 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -164,9 +164,15 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nextra: + specifier: ^4.6.1 + version: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + nextra-theme-docs: + specifier: ^4.6.1 + version: 4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) nuxt-og-image: specifier: ^5.1.13 - version: 5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.28(typescript@5.9.3)) + version: 5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3)) ogl: specifier: ^1.0.11 version: 1.0.11 @@ -661,9 +667,25 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@formatjs/intl-localematcher@0.6.2': + resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + + '@headlessui/react@2.2.9': + resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -716,89 +738,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -911,9 +949,112 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@napi-rs/simple-git-android-arm-eabi@0.1.22': + resolution: {integrity: sha512-JQZdnDNm8o43A5GOzwN/0Tz3CDBQtBUNqzVwEopm32uayjdjxev1Csp1JeaqF3v9djLDIvsSE39ecsN2LhCKKQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/simple-git-android-arm64@0.1.22': + resolution: {integrity: sha512-46OZ0SkhnvM+fapWjzg/eqbJvClxynUpWYyYBn4jAj7GQs1/Yyc8431spzDmkA8mL0M7Xo8SmbkzTDE7WwYAfg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/simple-git-darwin-arm64@0.1.22': + resolution: {integrity: sha512-zH3h0C8Mkn9//MajPI6kHnttywjsBmZ37fhLX/Fiw5XKu84eHA6dRyVtMzoZxj6s+bjNTgaMgMUucxPn9ktxTQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/simple-git-darwin-x64@0.1.22': + resolution: {integrity: sha512-GZN7lRAkGKB6PJxWsoyeYJhh85oOOjVNyl+/uipNX8bR+mFDCqRsCE3rRCFGV9WrZUHXkcuRL2laIRn7lLi3ag==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/simple-git-freebsd-x64@0.1.22': + resolution: {integrity: sha512-xyqX1C5I0WBrUgZONxHjZH5a4LqQ9oki3SKFAVpercVYAcx3pq6BkZy1YUOP4qx78WxU1CCNfHBN7V+XO7D99A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/simple-git-linux-arm-gnueabihf@0.1.22': + resolution: {integrity: sha512-4LOtbp9ll93B9fxRvXiUJd1/RM3uafMJE7dGBZGKWBMGM76+BAcCEUv2BY85EfsU/IgopXI6n09TycRfPWOjxA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/simple-git-linux-arm64-gnu@0.1.22': + resolution: {integrity: sha512-GVOjP/JjCzbQ0kSqao7ctC/1sodVtv5VF57rW9BFpo2y6tEYPCqHnkQkTpieuwMNe+TVOhBUC1+wH0d9/knIHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/simple-git-linux-arm64-musl@0.1.22': + resolution: {integrity: sha512-MOs7fPyJiU/wqOpKzAOmOpxJ/TZfP4JwmvPad/cXTOWYwwyppMlXFRms3i98EU3HOazI/wMU2Ksfda3+TBluWA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/simple-git-linux-ppc64-gnu@0.1.22': + resolution: {integrity: sha512-L59dR30VBShRUIZ5/cQHU25upNgKS0AMQ7537J6LCIUEFwwXrKORZKJ8ceR+s3Sr/4jempWVvMdjEpFDE4HYww==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@napi-rs/simple-git-linux-s390x-gnu@0.1.22': + resolution: {integrity: sha512-4FHkPlCSIZUGC6HiADffbe6NVoTBMd65pIwcd40IDbtFKOgFMBA+pWRqKiQ21FERGH16Zed7XHJJoY3jpOqtmQ==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@napi-rs/simple-git-linux-x64-gnu@0.1.22': + resolution: {integrity: sha512-Ei1tM5Ho/dwknF3pOzqkNW9Iv8oFzRxE8uOhrITcdlpxRxVrBVptUF6/0WPdvd7R9747D/q61QG/AVyWsWLFKw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/simple-git-linux-x64-musl@0.1.22': + resolution: {integrity: sha512-zRYxg7it0p3rLyEJYoCoL2PQJNgArVLyNavHW03TFUAYkYi5bxQ/UFNVpgxMaXohr5yu7qCBqeo9j4DWeysalg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/simple-git-win32-arm64-msvc@0.1.22': + resolution: {integrity: sha512-XGFR1fj+Y9cWACcovV2Ey/R2xQOZKs8t+7KHPerYdJ4PtjVzGznI4c2EBHXtdOIYvkw7tL5rZ7FN1HJKdD5Quw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/simple-git-win32-ia32-msvc@0.1.22': + resolution: {integrity: sha512-Gqr9Y0gs6hcNBA1IXBpoqTFnnIoHuZGhrYqaZzEvGMLrTrpbXrXVEtX3DAAD2RLc1b87CPcJ49a7sre3PU3Rfw==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/simple-git-win32-x64-msvc@0.1.22': + resolution: {integrity: sha512-hQjcreHmUcpw4UrtkOron1/TQObfe484lxiXFLLUj7aWnnnOVs1mnXq5/Bo9+3NYZldFpFRJPdPBeHCisXkKJg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/simple-git@0.1.22': + resolution: {integrity: sha512-bMVoAKhpjTOPHkW/lprDPwv5aD4R4C3Irt8vn+SKA9wudLe9COLxOhurrKRsxmZccUbWXRF7vukNeGUAj5P8kA==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -940,24 +1081,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.7': resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.7': resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.7': resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.7': resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} @@ -1516,6 +1661,43 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-aria/focus@3.21.5': + resolution: {integrity: sha512-V18fwCyf8zqgJdpLQeDU5ZRNd9TeOfBbhLgmX77Zr5ae9XwaoJ1R3SFJG1wCJX60t34AW+aLZSEEK+saQElf3Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.27.1': + resolution: {integrity: sha512-M3wLpTTmDflI0QGNK0PJNUaBXXfeBXue8ZxLMngfc1piHNiH4G5lUvWd9W14XVbqrSCVY8i8DfGrNYpyyZu0tw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/ssr@3.9.10': + resolution: {integrity: sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/utils@3.33.1': + resolution: {integrity: sha512-kIx1Sj6bbAT0pdqCegHuPanR9zrLn5zMRiM7LN12rgRf55S19ptd9g3ncahArifYTRkfEU9VIn+q0HjfMqS9/w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-stately/flags@3.1.2': + resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} + + '@react-stately/utils@3.11.0': + resolution: {integrity: sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-types/shared@3.33.1': + resolution: {integrity: sha512-oJHtjvLG43VjwemQDadlR5g/8VepK56B/xKO2XORPHt9zlW6IZs3tZrYlvH29BMvoqC7RtE7E5UjgbnbFtDGag==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@resvg/resvg-js-android-arm-eabi@2.6.2': resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} engines: {node: '>= 10'} @@ -1551,24 +1733,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-arm64-musl@2.6.2': resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@resvg/resvg-js-linux-x64-gnu@2.6.2': resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@resvg/resvg-js-linux-x64-musl@2.6.2': resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@resvg/resvg-js-win32-arm64-msvc@2.6.2': resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} @@ -1630,66 +1816,79 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -1733,6 +1932,9 @@ packages: '@shikijs/core@3.15.0': resolution: {integrity: sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg==} + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + '@shikijs/engine-javascript@3.15.0': resolution: {integrity: sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg==} @@ -1745,9 +1947,17 @@ packages: '@shikijs/themes@3.15.0': resolution: {integrity: sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ==} + '@shikijs/twoslash@3.23.0': + resolution: {integrity: sha512-pNaLJWMA3LU7PhT8tm9OQBZ1epy0jmdgeJzntBtr1EVXLbHxGzTj3mnf9vOdcl84l96qnlJXkJ/NGXZYBpXl5g==} + peerDependencies: + typescript: '>=5.5.0' + '@shikijs/types@3.15.0': resolution: {integrity: sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw==} + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -1832,24 +2042,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -1890,6 +2104,23 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-virtual@3.13.23': + resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.23': + resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + + '@theguild/remark-mermaid@0.3.0': + resolution: {integrity: sha512-Fy1J4FSj8totuHsHFpaeWyWRaRSIvpzGTRoEfnNJc1JmLV9uV70sYE3zcT+Jj5Yw20Xq4iCsiT+3Ho49BBZcBQ==} + peerDependencies: + react: ^18.2.0 || ^19.0.0 + + '@theguild/remark-npm2yarn@0.3.3': + resolution: {integrity: sha512-ma6DvR03gdbvwqfKx1omqhg9May/VYGdMHvTzB4VuxkyS7KzfZ/lzrj43hmcsggpMje0x7SADA/pcMph0ejRnA==} + '@tokenlens/core@1.3.0': resolution: {integrity: sha512-d8YNHNC+q10bVpi95fELJwJyPVf1HfvBEI18eFQxRSZTdByXrP+f/ZtlhSzkx0Jl0aEmYVeBA5tPeeYRioLViQ==} @@ -1902,6 +2133,9 @@ packages: '@tokenlens/models@1.3.0': resolution: {integrity: sha512-9mx7ZGeewW4ndXAiD7AT1bbCk4OpJeortbjHHyNkgap+pMPPn1chY6R5zqe1ggXIUzZ2l8VOAKfPqOvpcrisJw==} + '@ts-morph/common@0.28.1': + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2029,9 +2263,15 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} @@ -2114,6 +2354,11 @@ packages: resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/vfs@1.6.4': + resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} + peerDependencies: + typescript: '*' + '@uiw/codemirror-extensions-basic-setup@4.25.4': resolution: {integrity: sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==} peerDependencies: @@ -2212,41 +2457,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2301,6 +2554,11 @@ packages: '@vue/shared@3.5.28': resolution: {integrity: sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==} + '@xmldom/xmldom@0.9.8': + resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==} + engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version + '@xyflow/react@12.10.0': resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==} peerDependencies: @@ -2341,6 +2599,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2360,6 +2621,9 @@ packages: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + array.prototype.findlast@1.2.5: resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} @@ -2387,6 +2651,10 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2409,6 +2677,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@0.0.8: resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} engines: {node: '>= 0.4'} @@ -2494,12 +2766,21 @@ packages: zod: optional: true + better-react-mathjax@2.3.0: + resolution: {integrity: sha512-K0ceQC+jQmB+NLDogO5HCpqmYf18AU2FxDbLdduYgkHYWZApFggkHE4dIaXCV1NqeoscESYXXo1GSkY6fA295w==} + peerDependencies: + react: '>=16.8' + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -2548,6 +2829,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2592,6 +2877,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clipboardy@4.0.0: + resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} + engines: {node: '>=18'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2602,9 +2891,15 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2615,6 +2910,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -2623,6 +2922,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2995,6 +3297,12 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.27.4: resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} @@ -3107,6 +3415,10 @@ packages: jiti: optional: true + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3123,9 +3435,27 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-value-to-estree@3.5.0: + resolution: {integrity: sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -3167,6 +3497,10 @@ packages: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3176,6 +3510,9 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -3215,6 +3552,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + framer-motion@12.34.0: resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==} peerDependencies: @@ -3279,6 +3620,9 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3362,6 +3706,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -3371,6 +3718,9 @@ packages: hast-util-to-parse5@8.0.1: resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} @@ -3496,6 +3846,11 @@ packages: engines: {node: '>=8'} hasBin: true + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3515,6 +3870,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -3591,6 +3951,14 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + is64bit@2.0.0: + resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} + engines: {node: '>=18'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3738,24 +4106,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -3813,6 +4185,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3828,12 +4204,19 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathjax-full@3.2.2: + resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + deprecated: Version 4 replaces this package with the scoped package @mathjax/src + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} mdast-util-from-markdown@2.0.2: resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} @@ -3861,6 +4244,9 @@ packages: mdast-util-mdx-jsx@3.2.0: resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} @@ -3886,9 +4272,15 @@ packages: mermaid@11.12.2: resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==} + mhchemparser@4.2.1: + resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -3913,12 +4305,30 @@ packages: micromark-extension-math@3.1.0: resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} @@ -3949,6 +4359,9 @@ packages: micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + micromark-util-html-tag-name@2.0.1: resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} @@ -3981,6 +4394,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3991,6 +4408,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mj-context-menu@0.6.1: + resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -4050,6 +4470,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -4077,6 +4501,25 @@ packages: sass: optional: true + nextra-theme-docs@4.6.1: + resolution: {integrity: sha512-u5Hh8erVcGOXO1FVrwYBgrEjyzdYQY0k/iAhLd8RofKp+Bru3fyLy9V9W34mfJ0KHKHjv/ldlDTlb4KlL4eIuQ==} + peerDependencies: + next: '>=14' + nextra: 4.6.1 + react: '>=18' + react-dom: '>=18' + + nextra@4.6.1: + resolution: {integrity: sha512-yz5WMJFZ5c58y14a6Rmwt+SJUYDdIgzWSxwtnpD4XAJTq3mbOqOg3VTaJqLiJjwRSxoFRHNA1yAhnhbvbw9zSg==} + engines: {node: '>=18'} + peerDependencies: + next: '>=14' + react: '>=18' + react-dom: '>=18' + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -4095,6 +4538,10 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + npm-to-yarn@3.0.1: + resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + nuxt-og-image@5.1.13: resolution: {integrity: sha512-H9kqGlmcEb9agWURwT5iFQjbr7Ec7tcQHZZaYSpC/JXKq2/dFyRyAoo6oXTk6ob20dK9aNjkJDcX2XmgZy67+w==} engines: {node: '>=18.0.0'} @@ -4220,13 +4667,22 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-numeric-range@1.3.0: + resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} @@ -4402,6 +4858,11 @@ packages: rc9@3.0.0: resolution: {integrity: sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==} + react-compiler-runtime@19.1.0-rc.3: + resolution: {integrity: sha512-Cssogys2XZu6SqxRdX2xd8cQAf57BBvFbLEBlIa77161lninbKUn/EqbecCe7W3eqDQfg3rIoOwzExzgCh7h/g==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -4416,6 +4877,12 @@ packages: '@types/react': '>=18' react: '>=18' + react-medium-image-zoom@5.4.1: + resolution: {integrity: sha512-DD2iZYaCfAwiQGR8AN62r/cDJYoXhezlYJc5HY4TzBUGuGge43CptG0f7m0PEIM72aN6GfpjohvY1yYdtCJB7g==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4460,6 +4927,23 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + reading-time@1.5.0: + resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4483,21 +4967,46 @@ packages: rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-pretty-code@0.14.1: + resolution: {integrity: sha512-IpG4OL0iYlbx78muVldsK86hdfNoht0z63AP7sekQNW2QOTmjxB7RbTO+rhIYNGRljgHxgVZoPwUl6bIC9SbjA==} + engines: {node: '>=18'} + peerDependencies: + shiki: ^1.0.0 || ^2.0.0 || ^3.0.0 + rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} remark-math@6.0.0: resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + remark-reading-time@2.1.0: + resolution: {integrity: sha512-gBsJbQv87TUq4dRMSOgIX6P60Tk9ke8c29KsL7bccmsv2m9AycDfVu3ghRtrNpHLZU3TE5P/vImGOMSPzYU8rA==} + remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} @@ -4517,6 +5026,18 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -4566,6 +5087,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -4578,6 +5102,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -4640,6 +5167,10 @@ packages: peerDependencies: vue: ^3 + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -4650,9 +5181,17 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + speech-rule-engine@4.1.2: + resolution: {integrity: sha512-S6ji+flMEga+1QU79NDbwZ8Ivf0S/MpupQQiIC0rTpU/ZTKgcajijJJb1OcByBQDjrXCN1/DJtGz4ZJeBMPGJw==} + hasBin: true + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -4749,6 +5288,13 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + system-architecture@0.1.0: + resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} + engines: {node: '>=18'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -4770,6 +5316,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + title@4.0.1: + resolution: {integrity: sha512-xRnPkJx9nvE5MF6LkB5e8QJjE2FW8269wTu/LQdf7zZqBgPly0QJPf/CWAo7srj5so4yXfoLEdCFgurlpi47zg==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4797,6 +5347,9 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + ts-morph@27.0.2: + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -4806,6 +5359,14 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + twoslash-protocol@0.3.6: + resolution: {integrity: sha512-FHGsJ9Q+EsNr5bEbgG3hnbkvEBdW5STgPU824AHUjB4kw0Dn4p8tABT7Ncg1Ie6V0+mDg3Qpy41VafZXcQhWMA==} + + twoslash@0.3.6: + resolution: {integrity: sha512-VuI5OKl+MaUO9UIW3rXKoPgHI3X40ZgB/j12VY6h98Ae1mCBihjPvhOPeJWlxCYcmSbmeZt5ZKkK0dsVtp+6pA==} + peerDependencies: + typescript: ^5.5.0 + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4876,15 +5437,27 @@ packages: unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} unist-util-remove-position@5.0.0: resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-remove@4.0.0: + resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + unist-util-visit-parents@6.0.2: resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} @@ -5119,10 +5692,18 @@ packages: engines: {node: '>= 8'} hasBin: true + wicked-good-xpath@1.3.0: + resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5158,6 +5739,24 @@ packages: react: optional: true + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5655,8 +6254,30 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@floating-ui/react@0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.10 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tabbable: 6.4.0 + '@floating-ui/utils@0.2.10': {} + '@formatjs/intl-localematcher@0.6.2': + dependencies: + tslib: 2.8.1 + + '@headlessui/react@2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -5916,10 +6537,103 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.8 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + acorn: 8.15.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.15.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + '@mermaid-js/parser@0.6.3': dependencies: langium: 3.3.1 + '@napi-rs/simple-git-android-arm-eabi@0.1.22': + optional: true + + '@napi-rs/simple-git-android-arm64@0.1.22': + optional: true + + '@napi-rs/simple-git-darwin-arm64@0.1.22': + optional: true + + '@napi-rs/simple-git-darwin-x64@0.1.22': + optional: true + + '@napi-rs/simple-git-freebsd-x64@0.1.22': + optional: true + + '@napi-rs/simple-git-linux-arm-gnueabihf@0.1.22': + optional: true + + '@napi-rs/simple-git-linux-arm64-gnu@0.1.22': + optional: true + + '@napi-rs/simple-git-linux-arm64-musl@0.1.22': + optional: true + + '@napi-rs/simple-git-linux-ppc64-gnu@0.1.22': + optional: true + + '@napi-rs/simple-git-linux-s390x-gnu@0.1.22': + optional: true + + '@napi-rs/simple-git-linux-x64-gnu@0.1.22': + optional: true + + '@napi-rs/simple-git-linux-x64-musl@0.1.22': + optional: true + + '@napi-rs/simple-git-win32-arm64-msvc@0.1.22': + optional: true + + '@napi-rs/simple-git-win32-ia32-msvc@0.1.22': + optional: true + + '@napi-rs/simple-git-win32-x64-msvc@0.1.22': + optional: true + + '@napi-rs/simple-git@0.1.22': + optionalDependencies: + '@napi-rs/simple-git-android-arm-eabi': 0.1.22 + '@napi-rs/simple-git-android-arm64': 0.1.22 + '@napi-rs/simple-git-darwin-arm64': 0.1.22 + '@napi-rs/simple-git-darwin-x64': 0.1.22 + '@napi-rs/simple-git-freebsd-x64': 0.1.22 + '@napi-rs/simple-git-linux-arm-gnueabihf': 0.1.22 + '@napi-rs/simple-git-linux-arm64-gnu': 0.1.22 + '@napi-rs/simple-git-linux-arm64-musl': 0.1.22 + '@napi-rs/simple-git-linux-ppc64-gnu': 0.1.22 + '@napi-rs/simple-git-linux-s390x-gnu': 0.1.22 + '@napi-rs/simple-git-linux-x64-gnu': 0.1.22 + '@napi-rs/simple-git-linux-x64-musl': 0.1.22 + '@napi-rs/simple-git-win32-arm64-msvc': 0.1.22 + '@napi-rs/simple-git-win32-ia32-msvc': 0.1.22 + '@napi-rs/simple-git-win32-x64-msvc': 0.1.22 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -5975,11 +6689,11 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@nuxt/devtools-kit@3.1.1(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2))': + '@nuxt/devtools-kit@3.1.1(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))': dependencies: '@nuxt/kit': 4.3.1 execa: 8.0.1 - vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3) transitivePeerDependencies: - magicast @@ -6512,6 +7226,55 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-aria/focus@3.21.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/interactions': 3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/interactions@3.27.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-aria/utils': 3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-stately/flags': 3.1.2 + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.15 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-aria/ssr@3.9.10(react@19.2.4)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.2.4 + + '@react-aria/utils@3.33.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-stately/flags': 3.1.2 + '@react-stately/utils': 3.11.0(react@19.2.4) + '@react-types/shared': 3.33.1(react@19.2.4) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@react-stately/flags@3.1.2': + dependencies: + '@swc/helpers': 0.5.15 + + '@react-stately/utils@3.11.0(react@19.2.4)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.2.4 + + '@react-types/shared@3.33.1(react@19.2.4)': + dependencies: + react: 19.2.4 + '@resvg/resvg-js-android-arm-eabi@2.6.2': optional: true @@ -6653,6 +7416,13 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@3.15.0': dependencies: '@shikijs/types': 3.15.0 @@ -6672,11 +7442,25 @@ snapshots: dependencies: '@shikijs/types': 3.15.0 + '@shikijs/twoslash@3.23.0(typescript@5.9.3)': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + twoslash: 0.3.6(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@shikijs/types@3.15.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@shuding/opentype.js@1.4.0-beta.0': @@ -6780,6 +7564,25 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 + '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@tanstack/virtual-core@3.13.23': {} + + '@theguild/remark-mermaid@0.3.0(react@19.2.4)': + dependencies: + mermaid: 11.12.2 + react: 19.2.4 + unist-util-visit: 5.1.0 + + '@theguild/remark-npm2yarn@0.3.3': + dependencies: + npm-to-yarn: 3.0.1 + unist-util-visit: 5.1.0 + '@tokenlens/core@1.3.0': {} '@tokenlens/fetch@1.3.0': @@ -6795,6 +7598,12 @@ snapshots: dependencies: '@tokenlens/core': 1.3.0 + '@ts-morph/common@0.28.1': + dependencies: + minimatch: 10.2.5 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -6947,8 +7756,14 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mdx@2.0.13': {} + '@types/ms@2.1.0': {} + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + '@types/node@20.19.33': dependencies: undici-types: 6.21.0 @@ -7061,6 +7876,13 @@ snapshots: '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 + '@typescript/vfs@1.6.4(typescript@5.9.3)': + dependencies: + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.3)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.13)': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -7256,6 +8078,8 @@ snapshots: '@vue/shared@3.5.28': {} + '@xmldom/xmldom@0.9.8': {} + '@xyflow/react@12.10.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@xyflow/system': 0.0.74 @@ -7311,6 +8135,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + arg@5.0.2: {} + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -7335,6 +8161,8 @@ snapshots: is-string: 1.1.1 math-intrinsics: 1.1.0 + array-iterate@2.0.1: {} + array.prototype.findlast@1.2.5: dependencies: call-bind: 1.0.8 @@ -7388,6 +8216,8 @@ snapshots: ast-types-flow@0.0.8: {} + astring@1.9.0: {} + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -7402,6 +8232,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@0.0.8: {} base64-js@1.5.1: {} @@ -7439,6 +8271,11 @@ snapshots: optionalDependencies: zod: 4.3.6 + better-react-mathjax@2.3.0(react@19.2.4): + dependencies: + mathjax-full: 3.2.2 + react: 19.2.4 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -7448,6 +8285,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -7501,6 +8342,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7550,6 +8393,12 @@ snapshots: client-only@0.0.1: {} + clipboardy@4.0.0: + dependencies: + execa: 8.0.1 + is-wsl: 3.1.1 + is64bit: 2.0.0 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -7564,6 +8413,8 @@ snapshots: - '@types/react' - '@types/react-dom' + code-block-writer@13.0.3: {} + codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.20.0 @@ -7574,6 +8425,8 @@ snapshots: '@codemirror/state': 6.5.4 '@codemirror/view': 6.39.13 + collapse-white-space@2.1.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -7582,10 +8435,14 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@13.1.0: {} + commander@7.2.0: {} commander@8.3.0: {} + compute-scroll-into-view@3.1.1: {} + concat-map@0.0.1: {} confbox@0.1.8: {} @@ -8034,6 +8891,20 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.15.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + esbuild@0.27.4: optionalDependencies: '@esbuild/aix-ppc64': 0.27.4 @@ -8247,6 +9118,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm@3.2.25: {} + espree@10.4.0: dependencies: acorn: 8.15.0 @@ -8263,8 +9136,39 @@ snapshots: estraverse@5.3.0: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.8 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + estree-util-is-identifier-name@3.0.0: {} + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-value-to-estree@3.5.0: + dependencies: + '@types/estree': 1.0.8 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -8320,6 +9224,14 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} @@ -8328,6 +9240,10 @@ snapshots: dependencies: reusify: 1.1.0 + fault@2.0.1: + dependencies: + format: 0.2.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -8366,6 +9282,8 @@ snapshots: dependencies: is-callable: 1.2.7 + format@0.2.2: {} + framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.34.0 @@ -8439,6 +9357,8 @@ snapshots: nypm: 0.6.5 pathe: 2.0.3 + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -8565,6 +9485,27 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.8 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -8609,6 +9550,10 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 @@ -8728,6 +9673,8 @@ snapshots: is-docker@2.2.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -8748,6 +9695,10 @@ snapshots: is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -8812,6 +9763,14 @@ snapshots: dependencies: is-docker: 2.2.1 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + is64bit@2.0.0: + dependencies: + system-architecture: 0.1.0 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -9001,6 +9960,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-extensions@2.0.0: {} + markdown-table@3.0.4: {} marked@16.4.2: {} @@ -9009,6 +9970,13 @@ snapshots: math-intrinsics@1.1.0: {} + mathjax-full@3.2.2: + dependencies: + esm: 3.2.25 + mhchemparser: 4.2.1 + mj-context-menu: 0.6.1 + speech-rule-engine: 4.1.2 + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -9033,6 +10001,17 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-gfm-autolink-literal@2.0.1: dependencies: '@types/mdast': 4.0.4 @@ -9130,6 +10109,16 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -9201,6 +10190,8 @@ snapshots: ts-dedent: 2.2.0 uuid: 11.1.0 + mhchemparser@4.2.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -9220,6 +10211,13 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -9288,6 +10286,57 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -9301,6 +10350,18 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -9353,6 +10414,16 @@ snapshots: micromark-util-encode@2.0.1: {} + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.8 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + micromark-util-html-tag-name@2.0.1: {} micromark-util-normalize-identifier@2.0.1: @@ -9409,6 +10480,10 @@ snapshots: mimic-fn@4.0.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -9419,6 +10494,8 @@ snapshots: minimist@1.2.8: {} + mj-context-menu@0.6.1: {} + mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -9458,6 +10535,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -9488,6 +10567,76 @@ snapshots: - '@babel/core' - babel-plugin-macros + nextra-theme-docs@4.6.1(@types/react@19.2.13)(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + dependencies: + '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + clsx: 2.1.1 + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + nextra: 4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + react: 19.2.4 + react-compiler-runtime: 19.1.0-rc.3(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + scroll-into-view-if-needed: 3.1.0 + zod: 4.3.6 + zustand: 5.0.12(@types/react@19.2.13)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + transitivePeerDependencies: + - '@types/react' + - immer + - use-sync-external-store + + nextra@4.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@formatjs/intl-localematcher': 0.6.2 + '@headlessui/react': 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mdx-js/mdx': 3.1.1 + '@napi-rs/simple-git': 0.1.22 + '@shikijs/twoslash': 3.23.0(typescript@5.9.3) + '@theguild/remark-mermaid': 0.3.0(react@19.2.4) + '@theguild/remark-npm2yarn': 0.3.3 + better-react-mathjax: 2.3.0(react@19.2.4) + clsx: 2.1.1 + estree-util-to-js: 2.0.0 + estree-util-value-to-estree: 3.5.0 + fast-glob: 3.3.3 + github-slugger: 2.0.0 + hast-util-to-estree: 3.1.3 + katex: 0.16.28 + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm: 3.1.0 + mdast-util-to-hast: 13.2.1 + negotiator: 1.0.0 + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-compiler-runtime: 19.1.0-rc.3(react@19.2.4) + react-dom: 19.2.4(react@19.2.4) + react-medium-image-zoom: 5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + rehype-katex: 7.0.1 + rehype-pretty-code: 0.14.1(shiki@3.15.0) + rehype-raw: 7.0.0 + remark-frontmatter: 5.0.0 + remark-gfm: 4.0.1 + remark-math: 6.0.0 + remark-reading-time: 2.1.0 + remark-smartypants: 3.0.2 + server-only: 0.0.1 + shiki: 3.15.0 + slash: 5.1.0 + title: 4.0.1 + ts-morph: 27.0.2 + unist-util-remove: 4.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-children: 3.0.0 + yaml: 2.8.3 + zod: 4.3.6 + transitivePeerDependencies: + - supports-color + - typescript + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + node-fetch-native@1.6.7: {} node-mock-http@1.0.4: {} @@ -9503,9 +10652,11 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 - nuxt-og-image@5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.28(typescript@5.9.3)): + npm-to-yarn@3.0.1: {} + + nuxt-og-image@5.1.13(@unhead/vue@2.1.4(vue@3.5.28(typescript@5.9.3)))(unstorage@1.17.4)(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3)): dependencies: - '@nuxt/devtools-kit': 3.1.1(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)) + '@nuxt/devtools-kit': 3.1.1(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) '@nuxt/kit': 4.3.1 '@resvg/resvg-js': 2.6.2 '@resvg/resvg-wasm': 2.6.2 @@ -9519,7 +10670,7 @@ snapshots: image-size: 2.0.2 magic-string: 0.30.21 mocked-exports: 0.1.1 - nuxt-site-config: 3.2.19(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.28(typescript@5.9.3)) + nuxt-site-config: 3.2.19(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3)) nypm: 0.6.5 ofetch: 1.5.1 ohash: 2.0.11 @@ -9554,9 +10705,9 @@ snapshots: - magicast - vue - nuxt-site-config@3.2.19(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2))(vue@3.5.28(typescript@5.9.3)): + nuxt-site-config@3.2.19(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))(vue@3.5.28(typescript@5.9.3)): dependencies: - '@nuxt/devtools-kit': 3.1.1(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)) + '@nuxt/devtools-kit': 3.1.1(vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3)) '@nuxt/kit': 4.3.1 h3: 1.15.5 nuxt-site-config-kit: 3.2.19(vue@3.5.28(typescript@5.9.3)) @@ -9708,12 +10859,25 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + parse-ms@4.0.0: {} + parse-numeric-range@1.3.0: {} + parse5@7.3.0: dependencies: entities: 6.0.1 + path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} path-exists@4.0.0: {} @@ -9817,6 +10981,10 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + react-compiler-runtime@19.1.0-rc.3(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -9842,6 +11010,11 @@ snapshots: transitivePeerDependencies: - supports-color + react-medium-image-zoom@5.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll-bar@2.3.8(@types/react@19.2.13)(react@19.2.4): dependencies: react: 19.2.4 @@ -9878,6 +11051,37 @@ snapshots: readdirp@5.0.0: {} + reading-time@1.5.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.8 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.8 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.8 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9922,12 +11126,45 @@ snapshots: unist-util-visit-parents: 6.0.2 vfile: 6.0.3 + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-pretty-code@0.14.1(shiki@3.15.0): + dependencies: + '@types/hast': 3.0.4 + hast-util-to-string: 3.0.1 + parse-numeric-range: 1.3.0 + rehype-parse: 9.0.1 + shiki: 3.15.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 hast-util-raw: 9.1.0 vfile: 6.0.3 + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -9948,6 +11185,13 @@ snapshots: transitivePeerDependencies: - supports-color + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -9957,6 +11201,13 @@ snapshots: transitivePeerDependencies: - supports-color + remark-reading-time@2.1.0: + dependencies: + estree-util-is-identifier-name: 3.0.0 + estree-util-value-to-estree: 3.5.0 + reading-time: 1.5.0 + unist-util-visit: 5.1.0 + remark-rehype@11.1.2: dependencies: '@types/hast': 3.0.4 @@ -9965,6 +11216,13 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -9987,6 +11245,31 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + reusify@1.1.0: {} robust-predicates@3.0.2: {} @@ -10078,12 +11361,18 @@ snapshots: scheduler@0.27.0: {} + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + scule@1.3.0: {} semver@6.3.1: {} semver@7.7.4: {} + server-only@0.0.1: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -10200,6 +11489,8 @@ snapshots: ufo: 1.6.3 vue: 3.5.28(typescript@5.9.3) + slash@5.1.0: {} + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -10207,8 +11498,16 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} + speech-rule-engine@4.1.2: + dependencies: + '@xmldom/xmldom': 0.9.8 + commander: 13.1.0 + wicked-good-xpath: 1.3.0 + stable-hash@0.0.5: {} std-env@3.10.0: {} @@ -10330,6 +11629,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + system-architecture@0.1.0: {} + + tabbable@6.4.0: {} + tailwind-merge@3.4.0: {} tailwindcss@4.1.18: {} @@ -10345,6 +11648,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + title@4.0.1: + dependencies: + arg: 5.0.2 + chalk: 5.6.2 + clipboardy: 4.0.0 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -10368,6 +11677,11 @@ snapshots: ts-dedent@2.2.0: {} + ts-morph@27.0.2: + dependencies: + '@ts-morph/common': 0.28.1 + code-block-writer: 13.0.3 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -10379,6 +11693,16 @@ snapshots: tw-animate-css@1.4.0: {} + twoslash-protocol@0.3.6: {} + + twoslash@0.3.6(typescript@5.9.3): + dependencies: + '@typescript/vfs': 1.6.4(typescript@5.9.3) + twoslash-protocol: 0.3.6 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -10481,6 +11805,15 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 @@ -10490,10 +11823,20 @@ snapshots: '@types/unist': 3.0.3 unist-util-visit: 5.1.0 + unist-util-remove@4.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit-parents@6.0.2: dependencies: '@types/unist': 3.0.3 @@ -10612,7 +11955,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.1(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -10625,6 +11968,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + yaml: 2.8.3 vscode-jsonrpc@8.2.0: {} @@ -10704,8 +12048,12 @@ snapshots: dependencies: isexe: 2.0.0 + wicked-good-xpath@1.3.0: {} + word-wrap@1.2.5: {} + yaml@2.8.3: {} + yocto-queue@0.1.0: {} yoctocolors@2.1.2: {} @@ -10725,4 +12073,10 @@ snapshots: '@types/react': 19.2.13 react: 19.2.4 + zustand@5.0.12(@types/react@19.2.13)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.13 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + zwitch@2.0.4: {} diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx new file mode 100644 index 000000000..b916def52 --- /dev/null +++ b/frontend/src/app/(auth)/layout.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { type ReactNode } from "react"; + +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { getServerSideUser } from "@/core/auth/server"; +import { assertNever } from "@/core/auth/types"; + +export const dynamic = "force-dynamic"; + +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + const result = await getServerSideUser(); + + switch (result.tag) { + case "authenticated": + redirect("/workspace"); + case "needs_setup": + // Allow access to setup page + return <AuthProvider initialUser={result.user}>{children}</AuthProvider>; + case "unauthenticated": + return <AuthProvider initialUser={null}>{children}</AuthProvider>; + case "gateway_unavailable": + return ( + <div className="flex h-screen flex-col items-center justify-center gap-4"> + <p className="text-muted-foreground"> + Service temporarily unavailable. + </p> + <Link + href="/login" + className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm" + > + Retry + </Link> + </div> + ); + case "config_error": + throw new Error(result.message); + default: + assertNever(result); + } +} diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx new file mode 100644 index 000000000..90ca15238 --- /dev/null +++ b/frontend/src/app/(auth)/login/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { parseAuthError } from "@/core/auth/types"; + +/** + * Validate next parameter + * Prevent open redirect attacks + * Per RFC-001: Only allow relative paths starting with / + */ +function validateNextParam(next: string | null): string | null { + if (!next) { + return null; + } + + // Need start with / (relative path) + if (!next.startsWith("/")) { + return null; + } + + // Disallow protocol-relative URLs + if ( + next.startsWith("//") || + next.startsWith("http://") || + next.startsWith("https://") + ) { + return null; + } + + // Disallow URLs with different protocols (e.g., javascript:, data:, etc) + if (next.includes(":") && !next.startsWith("/")) { + return null; + } + + // Valid relative path + return next; +} + +export default function LoginPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { isAuthenticated } = useAuth(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [isLogin, setIsLogin] = useState(true); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + // Get next parameter for validated redirect + const nextParam = searchParams.get("next"); + const redirectPath = validateNextParam(nextParam) ?? "/workspace"; + + // Redirect if already authenticated (client-side, post-login) + useEffect(() => { + if (isAuthenticated) { + router.push(redirectPath); + } + }, [isAuthenticated, redirectPath, router]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const endpoint = isLogin + ? "/api/v1/auth/login/local" + : "/api/v1/auth/register"; + const body = isLogin + ? `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}` + : JSON.stringify({ email, password }); + + const headers: HeadersInit = isLogin + ? { "Content-Type": "application/x-www-form-urlencoded" } + : { "Content-Type": "application/json" }; + + const res = await fetch(endpoint, { + method: "POST", + headers, + body, + credentials: "include", // Important: include HttpOnly cookie + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + // Both login and register set a cookie โ€” redirect to workspace + router.push(redirectPath); + } catch (_err) { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + <div className="flex min-h-screen items-center justify-center bg-[#0a0a0a]"> + <div className="border-border/20 w-full max-w-md space-y-6 rounded-lg border bg-black/50 p-8 backdrop-blur-sm"> + <div className="text-center"> + <h1 className="font-serif text-3xl">DeerFlow</h1> + <p className="text-muted-foreground mt-2"> + {isLogin ? "Sign in to your account" : "Create a new account"} + </p> + </div> + + <form onSubmit={handleSubmit} className="space-y-4"> + <div> + <label htmlFor="email" className="text-sm font-medium"> + Email + </label> + <Input + id="email" + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + placeholder="you@example.com" + required + className="mt-1 bg-white text-black" + /> + </div> + + <div> + <label htmlFor="password" className="text-sm font-medium"> + Password + </label> + <Input + id="password" + type="password" + value={password} + onChange={(e) => setPassword(e.target.value)} + placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" + required + minLength={isLogin ? 6 : 8} + className="mt-1 bg-white text-black" + /> + </div> + + {error && <p className="text-sm text-red-500">{error}</p>} + + <Button type="submit" className="w-full" disabled={loading}> + {loading + ? "Please wait..." + : isLogin + ? "Sign In" + : "Create Account"} + </Button> + </form> + + <div className="text-center text-sm"> + <button + type="button" + onClick={() => { + setIsLogin(!isLogin); + setError(""); + }} + className="text-blue-500 hover:underline" + > + {isLogin + ? "Don't have an account? Sign up" + : "Already have an account? Sign in"} + </button> + </div> + + <div className="text-muted-foreground text-center text-xs"> + <Link href="/" className="hover:underline"> + โ† Back to home + </Link> + </div> + </div> + </div> + ); +} diff --git a/frontend/src/app/(auth)/setup/page.tsx b/frontend/src/app/(auth)/setup/page.tsx new file mode 100644 index 000000000..e70d1efc6 --- /dev/null +++ b/frontend/src/app/(auth)/setup/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getCsrfHeaders } from "@/core/api/fetcher"; +import { parseAuthError } from "@/core/auth/types"; + +export default function SetupPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSetup = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + try { + const res = await fetch("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getCsrfHeaders(), + }, + credentials: "include", + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + new_email: email || undefined, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + router.push("/workspace"); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + <div className="flex min-h-screen items-center justify-center"> + <div className="w-full max-w-sm space-y-6 p-6"> + <div className="text-center"> + <h1 className="font-serif text-3xl">DeerFlow</h1> + <p className="text-muted-foreground mt-2"> + Complete admin account setup + </p> + <p className="text-muted-foreground mt-1 text-xs"> + Set your real email and a new password. + </p> + </div> + <form onSubmit={handleSetup} className="space-y-4"> + <Input + type="email" + placeholder="Your email" + value={email} + onChange={(e) => setEmail(e.target.value)} + required + /> + <Input + type="password" + placeholder="Current password (from console log)" + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} + required + /> + <Input + type="password" + placeholder="New password" + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} + required + minLength={8} + /> + <Input + type="password" + placeholder="Confirm new password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error && <p className="text-sm text-red-500">{error}</p>} + <Button type="submit" className="w-full" disabled={loading}> + {loading ? "Setting up..." : "Complete Setup"} + </Button> + </form> + </div> + </div> + ); +} diff --git a/frontend/src/app/[lang]/docs/[[...mdxPath]]/page.tsx b/frontend/src/app/[lang]/docs/[[...mdxPath]]/page.tsx new file mode 100644 index 000000000..4d289c4a8 --- /dev/null +++ b/frontend/src/app/[lang]/docs/[[...mdxPath]]/page.tsx @@ -0,0 +1,29 @@ +import { generateStaticParamsFor, importPage } from "nextra/pages"; + +import { useMDXComponents as getMDXComponents } from "../../../../mdx-components"; + +export const generateStaticParams = generateStaticParamsFor("mdxPath"); + +export async function generateMetadata(props) { + const params = await props.params; + const { metadata } = await importPage(params.mdxPath, params.lang); + return metadata; +} + +// eslint-disable-next-line @typescript-eslint/unbound-method +const Wrapper = getMDXComponents().wrapper; + +export default async function Page(props) { + const params = await props.params; + const { + default: MDXContent, + toc, + metadata, + sourceCode, + } = await importPage(params.mdxPath, params.lang); + return ( + <Wrapper toc={toc} metadata={metadata} sourceCode={sourceCode}> + <MDXContent {...props} params={params} /> + </Wrapper> + ); +} diff --git a/frontend/src/app/[lang]/docs/layout.tsx b/frontend/src/app/[lang]/docs/layout.tsx new file mode 100644 index 000000000..988d1ff71 --- /dev/null +++ b/frontend/src/app/[lang]/docs/layout.tsx @@ -0,0 +1,51 @@ +import type { PageMapItem } from "nextra"; +import { getPageMap } from "nextra/page-map"; +import { Footer, Layout } from "nextra-theme-docs"; + +import { Header } from "@/components/landing/header"; +import { getLocaleByLang } from "@/core/i18n/locale"; +import "nextra-theme-docs/style.css"; + +const footer = <Footer>MIT {new Date().getFullYear()} ยฉ Nextra.</Footer>; + +const i18n = [ + { locale: "en", name: "English" }, + { locale: "zh", name: "ไธญๆ–‡" }, +]; + +function formatPageRoute(base: string, items: PageMapItem[]): PageMapItem[] { + return items.map((item) => { + if ("route" in item) { + item.route = `${base}${item.route}`; + } + if ("children" in item && item.children) { + item.children = formatPageRoute(base, item.children); + } + return item; + }); +} + +export default async function DocLayout({ children, params }) { + const { lang } = await params; + const locale = getLocaleByLang(lang); + const pages = await getPageMap(`/${lang}`); + + return ( + <Layout + navbar={ + <Header + className="relative max-w-full px-10" + homeURL="/" + locale={locale} + /> + } + pageMap={formatPageRoute(`/${lang}/docs`, pages)} + docsRepositoryBase="https://github.com/bytedance/deerflow/tree/main/frontend/src/app/content" + footer={footer} + i18n={i18n} + // ... Your additional layout options + > + {children} + </Layout> + ); +} diff --git a/frontend/src/app/api/auth/[...all]/route.ts b/frontend/src/app/api/auth/[...all]/route.ts deleted file mode 100644 index cde6018a8..000000000 --- a/frontend/src/app/api/auth/[...all]/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { toNextJsHandler } from "better-auth/next-js"; - -import { auth } from "@/server/better-auth"; - -export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 482f25c74..d0b386153 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -2,7 +2,7 @@ import { BotIcon, PlusSquare } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { Button } from "@/components/ui/button"; @@ -11,7 +11,11 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { InputBox } from "@/components/workspace/input-box"; -import { MessageList } from "@/components/workspace/messages"; +import { + MessageList, + MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, + MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM, +} from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; @@ -28,6 +32,7 @@ import { cn } from "@/lib/utils"; export default function AgentChatPage() { const { t } = useI18n(); + const [showFollowups, setShowFollowups] = useState(false); const router = useRouter(); const { agent_name } = useParams<{ @@ -81,6 +86,11 @@ export default function AgentChatPage() { await thread.stop(); }, [thread]); + const messageListPaddingBottom = showFollowups + ? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + + MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM + : undefined; + return ( <ThreadContext.Provider value={{ thread }}> <ChatBox threadId={threadId}> @@ -128,6 +138,7 @@ export default function AgentChatPage() { className={cn("size-full", !isNewThread && "pt-10")} threadId={threadId} thread={thread} + paddingBottom={messageListPaddingBottom} /> </div> @@ -173,6 +184,7 @@ export default function AgentChatPage() { } disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} onContextChange={(context) => setSettings("context", context)} + onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} onStop={handleStop} /> diff --git a/frontend/src/app/workspace/agents/new/page.tsx b/frontend/src/app/workspace/agents/new/page.tsx index 7b04b4486..33f6de213 100644 --- a/frontend/src/app/workspace/agents/new/page.tsx +++ b/frontend/src/app/workspace/agents/new/page.tsx @@ -1,8 +1,16 @@ "use client"; -import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react"; +import { + ArrowLeftIcon, + BotIcon, + CheckCircleIcon, + InfoIcon, + MoreHorizontalIcon, + SaveIcon, +} from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; import { PromptInput, @@ -10,17 +18,20 @@ import { PromptInputSubmit, PromptInputTextarea, } from "@/components/ai-elements/prompt-input"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import type { Agent } from "@/core/agents"; -import { - AgentNameCheckError, - checkAgentName, - getAgent, -} from "@/core/agents/api"; +import { checkAgentName, getAgent } from "@/core/agents/api"; import { useI18n } from "@/core/i18n/hooks"; import { useThreadStream } from "@/core/threads/hooks"; import { uuid } from "@/core/utils/uuid"; @@ -28,23 +39,46 @@ import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; type Step = "name" | "chat"; +type SetupAgentStatus = "idle" | "requested" | "completed"; const NAME_RE = /^[A-Za-z0-9-]+$/; +const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen"; +const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000]; + +function wait(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function getAgentWithRetry(agentName: string) { + for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) { + if (delay > 0) { + await wait(delay); + } + + try { + return await getAgent(agentName); + } catch { + // Retry until the write settles or the attempts are exhausted. + } + } + + return null; +} export default function NewAgentPage() { const { t } = useI18n(); const router = useRouter(); - // โ”€โ”€ Step 1: name form โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const [step, setStep] = useState<Step>("name"); const [nameInput, setNameInput] = useState(""); const [nameError, setNameError] = useState(""); const [isCheckingName, setIsCheckingName] = useState(false); const [agentName, setAgentName] = useState(""); const [agent, setAgent] = useState<Agent | null>(null); - // โ”€โ”€ Step 2: chat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [showSaveHint, setShowSaveHint] = useState(false); + const [setupAgentStatus, setSetupAgentStatus] = + useState<SetupAgentStatus>("idle"); - // Stable thread ID โ€” all turns belong to the same thread const threadId = useMemo(() => uuid(), []); const [thread, sendMessage] = useThreadStream({ @@ -53,17 +87,35 @@ export default function NewAgentPage() { mode: "flash", is_bootstrap: true, }, + onFinish() { + if (!agent && setupAgentStatus === "requested") { + setSetupAgentStatus("idle"); + } + }, onToolEnd({ name }) { if (name !== "setup_agent" || !agentName) return; - getAgent(agentName) - .then((fetched) => setAgent(fetched)) - .catch(() => { - // agent write may not be flushed yet โ€” ignore silently - }); + setSetupAgentStatus("completed"); + void getAgentWithRetry(agentName).then((fetched) => { + if (fetched) { + setAgent(fetched); + return; + } + + toast.error(t.agents.agentCreatedPendingRefresh); + }); }, }); - // โ”€โ”€ Handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + useEffect(() => { + if (typeof window === "undefined" || step !== "chat") { + return; + } + if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") { + return; + } + setShowSaveHint(true); + window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1"); + }, [step]); const handleConfirmName = useCallback(async () => { const trimmed = nameInput.trim(); @@ -72,6 +124,7 @@ export default function NewAgentPage() { setNameError(t.agents.nameStepInvalidError); return; } + setNameError(""); setIsCheckingName(true); try { @@ -90,6 +143,7 @@ export default function NewAgentPage() { } finally { setIsCheckingName(false); } + setAgentName(trimmed); setStep("chat"); await sendMessage(threadId, { @@ -99,12 +153,12 @@ export default function NewAgentPage() { }, [ nameInput, sendMessage, - threadId, - t.agents.nameStepBootstrapMessage, - t.agents.nameStepInvalidError, t.agents.nameStepAlreadyExistsError, t.agents.nameStepNetworkError, + t.agents.nameStepBootstrapMessage, t.agents.nameStepCheckError, + t.agents.nameStepInvalidError, + threadId, ]); const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { @@ -124,26 +178,82 @@ export default function NewAgentPage() { { agent_name: agentName }, ); }, - [thread.isLoading, sendMessage, threadId, agentName], + [agentName, sendMessage, thread.isLoading, threadId], ); - // โ”€โ”€ Shared header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const handleSaveAgent = useCallback(async () => { + if ( + !agentName || + agent || + thread.isLoading || + setupAgentStatus !== "idle" + ) { + return; + } + + setSetupAgentStatus("requested"); + setShowSaveHint(false); + try { + await sendMessage( + threadId, + { text: t.agents.saveCommandMessage, files: [] }, + { agent_name: agentName }, + { additionalKwargs: { hide_from_ui: true } }, + ); + toast.success(t.agents.saveRequested); + } catch (error) { + setSetupAgentStatus("idle"); + toast.error(error instanceof Error ? error.message : String(error)); + } + }, [ + agent, + agentName, + sendMessage, + setupAgentStatus, + t.agents.saveCommandMessage, + t.agents.saveRequested, + thread.isLoading, + threadId, + ]); const header = ( - <header className="flex shrink-0 items-center gap-3 border-b px-4 py-3"> - <Button - variant="ghost" - size="icon-sm" - onClick={() => router.push("/workspace/agents")} - > - <ArrowLeftIcon className="h-4 w-4" /> - </Button> - <h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1> + <header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3"> + <div className="flex items-center gap-3"> + <Button + variant="ghost" + size="icon-sm" + onClick={() => router.push("/workspace/agents")} + > + <ArrowLeftIcon className="h-4 w-4" /> + </Button> + <h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1> + </div> + + {step === "chat" ? ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon-sm" aria-label={t.agents.more}> + <MoreHorizontalIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onSelect={() => void handleSaveAgent()} + disabled={ + !!agent || thread.isLoading || setupAgentStatus !== "idle" + } + > + <SaveIcon className="h-4 w-4" /> + {setupAgentStatus === "requested" + ? t.agents.saving + : t.agents.save} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) : null} </header> ); - // โ”€โ”€ Step 1: name form โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (step === "name") { return ( <div className="flex size-full flex-col"> @@ -176,9 +286,9 @@ export default function NewAgentPage() { onKeyDown={handleNameKeyDown} className={cn(nameError && "border-destructive")} /> - {nameError && ( + {nameError ? ( <p className="text-destructive text-sm">{nameError}</p> - )} + ) : null} <Button className="w-full" onClick={() => void handleConfirmName()} @@ -193,8 +303,6 @@ export default function NewAgentPage() { ); } - // โ”€โ”€ Step 2: chat โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - return ( <ThreadContext.Provider value={{ thread }}> <ArtifactsProvider> @@ -202,20 +310,28 @@ export default function NewAgentPage() { {header} <main className="flex min-h-0 flex-1 flex-col"> - {/* โ”€โ”€ Message area โ”€โ”€ */} + {showSaveHint ? ( + <div className="px-4 pt-4"> + <div className="mx-auto w-full max-w-(--container-width-md)"> + <Alert> + <InfoIcon className="h-4 w-4" /> + <AlertDescription>{t.agents.saveHint}</AlertDescription> + </Alert> + </div> + </div> + ) : null} + <div className="flex min-h-0 flex-1 justify-center"> <MessageList - className="size-full pt-10" + className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")} threadId={threadId} thread={thread} /> </div> - {/* โ”€โ”€ Bottom action area โ”€โ”€ */} <div className="bg-background flex shrink-0 justify-center border-t px-4 py-4"> <div className="w-full max-w-(--container-width-md)"> {agent ? ( - // โœ… Success card <div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center"> <CheckCircleIcon className="text-primary h-10 w-10" /> <p className="font-semibold">{t.agents.agentCreated}</p> @@ -238,7 +354,6 @@ export default function NewAgentPage() { </div> </div> ) : ( - // ๐Ÿ“ Normal input <PromptInput onSubmit={({ text }) => void handleChatSubmit(text)} > diff --git a/frontend/src/app/workspace/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/chats/[thread_id]/page.tsx index 0cff87d0d..909ffbe07 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { ArtifactTrigger } from "@/components/workspace/artifacts"; @@ -11,7 +11,11 @@ import { } from "@/components/workspace/chats"; import { ExportTrigger } from "@/components/workspace/export-trigger"; import { InputBox } from "@/components/workspace/input-box"; -import { MessageList } from "@/components/workspace/messages"; +import { + MessageList, + MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, + MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM, +} from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; @@ -27,10 +31,16 @@ import { cn } from "@/lib/utils"; export default function ChatPage() { const { t } = useI18n(); + const [showFollowups, setShowFollowups] = useState(false); const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); const [settings, setSettings] = useThreadSettings(threadId); + const [mounted, setMounted] = useState(false); useSpecificChatMode(); + useEffect(() => { + setMounted(true); + }, []); + const { showNotification } = useNotification(); const [thread, sendMessage, isUploading] = useThreadStream({ @@ -70,6 +80,11 @@ export default function ChatPage() { await thread.stop(); }, [thread]); + const messageListPaddingBottom = showFollowups + ? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + + MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM + : undefined; + return ( <ThreadContext.Provider value={{ thread, isMock }}> <ChatBox threadId={threadId}> @@ -97,6 +112,7 @@ export default function ChatPage() { className={cn("size-full", !isNewThread && "pt-10")} threadId={threadId} thread={thread} + paddingBottom={messageListPaddingBottom} /> </div> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> @@ -120,30 +136,42 @@ export default function ChatPage() { /> </div> </div> - <InputBox - className={cn("bg-background/5 w-full -translate-y-4")} - isNewThread={isNewThread} - threadId={threadId} - autoFocus={isNewThread} - status={ - thread.error - ? "error" - : thread.isLoading - ? "streaming" - : "ready" - } - context={settings.context} - extraHeader={ - isNewThread && <Welcome mode={settings.context.mode} /> - } - disabled={ - env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || - isUploading - } - onContextChange={(context) => setSettings("context", context)} - onSubmit={handleSubmit} - onStop={handleStop} - /> + {mounted ? ( + <InputBox + className={cn("bg-background/5 w-full -translate-y-4")} + isNewThread={isNewThread} + threadId={threadId} + autoFocus={isNewThread} + status={ + thread.error + ? "error" + : thread.isLoading + ? "streaming" + : "ready" + } + context={settings.context} + extraHeader={ + isNewThread && <Welcome mode={settings.context.mode} /> + } + disabled={ + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || + isUploading + } + onContextChange={(context) => + setSettings("context", context) + } + onFollowupsVisibilityChange={setShowFollowups} + onSubmit={handleSubmit} + onStop={handleStop} + /> + ) : ( + <div + aria-hidden="true" + className={cn( + "bg-background/5 h-32 w-full -translate-y-4 rounded-2xl border", + )} + /> + )} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( <div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs"> {t.common.notAvailableInDemoMode} diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index 417c933d4..fa19025a0 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -1,47 +1,58 @@ -"use client"; +import Link from "next/link"; +import { redirect } from "next/navigation"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useCallback, useEffect, useLayoutEffect, useState } from "react"; -import { Toaster } from "sonner"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { getServerSideUser } from "@/core/auth/server"; +import { assertNever } from "@/core/auth/types"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import { CommandPalette } from "@/components/workspace/command-palette"; -import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; -import { getLocalSettings, useLocalSettings } from "@/core/settings"; +import { WorkspaceContent } from "./workspace-content"; -const queryClient = new QueryClient(); +export const dynamic = "force-dynamic"; -export default function WorkspaceLayout({ +export default async function WorkspaceLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const [settings, setSettings] = useLocalSettings(); - const [open, setOpen] = useState(false); // SSR default: open (matches server render) - useLayoutEffect(() => { - // Runs synchronously before first paint on the client โ€” no visual flash - setOpen(!getLocalSettings().layout.sidebar_collapsed); - }, []); - useEffect(() => { - setOpen(!settings.layout.sidebar_collapsed); - }, [settings.layout.sidebar_collapsed]); - const handleOpenChange = useCallback( - (open: boolean) => { - setOpen(open); - setSettings("layout", { sidebar_collapsed: !open }); - }, - [setSettings], - ); - return ( - <QueryClientProvider client={queryClient}> - <SidebarProvider - className="h-screen" - open={open} - onOpenChange={handleOpenChange} - > - <WorkspaceSidebar /> - <SidebarInset className="min-w-0">{children}</SidebarInset> - </SidebarProvider> - <CommandPalette /> - <Toaster position="top-center" /> - </QueryClientProvider> - ); + const result = await getServerSideUser(); + + switch (result.tag) { + case "authenticated": + return ( + <AuthProvider initialUser={result.user}> + <WorkspaceContent>{children}</WorkspaceContent> + </AuthProvider> + ); + case "needs_setup": + redirect("/setup"); + case "unauthenticated": + redirect("/login"); + case "gateway_unavailable": + return ( + <div className="flex h-screen flex-col items-center justify-center gap-4"> + <p className="text-muted-foreground"> + Service temporarily unavailable. + </p> + <p className="text-muted-foreground text-xs"> + The backend may be restarting. Please wait a moment and try again. + </p> + <div className="flex gap-3"> + <Link + href="/workspace" + className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-4 py-2 text-sm" + > + Retry + </Link> + <Link + href="/api/v1/auth/logout" + className="text-muted-foreground hover:bg-muted rounded-md border px-4 py-2 text-sm" + > + Logout & Reset + </Link> + </div> + </div> + ); + case "config_error": + throw new Error(result.message); + default: + assertNever(result); + } } diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx new file mode 100644 index 000000000..960ad28a2 --- /dev/null +++ b/frontend/src/app/workspace/workspace-content.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { Toaster } from "sonner"; + +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { CommandPalette } from "@/components/workspace/command-palette"; +import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; +import { getLocalSettings, useLocalSettings } from "@/core/settings"; + +export function WorkspaceContent({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const [queryClient] = useState(() => new QueryClient()); + const [settings, setSettings] = useLocalSettings(); + const [open, setOpen] = useState(false); // SSR default: open (matches server render) + + useLayoutEffect(() => { + // Runs synchronously before first paint on the client โ€” no visual flash + setOpen(!getLocalSettings().layout.sidebar_collapsed); + }, []); + + useEffect(() => { + setOpen(!settings.layout.sidebar_collapsed); + }, [settings.layout.sidebar_collapsed]); + + const handleOpenChange = useCallback( + (open: boolean) => { + setOpen(open); + setSettings("layout", { sidebar_collapsed: !open }); + }, + [setSettings], + ); + + return ( + <QueryClientProvider client={queryClient}> + <SidebarProvider + className="h-screen" + open={open} + onOpenChange={handleOpenChange} + > + <WorkspaceSidebar /> + <SidebarInset className="min-w-0">{children}</SidebarInset> + </SidebarProvider> + <CommandPalette /> + <Toaster position="top-center" /> + </QueryClientProvider> + ); +} diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index c178a5b8f..52a909cdd 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -34,9 +34,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import type { PromptInputFilePart } from "@/core/uploads"; +import { splitUnsupportedUploadFiles } from "@/core/uploads"; import { isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; -import type { ChatStatus, FileUIPart } from "ai"; +import type { ChatStatus } from "ai"; import { ArrowUpIcon, ImageIcon, @@ -71,13 +73,14 @@ import { useRef, useState, } from "react"; +import { toast } from "sonner"; // ============================================================================ // Provider Context & Types // ============================================================================ export type AttachmentsContext = { - files: (FileUIPart & { id: string })[]; + files: (PromptInputFilePart & { id: string })[]; add: (files: File[] | FileList) => void; remove: (id: string) => void; clear: () => void; @@ -107,6 +110,9 @@ const PromptInputController = createContext<PromptInputControllerProps | null>( const ProviderAttachmentsContext = createContext<AttachmentsContext | null>( null, ); +const PromptInputValidationContext = createContext< + ((files: File[] | FileList) => File[]) | null +>(null); export const usePromptInputController = () => { const ctx = useContext(PromptInputController); @@ -134,6 +140,7 @@ export const useProviderAttachments = () => { const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); +const usePromptInputValidation = () => useContext(PromptInputValidationContext); export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; @@ -153,7 +160,7 @@ export function PromptInputProvider({ // ----- attachments state (global when wrapped) const [attachmentFiles, setAttachmentFiles] = useState< - (FileUIPart & { id: string })[] + (PromptInputFilePart & { id: string })[] >([]); const fileInputRef = useRef<HTMLInputElement | null>(null); const openRef = useRef<() => void>(() => {}); @@ -172,6 +179,7 @@ export function PromptInputProvider({ url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, + file, })), ), ); @@ -279,7 +287,7 @@ export const usePromptInputAttachments = () => { }; export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & { - data: FileUIPart & { id: string }; + data: PromptInputFilePart & { id: string }; className?: string; }; @@ -378,7 +386,7 @@ export type PromptInputAttachmentsProps = Omit< HTMLAttributes<HTMLDivElement>, "children" > & { - children: (attachment: FileUIPart & { id: string }) => ReactNode; + children: (attachment: PromptInputFilePart & { id: string }) => ReactNode; }; export function PromptInputAttachments({ @@ -433,7 +441,7 @@ export const PromptInputActionAddAttachments = ({ export type PromptInputMessage = { text: string; - files: FileUIPart[]; + files: PromptInputFilePart[]; }; export type PromptInputProps = Omit< @@ -451,7 +459,7 @@ export type PromptInputProps = Omit< maxFiles?: number; maxFileSize?: number; // bytes onError?: (err: { - code: "max_files" | "max_file_size" | "accept"; + code: "max_files" | "max_file_size" | "accept" | "unsupported_package"; message: string; }) => void; onSubmit: ( @@ -483,7 +491,9 @@ export const PromptInput = ({ const formRef = useRef<HTMLFormElement | null>(null); // ----- Local attachments (only used when no provider) - const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); + const [items, setItems] = useState<(PromptInputFilePart & { id: string })[]>( + [], + ); const files = usingProvider ? controller.attachments.files : items; // Keep a ref to files for cleanup on unmount (avoids stale closure) @@ -551,7 +561,7 @@ export const PromptInput = ({ message: "Too many files. Some were not added.", }); } - const next: (FileUIPart & { id: string })[] = []; + const next: (PromptInputFilePart & { id: string })[] = []; for (const file of capped) { next.push({ id: nanoid(), @@ -559,6 +569,7 @@ export const PromptInput = ({ url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, + file, }); } return prev.concat(next); @@ -599,6 +610,23 @@ export const PromptInput = ({ ? controller.attachments.openFileDialog : openFileDialogLocal; + const sanitizeIncomingFiles = useCallback( + (fileList: File[] | FileList) => { + const { accepted, message } = splitUnsupportedUploadFiles(fileList); + if (message) { + onError?.({ + code: "unsupported_package", + message, + }); + if (!onError) { + toast.error(message); + } + } + return accepted; + }, + [onError], + ); + // Let provider know about our hidden file input so external menus can call openFileDialog() useEffect(() => { if (!usingProvider) return; @@ -629,7 +657,10 @@ export const PromptInput = ({ e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); + const accepted = sanitizeIncomingFiles(e.dataTransfer.files); + if (accepted.length > 0) { + add(accepted); + } } }; form.addEventListener("dragover", onDragOver); @@ -638,7 +669,7 @@ export const PromptInput = ({ form.removeEventListener("dragover", onDragOver); form.removeEventListener("drop", onDrop); }; - }, [add, globalDrop]); + }, [add, globalDrop, sanitizeIncomingFiles]); useEffect(() => { if (!globalDrop) return; @@ -653,7 +684,10 @@ export const PromptInput = ({ e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { - add(e.dataTransfer.files); + const accepted = sanitizeIncomingFiles(e.dataTransfer.files); + if (accepted.length > 0) { + add(accepted); + } } }; document.addEventListener("dragover", onDragOver); @@ -662,7 +696,7 @@ export const PromptInput = ({ document.removeEventListener("dragover", onDragOver); document.removeEventListener("drop", onDrop); }; - }, [add, globalDrop]); + }, [add, globalDrop, sanitizeIncomingFiles]); useEffect( () => () => { @@ -678,7 +712,10 @@ export const PromptInput = ({ const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => { if (event.currentTarget.files) { - add(event.currentTarget.files); + const accepted = sanitizeIncomingFiles(event.currentTarget.files); + if (accepted.length > 0) { + add(accepted); + } } // Reset input value to allow selecting files that were previously removed event.currentTarget.value = ""; @@ -733,6 +770,10 @@ export const PromptInput = ({ // Convert blob URLs to data URLs asynchronously Promise.all( files.map(async ({ id, ...item }) => { + if (item.file instanceof File) { + // Downstream upload prep reads the preserved File directly. + return item; + } if (item.url && item.url.startsWith("blob:")) { const dataUrl = await convertBlobUrlToDataUrl(item.url); // If conversion failed, keep the original blob URL @@ -744,7 +785,7 @@ export const PromptInput = ({ return item; }), ) - .then((convertedFiles: FileUIPart[]) => { + .then((convertedFiles: PromptInputFilePart[]) => { try { const result = onSubmit({ text, files: convertedFiles }, event); @@ -778,7 +819,7 @@ export const PromptInput = ({ // Render with or without local provider const inner = ( - <> + <PromptInputValidationContext.Provider value={sanitizeIncomingFiles}> <input accept={accept} aria-label="Upload files" @@ -797,7 +838,7 @@ export const PromptInput = ({ > <InputGroup>{children}</InputGroup> </form> - </> + </PromptInputValidationContext.Provider> ); return usingProvider ? ( @@ -830,6 +871,7 @@ export const PromptInputTextarea = ({ }: PromptInputTextareaProps) => { const controller = useOptionalPromptInputController(); const attachments = usePromptInputAttachments(); + const sanitizeIncomingFiles = usePromptInputValidation(); const [isComposing, setIsComposing] = useState(false); const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => { @@ -888,7 +930,12 @@ export const PromptInputTextarea = ({ if (files.length > 0) { event.preventDefault(); - attachments.add(files); + const accepted = sanitizeIncomingFiles + ? sanitizeIncomingFiles(files) + : files; + if (accepted.length > 0) { + attachments.add(accepted); + } } }; diff --git a/frontend/src/components/landing/header.tsx b/frontend/src/components/landing/header.tsx index 39e40d106..3941ac794 100644 --- a/frontend/src/components/landing/header.tsx +++ b/frontend/src/components/landing/header.tsx @@ -1,21 +1,54 @@ import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; import { NumberTicker } from "@/components/ui/number-ticker"; +import type { Locale } from "@/core/i18n/locale"; +import { getI18n } from "@/core/i18n/server"; import { env } from "@/env"; +import { cn } from "@/lib/utils"; -export function Header() { +export type HeaderProps = { + className?: string; + homeURL?: string; + locale?: Locale; +}; + +export async function Header({ className, homeURL, locale }: HeaderProps) { + const isExternalHome = !homeURL; + const { locale: resolvedLocale, t } = await getI18n(locale); + const lang = resolvedLocale.substring(0, 2); return ( - <header className="container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs"> - <div className="flex items-center gap-2"> + <header + className={cn( + "container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs", + className, + )} + > + <div className="flex items-center gap-6"> <a - href="https://github.com/bytedance/deer-flow" - target="_blank" - rel="noopener noreferrer" + href={homeURL ?? "https://github.com/bytedance/deer-flow"} + target={isExternalHome ? "_blank" : "_self"} + rel={isExternalHome ? "noopener noreferrer" : undefined} > <h1 className="font-serif text-xl">DeerFlow</h1> </a> </div> + <nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium"> + <Link + href={`/${lang}/docs`} + className="text-secondary-foreground hover:text-foreground transition-colors" + > + {t.home.docs} + </Link> + <a + href={`/${lang}/blog`} + target="_self" + className="text-secondary-foreground hover:text-foreground transition-colors" + > + {t.home.blog} + </a> + </nav> <div className="relative"> <div className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl" diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index f332e0d98..1c05b1abe 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -52,8 +52,8 @@ function Button({ return ( <Comp data-slot="button" - data-variant={variant} - data-size={size} + {...(variant !== undefined && { "data-variant": variant })} + {...(size !== undefined && { "data-size": size })} className={cn(buttonVariants({ variant, size, className }))} {...props} /> diff --git a/frontend/src/components/workspace/command-palette.tsx b/frontend/src/components/workspace/command-palette.tsx index a73f412b9..1d754f61d 100644 --- a/frontend/src/components/workspace/command-palette.tsx +++ b/frontend/src/components/workspace/command-palette.tsx @@ -6,7 +6,7 @@ import { SettingsIcon, } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CommandDialog, @@ -35,6 +35,7 @@ export function CommandPalette() { const [open, setOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const [isMac, setIsMac] = useState(false); const handleNewChat = useCallback(() => { router.push("/workspace/chats/new"); @@ -63,8 +64,9 @@ export function CommandPalette() { useGlobalShortcuts(shortcuts); - const isMac = - typeof navigator !== "undefined" && navigator.userAgent.includes("Mac"); + useEffect(() => { + setIsMac(navigator.userAgent.includes("Mac")); + }, []); const metaKey = isMac ? "โŒ˜" : "Ctrl+"; const shiftKey = isMac ? "โ‡ง" : "Shift+"; diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index d1682bb73..19e93b3f3 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -109,6 +109,7 @@ export function InputBox({ threadId, initialValue, onContextChange, + onFollowupsVisibilityChange, onSubmit, onStop, ...props @@ -136,6 +137,7 @@ export function InputBox({ reasoning_effort?: "minimal" | "low" | "medium" | "high"; }, ) => void; + onFollowupsVisibilityChange?: (visible: boolean) => void; onSubmit?: (message: PromptInputMessage) => void; onStop?: () => void; }) { @@ -186,6 +188,8 @@ export function InputBox({ return models.find((m) => m.name === context.model_name) ?? models[0]; }, [context.model_name, models]); + const resolvedModelName = selectedModel?.name; + const supportThinking = useMemo( () => selectedModel?.supports_thinking ?? false, [selectedModel], @@ -253,9 +257,33 @@ export function InputBox({ setFollowups([]); setFollowupsHidden(false); setFollowupsLoading(false); + + // Guard against submitting before the initial model auto-selection + // effect has flushed thread settings to storage/state. + if (resolvedModelName && context.model_name !== resolvedModelName) { + onContextChange?.({ + ...context, + model_name: resolvedModelName, + mode: getResolvedMode( + context.mode, + selectedModel?.supports_thinking ?? false, + ), + }); + setTimeout(() => onSubmit?.(message), 0); + return; + } + onSubmit?.(message); }, - [onSubmit, onStop, status], + [ + context, + onContextChange, + onSubmit, + onStop, + resolvedModelName, + selectedModel?.supports_thinking, + status, + ], ); const requestFormSubmit = useCallback(() => { @@ -309,6 +337,26 @@ export function InputBox({ setTimeout(() => requestFormSubmit(), 0); }, [pendingSuggestion, requestFormSubmit, textInput]); + const showFollowups = + !disabled && + !isNewThread && + !followupsHidden && + (followupsLoading || followups.length > 0); + + const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange); + + useEffect(() => { + followupsVisibilityChangeRef.current = onFollowupsVisibilityChange; + }, [onFollowupsVisibilityChange]); + + useEffect(() => { + followupsVisibilityChangeRef.current?.(showFollowups); + }, [showFollowups]); + + useEffect(() => { + return () => followupsVisibilityChangeRef.current?.(false); + }, []); + useEffect(() => { const streaming = status === "streaming"; const wasStreaming = wasStreamingRef.current; @@ -769,40 +817,37 @@ export function InputBox({ )} </PromptInput> - {!disabled && - !isNewThread && - !followupsHidden && - (followupsLoading || followups.length > 0) && ( - <div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center"> - <div className="flex items-center gap-2"> - {followupsLoading ? ( - <div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm"> - {t.inputBox.followupLoading} - </div> - ) : ( - <Suggestions className="min-h-16 w-fit items-start"> - {followups.map((s) => ( - <Suggestion - key={s} - suggestion={s} - onClick={() => handleFollowupClick(s)} - /> - ))} - <Button - aria-label={t.common.close} - className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal" - variant="outline" - size="sm" - type="button" - onClick={() => setFollowupsHidden(true)} - > - <XIcon className="size-4" /> - </Button> - </Suggestions> - )} - </div> + {showFollowups && ( + <div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center"> + <div className="flex items-center gap-2"> + {followupsLoading ? ( + <div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm"> + {t.inputBox.followupLoading} + </div> + ) : ( + <Suggestions className="min-h-16 w-fit items-start"> + {followups.map((s) => ( + <Suggestion + key={s} + suggestion={s} + onClick={() => handleFollowupClick(s)} + /> + ))} + <Button + aria-label={t.common.close} + className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal" + variant="outline" + size="sm" + type="button" + onClick={() => setFollowupsHidden(true)} + > + <XIcon className="size-4" /> + </Button> + </Suggestions> + )} </div> - )} + </div> + )} <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <DialogContent> diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 8d5e0f6b9..354a27f04 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -29,11 +29,14 @@ import { MessageListItem } from "./message-list-item"; import { MessageListSkeleton } from "./skeleton"; import { SubtaskCard } from "./subtask-card"; +export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160; +export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80; + export function MessageList({ className, threadId, thread, - paddingBottom = 160, + paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, }: { className?: string; threadId: string; diff --git a/frontend/src/components/workspace/settings/account-settings-page.tsx b/frontend/src/components/workspace/settings/account-settings-page.tsx new file mode 100644 index 000000000..6382b8859 --- /dev/null +++ b/frontend/src/components/workspace/settings/account-settings-page.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { LogOutIcon } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { fetchWithAuth, getCsrfHeaders } from "@/core/api/fetcher"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { parseAuthError } from "@/core/auth/types"; + +import { SettingsSection } from "./settings-section"; + +export function AccountSettingsPage() { + const { user, logout } = useAuth(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setMessage(""); + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + + setLoading(true); + try { + const res = await fetchWithAuth("/api/v1/auth/change-password", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...getCsrfHeaders(), + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); + + if (!res.ok) { + const data = await res.json(); + const authError = parseAuthError(data); + setError(authError.message); + return; + } + + setMessage("Password changed successfully"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + <div className="space-y-8"> + <SettingsSection title="Profile"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground text-sm">Email</span> + <span className="text-sm font-medium">{user?.email ?? "โ€”"}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground text-sm">Role</span> + <span className="text-sm font-medium capitalize"> + {user?.system_role ?? "โ€”"} + </span> + </div> + </div> + </SettingsSection> + + <SettingsSection title="Change Password"> + <form onSubmit={handleChangePassword} className="max-w-sm space-y-3"> + <Input + type="password" + placeholder="Current password" + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} + required + /> + <Input + type="password" + placeholder="New password" + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} + required + minLength={8} + /> + <Input + type="password" + placeholder="Confirm new password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + required + minLength={8} + /> + {error && <p className="text-sm text-red-500">{error}</p>} + {message && <p className="text-sm text-green-500">{message}</p>} + <Button type="submit" variant="outline" size="sm" disabled={loading}> + {loading ? "Updating..." : "Update Password"} + </Button> + </form> + </SettingsSection> + + <SettingsSection title="Session"> + <Button + variant="destructive" + size="sm" + onClick={logout} + className="gap-2" + > + <LogOutIcon className="size-4" /> + Sign Out + </Button> + </SettingsSection> + </div> + ); +} diff --git a/frontend/src/components/workspace/settings/settings-dialog.tsx b/frontend/src/components/workspace/settings/settings-dialog.tsx index 3a111564b..5ad8b8966 100644 --- a/frontend/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend/src/components/workspace/settings/settings-dialog.tsx @@ -6,6 +6,7 @@ import { BrainIcon, PaletteIcon, SparklesIcon, + UserIcon, WrenchIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -18,6 +19,7 @@ import { } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page"; +import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page"; import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page"; import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page"; import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page"; @@ -27,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; type SettingsSection = + | "account" | "appearance" | "memory" | "tools" @@ -54,6 +57,11 @@ export function SettingsDialog(props: SettingsDialogProps) { const sections = useMemo( () => [ + { + id: "account", + label: t.settings.sections.account, + icon: UserIcon, + }, { id: "appearance", label: t.settings.sections.appearance, @@ -74,6 +82,7 @@ export function SettingsDialog(props: SettingsDialogProps) { { id: "about", label: t.settings.sections.about, icon: InfoIcon }, ], [ + t.settings.sections.account, t.settings.sections.appearance, t.settings.sections.memory, t.settings.sections.tools, @@ -124,6 +133,7 @@ export function SettingsDialog(props: SettingsDialogProps) { </nav> <ScrollArea className="h-full min-h-0 rounded-lg border"> <div className="space-y-8 p-6"> + {activeSection === "account" && <AccountSettingsPage />} {activeSection === "appearance" && <AppearanceSettingsPage />} {activeSection === "memory" && <MemorySettingsPage />} {activeSection === "tools" && <ToolSettingsPage />} diff --git a/frontend/src/components/workspace/workspace-nav-menu.tsx b/frontend/src/components/workspace/workspace-nav-menu.tsx index 8b99be078..81477ce9b 100644 --- a/frontend/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend/src/components/workspace/workspace-nav-menu.tsx @@ -5,9 +5,10 @@ import { ChevronsUpDown, GlobeIcon, InfoIcon, + LogOutIcon, MailIcon, Settings2Icon, - SettingsIcon, + UserIcon, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -25,6 +26,7 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { useAuth } from "@/core/auth/AuthProvider"; import { useI18n } from "@/core/i18n/hooks"; import { GithubIcon } from "./github-icon"; @@ -32,20 +34,22 @@ import { SettingsDialog } from "./settings"; function NavMenuButtonContent({ isSidebarOpen, + email, t, }: { isSidebarOpen: boolean; + email?: string; t: ReturnType<typeof useI18n>["t"]; }) { return isSidebarOpen ? ( <div className="text-muted-foreground flex w-full items-center gap-2 text-left text-sm"> - <SettingsIcon className="size-4" /> - <span>{t.workspace.settingsAndMore}</span> - <ChevronsUpDown className="text-muted-foreground ml-auto size-4" /> + <UserIcon className="size-4 shrink-0" /> + <span className="truncate">{email ?? t.workspace.settingsAndMore}</span> + <ChevronsUpDown className="text-muted-foreground ml-auto size-4 shrink-0" /> </div> ) : ( <div className="flex size-full items-center justify-center"> - <SettingsIcon className="text-muted-foreground size-4" /> + <UserIcon className="text-muted-foreground size-4" /> </div> ); } @@ -53,11 +57,18 @@ function NavMenuButtonContent({ export function WorkspaceNavMenu() { const [settingsOpen, setSettingsOpen] = useState(false); const [settingsDefaultSection, setSettingsDefaultSection] = useState< - "appearance" | "memory" | "tools" | "skills" | "notification" | "about" + | "account" + | "appearance" + | "memory" + | "tools" + | "skills" + | "notification" + | "about" >("appearance"); const [mounted, setMounted] = useState(false); const { open: isSidebarOpen } = useSidebar(); const { t } = useI18n(); + const { user, logout } = useAuth(); useEffect(() => { setMounted(true); @@ -79,7 +90,11 @@ export function WorkspaceNavMenu() { size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - <NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} /> + <NavMenuButtonContent + isSidebarOpen={isSidebarOpen} + email={user?.email} + t={t} + /> </SidebarMenuButton> </DropdownMenuTrigger> <DropdownMenuContent @@ -87,6 +102,14 @@ export function WorkspaceNavMenu() { align="end" sideOffset={4} > + {user?.email && ( + <> + <div className="text-muted-foreground truncate px-2 py-1.5 text-xs"> + {user.email} + </div> + <DropdownMenuSeparator /> + </> + )} <DropdownMenuGroup> <DropdownMenuItem onClick={() => { @@ -146,11 +169,24 @@ export function WorkspaceNavMenu() { <InfoIcon /> {t.workspace.about} </DropdownMenuItem> + {user && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={logout}> + <LogOutIcon /> + {t.workspace.logout} + </DropdownMenuItem> + </> + )} </DropdownMenuContent> </DropdownMenu> ) : ( <SidebarMenuButton size="lg" className="pointer-events-none"> - <NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} /> + <NavMenuButtonContent + isSidebarOpen={isSidebarOpen} + email={user?.email} + t={t} + /> </SidebarMenuButton> )} </SidebarMenuItem> diff --git a/frontend/src/content/en/_meta.ts b/frontend/src/content/en/_meta.ts new file mode 100644 index 000000000..c86a7ba7b --- /dev/null +++ b/frontend/src/content/en/_meta.ts @@ -0,0 +1,27 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + index: { + title: "Overview", + }, + introduction: { + title: "Introduction", + }, + harness: { + title: "DeerFlow Harness", + }, + application: { + title: "DeerFlow App", + }, + tutorials: { + title: "Tutorials", + }, + reference: { + title: "Reference", + }, + workspace: { + type: "page", + }, +}; + +export default meta; diff --git a/frontend/src/content/en/application/_meta.ts b/frontend/src/content/en/application/_meta.ts new file mode 100644 index 000000000..75a96065a --- /dev/null +++ b/frontend/src/content/en/application/_meta.ts @@ -0,0 +1,27 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + index: { + title: "Overview", + }, + "quick-start": { + title: "Quick Start", + }, + "deployment-guide": { + title: "Deployment Guide", + }, + configuration: { + title: "Configuration", + }, + "workspace-usage": { + title: "Workspace Usage", + }, + "agents-and-threads": { + title: "Agents and Threads", + }, + "operations-and-troubleshooting": { + title: "Operations and Troubleshooting", + }, +}; + +export default meta; diff --git a/frontend/src/content/en/application/agents-and-threads.mdx b/frontend/src/content/en/application/agents-and-threads.mdx new file mode 100644 index 000000000..9ad5eb851 --- /dev/null +++ b/frontend/src/content/en/application/agents-and-threads.mdx @@ -0,0 +1,3 @@ +# Agents and Threads + +TBD diff --git a/frontend/src/content/en/application/configuration.mdx b/frontend/src/content/en/application/configuration.mdx new file mode 100644 index 000000000..79de38b76 --- /dev/null +++ b/frontend/src/content/en/application/configuration.mdx @@ -0,0 +1,3 @@ +# Configuration + +TBD diff --git a/frontend/src/content/en/application/deployment-guide.mdx b/frontend/src/content/en/application/deployment-guide.mdx new file mode 100644 index 000000000..05fe72289 --- /dev/null +++ b/frontend/src/content/en/application/deployment-guide.mdx @@ -0,0 +1,3 @@ +# Deployment Guide + +TBD diff --git a/frontend/src/content/en/application/index.mdx b/frontend/src/content/en/application/index.mdx new file mode 100644 index 000000000..a951ea81b --- /dev/null +++ b/frontend/src/content/en/application/index.mdx @@ -0,0 +1,3 @@ +# DeerFlow App + +TBD diff --git a/frontend/src/content/en/application/operations-and-troubleshooting.mdx b/frontend/src/content/en/application/operations-and-troubleshooting.mdx new file mode 100644 index 000000000..6bf022503 --- /dev/null +++ b/frontend/src/content/en/application/operations-and-troubleshooting.mdx @@ -0,0 +1,3 @@ +# Operations and Troubleshooting + +TBD diff --git a/frontend/src/content/en/application/quick-start.mdx b/frontend/src/content/en/application/quick-start.mdx new file mode 100644 index 000000000..81c102836 --- /dev/null +++ b/frontend/src/content/en/application/quick-start.mdx @@ -0,0 +1,3 @@ +# Quick Start + +TBD diff --git a/frontend/src/content/en/application/workspace-usage.mdx b/frontend/src/content/en/application/workspace-usage.mdx new file mode 100644 index 000000000..567a6fb98 --- /dev/null +++ b/frontend/src/content/en/application/workspace-usage.mdx @@ -0,0 +1,3 @@ +# Workspace Usage + +TBD diff --git a/frontend/src/content/en/harness/_meta.ts b/frontend/src/content/en/harness/_meta.ts new file mode 100644 index 000000000..f96fd39f9 --- /dev/null +++ b/frontend/src/content/en/harness/_meta.ts @@ -0,0 +1,36 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + index: { + title: "Install", + }, + "quick-start": { + title: "Quick Start", + }, + "design-principles": { + title: "Design Principles", + }, + configuration: { + title: "Configuration", + }, + memory: { + title: "Memory", + }, + tools: { + title: "Tools", + }, + skills: { + title: "Skills", + }, + sandbox: { + title: "Sandbox", + }, + customization: { + title: "Customization", + }, + "integration-guide": { + title: "Integration Guide", + }, +}; + +export default meta; diff --git a/frontend/src/content/en/harness/configuration.mdx b/frontend/src/content/en/harness/configuration.mdx new file mode 100644 index 000000000..79de38b76 --- /dev/null +++ b/frontend/src/content/en/harness/configuration.mdx @@ -0,0 +1,3 @@ +# Configuration + +TBD diff --git a/frontend/src/content/en/harness/customization.mdx b/frontend/src/content/en/harness/customization.mdx new file mode 100644 index 000000000..c900eb2ca --- /dev/null +++ b/frontend/src/content/en/harness/customization.mdx @@ -0,0 +1,3 @@ +# Customization + +TBD diff --git a/frontend/src/content/en/harness/design-principles.mdx b/frontend/src/content/en/harness/design-principles.mdx new file mode 100644 index 000000000..bae1cc217 --- /dev/null +++ b/frontend/src/content/en/harness/design-principles.mdx @@ -0,0 +1,3 @@ +# Design Principles + +TBD diff --git a/frontend/src/content/en/harness/index.mdx b/frontend/src/content/en/harness/index.mdx new file mode 100644 index 000000000..02c9e46f3 --- /dev/null +++ b/frontend/src/content/en/harness/index.mdx @@ -0,0 +1,50 @@ +import { Callout, Cards } from "nextra/components"; + +# Install DeerFlow Harness + +<Callout type="info" emoji="๐Ÿ“ฆ"> + The DeerFlow Harness Python package will be published as <code>deerflow</code> + . It is not released yet, so installation is currently{" "} + <strong>Coming Soon</strong>. +</Callout> + +The DeerFlow Harness is the Python SDK and runtime foundation for building your own Super Agent systems. + +If you want to compose agents with skills, memory, tools, sandboxes, and subagents inside your own product or workflow, this is the part of DeerFlow you will build on. + +## Package name + +The package name will be: + +```bash +pip install deerflow +``` + +That package is not publicly available yet, but this is the installation path the documentation will use once it is released. + +## Current status + +The DeerFlow Harness package is **coming soon**. + +At the moment, this section exists to establish the SDK entry point and package identity, while the public distribution flow is being finalized. + +## What the Harness will give you + +The Harness is designed for developers who want to build their own agent system on top of DeerFlow's runtime model. + +It will provide the foundation for: + +- building long-horizon agents, +- composing runtime capabilities such as memory, tools, skills, and subagents, +- running agents with sandboxed execution, +- customizing agent behavior through configuration and code, and +- integrating DeerFlow into your own application architecture. + +## What to do next + +Until the package is released, the best way to understand the DeerFlow Harness is to read the conceptual and implementation docs in this section. + +<Cards num={2}> + <Cards.Card title="Quick Start" href="/docs/harness/quick-start" /> + <Cards.Card title="Configuration" href="/docs/harness/configuration" /> +</Cards> diff --git a/frontend/src/content/en/harness/integration-guide.mdx b/frontend/src/content/en/harness/integration-guide.mdx new file mode 100644 index 000000000..c81251a8b --- /dev/null +++ b/frontend/src/content/en/harness/integration-guide.mdx @@ -0,0 +1,3 @@ +# Integration Guide + +TBD diff --git a/frontend/src/content/en/harness/memory.mdx b/frontend/src/content/en/harness/memory.mdx new file mode 100644 index 000000000..30fd1060f --- /dev/null +++ b/frontend/src/content/en/harness/memory.mdx @@ -0,0 +1,3 @@ +# Memory + +TBD diff --git a/frontend/src/content/en/harness/quick-start.mdx b/frontend/src/content/en/harness/quick-start.mdx new file mode 100644 index 000000000..81c102836 --- /dev/null +++ b/frontend/src/content/en/harness/quick-start.mdx @@ -0,0 +1,3 @@ +# Quick Start + +TBD diff --git a/frontend/src/content/en/harness/sandbox.mdx b/frontend/src/content/en/harness/sandbox.mdx new file mode 100644 index 000000000..318d5475f --- /dev/null +++ b/frontend/src/content/en/harness/sandbox.mdx @@ -0,0 +1,3 @@ +# Sandbox + +TBD diff --git a/frontend/src/content/en/harness/skills.mdx b/frontend/src/content/en/harness/skills.mdx new file mode 100644 index 000000000..375984c3f --- /dev/null +++ b/frontend/src/content/en/harness/skills.mdx @@ -0,0 +1,3 @@ +# Skills + +TBD diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx new file mode 100644 index 000000000..ad9493e40 --- /dev/null +++ b/frontend/src/content/en/harness/tools.mdx @@ -0,0 +1,3 @@ +# Tools + +TBD diff --git a/frontend/src/content/en/index.mdx b/frontend/src/content/en/index.mdx new file mode 100644 index 000000000..0dd2efe12 --- /dev/null +++ b/frontend/src/content/en/index.mdx @@ -0,0 +1,95 @@ +--- +title: DeerFlow Documentation +description: Understand DeerFlow, build with the Harness, and deploy the App. +--- + +# DeerFlow Documentation + +DeerFlow is a framework for building and operating agent systems. It gives you a runtime harness for composing agents with memory, tools, skills, sandboxes, and subagents, and it also provides an application layer that turns those capabilities into a usable product experience. + +This documentation is organized around those two parts: + +- **DeerFlow Harness**: the core SDK and runtime layer for building your own agent system. +- **DeerFlow App**: a reference application built on top of the Harness for deployment, operations, and end-user workflows. + +If you want to understand how DeerFlow works, start with the Introduction. If you want to build on the core runtime, go to the Harness docs. If you want to deploy and use DeerFlow as an application, go to the App docs. + +## Start here + +### If you are new to DeerFlow + +Start with the conceptual overview first. + +- [Introduction](/docs/introduction) +- [Why DeerFlow](/docs/introduction/why-deerflow) +- [Harness vs App](/docs/introduction/harness-vs-app) + +### If you want to build with DeerFlow + +Start with the Harness section. This path is for teams who want to integrate DeerFlow capabilities into their own system or build a custom agent product on top of the DeerFlow runtime. + +- [DeerFlow Harness](/docs/harness) +- [Quick Start](/docs/harness/quick-start) +- [Configuration](/docs/harness/configuration) +- [Customization](/docs/harness/customization) + +### If you want to deploy and use DeerFlow + +Start with the App section. This path is for teams who want to run DeerFlow as a complete application and understand how to configure, operate, and use it in practice. + +- [DeerFlow App](/docs/app) +- [Quick Start](/docs/app/quick-start) +- [Deployment Guide](/docs/app/deployment-guide) +- [Workspace Usage](/docs/app/workspace-usage) + +## Documentation structure + +### Introduction + +The Introduction section helps you build the right mental model before you look at implementation details. + +- **What is DeerFlow** explains what DeerFlow is and what problems it is designed to solve. +- **Why DeerFlow** explains the motivation behind the project. +- **Core Concepts** introduces the concepts that appear across the documentation. +- **Harness vs App** explains the relationship between the runtime layer and the application layer. + +### DeerFlow Harness + +The Harness section is the core of the technical documentation. It is written for developers who want to build their own DeerFlow-based system. + +- **Quick Start** shows how to create your first harness with the core DeerFlow API. +- **Design Principles** explains the architectural ideas behind the Harness. +- **Configuration** covers the main configuration surface of the SDK/runtime. +- **Memory**, **Tools**, **Skills**, and **Sandbox** explain the major system capabilities separately. +- **Customization** shows how to adapt DeerFlow to your own product requirements. +- **Integration Guide** explains how to integrate the Harness into a larger system. + +### DeerFlow App + +The App section is written for teams who want to deploy DeerFlow as a usable product. + +- **Quick Start** helps you run the application locally. +- **Deployment Guide** explains how to deploy DeerFlow in your own environment. +- **Configuration** covers the application-level configuration model. +- **Workspace Usage** explains the main user workflows. +- **Agents and Threads** explains how users interact with DeerFlow in practice. +- **Operations and Troubleshooting** covers maintenance and production usage. + +### Tutorials + +The Tutorials section is for hands-on, task-oriented learning. + +- [Tutorials](/docs/tutorials) + +### Reference + +The Reference section is for detailed lookup material, including configuration, runtime modes, APIs, and source-oriented mapping. + +- [Reference](/docs/reference) + +## Choose the right path + +- If you are **evaluating the project**, start with [Introduction](/docs/introduction). +- If you are **building your own agent system**, start with [DeerFlow Harness](/docs/harness). +- If you are **deploying DeerFlow for users**, start with [DeerFlow App](/docs/app). +- If you want to **learn by doing**, go to [Tutorials](/docs/tutorials). diff --git a/frontend/src/content/en/introduction/_meta.ts b/frontend/src/content/en/introduction/_meta.ts new file mode 100644 index 000000000..e9ce8f592 --- /dev/null +++ b/frontend/src/content/en/introduction/_meta.ts @@ -0,0 +1,15 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + "why-deerflow": { + title: "Why DeerFlow", + }, + "core-concepts": { + title: "Core Concepts", + }, + "harness-vs-app": { + title: "Harness vs App", + }, +}; + +export default meta; diff --git a/frontend/src/content/en/introduction/core-concepts.mdx b/frontend/src/content/en/introduction/core-concepts.mdx new file mode 100644 index 000000000..dfed365cb --- /dev/null +++ b/frontend/src/content/en/introduction/core-concepts.mdx @@ -0,0 +1,113 @@ +import { Callout, Cards } from "nextra/components"; + +# Core Concepts + +<Callout type="important" emoji="๐Ÿง "> + DeerFlow makes the most sense if you think of it as a runtime for long-horizon + agents, not just a chat interface or a workflow graph. +</Callout> + +Before you go deeper into DeerFlow, it helps to anchor on a few concepts that appear throughout the system. These concepts explain what DeerFlow is optimizing for and why its architecture looks the way it does. + +## Harness + +In DeerFlow, a **harness** is the runtime layer that gives an agent the environment it needs to do real work. + +A framework usually gives you abstractions and building blocks. A harness goes further: it packages an opinionated set of runtime capabilities so the agent can plan, act, use tools, manage files, and operate across longer tasks without you rebuilding the same infrastructure each time. + +In practice, DeerFlow's harness includes things like: + +- tool access, +- skill loading, +- sandboxed execution, +- memory, +- subagent orchestration, and +- context management. + +That is why DeerFlow is not only a model wrapper and not only a workflow graph. It is a runtime environment for agents. + +## Long-horizon agent + +A **long-horizon agent** is an agent that stays useful across a chain of actions instead of producing only a single answer. + +This kind of agent may need to: + +1. make a plan, +2. decide the next step repeatedly, +3. call tools many times, +4. inspect and modify files, +5. store intermediate results, and +6. return a usable artifact at the end. + +The important point is not just duration. It is sustained coordination across multiple steps. + +DeerFlow is designed for this kind of work. Its architecture assumes that useful tasks often take more than one tool call and more than one reasoning pass. + +## Skill + +A **skill** is a task-oriented capability package that teaches the agent how to do a certain class of work. + +A skill is not just a label. It usually includes structured instructions, workflows, best practices, and supporting resources that can be loaded when relevant. This keeps the base agent general while allowing specialized behavior to be added only when needed. + +In DeerFlow, deep research is one skill. Data analysis, content generation, design-oriented workflows, and other task families can also be represented as skills. + +This is a major part of the DeerFlow mental model: the runtime stays general, while skills provide specialization. + +## Sandbox + +A **sandbox** is the isolated execution environment where the agent does file and command-based work. + +Instead of treating the agent as a pure text generator, DeerFlow gives it a workspace where it can read files, write outputs, run commands, and produce artifacts. This makes the system much more useful for coding, analysis, and multi-step workflows. + +Isolation matters because execution should be controlled and reproducible. The sandbox is what lets DeerFlow support action, not just conversation. + +## Subagent + +A **subagent** is a focused worker that handles a delegated subtask. + +When a task is too broad for one reasoning thread, DeerFlow can split the work into smaller units and run them separately. Subagents help with parallel exploration, scoped execution, and reducing overload on the main agent. + +The key idea is isolation. A subagent does not need the full conversation history or every detail from the parent context. It only needs the information required to solve its assigned piece of work well. + +## Context engineering + +**Context engineering** is the practice of controlling what the agent sees, remembers, and ignores so it can stay effective over time. + +Long tasks put pressure on the context window. If everything is kept inline forever, the agent becomes slower, noisier, and less reliable. DeerFlow addresses this with techniques such as summarization, scoped context for subagents, and using the file system as external working memory. + +This is one of the most important ideas in DeerFlow. Good agent behavior is not only about a stronger model. It is also about giving the model the right working set at the right time. + +## Memory + +**Memory** is DeerFlow's mechanism for carrying useful information across sessions. + +Instead of starting from zero every time, the system can retain information such as user preferences, recurring project context, and durable facts that improve future interactions. Memory makes the agent more adaptive and less repetitive. + +In DeerFlow, memory is part of the runtime, not an afterthought layered on top. + +## Artifact + +An **artifact** is the concrete output of the agent's work. + +That output might be a report, a generated file, a code change, a chart, a design asset, or another deliverable that can be reviewed and used outside the chat itself. + +This matters because DeerFlow is designed around task completion, not just answer generation. The system is trying to produce something you can inspect, refine, or hand off. + +## Putting the concepts together + +These concepts fit together as one model: + +- The **harness** provides the runtime. +- **Skills** provide specialization. +- The **sandbox** provides an execution environment. +- **Subagents** provide decomposition and parallelism. +- **Context engineering** keeps long tasks manageable. +- **Memory** preserves useful continuity. +- **Artifacts** are the outputs that make the work tangible. + +If you keep that model in mind, the rest of the DeerFlow docs will be much easier to navigate. + +<Cards num={2}> + <Cards.Card title="Why DeerFlow" href="/docs/introduction/why-deerflow" /> + <Cards.Card title="Harness vs App" href="/docs/introduction/harness-vs-app" /> +</Cards> diff --git a/frontend/src/content/en/introduction/harness-vs-app.mdx b/frontend/src/content/en/introduction/harness-vs-app.mdx new file mode 100644 index 000000000..e0bbb6d55 --- /dev/null +++ b/frontend/src/content/en/introduction/harness-vs-app.mdx @@ -0,0 +1,103 @@ +import { Callout, Cards } from "nextra/components"; + +# Harness vs App + +<Callout type="info" emoji="โš™๏ธ"> + DeerFlow App is the best-practice Super Agent application built on top of + DeerFlow Harness, while DeerFlow Harness is the Python SDK and runtime + foundation for building your own agent system. +</Callout> + +DeerFlow has two layers that are closely related but serve different purposes: + +- **DeerFlow Harness** is the runtime foundation. +- **DeerFlow App** is the best-practice application built on top of that foundation. + +Understanding this distinction makes the rest of the documentation much easier to navigate. + +## The Harness is the runtime layer + +The **DeerFlow Harness** is the reusable system for building and operating long-horizon agents. + +It is also delivered as a **Python library SDK**, so developers can use DeerFlow as a programmable foundation for building their own Super Agent systems instead of starting from scratch. + +It provides the core runtime capabilities that make an agent useful in real work: + +- skills, +- tool use, +- sandboxed execution, +- memory, +- subagent orchestration, +- context management, and +- configurable runtime behavior. + +If you want to build your own agent product, integrate DeerFlow into an existing system, or customize the runtime deeply, the Harness is the part you should focus on. + +The Harness is the foundation. It is the part that makes DeerFlow programmable, extensible, and reusable. + +## The App is the reference implementation + +The **DeerFlow App** is a complete Super Agent application built with the Harness. + +Instead of only exposing runtime primitives, it shows what a production-oriented DeerFlow experience looks like when those primitives are assembled into a usable product. In other words, the App is not separate from the Harness. It is the best-practice implementation of what the Harness enables. + +That is why the App is the easiest way to understand DeerFlow as a working system: it demonstrates how the runtime, UI, workflows, and operational pieces come together in one place. + +## Why the App matters + +Many teams do not just want agent infrastructure. They want a usable application that already solves the common product and operations problems around running agents. + +The DeerFlow App addresses that need. + +It presents DeerFlow as a **Super Agent application** rather than only a set of low-level building blocks. That means users interact with a complete system that can manage conversations, run tasks, produce artifacts, and expose the core DeerFlow capabilities through an opinionated product surface. + +The App is therefore useful in two different ways: + +1. as a ready-to-run product for teams that want to deploy DeerFlow directly, and +2. as a reference architecture for teams that want to build their own application on top of the Harness. + +## Best practices encoded in the App + +The App is where DeerFlow's recommended patterns become concrete. + +It reflects best practices for: + +- how users interact with a lead agent, +- how threads and artifacts are organized, +- how runtime capabilities are exposed in a product workflow, +- how the system is configured and operated, and +- how a self-hosted DeerFlow deployment should be structured. + +So when we say the App is built on the Harness, we do not only mean it imports the runtime. We mean it packages DeerFlow's preferred way to deliver a Super Agent experience end to end. + +## Self-hosting and deployment + +A key property of DeerFlow App is that it can be **self-hosted**. + +That matters for teams that want control over infrastructure, data handling, runtime configuration, and integration with their own environment. The App is designed so you can run DeerFlow in your own setup instead of treating it as a closed hosted service. + +This makes DeerFlow practical for internal tools, team workflows, and organization-specific deployments where control and extensibility matter as much as raw model capability. + +## How to choose between them + +The simplest rule is: + +- Choose the **Harness** if you want to build your own agent system. +- Choose the **App** if you want DeerFlow as a complete Super Agent product. +- Use both if you want to start from the App while still keeping access to the underlying runtime. + +The two layers are complementary. The Harness gives you the agent runtime. The App turns that runtime into a concrete product with deployment, operations, and user workflows already thought through. + +<Cards num={2}> + <Cards.Card title="DeerFlow Harness" href="/docs/harness" /> + <Cards.Card title="DeerFlow App" href="/docs/app" /> +</Cards> + +## One foundation, two entry points + +DeerFlow is not split into two unrelated products. It is one system with two entry points. + +The **Harness** is the core runtime for developers. +The **App** is the best-practice Super Agent application built on top of that runtime. + +If you want to go deeper into the runtime itself, continue with the [DeerFlow Harness](/docs/harness). If you want to run DeerFlow as a product, continue with the [DeerFlow App](/docs/app). diff --git a/frontend/src/content/en/introduction/why-deerflow.mdx b/frontend/src/content/en/introduction/why-deerflow.mdx new file mode 100644 index 000000000..f7fad9628 --- /dev/null +++ b/frontend/src/content/en/introduction/why-deerflow.mdx @@ -0,0 +1,73 @@ +import { Callout, Cards } from "nextra/components"; + +# Why DeerFlow + +<Callout type="info" emoji="๐ŸฆŒ"> + DeerFlow started with deep research, but it grew into a general runtime for + long-horizon agents that need skills, memory, tools, and coordination. +</Callout> + +DeerFlow exists because modern agent systems need more than a chat loop. A useful agent must plan over long horizons, break work into sub-tasks, use tools, manipulate files, run code safely, and preserve enough context to stay coherent across a complex task. DeerFlow was built to provide that runtime foundation. + +## It started as deep research + +The first version of DeerFlow was designed around a specific goal: produce real research outputs instead of lightweight chatbot summaries. The idea was to let an AI system work more like a research team: make a plan, gather sources, cross-check findings, and deliver a structured result with useful depth. + +That framing worked, but the project quickly revealed something more important. Teams were not only using DeerFlow for research. They were adapting it for data analysis, report generation, internal automation, operations workflows, and other tasks that also require multi-step execution. + +The common thread was clear: the valuable part was not only the research workflow itself, but the runtime capabilities underneath it. + +## Research was the first skill, not the whole system + +That shift in usage led to a key conclusion: deep research should be treated as one capability inside a broader agent runtime, not as the definition of the entire product. + +DeerFlow therefore evolved from a project centered on a single research pattern into a general-purpose harness for long-running agents. In this model, research is still important, but it becomes one skill among many rather than the fixed shape of the system. + +This is why DeerFlow is described as a **harness** instead of only a framework or only an application. + +## Why a harness matters + +A harness is an opinionated runtime for agents. It does not just expose abstractions. It packages the infrastructure an agent needs to do useful work in realistic environments. + +For DeerFlow, that means combining the core pieces required for long-horizon execution: + +- **Skills** for task-specific capabilities that can be loaded only when needed. +- **Sandboxed execution** so agents can work with files, run commands, and produce artifacts safely. +- **Subagents** so complex work can be decomposed and executed in parallel. +- **Memory** so the system can retain user preferences and recurring context across sessions. +- **Context management** so long tasks remain tractable even when conversations and outputs grow. + +These are the building blocks that make an agent useful beyond a single prompt-response exchange. + +## Why DeerFlow moved beyond fixed multi-agent graphs + +Earlier agent systems often modeled work as a fixed graph of specialized roles. That approach can work for a narrow workflow, but it becomes rigid once users want the system to handle a broader range of tasks. + +DeerFlow moved toward a different architecture: a lead agent with middleware, tools, and dynamically invoked subagents. This makes the system easier to extend because new capabilities can be introduced as skills, tools, or runtime policies instead of requiring the whole orchestration graph to be redesigned. + +That architectural shift reflects the main motivation behind DeerFlow: build the reusable runtime layer first, then let many workflows sit on top of it. + +## DeerFlow is built for long-horizon work + +DeerFlow is motivated by a specific view of agents: the most valuable systems are not the ones that generate a single answer fastest, but the ones that can stay productive across a longer chain of actions. + +A long-horizon agent needs to do more than respond. It needs to: + +1. decide what to do next, +2. keep track of intermediate state, +3. store work outside the model context when necessary, +4. recover from complexity without losing direction, and +5. return an artifact that a human can review, refine, or continue from. + +That is the category of problem DeerFlow is designed for. + +## The goal + +The goal of DeerFlow is to provide a solid foundation for building and operating agent systems that can actually do work. + +If you are evaluating DeerFlow, the important idea is this: DeerFlow is not just a research demo and not just a UI wrapper around an LLM. It is a runtime harness for agents that need skills, memory, tools, isolation, and coordination to complete real tasks. + +<Cards num={2}> + <Cards.Card title="Core Concepts" href="/docs/introduction/core-concepts" /> + <Cards.Card title="Harness vs App" href="/docs/introduction/harness-vs-app" /> +</Cards> diff --git a/frontend/src/content/en/reference/_meta.ts b/frontend/src/content/en/reference/_meta.ts new file mode 100644 index 000000000..cf53ad9a8 --- /dev/null +++ b/frontend/src/content/en/reference/_meta.ts @@ -0,0 +1,21 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + "concepts-glossary": { + title: "Concepts Glossary", + }, + "configuration-reference": { + title: "Configuration Reference", + }, + "api-gateway-reference": { + title: "API / Gateway Reference", + }, + "runtime-flags-and-modes": { + title: "Runtime Flags and Modes", + }, + "source-map": { + title: "Source Map", + }, +}; + +export default meta; diff --git a/frontend/src/content/en/reference/api-gateway-reference.mdx b/frontend/src/content/en/reference/api-gateway-reference.mdx new file mode 100644 index 000000000..2a5922d2b --- /dev/null +++ b/frontend/src/content/en/reference/api-gateway-reference.mdx @@ -0,0 +1,3 @@ +# API / Gateway Reference + +TBD diff --git a/frontend/src/content/en/reference/concepts-glossary.mdx b/frontend/src/content/en/reference/concepts-glossary.mdx new file mode 100644 index 000000000..32f000a7e --- /dev/null +++ b/frontend/src/content/en/reference/concepts-glossary.mdx @@ -0,0 +1,3 @@ +# Concepts Glossary + +TBD diff --git a/frontend/src/content/en/reference/configuration-reference.mdx b/frontend/src/content/en/reference/configuration-reference.mdx new file mode 100644 index 000000000..cbc914dda --- /dev/null +++ b/frontend/src/content/en/reference/configuration-reference.mdx @@ -0,0 +1,3 @@ +# Configuration Reference + +TBD diff --git a/frontend/src/content/en/reference/runtime-flags-and-modes.mdx b/frontend/src/content/en/reference/runtime-flags-and-modes.mdx new file mode 100644 index 000000000..76ce6f9ab --- /dev/null +++ b/frontend/src/content/en/reference/runtime-flags-and-modes.mdx @@ -0,0 +1,3 @@ +# Runtime Flags and Modes + +TBD diff --git a/frontend/src/content/en/reference/source-map.mdx b/frontend/src/content/en/reference/source-map.mdx new file mode 100644 index 000000000..f5a4823da --- /dev/null +++ b/frontend/src/content/en/reference/source-map.mdx @@ -0,0 +1,3 @@ +# Source Map + +TBD diff --git a/frontend/src/content/en/tutorials/_meta.ts b/frontend/src/content/en/tutorials/_meta.ts new file mode 100644 index 000000000..99402eca0 --- /dev/null +++ b/frontend/src/content/en/tutorials/_meta.ts @@ -0,0 +1,21 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + "first-conversation": { + title: "First Conversation", + }, + "create-your-first-harness": { + title: "Create Your First Harness", + }, + "use-tools-and-skills": { + title: "Use Tools and Skills", + }, + "work-with-memory": { + title: "Work with Memory", + }, + "deploy-your-own-deerflow": { + title: "Deploy Your Own DeerFlow", + }, +}; + +export default meta; diff --git a/frontend/src/content/en/tutorials/create-your-first-harness.mdx b/frontend/src/content/en/tutorials/create-your-first-harness.mdx new file mode 100644 index 000000000..b692e09e0 --- /dev/null +++ b/frontend/src/content/en/tutorials/create-your-first-harness.mdx @@ -0,0 +1,3 @@ +# Create Your First Harness + +TBD diff --git a/frontend/src/content/en/tutorials/deploy-your-own-deerflow.mdx b/frontend/src/content/en/tutorials/deploy-your-own-deerflow.mdx new file mode 100644 index 000000000..4ecfbaec1 --- /dev/null +++ b/frontend/src/content/en/tutorials/deploy-your-own-deerflow.mdx @@ -0,0 +1,3 @@ +# Deploy Your Own DeerFlow + +TBD diff --git a/frontend/src/content/en/tutorials/first-conversation.mdx b/frontend/src/content/en/tutorials/first-conversation.mdx new file mode 100644 index 000000000..71ff683cf --- /dev/null +++ b/frontend/src/content/en/tutorials/first-conversation.mdx @@ -0,0 +1,3 @@ +# First Conversation + +TBD diff --git a/frontend/src/content/en/tutorials/use-tools-and-skills.mdx b/frontend/src/content/en/tutorials/use-tools-and-skills.mdx new file mode 100644 index 000000000..0d1af3a81 --- /dev/null +++ b/frontend/src/content/en/tutorials/use-tools-and-skills.mdx @@ -0,0 +1,3 @@ +# Use Tools and Skills + +TBD diff --git a/frontend/src/content/en/tutorials/work-with-memory.mdx b/frontend/src/content/en/tutorials/work-with-memory.mdx new file mode 100644 index 000000000..f32d0f9ee --- /dev/null +++ b/frontend/src/content/en/tutorials/work-with-memory.mdx @@ -0,0 +1,3 @@ +# Work with Memory + +TBD diff --git a/frontend/src/content/zh/_meta.ts b/frontend/src/content/zh/_meta.ts new file mode 100644 index 000000000..c72559ebc --- /dev/null +++ b/frontend/src/content/zh/_meta.ts @@ -0,0 +1,12 @@ +import type { MetaRecord } from "nextra"; + +const meta: MetaRecord = { + index: { + title: "ๆฆ‚่งˆ", + }, + workspace: { + type: "page", + }, +}; + +export default meta; diff --git a/frontend/src/content/zh/index.mdx b/frontend/src/content/zh/index.mdx new file mode 100644 index 000000000..08d2d3557 --- /dev/null +++ b/frontend/src/content/zh/index.mdx @@ -0,0 +1,6 @@ +--- +title: ๆฆ‚่งˆ +description: ไบ†่งฃ DeerFlow๏ผŒไฝฟ็”จ Harness ๆž„ๅปบ๏ผŒๅนถ้ƒจ็ฝฒๅบ”็”จใ€‚ +--- + +# ๆฆ‚่งˆ diff --git a/frontend/src/core/agents/api.ts b/frontend/src/core/agents/api.ts index 927b5f20b..654c1de5f 100644 --- a/frontend/src/core/agents/api.ts +++ b/frontend/src/core/agents/api.ts @@ -1,3 +1,4 @@ +import { getCsrfHeaders } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types"; @@ -30,8 +31,9 @@ export async function getAgent(name: string): Promise<Agent> { export async function createAgent(request: CreateAgentRequest): Promise<Agent> { const res = await fetch(`${getBackendBaseURL()}/api/agents`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...getCsrfHeaders() }, body: JSON.stringify(request), + credentials: "include", }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { detail?: string }; @@ -46,8 +48,9 @@ export async function updateAgent( ): Promise<Agent> { const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...getCsrfHeaders() }, body: JSON.stringify(request), + credentials: "include", }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { detail?: string }; @@ -59,6 +62,8 @@ export async function updateAgent( export async function deleteAgent(name: string): Promise<void> { const res = await fetch(`${getBackendBaseURL()}/api/agents/${name}`, { method: "DELETE", + headers: { ...getCsrfHeaders() }, + credentials: "include", }); if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); } diff --git a/frontend/src/core/api/api-client.ts b/frontend/src/core/api/api-client.ts index b86251513..0ec0d74e8 100644 --- a/frontend/src/core/api/api-client.ts +++ b/frontend/src/core/api/api-client.ts @@ -4,11 +4,17 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client"; import { getLangGraphBaseURL } from "../config"; +import { getCsrfHeaders } from "./fetcher"; import { sanitizeRunStreamOptions } from "./stream-mode"; function createCompatibleClient(isMock?: boolean): LangGraphClient { const client = new LangGraphClient({ apiUrl: getLangGraphBaseURL(isMock), + onRequest: (_url, init) => ({ + ...init, + credentials: "include", + headers: { ...init.headers, ...getCsrfHeaders() }, + }), }); const originalRunStream = client.runs.stream.bind(client.runs); diff --git a/frontend/src/core/api/fetcher.ts b/frontend/src/core/api/fetcher.ts new file mode 100644 index 000000000..a9dd30387 --- /dev/null +++ b/frontend/src/core/api/fetcher.ts @@ -0,0 +1,39 @@ +import { buildLoginUrl } from "@/core/auth/types"; + +/** + * Fetch with credentials. Automatically redirects to login on 401. + */ +export async function fetchWithAuth( + input: RequestInfo | string, + init?: RequestInit, +): Promise<Response> { + const url = typeof input === "string" ? input : input.url; + const res = await fetch(url, { + ...init, + credentials: "include", + }); + + if (res.status === 401) { + window.location.href = buildLoginUrl(window.location.pathname); + throw new Error("Unauthorized"); + } + + return res; +} + +/** + * Build headers for CSRF-protected requests + * Per RFC-001: Double Submit Cookie pattern + */ +export function getCsrfHeaders(): HeadersInit { + const token = getCsrfToken(); + return token ? { "X-CSRF-Token": token } : {}; +} + +/** + * Get CSRF token from cookie + */ +function getCsrfToken(): string | null { + const match = /csrf_token=([^;]+)/.exec(document.cookie); + return match?.[1] ?? null; +} diff --git a/frontend/src/core/auth/AuthProvider.tsx b/frontend/src/core/auth/AuthProvider.tsx new file mode 100644 index 000000000..652cc49b8 --- /dev/null +++ b/frontend/src/core/auth/AuthProvider.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + type ReactNode, +} from "react"; + +import { type User, buildLoginUrl } from "./types"; + +// Re-export for consumers +export type { User }; + +/** + * Authentication context provided to consuming components + */ +interface AuthContextType { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + logout: () => Promise<void>; + refreshUser: () => Promise<void>; +} + +const AuthContext = createContext<AuthContextType | undefined>(undefined); + +interface AuthProviderProps { + children: ReactNode; + initialUser: User | null; +} + +/** + * AuthProvider - Unified authentication context for the application + * + * Per RFC-001: + * - Only holds display information (user), never JWT or tokens + * - initialUser comes from server-side guard, avoiding client flicker + * - Provides logout and refresh capabilities + */ +export function AuthProvider({ children, initialUser }: AuthProviderProps) { + const [user, setUser] = useState<User | null>(initialUser); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + + const isAuthenticated = user !== null; + + /** + * Fetch current user from FastAPI + * Used when initialUser might be stale (e.g., after tab was inactive) + */ + const refreshUser = useCallback(async () => { + try { + setIsLoading(true); + const res = await fetch("/api/v1/auth/me", { + credentials: "include", + }); + + if (res.ok) { + const data = await res.json(); + setUser(data); + } else if (res.status === 401) { + // Session expired or invalid + setUser(null); + // Redirect to login if on a protected route + if (pathname?.startsWith("/workspace")) { + router.push(buildLoginUrl(pathname)); + } + } + } catch (err) { + console.error("Failed to refresh user:", err); + setUser(null); + } finally { + setIsLoading(false); + } + }, [pathname, router]); + + /** + * Logout - call FastAPI logout endpoint and clear local state + * Per RFC-001: Immediately clear local state, don't wait for server confirmation + */ + const logout = useCallback(async () => { + // Immediately clear local state to prevent UI flicker + setUser(null); + + try { + await fetch("/api/v1/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch (err) { + console.error("Logout request failed:", err); + // Still redirect even if logout request fails + } + + // Redirect to home page + router.push("/"); + }, [router]); + + /** + * Handle visibility change - refresh user when tab becomes visible again. + * Throttled to at most once per 60 s to avoid spamming the backend on rapid tab switches. + */ + const lastCheckRef = React.useRef(0); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== "visible" || user === null) return; + const now = Date.now(); + if (now - lastCheckRef.current < 60_000) return; + lastCheckRef.current = now; + void refreshUser(); + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [user, refreshUser]); + + const value: AuthContextType = { + user, + isAuthenticated, + isLoading, + logout, + refreshUser, + }; + + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; +} + +/** + * Hook to access authentication context + * Throws if used outside AuthProvider - this is intentional for proper usage + */ +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} + +/** + * Hook to require authentication - redirects to login if not authenticated + * Useful for client-side checks in addition to server-side guards + */ +export function useRequireAuth(): AuthContextType { + const auth = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + // Only redirect if we're sure user is not authenticated (not just loading) + if (!auth.isLoading && !auth.isAuthenticated) { + router.push(buildLoginUrl(pathname || "/workspace")); + } + }, [auth.isAuthenticated, auth.isLoading, router, pathname]); + + return auth; +} diff --git a/frontend/src/core/auth/gateway-config.ts b/frontend/src/core/auth/gateway-config.ts new file mode 100644 index 000000000..61c6ae850 --- /dev/null +++ b/frontend/src/core/auth/gateway-config.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +const gatewayConfigSchema = z.object({ + internalGatewayUrl: z.string().url(), + trustedOrigins: z.array(z.string()).min(1), +}); + +export type GatewayConfig = z.infer<typeof gatewayConfigSchema>; + +let _cached: GatewayConfig | null = null; + +export function getGatewayConfig(): GatewayConfig { + if (_cached) return _cached; + + const isDev = process.env.NODE_ENV === "development"; + + const rawUrl = process.env.DEER_FLOW_INTERNAL_GATEWAY_BASE_URL?.trim(); + const internalGatewayUrl = + rawUrl?.replace(/\/+$/, "") ?? + (isDev ? "http://localhost:8001" : undefined); + + const rawOrigins = process.env.DEER_FLOW_TRUSTED_ORIGINS?.trim(); + const trustedOrigins = rawOrigins + ? rawOrigins + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : isDev + ? ["http://localhost:3000"] + : undefined; + + _cached = gatewayConfigSchema.parse({ internalGatewayUrl, trustedOrigins }); + return _cached; +} diff --git a/frontend/src/core/auth/proxy-policy.ts b/frontend/src/core/auth/proxy-policy.ts new file mode 100644 index 000000000..9e6f1f424 --- /dev/null +++ b/frontend/src/core/auth/proxy-policy.ts @@ -0,0 +1,55 @@ +export interface ProxyPolicy { + /** Allowed upstream path prefixes */ + readonly allowedPaths: readonly string[]; + /** Request headers to strip before forwarding */ + readonly strippedRequestHeaders: ReadonlySet<string>; + /** Response headers to strip before returning */ + readonly strippedResponseHeaders: ReadonlySet<string>; + /** Credential mode: which cookie to forward */ + readonly credential: { readonly type: "cookie"; readonly name: string }; + /** Timeout in ms */ + readonly timeoutMs: number; + /** CSRF: required for non-GET/HEAD */ + readonly csrf: boolean; +} + +export const LANGGRAPH_COMPAT_POLICY: ProxyPolicy = { + allowedPaths: [ + "threads", + "runs", + "assistants", + "store", + "models", + "mcp", + "skills", + "memory", + ], + strippedRequestHeaders: new Set([ + "host", + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "authorization", + "x-api-key", + "origin", + "referer", + "proxy-authorization", + "proxy-authenticate", + ]), + strippedResponseHeaders: new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "content-length", + "set-cookie", + ]), + credential: { type: "cookie", name: "access_token" }, + timeoutMs: 120_000, + csrf: true, +}; diff --git a/frontend/src/core/auth/server.ts b/frontend/src/core/auth/server.ts new file mode 100644 index 000000000..4229143aa --- /dev/null +++ b/frontend/src/core/auth/server.ts @@ -0,0 +1,57 @@ +import { cookies } from "next/headers"; + +import { getGatewayConfig } from "./gateway-config"; +import { type AuthResult, userSchema } from "./types"; + +const SSR_AUTH_TIMEOUT_MS = 5_000; + +/** + * Fetch the authenticated user from the gateway using the request's cookies. + * Returns a tagged AuthResult โ€” callers use exhaustive switch, no try/catch. + */ +export async function getServerSideUser(): Promise<AuthResult> { + const cookieStore = await cookies(); + const sessionCookie = cookieStore.get("access_token"); + + let internalGatewayUrl: string; + try { + internalGatewayUrl = getGatewayConfig().internalGatewayUrl; + } catch (err) { + return { tag: "config_error", message: String(err) }; + } + + if (!sessionCookie) return { tag: "unauthenticated" }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SSR_AUTH_TIMEOUT_MS); + + try { + const res = await fetch(`${internalGatewayUrl}/api/v1/auth/me`, { + headers: { Cookie: `access_token=${sessionCookie.value}` }, + cache: "no-store", + signal: controller.signal, + }); + clearTimeout(timeout); // Clear immediately โ€” covers all response branches + + if (res.ok) { + const parsed = userSchema.safeParse(await res.json()); + if (!parsed.success) { + console.error("[SSR auth] Malformed /auth/me response:", parsed.error); + return { tag: "gateway_unavailable" }; + } + if (parsed.data.needs_setup) { + return { tag: "needs_setup", user: parsed.data }; + } + return { tag: "authenticated", user: parsed.data }; + } + if (res.status === 401 || res.status === 403) { + return { tag: "unauthenticated" }; + } + console.error(`[SSR auth] /api/v1/auth/me responded ${res.status}`); + return { tag: "gateway_unavailable" }; + } catch (err) { + clearTimeout(timeout); + console.error("[SSR auth] Failed to reach gateway:", err); + return { tag: "gateway_unavailable" }; + } +} diff --git a/frontend/src/core/auth/types.ts b/frontend/src/core/auth/types.ts new file mode 100644 index 000000000..4cf42583e --- /dev/null +++ b/frontend/src/core/auth/types.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +// โ”€โ”€ User schema (single source of truth) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export const userSchema = z.object({ + id: z.string(), + email: z.string().email(), + system_role: z.enum(["admin", "user"]), + needs_setup: z.boolean().optional().default(false), +}); + +export type User = z.infer<typeof userSchema>; + +// โ”€โ”€ SSR auth result (tagged union) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export type AuthResult = + | { tag: "authenticated"; user: User } + | { tag: "needs_setup"; user: User } + | { tag: "unauthenticated" } + | { tag: "gateway_unavailable" } + | { tag: "config_error"; message: string }; + +export function assertNever(x: never): never { + throw new Error(`Unexpected auth result: ${JSON.stringify(x)}`); +} + +export function buildLoginUrl(returnPath: string): string { + return `/login?next=${encodeURIComponent(returnPath)}`; +} + +// โ”€โ”€ Backend error response parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const AUTH_ERROR_CODES = [ + "invalid_credentials", + "token_expired", + "token_invalid", + "user_not_found", + "email_already_exists", + "provider_not_found", + "not_authenticated", +] as const; + +export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[number]; + +export interface AuthErrorResponse { + code: AuthErrorCode; + message: string; +} + +const authErrorSchema = z.object({ + code: z.enum(AUTH_ERROR_CODES), + message: z.string(), +}); + +export function parseAuthError(data: unknown): AuthErrorResponse { + // Try top-level {code, message} first + const parsed = authErrorSchema.safeParse(data); + if (parsed.success) return parsed.data; + + // Unwrap FastAPI's {detail: {code, message}} envelope + if (typeof data === "object" && data !== null && "detail" in data) { + const detail = (data as Record<string, unknown>).detail; + const nested = authErrorSchema.safeParse(detail); + if (nested.success) return nested.data; + // Legacy string-detail responses + if (typeof detail === "string") { + return { code: "invalid_credentials", message: detail }; + } + } + + return { code: "invalid_credentials", message: "Authentication failed" }; +} diff --git a/frontend/src/core/i18n/hooks.ts b/frontend/src/core/i18n/hooks.ts index b96acb008..c5fd2f260 100644 --- a/frontend/src/core/i18n/hooks.ts +++ b/frontend/src/core/i18n/hooks.ts @@ -4,22 +4,15 @@ import { useEffect } from "react"; import { useI18nContext } from "./context"; import { getLocaleFromCookie, setLocaleInCookie } from "./cookies"; -import { enUS } from "./locales/en-US"; -import { zhCN } from "./locales/zh-CN"; +import { translations } from "./translations"; import { DEFAULT_LOCALE, detectLocale, normalizeLocale, type Locale, - type Translations, } from "./index"; -const translations: Record<Locale, Translations> = { - "en-US": enUS, - "zh-CN": zhCN, -}; - export function useI18n() { const { locale, setLocale } = useI18nContext(); diff --git a/frontend/src/core/i18n/locale.ts b/frontend/src/core/i18n/locale.ts index 7fbde14dd..249deb3c4 100644 --- a/frontend/src/core/i18n/locale.ts +++ b/frontend/src/core/i18n/locale.ts @@ -6,6 +6,16 @@ export function isLocale(value: string): value is Locale { return (SUPPORTED_LOCALES as readonly string[]).includes(value); } +export function getLocaleByLang(lang: string): Locale { + const normalizedLang = lang.toLowerCase(); + for (const locale of SUPPORTED_LOCALES) { + if (locale.startsWith(normalizedLang)) { + return locale; + } + } + return DEFAULT_LOCALE; +} + export function normalizeLocale(locale: string | null | undefined): Locale { if (!locale) { return DEFAULT_LOCALE; diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index d95977dd2..b357d25a4 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -51,6 +51,12 @@ export const enUS: Translations = { exportSuccess: "Conversation exported", }, + // Home + home: { + docs: "Docs", + blog: "Blog", + }, + // Welcome welcome: { greeting: "Hello, again!", @@ -199,6 +205,17 @@ export const enUS: Translations = { nameStepCheckError: "Could not verify name availability โ€” please try again", nameStepBootstrapMessage: "The new custom agent name is {name}. Let's bootstrap it's **SOUL**.", + save: "Save agent", + saving: "Saving agent...", + saveRequested: + "Save requested. DeerFlow is generating and saving an initial version now.", + saveHint: + "You can save this agent at any time from the top-right menu, even if this is only a first draft.", + saveCommandMessage: + "Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.", + agentCreatedPendingRefresh: + "The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.", + more: "More actions", agentCreated: "Agent created!", startChatting: "Start chatting", backToGallery: "Back to Gallery", @@ -219,6 +236,7 @@ export const enUS: Translations = { reportIssue: "Report a issue", contactUs: "Contact us", about: "About DeerFlow", + logout: "Log out", }, // Conversation @@ -303,6 +321,7 @@ export const enUS: Translations = { title: "Settings", description: "Adjust how DeerFlow looks and behaves for you.", sections: { + account: "Account", appearance: "Appearance", memory: "Memory", tools: "Tools", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 496a61cb5..38f3c62a7 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -40,6 +40,11 @@ export interface Translations { exportSuccess: string; }; + home: { + docs: string; + blog: string; + }; + // Welcome welcome: { greeting: string; @@ -136,6 +141,13 @@ export interface Translations { nameStepNetworkError: string; nameStepCheckError: string; nameStepBootstrapMessage: string; + save: string; + saving: string; + saveRequested: string; + saveHint: string; + saveCommandMessage: string; + agentCreatedPendingRefresh: string; + more: string; agentCreated: string; startChatting: string; backToGallery: string; @@ -156,6 +168,7 @@ export interface Translations { reportIssue: string; contactUs: string; about: string; + logout: string; }; // Conversation @@ -238,6 +251,7 @@ export interface Translations { title: string; description: string; sections: { + account: string; appearance: string; memory: string; tools: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index d3f79c4db..7beab747d 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -51,6 +51,12 @@ export const zhCN: Translations = { exportSuccess: "ๅฏน่ฏๅทฒๅฏผๅ‡บ", }, + // Home + home: { + docs: "ๆ–‡ๆกฃ", + blog: "ๅšๅฎข", + }, + // Welcome welcome: { greeting: "ไฝ ๅฅฝ๏ผŒๆฌข่ฟŽๅ›žๆฅ๏ผ", @@ -187,6 +193,17 @@ export const zhCN: Translations = { nameStepCheckError: "ๆ— ๆณ•้ชŒ่ฏๅ็งฐๅฏ็”จๆ€ง๏ผŒ่ฏท็จๅŽ้‡่ฏ•", nameStepBootstrapMessage: "ๆ–ฐๆ™บ่ƒฝไฝ“็š„ๅ็งฐๆ˜ฏ {name}๏ผŒ็Žฐๅœจๅผ€ๅง‹ไธบๅฎƒ็”Ÿๆˆ **SOUL**ใ€‚", + save: "ไฟๅญ˜ๆ™บ่ƒฝไฝ“", + saving: "ๆญฃๅœจไฟๅญ˜ๆ™บ่ƒฝไฝ“...", + saveRequested: + "ๅทฒๆไบคไฟๅญ˜่ฏทๆฑ‚๏ผŒDeerFlow ๆญฃๅœจๆ นๆฎๅฝ“ๅ‰ๅฏน่ฏ็”Ÿๆˆๅนถไฟๅญ˜ๅˆ็‰ˆๆ™บ่ƒฝไฝ“ใ€‚", + saveHint: + "ไฝ ๅฏไปฅๅœจๅณไธŠ่ง’็š„่œๅ•้‡Œ้šๆ—ถไฟๅญ˜่ฟ™ไธชๆ™บ่ƒฝไฝ“๏ผŒๅฐฑ็ฎ—็›ฎๅ‰่ฟ˜ๅชๆ˜ฏๅˆ็จฟไนŸๅฏไปฅใ€‚", + saveCommandMessage: + "่ฏท็Žฐๅœจๆ นๆฎๆˆ‘ไปฌ็›ฎๅ‰ๅทฒ็ป่ฎจ่ฎบ็š„ๅ…จ้ƒจๅ†…ๅฎนไฟๅญ˜่ฟ™ไธช่‡ชๅฎšไน‰ๆ™บ่ƒฝไฝ“ใ€‚่ฟ™ๅฐฑๆ˜ฏๆˆ‘ๆ˜Ž็กฎ็š„ไฟๅญ˜็กฎ่ฎคใ€‚ๅฆ‚ๆžœไปๆœ‰ๅฐ‘้‡็ป†่Š‚็ผบๅคฑ๏ผŒ่ฏทๆ นๆฎไธŠไธ‹ๆ–‡ๅšๅ‡บๅˆ็†ๅ‡่ฎพ๏ผŒ็”Ÿๆˆไธ€ไปฝ็ฎ€ๆด็š„่‹ฑๆ–‡ๅˆๅง‹ SOUL.md๏ผŒๅนถ็›ดๆŽฅ่ฐƒ็”จ setup_agent๏ผŒไธ่ฆๅ†ๅ‘ๆˆ‘็ดข่ฆ้ขๅค–็กฎ่ฎคใ€‚", + agentCreatedPendingRefresh: + "ๆ™บ่ƒฝไฝ“ๅทฒๅˆ›ๅปบ๏ผŒไฝ† DeerFlow ๆš‚ๆ—ถ่ฟ˜ๆ— ๆณ•่ฏปๅ–ๅˆฐๅฎƒใ€‚่ฏท็จๅŽๅˆทๆ–ฐๅฝ“ๅ‰้กต้ขใ€‚", + more: "ๆ›ดๅคšๆ“ไฝœ", agentCreated: "ๆ™บ่ƒฝไฝ“ๅทฒๅˆ›ๅปบ๏ผ", startChatting: "ๅผ€ๅง‹ๅฏน่ฏ", backToGallery: "่ฟ”ๅ›ž Gallery", @@ -207,6 +224,7 @@ export const zhCN: Translations = { reportIssue: "ๆŠฅๅ‘Š้—ฎ้ข˜", contactUs: "่”็ณปๆˆ‘ไปฌ", about: "ๅ…ณไบŽ DeerFlow", + logout: "้€€ๅ‡บ็™ปๅฝ•", }, // Conversation @@ -288,6 +306,7 @@ export const zhCN: Translations = { title: "่ฎพ็ฝฎ", description: "ๆ นๆฎไฝ ็š„ๅๅฅฝ่ฐƒๆ•ด DeerFlow ็š„็•Œ้ขๅ’Œ่กŒไธบใ€‚", sections: { + account: "่ดฆๅท", appearance: "ๅค–่ง‚", memory: "่ฎฐๅฟ†", tools: "ๅทฅๅ…ท", diff --git a/frontend/src/core/i18n/server.ts b/frontend/src/core/i18n/server.ts index 3600bd733..8a0b32ec1 100644 --- a/frontend/src/core/i18n/server.ts +++ b/frontend/src/core/i18n/server.ts @@ -1,6 +1,7 @@ import { cookies } from "next/headers"; -import { normalizeLocale, type Locale } from "./locale"; +import { DEFAULT_LOCALE, normalizeLocale, type Locale } from "./locale"; +import { translations } from "./translations"; export async function detectLocaleServer(): Promise<Locale> { const cookieStore = await cookies(); @@ -15,3 +16,26 @@ export async function detectLocaleServer(): Promise<Locale> { return normalizeLocale(locale); } + +export async function setLocale(locale: string | Locale): Promise<Locale> { + const normalizedLocale = normalizeLocale(locale); + const cookieStore = await cookies(); + cookieStore.set("locale", encodeURIComponent(normalizedLocale), { + maxAge: 365 * 24 * 60 * 60, + path: "/", + sameSite: "lax", + }); + + return normalizedLocale; +} + +export async function getI18n(localeOverride?: string | Locale) { + const locale = localeOverride + ? normalizeLocale(localeOverride) + : await detectLocaleServer(); + const t = translations[locale] ?? translations[DEFAULT_LOCALE]; + return { + locale, + t, + }; +} diff --git a/frontend/src/core/i18n/translations.ts b/frontend/src/core/i18n/translations.ts new file mode 100644 index 000000000..42166f3a2 --- /dev/null +++ b/frontend/src/core/i18n/translations.ts @@ -0,0 +1,7 @@ +import type { Locale } from "./locale"; +import { enUS, zhCN, type Translations } from "./locales"; + +export const translations: Record<Locale, Translations> = { + "en-US": enUS, + "zh-CN": zhCN, +}; diff --git a/frontend/src/core/mcp/api.ts b/frontend/src/core/mcp/api.ts index 003303238..7e0508cc6 100644 --- a/frontend/src/core/mcp/api.ts +++ b/frontend/src/core/mcp/api.ts @@ -1,3 +1,4 @@ +import { getCsrfHeaders } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { MCPConfig } from "./types"; @@ -12,8 +13,10 @@ export async function updateMCPConfig(config: MCPConfig) { method: "PUT", headers: { "Content-Type": "application/json", + ...getCsrfHeaders(), }, body: JSON.stringify(config), + credentials: "include", }); return response.json(); } diff --git a/frontend/src/core/memory/api.ts b/frontend/src/core/memory/api.ts index 5fcf8e4c0..be4f36b3b 100644 --- a/frontend/src/core/memory/api.ts +++ b/frontend/src/core/memory/api.ts @@ -1,3 +1,4 @@ +import { getCsrfHeaders } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; import type { @@ -87,6 +88,8 @@ export async function loadMemory(): Promise<UserMemory> { export async function clearMemory(): Promise<UserMemory> { const response = await fetch(`${getBackendBaseURL()}/api/memory`, { method: "DELETE", + headers: { ...getCsrfHeaders() }, + credentials: "include", }); return readMemoryResponse(response, "Failed to clear memory"); } @@ -96,6 +99,8 @@ export async function deleteMemoryFact(factId: string): Promise<UserMemory> { `${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`, { method: "DELETE", + headers: { ...getCsrfHeaders() }, + credentials: "include", }, ); return readMemoryResponse(response, "Failed to delete memory fact"); @@ -111,8 +116,10 @@ export async function importMemory(memory: UserMemory): Promise<UserMemory> { method: "POST", headers: { "Content-Type": "application/json", + ...getCsrfHeaders(), }, body: JSON.stringify(memory), + credentials: "include", }); return readMemoryResponse(response, "Failed to import memory"); } @@ -124,8 +131,10 @@ export async function createMemoryFact( method: "POST", headers: { "Content-Type": "application/json", + ...getCsrfHeaders(), }, body: JSON.stringify(input), + credentials: "include", }); return readMemoryResponse(response, "Failed to create memory fact"); } @@ -140,8 +149,10 @@ export async function updateMemoryFact( method: "PATCH", headers: { "Content-Type": "application/json", + ...getCsrfHeaders(), }, body: JSON.stringify(input), + credentials: "include", }, ); return readMemoryResponse(response, "Failed to update memory fact"); diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index c86c6323f..5ff14bbb7 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -52,6 +52,10 @@ export function groupMessages<T>( } for (const message of messages) { + if (isHiddenFromUIMessage(message)) { + continue; + } + if (message.name === "todo_reminder") { continue; } @@ -323,6 +327,10 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) { return undefined; } +export function isHiddenFromUIMessage(message: Message) { + return message.additional_kwargs?.hide_from_ui === true; +} + /** * Represents a file stored in message additional_kwargs.files. * Used for optimistic UI (uploading state) and structured file metadata. diff --git a/frontend/src/core/rehype/index.ts b/frontend/src/core/rehype/index.ts index 9e4112283..bac5d497e 100644 --- a/frontend/src/core/rehype/index.ts +++ b/frontend/src/core/rehype/index.ts @@ -3,6 +3,9 @@ import { useMemo } from "react"; import { visit } from "unist-util-visit"; import type { BuildVisitor } from "unist-util-visit"; +const CJK_TEXT_RE = + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + export function rehypeSplitWordsIntoSpans() { return (tree: Root) => { visit(tree, "element", ((node: Element) => { @@ -15,6 +18,10 @@ export function rehypeSplitWordsIntoSpans() { const newChildren: Array<ElementContent> = []; node.children.forEach((child) => { if (child.type === "text") { + if (CJK_TEXT_RE.test(child.value)) { + newChildren.push(child); + return; + } const segmenter = new Intl.Segmenter("zh", { granularity: "word" }); const segments = segmenter.segment(child.value); const words = Array.from(segments) diff --git a/frontend/src/core/skills/api.ts b/frontend/src/core/skills/api.ts index b6a358f03..25c3cb8a0 100644 --- a/frontend/src/core/skills/api.ts +++ b/frontend/src/core/skills/api.ts @@ -1,3 +1,4 @@ +import { getCsrfHeaders } from "@/core/api/fetcher"; import { getBackendBaseURL } from "@/core/config"; import type { Skill } from "./type"; @@ -15,10 +16,12 @@ export async function enableSkill(skillName: string, enabled: boolean) { method: "PUT", headers: { "Content-Type": "application/json", + ...getCsrfHeaders(), }, body: JSON.stringify({ enabled, }), + credentials: "include", }, ); return response.json(); @@ -42,8 +45,10 @@ export async function installSkill( method: "POST", headers: { "Content-Type": "application/json", + ...getCsrfHeaders(), }, body: JSON.stringify(request), + credentials: "include", }); if (!response.ok) { diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 305879045..95cc0a68d 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -14,7 +14,7 @@ import type { FileInMessage } from "../messages/utils"; import type { LocalSettings } from "../settings"; import { useUpdateSubtask } from "../tasks/context"; import type { UploadedFileInfo } from "../uploads"; -import { uploadFiles } from "../uploads"; +import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import type { AgentThread, AgentThreadState } from "./types"; @@ -32,6 +32,10 @@ export type ThreadStreamOptions = { onToolEnd?: (event: ToolEndEvent) => void; }; +type SendMessageOptions = { + additionalKwargs?: Record<string, unknown>; +}; + function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; @@ -218,6 +222,7 @@ export function useThreadStream({ threadId: string, message: PromptInputMessage, extraContext?: Record<string, unknown>, + options?: SendMessageOptions, ) => { if (sendInFlightRef.current) { return; @@ -238,17 +243,23 @@ export function useThreadStream({ }), ); - // Create optimistic human message (shown immediately) - const optimisticHumanMsg: Message = { - type: "human", - id: `opt-human-${Date.now()}`, - content: text ? [{ type: "text", text }] : "", - additional_kwargs: - optimisticFiles.length > 0 ? { files: optimisticFiles } : {}, + const hideFromUI = options?.additionalKwargs?.hide_from_ui === true; + const optimisticAdditionalKwargs = { + ...options?.additionalKwargs, + ...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}), }; - const newOptimistic: Message[] = [optimisticHumanMsg]; - if (optimisticFiles.length > 0) { + const newOptimistic: Message[] = []; + if (!hideFromUI) { + newOptimistic.push({ + type: "human", + id: `opt-human-${Date.now()}`, + content: text ? [{ type: "text", text }] : "", + additional_kwargs: optimisticAdditionalKwargs, + }); + } + + if (optimisticFiles.length > 0 && !hideFromUI) { // Mock AI message while files are being uploaded newOptimistic.push({ type: "ai", @@ -268,28 +279,9 @@ export function useThreadStream({ if (message.files && message.files.length > 0) { setIsUploading(true); try { - // Convert FileUIPart to File objects by fetching blob URLs - const filePromises = message.files.map(async (fileUIPart) => { - if (fileUIPart.url && fileUIPart.filename) { - try { - // Fetch the blob URL to get the file data - const response = await fetch(fileUIPart.url); - const blob = await response.blob(); - - // Create a File object from the blob - return new File([blob], fileUIPart.filename, { - type: fileUIPart.mediaType || blob.type, - }); - } catch (error) { - console.error( - `Failed to fetch file ${fileUIPart.filename}:`, - error, - ); - return null; - } - } - return null; - }); + const filePromises = message.files.map((fileUIPart) => + promptInputFilePartToFile(fileUIPart), + ); const conversionResults = await Promise.all(filePromises); const files = conversionResults.filter( @@ -335,7 +327,6 @@ export function useThreadStream({ }); } } catch (error) { - console.error("Failed to upload files:", error); const errorMessage = error instanceof Error ? error.message @@ -369,8 +360,12 @@ export function useThreadStream({ text, }, ], - additional_kwargs: - filesForSubmit.length > 0 ? { files: filesForSubmit } : {}, + additional_kwargs: { + ...options?.additionalKwargs, + ...(filesForSubmit.length > 0 + ? { files: filesForSubmit } + : {}), + }, }, ], }, @@ -498,10 +493,13 @@ export function useDeleteThread() { mutationFn: async ({ threadId }: { threadId: string }) => { await apiClient.threads.delete(threadId); + const { getCsrfHeaders } = await import("@/core/api/fetcher"); const response = await fetch( `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`, { method: "DELETE", + headers: { ...getCsrfHeaders() }, + credentials: "include", }, ); diff --git a/frontend/src/core/uploads/api.ts b/frontend/src/core/uploads/api.ts index 23d463c2d..910a35d78 100644 --- a/frontend/src/core/uploads/api.ts +++ b/frontend/src/core/uploads/api.ts @@ -2,6 +2,7 @@ * API functions for file uploads */ +import { getCsrfHeaders } from "../api/fetcher"; import { getBackendBaseURL } from "../config"; export interface UploadedFileInfo { @@ -54,7 +55,9 @@ export async function uploadFiles( `${getBackendBaseURL()}/api/threads/${threadId}/uploads`, { method: "POST", + headers: { ...getCsrfHeaders() }, body: formData, + credentials: "include", }, ); @@ -95,6 +98,8 @@ export async function deleteUploadedFile( `${getBackendBaseURL()}/api/threads/${threadId}/uploads/${filename}`, { method: "DELETE", + headers: { ...getCsrfHeaders() }, + credentials: "include", }, ); diff --git a/frontend/src/core/uploads/file-validation.test.mjs b/frontend/src/core/uploads/file-validation.test.mjs new file mode 100644 index 000000000..62a903e97 --- /dev/null +++ b/frontend/src/core/uploads/file-validation.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MACOS_APP_BUNDLE_UPLOAD_MESSAGE, + isLikelyMacOSAppBundle, + splitUnsupportedUploadFiles, +} from "./file-validation.ts"; + +test("identifies Finder-style .app bundle uploads as unsupported", () => { + assert.equal( + isLikelyMacOSAppBundle({ + name: "Vibe Island.app", + type: "application/octet-stream", + }), + true, + ); +}); + +test("keeps normal files and reports rejected app bundles", () => { + const files = [ + new File(["demo"], "Vibe Island.app", { + type: "application/octet-stream", + }), + new File(["notes"], "notes.txt", { type: "text/plain" }), + ]; + + const result = splitUnsupportedUploadFiles(files); + + assert.equal(result.accepted.length, 1); + assert.equal(result.accepted[0]?.name, "notes.txt"); + assert.equal(result.rejected.length, 1); + assert.equal(result.rejected[0]?.name, "Vibe Island.app"); + assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE); +}); + +test("treats empty MIME .app uploads as unsupported", () => { + const result = splitUnsupportedUploadFiles([ + new File(["demo"], "Another.app", { type: "" }), + ]); + + assert.equal(result.accepted.length, 0); + assert.equal(result.rejected.length, 1); + assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE); +}); + +test("returns no message when every file is supported", () => { + const result = splitUnsupportedUploadFiles([ + new File(["notes"], "notes.txt", { type: "text/plain" }), + ]); + + assert.equal(result.accepted.length, 1); + assert.equal(result.rejected.length, 0); + assert.equal(result.message, undefined); +}); diff --git a/frontend/src/core/uploads/file-validation.ts b/frontend/src/core/uploads/file-validation.ts new file mode 100644 index 000000000..1b796a9ac --- /dev/null +++ b/frontend/src/core/uploads/file-validation.ts @@ -0,0 +1,34 @@ +const MACOS_APP_BUNDLE_CONTENT_TYPES = new Set([ + "", + "application/octet-stream", +]); + +export const MACOS_APP_BUNDLE_UPLOAD_MESSAGE = + "macOS .app bundles can't be uploaded directly from the browser. Compress the app as a .zip or upload the .dmg instead."; + +export function isLikelyMacOSAppBundle(file: Pick<File, "name" | "type">) { + return ( + file.name.toLowerCase().endsWith(".app") && + MACOS_APP_BUNDLE_CONTENT_TYPES.has(file.type) + ); +} + +export function splitUnsupportedUploadFiles(fileList: File[] | FileList) { + const incoming = Array.from(fileList); + const accepted: File[] = []; + const rejected: File[] = []; + + for (const file of incoming) { + if (isLikelyMacOSAppBundle(file)) { + rejected.push(file); + continue; + } + accepted.push(file); + } + + return { + accepted, + rejected, + message: rejected.length > 0 ? MACOS_APP_BUNDLE_UPLOAD_MESSAGE : undefined, + }; +} diff --git a/frontend/src/core/uploads/index.ts b/frontend/src/core/uploads/index.ts index 4c11a990e..fd7fba0d0 100644 --- a/frontend/src/core/uploads/index.ts +++ b/frontend/src/core/uploads/index.ts @@ -3,4 +3,6 @@ */ export * from "./api"; +export * from "./file-validation"; export * from "./hooks"; +export * from "./prompt-input-files"; diff --git a/frontend/src/core/uploads/prompt-input-files.test.mjs b/frontend/src/core/uploads/prompt-input-files.test.mjs new file mode 100644 index 000000000..f6579539e --- /dev/null +++ b/frontend/src/core/uploads/prompt-input-files.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +async function loadModule() { + try { + return await import("./prompt-input-files.ts"); + } catch (error) { + return { error }; + } +} + +test("exports the prompt-input file conversion helper", async () => { + const loaded = await loadModule(); + + assert.ok( + !("error" in loaded), + loaded.error instanceof Error + ? loaded.error.message + : "prompt-input-files module is missing", + ); + assert.equal(typeof loaded.promptInputFilePartToFile, "function"); +}); + +test("reuses the original File when a prompt attachment already has one", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const file = new File(["hello"], "note.txt", { type: "text/plain" }); + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => { + throw new Error("fetch should not run when File is already present"); + }; + + try { + const converted = await promptInputFilePartToFile({ + type: "file", + filename: file.name, + mediaType: file.type, + url: "blob:http://localhost:2026/stale-preview-url", + file, + }); + + assert.equal(converted, file); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reconstructs a File from a data URL when no original File is present", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + mediaType: "text/plain", + url: "data:text/plain;base64,aGVsbG8=", + }); + + assert.ok(converted); + assert.equal(converted.name, "note.txt"); + assert.equal(converted.type, "text/plain"); + assert.equal(await converted.text(), "hello"); +}); + +test("rewraps the original File when the prompt metadata changes", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const file = new File(["hello"], "note.txt", { type: "text/plain" }); + + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "renamed.txt", + mediaType: "text/markdown", + file, + }); + + assert.ok(converted); + assert.notEqual(converted, file); + assert.equal(converted.name, "renamed.txt"); + assert.equal(converted.type, "text/markdown"); + assert.equal(await converted.text(), "hello"); +}); + +test("returns null when upload preparation is missing required data", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + + const converted = await promptInputFilePartToFile({ + type: "file", + mediaType: "text/plain", + }); + + assert.equal(converted, null); +}); + +test("returns null when the URL fallback fetch fails", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const originalFetch = globalThis.fetch; + const originalWarn = console.warn; + const warnCalls = []; + + console.warn = (...args) => { + warnCalls.push(args); + }; + + globalThis.fetch = async () => { + throw new Error("network down"); + }; + + try { + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + url: "blob:http://localhost:2026/missing-preview-url", + }); + + assert.equal(converted, null); + assert.equal(warnCalls.length, 1); + } finally { + globalThis.fetch = originalFetch; + console.warn = originalWarn; + } +}); + +test("returns null when the URL fallback fetch response is non-ok", async () => { + const { promptInputFilePartToFile } = await import("./prompt-input-files.ts"); + const originalFetch = globalThis.fetch; + const originalWarn = console.warn; + const warnCalls = []; + + console.warn = (...args) => { + warnCalls.push(args); + }; + + globalThis.fetch = async () => + new Response("missing", { + status: 404, + statusText: "Not Found", + }); + + try { + const converted = await promptInputFilePartToFile({ + type: "file", + filename: "note.txt", + url: "blob:http://localhost:2026/missing-preview-url", + }); + + assert.equal(converted, null); + assert.equal(warnCalls.length, 1); + } finally { + globalThis.fetch = originalFetch; + console.warn = originalWarn; + } +}); diff --git a/frontend/src/core/uploads/prompt-input-files.ts b/frontend/src/core/uploads/prompt-input-files.ts new file mode 100644 index 000000000..33157a209 --- /dev/null +++ b/frontend/src/core/uploads/prompt-input-files.ts @@ -0,0 +1,52 @@ +import type { FileUIPart } from "ai"; + +export type PromptInputFilePart = FileUIPart & { + // Transient submit-time handle to the original browser File; not serializable. + file?: File; +}; + +export async function promptInputFilePartToFile( + filePart: PromptInputFilePart, +): Promise<File | null> { + if (filePart.file instanceof File) { + const filename = + typeof filePart.filename === "string" && filePart.filename.length > 0 + ? filePart.filename + : filePart.file.name; + const mediaType = + typeof filePart.mediaType === "string" && filePart.mediaType.length > 0 + ? filePart.mediaType + : filePart.file.type; + + if (filePart.file.name === filename && filePart.file.type === mediaType) { + return filePart.file; + } + + return new File([filePart.file], filename, { type: mediaType }); + } + + if (!filePart.url || !filePart.filename) { + return null; + } + + try { + const response = await fetch(filePart.url); + if (!response.ok) { + throw new Error( + `HTTP ${response.status} while fetching fallback file URL`, + ); + } + const blob = await response.blob(); + + return new File([blob], filePart.filename, { + type: filePart.mediaType || blob.type, + }); + } catch (error) { + console.warn("promptInputFilePartToFile: fetch fallback failed", { + error, + url: filePart.url, + filename: filePart.filename, + }); + return null; + } +} diff --git a/frontend/src/mdx-components.ts b/frontend/src/mdx-components.ts new file mode 100644 index 000000000..71182654c --- /dev/null +++ b/frontend/src/mdx-components.ts @@ -0,0 +1,11 @@ +import { useMDXComponents as getThemeComponents } from "nextra-theme-docs"; // nextra-theme-blog or your custom theme + +// Get the default MDX components +const themeComponents = getThemeComponents(); + +// Merge components +export function useMDXComponents() { + return { + ...themeComponents, + }; +} diff --git a/frontend/src/server/better-auth/client.ts b/frontend/src/server/better-auth/client.ts deleted file mode 100644 index 493f84993..000000000 --- a/frontend/src/server/better-auth/client.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createAuthClient } from "better-auth/react"; - -export const authClient = createAuthClient(); - -export type Session = typeof authClient.$Infer.Session; diff --git a/frontend/src/server/better-auth/config.ts b/frontend/src/server/better-auth/config.ts deleted file mode 100644 index abf50faca..000000000 --- a/frontend/src/server/better-auth/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { betterAuth } from "better-auth"; - -export const auth = betterAuth({ - emailAndPassword: { - enabled: true, - }, -}); - -export type Session = typeof auth.$Infer.Session; diff --git a/frontend/src/server/better-auth/index.ts b/frontend/src/server/better-auth/index.ts deleted file mode 100644 index d705e873e..000000000 --- a/frontend/src/server/better-auth/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { auth } from "./config"; diff --git a/frontend/src/server/better-auth/server.ts b/frontend/src/server/better-auth/server.ts deleted file mode 100644 index 064cd349c..000000000 --- a/frontend/src/server/better-auth/server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { headers } from "next/headers"; -import { cache } from "react"; - -import { auth } from "."; - -export const getSession = cache(async () => - auth.api.getSession({ headers: await headers() }), -); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 66df240ef..d0455c69f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,20 +1,68 @@ #!/usr/bin/env bash # -# deploy.sh - Build and start (or stop) DeerFlow production services +# deploy.sh - Build, start, or stop DeerFlow production services # -# Usage: -# deploy.sh [up] โ€” build images and start containers (default) -# deploy.sh down โ€” stop and remove containers +# Commands: +# deploy.sh [--MODE] โ€” build + start (default: --standard) +# deploy.sh build โ€” build all images (mode-agnostic) +# deploy.sh start [--MODE] โ€” start from pre-built images (default: --standard) +# deploy.sh down โ€” stop and remove containers +# +# Runtime modes: +# --standard (default) All services including LangGraph server. +# --gateway No LangGraph container; nginx routes /api/langgraph/* +# to the Gateway compat API instead. +# +# Sandbox mode (local / aio / provisioner) is auto-detected from config.yaml. +# +# Examples: +# deploy.sh # build + start in standard mode +# deploy.sh --gateway # build + start in gateway mode +# deploy.sh build # build all images +# deploy.sh start --gateway # start pre-built images in gateway mode +# deploy.sh down # stop and remove containers # # Must be run from the repo root directory. set -e -CMD="${1:-up}" +RUNTIME_MODE="standard" + +case "${1:-}" in + build|start|down) + CMD="$1" + if [ -n "${2:-}" ]; then + case "$2" in + --standard) RUNTIME_MODE="standard" ;; + --gateway) RUNTIME_MODE="gateway" ;; + *) echo "Unknown mode: $2"; echo "Usage: deploy.sh [build|start|down] [--standard|--gateway]"; exit 1 ;; + esac + fi + ;; + --standard|--gateway) + CMD="" + RUNTIME_MODE="${1#--}" + ;; + "") + CMD="" + ;; + *) + echo "Unknown argument: $1" + echo "Usage: deploy.sh [build|start|down] [--standard|--gateway]" + exit 1 + ;; +esac REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" +# Load .env so build args (APT_MIRROR, NODE_DIST_URL, UV_INDEX_URL, etc.) are available +if [ -f "$REPO_ROOT/.env" ]; then + set -a + source "$REPO_ROOT/.env" + set +a +fi + DOCKER_DIR="$REPO_ROOT/docker" COMPOSE_CMD=(docker compose -p deer-flow -f "$DOCKER_DIR/docker-compose.yaml") @@ -150,6 +198,32 @@ if [ "$CMD" = "down" ]; then exit 0 fi +# โ”€โ”€ build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Build produces mode-agnostic images. No --gateway or sandbox detection needed. + +if [ "$CMD" = "build" ]; then + echo "==========================================" + echo " DeerFlow โ€” Building Images" + echo "==========================================" + echo "" + + # Docker socket is needed for compose to parse volume specs + if [ -z "$DEER_FLOW_DOCKER_SOCKET" ]; then + export DEER_FLOW_DOCKER_SOCKET="/var/run/docker.sock" + fi + + "${COMPOSE_CMD[@]}" build + + echo "" + echo "==========================================" + echo " โœ“ Images built successfully" + echo "==========================================" + echo "" + echo " Next: deploy.sh start [--gateway]" + echo "" + exit 0 +fi + # โ”€โ”€ Banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ echo "==========================================" @@ -157,19 +231,28 @@ echo " DeerFlow Production Deployment" echo "==========================================" echo "" -# โ”€โ”€ Step 1: Detect sandbox mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€ Detect runtime configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Only needed for start / up โ€” determines which containers to launch. sandbox_mode="$(detect_sandbox_mode)" echo -e "${BLUE}Sandbox mode: $sandbox_mode${NC}" -if [ "$sandbox_mode" = "provisioner" ]; then - services="" - extra_args="--profile provisioner" -else - services="frontend gateway langgraph nginx" - extra_args="" -fi +echo -e "${BLUE}Runtime mode: $RUNTIME_MODE${NC}" +case "$RUNTIME_MODE" in + gateway) + export LANGGRAPH_UPSTREAM=gateway:8001 + export LANGGRAPH_REWRITE=/api/ + services="frontend gateway nginx" + ;; + standard) + services="frontend gateway langgraph nginx" + ;; +esac + +if [ "$sandbox_mode" = "provisioner" ]; then + services="$services provisioner" +fi # โ”€โ”€ DEER_FLOW_DOCKER_SOCKET โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -189,22 +272,34 @@ fi echo "" -# โ”€โ”€ Step 2: Build and start โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€ Start / Up โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -echo "Building images and starting containers..." -echo "" - -# shellcheck disable=SC2086 -"${COMPOSE_CMD[@]}" $extra_args up --build -d --remove-orphans $services +if [ "$CMD" = "start" ]; then + echo "Starting containers (no rebuild)..." + echo "" + # shellcheck disable=SC2086 + "${COMPOSE_CMD[@]}" up -d --remove-orphans $services +else + # Default: build + start + echo "Building images and starting containers..." + echo "" + # shellcheck disable=SC2086 + "${COMPOSE_CMD[@]}" up --build -d --remove-orphans $services +fi echo "" echo "==========================================" -echo " DeerFlow is running!" +echo " DeerFlow is running! ($RUNTIME_MODE mode)" echo "==========================================" echo "" echo " ๐ŸŒ Application: http://localhost:${PORT:-2026}" echo " ๐Ÿ“ก API Gateway: http://localhost:${PORT:-2026}/api/*" -echo " ๐Ÿค– LangGraph: http://localhost:${PORT:-2026}/api/langgraph/*" +if [ "$RUNTIME_MODE" = "gateway" ]; then + echo " ๐Ÿค– Runtime: Gateway embedded" + echo " API: /api/langgraph/* โ†’ Gateway (compat)" +else + echo " ๐Ÿค– LangGraph: http://localhost:${PORT:-2026}/api/langgraph/*" +fi echo "" echo " Manage:" echo " make down โ€” stop and remove containers" diff --git a/scripts/docker.sh b/scripts/docker.sh index bc20d4177..0ef1896fe 100755 --- a/scripts/docker.sh +++ b/scripts/docker.sh @@ -148,9 +148,18 @@ init() { } # Start Docker development environment +# Usage: start [--gateway] start() { local sandbox_mode local services + local gateway_mode=false + + # Check for --gateway flag + for arg in "$@"; do + if [ "$arg" = "--gateway" ]; then + gateway_mode=true + fi + done echo "==========================================" echo " Starting DeerFlow Docker Development" @@ -159,12 +168,21 @@ start() { sandbox_mode="$(detect_sandbox_mode)" - if [ "$sandbox_mode" = "provisioner" ]; then - services="frontend gateway langgraph provisioner nginx" + if $gateway_mode; then + services="frontend gateway nginx" + if [ "$sandbox_mode" = "provisioner" ]; then + services="frontend gateway provisioner nginx" + fi else services="frontend gateway langgraph nginx" + if [ "$sandbox_mode" = "provisioner" ]; then + services="frontend gateway langgraph provisioner nginx" + fi fi + if $gateway_mode; then + echo -e "${BLUE}Runtime: Gateway mode (experimental) โ€” no LangGraph container${NC}" + fi echo -e "${BLUE}Detected sandbox mode: $sandbox_mode${NC}" if [ "$sandbox_mode" = "provisioner" ]; then echo -e "${BLUE}Provisioner enabled (Kubernetes mode).${NC}" @@ -213,6 +231,12 @@ start() { fi fi + # Set nginx routing for gateway mode (envsubst in nginx container) + if $gateway_mode; then + export LANGGRAPH_UPSTREAM=gateway:8001 + export LANGGRAPH_REWRITE=/api/ + fi + echo "Building and starting containers..." cd "$DOCKER_DIR" && $COMPOSE_CMD up --build -d --remove-orphans $services echo "" @@ -222,7 +246,12 @@ start() { echo "" echo " ๐ŸŒ Application: http://localhost:2026" echo " ๐Ÿ“ก API Gateway: http://localhost:2026/api/*" - echo " ๐Ÿค– LangGraph: http://localhost:2026/api/langgraph/*" + if $gateway_mode; then + echo " ๐Ÿค– Runtime: Gateway embedded" + echo " API: /api/langgraph/* โ†’ Gateway (compat)" + else + echo " ๐Ÿค– LangGraph: http://localhost:2026/api/langgraph/*" + fi echo "" echo " ๐Ÿ“‹ View logs: make docker-logs" echo " ๐Ÿ›‘ Stop: make docker-stop" @@ -300,9 +329,10 @@ help() { echo "Usage: $0 <command> [options]" echo "" echo "Commands:" - echo " init - Pull the sandbox image (speeds up first Pod startup)" - echo " start - Start Docker services (auto-detects sandbox mode from config.yaml)" - echo " restart - Restart all running Docker services" + echo " init - Pull the sandbox image (speeds up first Pod startup)" + echo " start - Start Docker services (auto-detects sandbox mode from config.yaml)" + echo " start --gateway - Start without LangGraph container (Gateway mode, experimental)" + echo " restart - Restart all running Docker services" echo " logs [option] - View Docker development logs" echo " --frontend View frontend logs only" echo " --gateway View gateway logs only" @@ -320,7 +350,8 @@ main() { init ;; start) - start + shift + start "$@" ;; restart) restart diff --git a/scripts/serve.sh b/scripts/serve.sh index ae1c153ac..bd810e05e 100755 --- a/scripts/serve.sh +++ b/scripts/serve.sh @@ -1,15 +1,40 @@ #!/usr/bin/env bash # -# start.sh - Start all DeerFlow development services +# serve.sh โ€” Unified DeerFlow service launcher +# +# Usage: +# ./scripts/serve.sh [--dev|--prod] [--gateway] [--daemon] [--stop|--restart] +# +# Modes: +# --dev Development mode with hot-reload (default) +# --prod Production mode, pre-built frontend, no hot-reload +# --gateway Gateway mode (experimental): skip LangGraph server, +# agent runtime embedded in Gateway API +# --daemon Run all services in background (nohup), exit after startup +# +# Actions: +# --skip-install Skip dependency installation (faster restart) +# --stop Stop all running services and exit +# --restart Stop all services, then start with the given mode flags +# +# Examples: +# ./scripts/serve.sh --dev # Standard dev (4 processes) +# ./scripts/serve.sh --dev --gateway # Gateway dev (3 processes) +# ./scripts/serve.sh --prod --gateway # Gateway prod (3 processes) +# ./scripts/serve.sh --dev --daemon # Standard dev, background +# ./scripts/serve.sh --dev --gateway --daemon # Gateway dev, background +# ./scripts/serve.sh --stop # Stop all services +# ./scripts/serve.sh --restart --dev --gateway # Restart in gateway mode # # Must be run from the repo root directory. set -e -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd -P)" cd "$REPO_ROOT" -# โ”€โ”€ Load environment variables from .env โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€ Load .env โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if [ -f "$REPO_ROOT/.env" ]; then set -a source "$REPO_ROOT/.env" @@ -19,14 +44,80 @@ fi # โ”€โ”€ Argument parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ DEV_MODE=true +GATEWAY_MODE=false +DAEMON_MODE=false +SKIP_INSTALL=false +ACTION="start" # start | stop | restart + for arg in "$@"; do case "$arg" in - --dev) DEV_MODE=true ;; - --prod) DEV_MODE=false ;; - *) echo "Unknown argument: $arg"; echo "Usage: $0 [--dev|--prod]"; exit 1 ;; + --dev) DEV_MODE=true ;; + --prod) DEV_MODE=false ;; + --gateway) GATEWAY_MODE=true ;; + --daemon) DAEMON_MODE=true ;; + --skip-install) SKIP_INSTALL=true ;; + --stop) ACTION="stop" ;; + --restart) ACTION="restart" ;; + *) + echo "Unknown argument: $arg" + echo "Usage: $0 [--dev|--prod] [--gateway] [--daemon] [--skip-install] [--stop|--restart]" + exit 1 + ;; esac done +# โ”€โ”€ Stop helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +stop_all() { + echo "Stopping all services..." + pkill -f "langgraph dev" 2>/dev/null || true + pkill -f "uvicorn app.gateway.app:app" 2>/dev/null || true + pkill -f "next dev" 2>/dev/null || true + pkill -f "next start" 2>/dev/null || true + pkill -f "next-server" 2>/dev/null || true + nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit 2>/dev/null || true + sleep 1 + pkill -9 nginx 2>/dev/null || true + ./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true + echo "โœ“ All services stopped" +} + +# โ”€โ”€ Action routing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +if [ "$ACTION" = "stop" ]; then + stop_all + exit 0 +fi + +ALREADY_STOPPED=false +if [ "$ACTION" = "restart" ]; then + stop_all + sleep 1 + ALREADY_STOPPED=true +fi + +# โ”€โ”€ Derive runtime flags โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +if $GATEWAY_MODE; then + export SKIP_LANGGRAPH_SERVER=1 +fi + +# Mode label for banner +if $DEV_MODE && $GATEWAY_MODE; then + MODE_LABEL="DEV + GATEWAY (experimental)" +elif $DEV_MODE; then + MODE_LABEL="DEV (hot-reload enabled)" +elif $GATEWAY_MODE; then + MODE_LABEL="PROD + GATEWAY (experimental)" +else + MODE_LABEL="PROD (optimized)" +fi + +if $DAEMON_MODE; then + MODE_LABEL="$MODE_LABEL [daemon]" +fi + +# Frontend command if $DEV_MODE; then FRONTEND_CMD="pnpm run dev" else @@ -35,46 +126,26 @@ else elif command -v python >/dev/null 2>&1; then PYTHON_BIN="python" else - echo "Python is required to generate BETTER_AUTH_SECRET, but neither python3 nor python was found." + echo "Python is required to generate BETTER_AUTH_SECRET." exit 1 fi FRONTEND_CMD="env BETTER_AUTH_SECRET=$($PYTHON_BIN -c 'import secrets; print(secrets.token_hex(16))') pnpm run preview" fi -# โ”€โ”€ Stop existing services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -echo "Stopping existing services if any..." -pkill -f "langgraph dev" 2>/dev/null || true -pkill -f "uvicorn app.gateway.app:app" 2>/dev/null || true -pkill -f "next dev" 2>/dev/null || true -pkill -f "next-server" 2>/dev/null || true -nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit 2>/dev/null || true -sleep 1 -pkill -9 nginx 2>/dev/null || true -killall -9 nginx 2>/dev/null || true -./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true -sleep 1 - -# โ”€โ”€ Banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -echo "" -echo "==========================================" -echo " Starting DeerFlow Development Server" -echo "==========================================" -echo "" -if $DEV_MODE; then - echo " Mode: DEV (hot-reload enabled)" - echo " Tip: run \`make start\` in production mode" +# Extra flags for uvicorn/langgraph +LANGGRAPH_EXTRA_FLAGS="--no-reload" +if $DEV_MODE && ! $DAEMON_MODE; then + GATEWAY_EXTRA_FLAGS="--reload --reload-include='*.yaml' --reload-include='.env' --reload-exclude='*.pyc' --reload-exclude='__pycache__' --reload-exclude='sandbox/' --reload-exclude='.deer-flow/'" else - echo " Mode: PROD (hot-reload disabled)" - echo " Tip: run \`make dev\` to start in development mode" + GATEWAY_EXTRA_FLAGS="" +fi + +# โ”€โ”€ Stop existing services (skip if restart already did it) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +if ! $ALREADY_STOPPED; then + stop_all + sleep 1 fi -echo "" -echo "Services starting up..." -echo " โ†’ Backend: LangGraph + Gateway" -echo " โ†’ Frontend: Next.js" -echo " โ†’ Nginx: Reverse Proxy" -echo "" # โ”€โ”€ Config check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -84,145 +155,164 @@ if ! { \ [ -f config.yaml ]; \ }; then echo "โœ— No DeerFlow config file found." - echo " Checked these locations:" - echo " - $DEER_FLOW_CONFIG_PATH (when DEER_FLOW_CONFIG_PATH is set)" - echo " - backend/config.yaml" - echo " - ./config.yaml" - echo "" - echo " Run 'make config' from the repo root to generate ./config.yaml, then set required model API keys in .env or your config file." + echo " Run 'make config' to generate config.yaml." exit 1 fi -# โ”€โ”€ Auto-upgrade config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - "$REPO_ROOT/scripts/config-upgrade.sh" -# โ”€โ”€ Cleanup trap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€ Install dependencies โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +if ! $SKIP_INSTALL; then + echo "Syncing dependencies..." + (cd backend && uv sync --quiet) || { echo "โœ— Backend dependency install failed"; exit 1; } + (cd frontend && pnpm install --silent) || { echo "โœ— Frontend dependency install failed"; exit 1; } + echo "โœ“ Dependencies synced" +else + echo "โฉ Skipping dependency install (--skip-install)" +fi + +# โ”€โ”€ Sync frontend .env.local โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Next.js .env.local takes precedence over process env vars. +# The script manages the NEXT_PUBLIC_LANGGRAPH_BASE_URL line to ensure +# the frontend routes match the active backend mode. + +FRONTEND_ENV_LOCAL="$REPO_ROOT/frontend/.env.local" +ENV_KEY="NEXT_PUBLIC_LANGGRAPH_BASE_URL" + +sync_frontend_env() { + if $GATEWAY_MODE; then + # Point frontend to Gateway's compat API + if [ -f "$FRONTEND_ENV_LOCAL" ] && grep -q "^${ENV_KEY}=" "$FRONTEND_ENV_LOCAL"; then + sed -i.bak "s|^${ENV_KEY}=.*|${ENV_KEY}=/api/langgraph-compat|" "$FRONTEND_ENV_LOCAL" && rm -f "${FRONTEND_ENV_LOCAL}.bak" + else + echo "${ENV_KEY}=/api/langgraph-compat" >> "$FRONTEND_ENV_LOCAL" + fi + else + # Remove override โ€” frontend falls back to /api/langgraph (standard) + if [ -f "$FRONTEND_ENV_LOCAL" ] && grep -q "^${ENV_KEY}=" "$FRONTEND_ENV_LOCAL"; then + sed -i.bak "/^${ENV_KEY}=/d" "$FRONTEND_ENV_LOCAL" && rm -f "${FRONTEND_ENV_LOCAL}.bak" + fi + fi +} + +sync_frontend_env + +# โ”€โ”€ Banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +echo "" +echo "==========================================" +echo " Starting DeerFlow" +echo "==========================================" +echo "" +echo " Mode: $MODE_LABEL" +echo "" +echo " Services:" +if ! $GATEWAY_MODE; then + echo " LangGraph โ†’ localhost:2024 (agent runtime)" +fi +echo " Gateway โ†’ localhost:8001 (REST API$(if $GATEWAY_MODE; then echo " + agent runtime"; fi))" +echo " Frontend โ†’ localhost:3000 (Next.js)" +echo " Nginx โ†’ localhost:2026 (reverse proxy)" +echo "" + +# โ”€โ”€ Cleanup handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ cleanup() { trap - INT TERM echo "" - echo "Shutting down services..." - if [ "${SKIP_LANGGRAPH_SERVER:-0}" != "1" ]; then - pkill -f "langgraph dev" 2>/dev/null || true - fi - pkill -f "uvicorn app.gateway.app:app" 2>/dev/null || true - pkill -f "next dev" 2>/dev/null || true - pkill -f "next start" 2>/dev/null || true - pkill -f "next-server" 2>/dev/null || true - # Kill nginx using the captured PID first (most reliable), - # then fall back to pkill/killall for any stray nginx workers. - if [ -n "${NGINX_PID:-}" ] && kill -0 "$NGINX_PID" 2>/dev/null; then - kill -TERM "$NGINX_PID" 2>/dev/null || true - sleep 1 - kill -9 "$NGINX_PID" 2>/dev/null || true - fi - pkill -9 nginx 2>/dev/null || true - killall -9 nginx 2>/dev/null || true - echo "Cleaning up sandbox containers..." - ./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true - echo "โœ“ All services stopped" + stop_all exit 0 } + trap cleanup INT TERM -# โ”€โ”€ Start services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€ Helper: start a service โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# run_service NAME COMMAND PORT TIMEOUT +# In daemon mode, wraps with nohup. Waits for port to be ready. +run_service() { + local name="$1" cmd="$2" port="$3" timeout="$4" + + echo "Starting $name..." + if $DAEMON_MODE; then + nohup sh -c "$cmd" > /dev/null 2>&1 & + else + sh -c "$cmd" & + fi + + ./scripts/wait-for-port.sh "$port" "$timeout" "$name" || { + local logfile="logs/$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-').log" + echo "โœ— $name failed to start." + [ -f "$logfile" ] && tail -20 "$logfile" + cleanup + } + echo "โœ“ $name started on localhost:$port" +} + +# โ”€โ”€ Start services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ mkdir -p logs mkdir -p temp/client_body_temp temp/proxy_temp temp/fastcgi_temp temp/uwsgi_temp temp/scgi_temp -if $DEV_MODE; then - LANGGRAPH_EXTRA_FLAGS="--no-reload" - GATEWAY_EXTRA_FLAGS="--reload --reload-include='*.yaml' --reload-include='.env' --reload-exclude='*.pyc' --reload-exclude='__pycache__' --reload-exclude='sandbox/' --reload-exclude='.deer-flow/'" -else - LANGGRAPH_EXTRA_FLAGS="--no-reload" - GATEWAY_EXTRA_FLAGS="" -fi - -if [ "${SKIP_LANGGRAPH_SERVER:-0}" != "1" ]; then - echo "Starting LangGraph server..." - # Read log_level from config.yaml, fallback to env var, then to "info" +# 1. LangGraph (skip in gateway mode) +if ! $GATEWAY_MODE; then CONFIG_LOG_LEVEL=$(grep -m1 '^log_level:' config.yaml 2>/dev/null | awk '{print $2}' | tr -d ' ') LANGGRAPH_LOG_LEVEL="${LANGGRAPH_LOG_LEVEL:-${CONFIG_LOG_LEVEL:-info}}" - (cd backend && NO_COLOR=1 uv run langgraph dev --no-browser --allow-blocking --server-log-level $LANGGRAPH_LOG_LEVEL $LANGGRAPH_EXTRA_FLAGS > ../logs/langgraph.log 2>&1) & - ./scripts/wait-for-port.sh 2024 60 "LangGraph" || { - echo " See logs/langgraph.log for details" - tail -20 logs/langgraph.log - if grep -qE "config_version|outdated|Environment variable .* not found|KeyError|ValidationError|config\.yaml" logs/langgraph.log 2>/dev/null; then - echo "" - echo " Hint: This may be a configuration issue. Try running 'make config-upgrade' to update your config.yaml." - fi - cleanup - } - echo "โœ“ LangGraph server started on localhost:2024" + LANGGRAPH_JOBS_PER_WORKER="${LANGGRAPH_JOBS_PER_WORKER:-10}" + LANGGRAPH_ALLOW_BLOCKING="${LANGGRAPH_ALLOW_BLOCKING:-0}" + LANGGRAPH_ALLOW_BLOCKING_FLAG="" + if [ "$LANGGRAPH_ALLOW_BLOCKING" = "1" ]; then + LANGGRAPH_ALLOW_BLOCKING_FLAG="--allow-blocking" + fi + run_service "LangGraph" \ + "cd backend && NO_COLOR=1 uv run langgraph dev --no-browser $LANGGRAPH_ALLOW_BLOCKING_FLAG --n-jobs-per-worker $LANGGRAPH_JOBS_PER_WORKER --server-log-level $LANGGRAPH_LOG_LEVEL $LANGGRAPH_EXTRA_FLAGS > ../logs/langgraph.log 2>&1" \ + 2024 60 else - echo "โฉ Skipping LangGraph server (SKIP_LANGGRAPH_SERVER=1)" - echo " Use /api/langgraph-compat/* via Gateway instead" + echo "โฉ Skipping LangGraph (Gateway mode โ€” runtime embedded in Gateway)" fi -echo "Starting Gateway API..." -(cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 $GATEWAY_EXTRA_FLAGS > ../logs/gateway.log 2>&1) & -./scripts/wait-for-port.sh 8001 30 "Gateway API" || { - echo "โœ— Gateway API failed to start. Last log output:" - tail -60 logs/gateway.log - echo "" - echo "Likely configuration errors:" - grep -E "Failed to load configuration|Environment variable .* not found|config\.yaml.*not found" logs/gateway.log | tail -5 || true - echo "" - echo " Hint: Try running 'make config-upgrade' to update your config.yaml with the latest fields." - cleanup -} -echo "โœ“ Gateway API started on localhost:8001" +# 2. Gateway API +run_service "Gateway" \ + "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 $GATEWAY_EXTRA_FLAGS > ../logs/gateway.log 2>&1" \ + 8001 30 -echo "Starting Frontend..." -(cd frontend && $FRONTEND_CMD > ../logs/frontend.log 2>&1) & -./scripts/wait-for-port.sh 3000 120 "Frontend" || { - echo " See logs/frontend.log for details" - tail -20 logs/frontend.log - cleanup -} -echo "โœ“ Frontend started on localhost:3000" +# 3. Frontend +run_service "Frontend" \ + "cd frontend && $FRONTEND_CMD > ../logs/frontend.log 2>&1" \ + 3000 120 -echo "Starting Nginx reverse proxy..." -nginx -g 'daemon off;' -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" > logs/nginx.log 2>&1 & -NGINX_PID=$! -./scripts/wait-for-port.sh 2026 10 "Nginx" || { - echo " See logs/nginx.log for details" - tail -10 logs/nginx.log - cleanup -} -echo "โœ“ Nginx started on localhost:2026" +# 4. Nginx +run_service "Nginx" \ + "nginx -g 'daemon off;' -c '$REPO_ROOT/docker/nginx/nginx.local.conf' -p '$REPO_ROOT' > logs/nginx.log 2>&1" \ + 2026 10 -# โ”€โ”€ Ready โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# โ”€โ”€ Ready โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ echo "" echo "==========================================" -if $DEV_MODE; then - echo " โœ“ DeerFlow development server is running!" -else - echo " โœ“ DeerFlow production server is running!" -fi +echo " โœ“ DeerFlow is running! [$MODE_LABEL]" echo "==========================================" echo "" -echo " ๐ŸŒ Application: http://localhost:2026" -echo " ๐Ÿ“ก API Gateway: http://localhost:2026/api/*" -if [ "${SKIP_LANGGRAPH_SERVER:-0}" = "1" ]; then - echo " ๐Ÿค– LangGraph: skipped (SKIP_LANGGRAPH_SERVER=1)" +echo " ๐ŸŒ http://localhost:2026" +echo "" +if $GATEWAY_MODE; then + echo " Routing: Frontend โ†’ Nginx โ†’ Gateway (embedded runtime)" + echo " API: /api/langgraph-compat/* โ†’ Gateway agent runtime" else - echo " ๐Ÿค– LangGraph: http://localhost:2026/api/langgraph/* (served by langgraph dev)" -fi -echo " ๐Ÿงช LangGraph Compat (experimental): http://localhost:2026/api/langgraph-compat/* (served by Gateway)" -if [ "${SKIP_LANGGRAPH_SERVER:-0}" = "1" ]; then - echo "" - echo " ๐Ÿ’ก Set NEXT_PUBLIC_LANGGRAPH_BASE_URL=/api/langgraph-compat in frontend/.env.local" + echo " Routing: Frontend โ†’ Nginx โ†’ LangGraph + Gateway" + echo " API: /api/langgraph/* โ†’ LangGraph server (2024)" fi +echo " /api/* โ†’ Gateway REST API (8001)" echo "" -echo " ๐Ÿ“‹ Logs:" -echo " - LangGraph: logs/langgraph.log" -echo " - Gateway: logs/gateway.log" -echo " - Frontend: logs/frontend.log" -echo " - Nginx: logs/nginx.log" +echo " ๐Ÿ“‹ Logs: logs/{langgraph,gateway,frontend,nginx}.log" echo "" -echo "Press Ctrl+C to stop all services" -wait +if $DAEMON_MODE; then + echo " ๐Ÿ›‘ Stop: make stop" + # Detach โ€” trap is no longer needed + trap - INT TERM +else + echo " Press Ctrl+C to stop all services" + wait +fi diff --git a/scripts/start-daemon.sh b/scripts/start-daemon.sh index 96ee788e1..8822b73a0 100755 --- a/scripts/start-daemon.sh +++ b/scripts/start-daemon.sh @@ -1,140 +1,9 @@ #!/usr/bin/env bash # -# start-daemon.sh - Start all DeerFlow development services in daemon mode +# start-daemon.sh โ€” Start DeerFlow in daemon (background) mode # -# This script starts DeerFlow services in the background without keeping -# the terminal connection. Logs are written to separate files. -# -# Must be run from the repo root directory. - -set -e +# Thin wrapper around serve.sh --daemon. +# Kept for backward compatibility. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$REPO_ROOT" - -# โ”€โ”€ Stop existing services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -echo "Stopping existing services if any..." -pkill -f "langgraph dev" 2>/dev/null || true -pkill -f "uvicorn app.gateway.app:app" 2>/dev/null || true -pkill -f "next dev" 2>/dev/null || true -nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit 2>/dev/null || true -sleep 1 -pkill -9 nginx 2>/dev/null || true -./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true -sleep 1 - -# โ”€โ”€ Banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -echo "" -echo "==========================================" -echo " Starting DeerFlow in Daemon Mode" -echo "==========================================" -echo "" - -# โ”€โ”€ Config check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -if ! { \ - [ -n "$DEER_FLOW_CONFIG_PATH" ] && [ -f "$DEER_FLOW_CONFIG_PATH" ] || \ - [ -f backend/config.yaml ] || \ - [ -f config.yaml ]; \ - }; then - echo "โœ— No DeerFlow config file found." - echo " Checked these locations:" - echo " - $DEER_FLOW_CONFIG_PATH (when DEER_FLOW_CONFIG_PATH is set)" - echo " - backend/config.yaml" - echo " - ./config.yaml" - echo "" - echo " Run 'make config' from the repo root to generate ./config.yaml, then set required model API keys in .env or your config file." - exit 1 -fi - -# โ”€โ”€ Auto-upgrade config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -"$REPO_ROOT/scripts/config-upgrade.sh" - -# โ”€โ”€ Cleanup on failure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -cleanup_on_failure() { - echo "Failed to start services, cleaning up..." - pkill -f "langgraph dev" 2>/dev/null || true - pkill -f "uvicorn app.gateway.app:app" 2>/dev/null || true - pkill -f "next dev" 2>/dev/null || true - nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit 2>/dev/null || true - sleep 1 - pkill -9 nginx 2>/dev/null || true - echo "โœ“ Cleanup complete" -} - -trap cleanup_on_failure INT TERM - -# โ”€โ”€ Start services โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -mkdir -p logs -mkdir -p temp/client_body_temp temp/proxy_temp temp/fastcgi_temp temp/uwsgi_temp temp/scgi_temp - -echo "Starting LangGraph server..." -nohup sh -c 'cd backend && NO_COLOR=1 uv run langgraph dev --no-browser --allow-blocking --no-reload > ../logs/langgraph.log 2>&1' & -./scripts/wait-for-port.sh 2024 60 "LangGraph" || { - echo "โœ— LangGraph failed to start. Last log output:" - tail -60 logs/langgraph.log - if grep -qE "config_version|outdated|Environment variable .* not found|KeyError|ValidationError|config\.yaml" logs/langgraph.log 2>/dev/null; then - echo "" - echo " Hint: This may be a configuration issue. Try running 'make config-upgrade' to update your config.yaml." - fi - cleanup_on_failure - exit 1 -} -echo "โœ“ LangGraph server started on localhost:2024" - -echo "Starting Gateway API..." -nohup sh -c 'cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 > ../logs/gateway.log 2>&1' & -./scripts/wait-for-port.sh 8001 30 "Gateway API" || { - echo "โœ— Gateway API failed to start. Last log output:" - tail -60 logs/gateway.log - echo "" - echo " Hint: Try running 'make config-upgrade' to update your config.yaml with the latest fields." - cleanup_on_failure - exit 1 -} -echo "โœ“ Gateway API started on localhost:8001" - -echo "Starting Frontend..." -nohup sh -c 'cd frontend && pnpm run dev > ../logs/frontend.log 2>&1' & -./scripts/wait-for-port.sh 3000 120 "Frontend" || { - echo "โœ— Frontend failed to start. Last log output:" - tail -60 logs/frontend.log - cleanup_on_failure - exit 1 -} -echo "โœ“ Frontend started on localhost:3000" - -echo "Starting Nginx reverse proxy..." -nohup sh -c 'nginx -g "daemon off;" -c "$1/docker/nginx/nginx.local.conf" -p "$1" > logs/nginx.log 2>&1' _ "$REPO_ROOT" & -./scripts/wait-for-port.sh 2026 10 "Nginx" || { - echo "โœ— Nginx failed to start. Last log output:" - tail -60 logs/nginx.log - cleanup_on_failure - exit 1 -} -echo "โœ“ Nginx started on localhost:2026" - -# โ”€โ”€ Ready โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -echo "" -echo "==========================================" -echo " DeerFlow is running in daemon mode!" -echo "==========================================" -echo "" -echo " ๐ŸŒ Application: http://localhost:2026" -echo " ๐Ÿ“ก API Gateway: http://localhost:2026/api/*" -echo " ๐Ÿค– LangGraph: http://localhost:2026/api/langgraph/*" -echo "" -echo " ๐Ÿ“‹ Logs:" -echo " - LangGraph: logs/langgraph.log" -echo " - Gateway: logs/gateway.log" -echo " - Frontend: logs/frontend.log" -echo " - Nginx: logs/nginx.log" -echo "" -echo " ๐Ÿ›‘ Stop daemon: make stop" -echo "" +exec "$REPO_ROOT/scripts/serve.sh" --dev --daemon "$@" diff --git a/skills/public/academic-paper-review/SKILL.md b/skills/public/academic-paper-review/SKILL.md new file mode 100644 index 000000000..165321cb6 --- /dev/null +++ b/skills/public/academic-paper-review/SKILL.md @@ -0,0 +1,289 @@ +--- +name: academic-paper-review +description: Use this skill when the user requests to review, analyze, critique, or summarize academic papers, research articles, preprints, or scientific publications. Supports comprehensive structured reviews covering methodology assessment, contribution evaluation, literature positioning, and constructive feedback generation. Trigger on queries involving paper URLs, uploaded PDFs, arXiv links, or requests like "review this paper", "analyze this research", "summarize this study", or "write a peer review". +--- + +# Academic Paper Review Skill + +## Overview + +This skill produces structured, peer-review-quality analyses of academic papers and research publications. It follows established academic review standards used by top-tier venues (NeurIPS, ICML, ACL, Nature, IEEE) to provide rigorous, constructive, and balanced assessments. + +The review covers **summary, strengths, weaknesses, methodology assessment, contribution evaluation, literature positioning, and actionable recommendations** โ€” all grounded in evidence from the paper itself. + +## Core Capabilities + +- Parse and comprehend academic papers from uploaded PDFs or fetched URLs +- Generate structured reviews following top-venue review templates +- Assess methodology rigor (experimental design, statistical validity, reproducibility) +- Evaluate novelty and significance of contributions +- Position the work within the broader research landscape via targeted literature search +- Identify limitations, gaps, and potential improvements +- Produce both detailed review and concise executive summary formats +- Support papers in any scientific domain (CS, biology, physics, social sciences, etc.) + +## When to Use This Skill + +**Always load this skill when:** + +- User provides a paper URL (arXiv, DOI, conference proceedings, journal link) +- User uploads a PDF of a research paper or preprint +- User asks to "review", "analyze", "critique", "assess", or "summarize" a research paper +- User wants to understand the strengths and weaknesses of a study +- User requests a peer-review-style evaluation of academic work +- User asks for help preparing a review for a conference or journal submission + +## Review Methodology + +### Phase 1: Paper Comprehension + +Thoroughly read and understand the paper before forming any judgments. + +#### Step 1.1: Identify Paper Metadata + +Extract and record: + +| Field | Description | +|-------|-------------| +| **Title** | Full paper title | +| **Authors** | Author list and affiliations | +| **Venue / Status** | Publication venue, preprint server, or submission status | +| **Year** | Publication or submission year | +| **Domain** | Research field and subfield | +| **Paper Type** | Empirical, theoretical, survey, position paper, systems paper, etc. | + +#### Step 1.2: Deep Reading Pass + +Read the paper systematically: + +1. **Abstract & Introduction** โ€” Identify the claimed contributions and motivation +2. **Related Work** โ€” Note how authors position their work relative to prior art +3. **Methodology** โ€” Understand the proposed approach, model, or framework in detail +4. **Experiments / Results** โ€” Examine datasets, baselines, metrics, and reported outcomes +5. **Discussion & Limitations** โ€” Note any self-identified limitations +6. **Conclusion** โ€” Compare concluded claims against actual evidence presented + +#### Step 1.3: Key Claims Extraction + +List the paper's main claims explicitly: + +``` +Claim 1: [Specific claim about contribution or finding] +Evidence: [What evidence supports this claim in the paper] +Strength: [Strong / Moderate / Weak] + +Claim 2: [...] +... +``` + +### Phase 2: Critical Analysis + +#### Step 2.1: Literature Context Search + +Use web search to understand the research landscape: + +``` +Search queries: +- "[paper topic] state of the art [current year]" +- "[key method name] comparison benchmark" +- "[authors] previous work [topic]" +- "[specific technique] limitations criticism" +- "survey [research area] recent advances" +``` + +Use `web_fetch` on key related papers or surveys to understand where this work fits. + +#### Step 2.2: Methodology Assessment + +Evaluate the methodology using the following framework: + +| Criterion | Questions to Ask | Rating | +|-----------|-----------------|--------| +| **Soundness** | Is the approach technically correct? Are there logical flaws? | 1-5 | +| **Novelty** | What is genuinely new vs. incremental improvement? | 1-5 | +| **Reproducibility** | Are details sufficient to reproduce? Code/data available? | 1-5 | +| **Experimental Design** | Are baselines fair? Are ablations adequate? Are datasets appropriate? | 1-5 | +| **Statistical Rigor** | Are results statistically significant? Error bars reported? Multiple runs? | 1-5 | +| **Scalability** | Does the approach scale? Are computational costs discussed? | 1-5 | + +#### Step 2.3: Contribution Significance Assessment + +Evaluate the significance level: + +| Level | Description | Criteria | +|-------|-------------|----------| +| **Landmark** | Fundamentally changes the field | New paradigm, widely applicable breakthrough | +| **Significant** | Strong contribution advancing the state of the art | Clear improvement with solid evidence | +| **Moderate** | Useful contribution with some limitations | Incremental but valid improvement | +| **Marginal** | Minimal advance over existing work | Small gains, narrow applicability | +| **Below threshold** | Does not meet publication standards | Fundamental flaws, insufficient evidence | + +#### Step 2.4: Strengths and Weaknesses Analysis + +For each strength or weakness, provide: +- **What**: Specific observation +- **Where**: Section/figure/table reference +- **Why it matters**: Impact on the paper's claims or utility + +### Phase 3: Review Synthesis + +#### Step 3.1: Assemble the Structured Review + +Produce the final review using the template below. + +## Review Output Template + +```markdown +# Paper Review: [Paper Title] + +## Paper Metadata +- **Authors**: [Author list] +- **Venue**: [Publication venue or preprint server] +- **Year**: [Year] +- **Domain**: [Research field] +- **Paper Type**: [Empirical / Theoretical / Survey / Systems / Position] + +## Executive Summary + +[2-3 paragraph summary of the paper's core contribution, approach, and main findings. +State your overall assessment upfront: what the paper does well, where it falls short, +and whether the contribution is sufficient for the claimed venue/impact level.] + +## Summary of Contributions + +1. [First claimed contribution โ€” one sentence] +2. [Second claimed contribution โ€” one sentence] +3. [Additional contributions if any] + +## Strengths + +### S1: [Concise strength title] +[Detailed explanation with specific references to sections, figures, or tables in the paper. +Explain WHY this is a strength and its significance.] + +### S2: [Concise strength title] +[...] + +### S3: [Concise strength title] +[...] + +## Weaknesses + +### W1: [Concise weakness title] +[Detailed explanation with specific references. Explain the impact of this weakness on +the paper's claims. Suggest how it could be addressed.] + +### W2: [Concise weakness title] +[...] + +### W3: [Concise weakness title] +[...] + +## Methodology Assessment + +| Criterion | Rating (1-5) | Assessment | +|-----------|:---:|------------| +| Soundness | X | [Brief justification] | +| Novelty | X | [Brief justification] | +| Reproducibility | X | [Brief justification] | +| Experimental Design | X | [Brief justification] | +| Statistical Rigor | X | [Brief justification] | +| Scalability | X | [Brief justification] | + +## Questions for the Authors + +1. [Specific question that would clarify a concern or ambiguity] +2. [Question about methodology choices or alternative approaches] +3. [Question about generalizability or practical applicability] + +## Minor Issues + +- [Typos, formatting issues, unclear figures, notation inconsistencies] +- [Missing references that should be cited] +- [Suggestions for improved clarity] + +## Literature Positioning + +[How does this work relate to the current state of the art? +Are key related works cited? Are comparisons fair and comprehensive? +What important related work is missing?] + +## Recommendations + +**Overall Assessment**: [Accept / Weak Accept / Borderline / Weak Reject / Reject] + +**Confidence**: [High / Medium / Low] โ€” [Justification for confidence level] + +**Contribution Level**: [Landmark / Significant / Moderate / Marginal / Below threshold] + +### Actionable Suggestions for Improvement +1. [Specific, constructive suggestion] +2. [Specific, constructive suggestion] +3. [Specific, constructive suggestion] +``` + +## Review Principles + +### Constructive Criticism +- **Always suggest how to fix it** โ€” Don't just point out problems; propose solutions +- **Give credit where due** โ€” Acknowledge genuine contributions even in flawed papers +- **Be specific** โ€” Reference exact sections, equations, figures, and tables +- **Separate minor from major** โ€” Distinguish fatal flaws from fixable issues + +### Objectivity Standards +- โŒ "This paper is poorly written" (vague, unhelpful) +- โœ… "Section 3.2 introduces notation X without formal definition, making the proof in Theorem 1 difficult to follow. Consider adding a notation table after the problem formulation." (specific, actionable) + +### Ethical Review Practices +- Do NOT dismiss work based on author reputation or affiliation +- Evaluate the work on its own merits +- Flag potential ethical concerns (bias in datasets, dual-use implications) constructively +- Maintain confidentiality of unpublished work + +## Adaptation by Paper Type + +| Paper Type | Focus Areas | +|------------|-------------| +| **Empirical** | Experimental design, baselines, statistical significance, ablations, reproducibility | +| **Theoretical** | Proof correctness, assumption reasonableness, tightness of bounds, connection to practice | +| **Survey** | Comprehensiveness, taxonomy quality, coverage of recent work, synthesis insights | +| **Systems** | Architecture decisions, scalability evidence, real-world deployment, engineering contributions | +| **Position** | Argument coherence, evidence for claims, impact potential, fairness of characterizations | + +## Common Pitfalls to Avoid + +- โŒ Reviewing the paper you wish was written instead of the paper that was submitted +- โŒ Demanding additional experiments that are unreasonable in scope +- โŒ Penalizing the paper for not solving a different problem +- โŒ Being overly influenced by writing quality versus technical contribution +- โŒ Treating absence of comparison to your own work as a weakness +- โŒ Providing only a summary without critical analysis + +## Quality Checklist + +Before finalizing the review, verify: + +- [ ] Paper was read completely (not just abstract and introduction) +- [ ] All major claims are identified and evaluated against evidence +- [ ] At least 3 strengths and 3 weaknesses are provided with specific references +- [ ] The methodology assessment table is complete with ratings and justifications +- [ ] Questions for authors target genuine ambiguities, not rhetorical critiques +- [ ] Literature search was conducted to contextualize the contribution +- [ ] Recommendations are actionable and constructive +- [ ] The overall assessment is consistent with the identified strengths and weaknesses +- [ ] The review tone is professional and respectful +- [ ] Minor issues are separated from major concerns + +## Output Format + +- Output the complete review in **Markdown** format +- Save the review to `/mnt/user-data/outputs/review-{paper-topic}.md` when working in sandbox +- Present the review to the user using the `present_files` tool + +## Notes + +- This skill complements the `deep-research` skill โ€” load both when the user wants the paper reviewed in the context of the broader field +- For papers behind paywalls, work with whatever content is accessible (abstract, publicly available versions, preprint mirrors) +- Adapt the review depth to the user's needs: a brief assessment for quick triage versus a full review for submission preparation +- When reviewing multiple papers comparatively, maintain consistent criteria across all reviews +- Always disclose limitations of your review (e.g., "I could not verify the proofs in Appendix B in detail") diff --git a/skills/public/code-documentation/SKILL.md b/skills/public/code-documentation/SKILL.md new file mode 100644 index 000000000..8a2e2c47b --- /dev/null +++ b/skills/public/code-documentation/SKILL.md @@ -0,0 +1,415 @@ +--- +name: code-documentation +description: Use this skill when the user requests to generate, create, or improve documentation for code, APIs, libraries, repositories, or software projects. Supports README generation, API reference documentation, inline code comments, architecture documentation, changelog generation, and developer guides. Trigger on requests like "document this code", "create a README", "generate API docs", "write developer guide", or when analyzing codebases for documentation purposes. +--- + +# Code Documentation Skill + +## Overview + +This skill generates professional, comprehensive documentation for software projects, codebases, libraries, and APIs. It follows industry best practices from projects like React, Django, Stripe, and Kubernetes to produce documentation that is accurate, well-structured, and useful for both new contributors and experienced developers. + +The output ranges from single-file READMEs to multi-document developer guides, always matched to the project's complexity and the user's needs. + +## Core Capabilities + +- Generate comprehensive README.md files with badges, installation, usage, and API reference +- Create API reference documentation from source code analysis +- Produce architecture and design documentation with diagrams +- Write developer onboarding and contribution guides +- Generate changelogs from commit history or release notes +- Create inline code documentation following language-specific conventions +- Support JSDoc, docstrings, GoDoc, Javadoc, and Rustdoc formats +- Adapt documentation style to the project's language and ecosystem + +## When to Use This Skill + +**Always load this skill when:** + +- User asks to "document", "create docs", or "write documentation" for any code +- User requests a README, API reference, or developer guide +- User shares a codebase or repository and wants documentation generated +- User asks to improve or update existing documentation +- User needs architecture documentation, including diagrams +- User requests a changelog or migration guide + +## Documentation Workflow + +### Phase 1: Codebase Analysis + +Before writing any documentation, thoroughly understand the codebase. + +#### Step 1.1: Project Discovery + +Identify the project fundamentals: + +| Field | How to Determine | +|-------|-----------------| +| **Language(s)** | Check file extensions, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc. | +| **Framework** | Look at dependencies for known frameworks (React, Django, Express, Spring, etc.) | +| **Build System** | Check for `Makefile`, `CMakeLists.txt`, `webpack.config.js`, `build.gradle`, etc. | +| **Package Manager** | npm/yarn/pnpm, pip/uv/poetry, cargo, go modules, etc. | +| **Project Structure** | Map out the directory tree to understand the architecture | +| **Entry Points** | Find main files, CLI entry points, exported modules | +| **Existing Docs** | Check for existing README, docs/, wiki, or inline documentation | + +#### Step 1.2: Code Structure Analysis + +Use sandbox tools to explore the codebase: + +```bash +# Get directory structure +ls /mnt/user-data/uploads/project-dir/ + +# Read key files +read_file /mnt/user-data/uploads/project-dir/package.json +read_file /mnt/user-data/uploads/project-dir/pyproject.toml + +# Search for public API surfaces +grep -r "export " /mnt/user-data/uploads/project-dir/src/ +grep -r "def " /mnt/user-data/uploads/project-dir/src/ --include="*.py" +grep -r "func " /mnt/user-data/uploads/project-dir/ --include="*.go" +``` + +#### Step 1.3: Identify Documentation Scope + +Based on analysis, determine what documentation to produce: + +| Project Size | Recommended Documentation | +|-------------|--------------------------| +| **Single file / script** | Inline comments + usage header | +| **Small library** | README with API reference | +| **Medium project** | README + API docs + examples | +| **Large project** | README + Architecture + API + Contributing + Changelog | + +### Phase 2: Documentation Generation + +#### Step 2.1: README Generation + +Every project needs a README. Follow this structure: + +```markdown +# Project Name + +[One-line project description โ€” what it does and why it matters] + +[![Badge](link)](#) [![Badge](link)](#) + +## Features + +- [Key feature 1 โ€” brief description] +- [Key feature 2 โ€” brief description] +- [Key feature 3 โ€” brief description] + +## Quick Start + +### Prerequisites + +- [Prerequisite 1 with version requirement] +- [Prerequisite 2 with version requirement] + +### Installation + +[Installation commands with copy-paste-ready code blocks] + +### Basic Usage + +[Minimal working example that demonstrates core functionality] + +## Documentation + +- [Link to full API reference if separate] +- [Link to architecture docs if separate] +- [Link to examples directory if applicable] + +## API Reference + +[Inline API reference for smaller projects OR link to generated docs] + +## Configuration + +[Environment variables, config files, or runtime options] + +## Examples + +[2-3 practical examples covering common use cases] + +## Development + +### Setup + +[How to set up a development environment] + +### Testing + +[How to run tests] + +### Building + +[How to build the project] + +## Contributing + +[Contribution guidelines or link to CONTRIBUTING.md] + +## License + +[License information] +``` + +#### Step 2.2: API Reference Generation + +For each public API surface, document: + +**Function / Method Documentation**: + +```markdown +### `functionName(param1, param2, options?)` + +Brief description of what this function does. + +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `param1` | `string` | Yes | โ€” | Description of param1 | +| `param2` | `number` | Yes | โ€” | Description of param2 | +| `options` | `Object` | No | `{}` | Configuration options | +| `options.timeout` | `number` | No | `5000` | Timeout in milliseconds | + +**Returns:** `Promise<Result>` โ€” Description of return value + +**Throws:** +- `ValidationError` โ€” When param1 is empty +- `TimeoutError` โ€” When the operation exceeds the timeout + +**Example:** + +\`\`\`javascript +const result = await functionName("hello", 42, { timeout: 10000 }); +console.log(result.data); +\`\`\` +``` + +**Class Documentation**: + +```markdown +### `ClassName` + +Brief description of the class and its purpose. + +**Constructor:** + +\`\`\`javascript +new ClassName(config) +\`\`\` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `config.option1` | `string` | Description | +| `config.option2` | `boolean` | Description | + +**Methods:** + +- [`method1()`](#method1) โ€” Brief description +- [`method2(param)`](#method2) โ€” Brief description + +**Properties:** + +| Property | Type | Description | +|----------|------|-------------| +| `property1` | `string` | Description | +| `property2` | `number` | Read-only. Description | +``` + +#### Step 2.3: Architecture Documentation + +For medium-to-large projects, include architecture documentation: + +```markdown +# Architecture Overview + +## System Diagram + +[Include a Mermaid diagram showing the high-level architecture] + +\`\`\`mermaid +graph TD + A[Client] --> B[API Gateway] + B --> C[Service A] + B --> D[Service B] + C --> E[(Database)] + D --> E +\`\`\` + +## Component Overview + +### Component Name +- **Purpose**: What this component does +- **Location**: `src/components/name/` +- **Dependencies**: What it depends on +- **Public API**: Key exports or interfaces + +## Data Flow + +[Describe how data flows through the system for key operations] + +## Design Decisions + +### Decision Title +- **Context**: What situation led to this decision +- **Decision**: What was decided +- **Rationale**: Why this approach was chosen +- **Trade-offs**: What was sacrificed +``` + +#### Step 2.4: Inline Code Documentation + +Generate language-appropriate inline documentation: + +**Python (Docstrings โ€” Google style)**: +```python +def process_data(input_path: str, options: dict | None = None) -> ProcessResult: + """Process data from the given file path. + + Reads the input file, applies transformations based on the provided + options, and returns a structured result object. + + Args: + input_path: Absolute path to the input data file. + Supports CSV, JSON, and Parquet formats. + options: Optional configuration dictionary. + - "validate" (bool): Enable input validation. Defaults to True. + - "format" (str): Output format ("json" or "csv"). Defaults to "json". + + Returns: + A ProcessResult containing the transformed data and metadata. + + Raises: + FileNotFoundError: If input_path does not exist. + ValidationError: If validation is enabled and data is malformed. + + Example: + >>> result = process_data("/data/input.csv", {"validate": True}) + >>> print(result.row_count) + 1500 + """ +``` + +**TypeScript (JSDoc / TSDoc)**: +```typescript +/** + * Fetches user data from the API and transforms it for display. + * + * @param userId - The unique identifier of the user + * @param options - Configuration options for the fetch operation + * @param options.includeProfile - Whether to include the full profile. Defaults to `false`. + * @param options.cache - Cache duration in seconds. Set to `0` to disable. + * @returns The transformed user data ready for rendering + * @throws {NotFoundError} When the user ID does not exist + * @throws {NetworkError} When the API is unreachable + * + * @example + * ```ts + * const user = await fetchUser("usr_123", { includeProfile: true }); + * console.log(user.displayName); + * ``` + */ +``` + +**Go (GoDoc)**: +```go +// ProcessData reads the input file at the given path, applies the specified +// transformations, and returns the processed result. +// +// The input path must be an absolute path to a CSV or JSON file. +// If options is nil, default options are used. +// +// ProcessData returns an error if the file does not exist or cannot be parsed. +func ProcessData(inputPath string, options *ProcessOptions) (*Result, error) { +``` + +### Phase 3: Quality Assurance + +#### Step 3.1: Documentation Completeness Check + +Verify the documentation covers: + +- [ ] **What it is** โ€” Clear project description that a newcomer can understand +- [ ] **Why it exists** โ€” Problem it solves and value proposition +- [ ] **How to install** โ€” Copy-paste-ready installation commands +- [ ] **How to use** โ€” At least one minimal working example +- [ ] **API surface** โ€” All public functions, classes, and types documented +- [ ] **Configuration** โ€” All environment variables, config files, and options +- [ ] **Error handling** โ€” Common errors and how to resolve them +- [ ] **Contributing** โ€” How to set up dev environment and submit changes + +#### Step 3.2: Quality Standards + +| Standard | Check | +|----------|-------| +| **Accuracy** | Every code example must actually work with the described API | +| **Completeness** | No public API surface left undocumented | +| **Consistency** | Same formatting and structure throughout | +| **Freshness** | Documentation matches the current code, not an older version | +| **Accessibility** | No jargon without explanation, acronyms defined on first use | +| **Examples** | Every complex concept has at least one practical example | + +#### Step 3.3: Cross-reference Validation + +Ensure: +- All mentioned file paths exist in the project +- All referenced functions and classes exist in the code +- All code examples use the correct function signatures +- Version numbers match the project's actual version +- All links (internal and external) are valid + +## Documentation Style Guide + +### Writing Principles + +1. **Lead with the "why"** โ€” Before explaining how something works, explain why it exists +2. **Progressive disclosure** โ€” Start simple, add complexity gradually +3. **Show, don't tell** โ€” Prefer code examples over lengthy explanations +4. **Active voice** โ€” "The function returns X" not "X is returned by the function" +5. **Present tense** โ€” "The server starts on port 8080" not "The server will start on port 8080" +6. **Second person** โ€” "You can configure..." not "Users can configure..." + +### Formatting Rules + +- Use ATX-style headers (`#`, `##`, `###`) +- Use fenced code blocks with language specification (` ```python `, ` ```bash `) +- Use tables for structured information (parameters, options, configuration) +- Use admonitions for important notes, warnings, and tips +- Keep line length readable (wrap prose at ~80-100 characters in source) +- Use `code formatting` for function names, file paths, variable names, and CLI commands + +### Language-Specific Conventions + +| Language | Doc Format | Style Guide | +|----------|-----------|-------------| +| Python | Google-style docstrings | PEP 257 | +| TypeScript/JavaScript | TSDoc / JSDoc | TypeDoc conventions | +| Go | GoDoc comments | Effective Go | +| Rust | Rustdoc (`///`) | Rust API Guidelines | +| Java | Javadoc | Oracle Javadoc Guide | +| C/C++ | Doxygen | Doxygen manual | + +## Output Handling + +After generation: + +- Save documentation files to `/mnt/user-data/outputs/` +- For multi-file documentation, maintain the project directory structure +- Present generated files to the user using the `present_files` tool +- Offer to iterate on specific sections or adjust the level of detail +- Suggest additional documentation that might be valuable + +## Notes + +- Always analyze the actual code before writing documentation โ€” never guess at API signatures or behavior +- When existing documentation exists, preserve its structure unless the user explicitly asks for a rewrite +- For large codebases, prioritize documenting the public API surface and key abstractions first +- Documentation should be written in the same language as the project's existing docs; default to English if none exist +- When generating changelogs, use the [Keep a Changelog](https://keepachangelog.com/) format +- This skill works well in combination with the `deep-research` skill for documenting third-party integrations or dependencies diff --git a/skills/public/newsletter-generation/SKILL.md b/skills/public/newsletter-generation/SKILL.md new file mode 100644 index 000000000..0f0221e50 --- /dev/null +++ b/skills/public/newsletter-generation/SKILL.md @@ -0,0 +1,343 @@ +--- +name: newsletter-generation +description: Use this skill when the user requests to generate, create, write, or draft a newsletter, email digest, weekly roundup, industry briefing, or curated content summary. Supports topic-based research, content curation from multiple sources, and professional formatting for email or web distribution. Trigger on requests like "create a newsletter about X", "write a weekly digest", "generate a tech roundup", or "curate news about Y". +--- + +# Newsletter Generation Skill + +## Overview + +This skill generates professional, well-researched newsletters that combine curated content from multiple sources with original analysis and commentary. It follows modern newsletter best practices from publications like Morning Brew, The Hustle, TLDR, and Benedict Evans to produce content that is informative, engaging, and actionable. + +The output is a complete, ready-to-publish newsletter in Markdown format, suitable for email distribution platforms, web publishing, or conversion to HTML. + +## Core Capabilities + +- Research and curate content from multiple web sources on specified topics +- Generate topic-focused or multi-topic newsletters with consistent voice +- Write engaging headlines, summaries, and original commentary +- Structure content for optimal readability and scanning +- Support multiple newsletter formats (daily digest, weekly roundup, deep-dive, industry briefing) +- Include relevant links, sources, and attributions +- Adapt tone and style to target audience (technical, executive, general) +- Generate recurring newsletter series with consistent branding and structure + +## When to Use This Skill + +**Always load this skill when:** + +- User asks to generate a newsletter, email digest, or content roundup +- User requests a curated summary of news or developments on a topic +- User wants to create a recurring newsletter format +- User asks to compile recent developments in a field into a briefing +- User needs a formatted email-ready content piece with multiple curated items +- User asks for a "weekly roundup", "monthly digest", or "morning briefing" + +## Newsletter Workflow + +### Phase 1: Planning + +#### Step 1.1: Understand Newsletter Requirements + +Identify the key parameters: + +| Parameter | Description | Default | +|-----------|-------------|---------| +| **Topic(s)** | Primary subject area(s) to cover | Required | +| **Format** | Daily digest, weekly roundup, deep-dive, or industry briefing | Weekly roundup | +| **Target Audience** | Technical, executive, general, or niche community | General | +| **Tone** | Professional, conversational, witty, or analytical | Conversational-professional | +| **Length** | Short (5-min read), medium (10-min), long (15-min+) | Medium | +| **Sections** | Number and type of content sections | 4-6 sections | +| **Frequency Context** | One-time or part of a recurring series | One-time | + +#### Step 1.2: Define Newsletter Structure + +Based on the format, select the appropriate structure: + +**Daily Digest Structure**: +``` +1. Top Story (1 item, detailed) +2. Quick Hits (3-5 items, brief) +3. One Stat / Quote of the Day +4. What to Watch +``` + +**Weekly Roundup Structure**: +``` +1. Editor's Note / Intro +2. Top Stories (2-3 items, detailed) +3. Trends & Analysis (1-2 items, original commentary) +4. Quick Bites (4-6 items, brief summaries) +5. Tools & Resources (2-3 items) +6. One More Thing / Closing +``` + +**Deep-Dive Structure**: +``` +1. Introduction & Context +2. Background / Why It Matters +3. Key Developments (detailed analysis) +4. Expert Perspectives +5. What's Next / Implications +6. Further Reading +``` + +**Industry Briefing Structure**: +``` +1. Executive Summary +2. Market Developments +3. Company News & Moves +4. Product & Technology Updates +5. Regulatory & Policy Changes +6. Data & Metrics +7. Outlook +``` + +### Phase 2: Research & Curation + +#### Step 2.1: Multi-Source Research + +Conduct thorough research using web search. **The quality of the newsletter depends directly on the quality and recency of research.** + +**Search Strategy**: + +``` +# Current news and developments +"[topic] news [current month] [current year]" +"[topic] latest developments" +"[topic] announcement this week" + +# Trends and analysis +"[topic] trends [current year]" +"[topic] analysis expert opinion" +"[topic] industry report" + +# Data and statistics +"[topic] statistics [current year]" +"[topic] market data latest" +"[topic] growth metrics" + +# Tools and resources +"[topic] new tools [current year]" +"[topic] open source release" +"best [topic] resources [current year]" +``` + +> **IMPORTANT**: Always check `<current_date>` to ensure search queries use the correct temporal context. Never use hardcoded years. + +#### Step 2.2: Source Evaluation and Selection + +Evaluate each source and curate the best content: + +| Criterion | Priority | +|-----------|----------| +| **Recency** | Prefer content from the last 7-30 days | +| **Authority** | Prioritize primary sources, official announcements, established publications | +| **Uniqueness** | Select stories that offer fresh perspective or are underreported | +| **Relevance** | Every item must clearly connect to the newsletter's stated topic(s) | +| **Actionability** | Prefer content readers can act on (tools, insights, strategies) | +| **Diversity** | Mix of news, analysis, data, and practical resources | + +#### Step 2.3: Deep Content Extraction + +For key stories, use `web_fetch` to read full articles and extract: + +1. **Core facts** โ€” What happened, who is involved, when +2. **Context** โ€” Why this matters, background information +3. **Data points** โ€” Specific numbers, metrics, or statistics +4. **Quotes** โ€” Relevant expert quotes or official statements +5. **Implications** โ€” What this means for the reader + +### Phase 3: Writing + +#### Step 3.1: Newsletter Header + +Every newsletter starts with a consistent header: + +```markdown +# [Newsletter Name] + +*[Tagline or description] โ€” [Date]* + +--- + +[Optional: One-sentence preview of what's inside] +``` + +#### Step 3.2: Section Writing Guidelines + +**Top Stories / Featured Items**: +- **Headline**: Compelling, clear, benefit-oriented (not clickbait) +- **Hook**: Opening sentence that makes the reader care (1-2 sentences) +- **Body**: Key facts and context (2-4 paragraphs) +- **Why it matters**: Connect to the reader's world (1 paragraph) +- **Source link**: Always attribute and link to the original source + +**Quick Bites / Brief Items**: +- **Format**: Bold headline + 2-3 sentence summary + source link +- **Focus**: One key takeaway per item +- **Efficiency**: Readers should get the essential insight without clicking through + +**Analysis / Commentary Sections**: +- **Voice**: The newsletter's unique perspective on trends or developments +- **Structure**: Observation โ†’ Context โ†’ Implication โ†’ (Optional) Actionable takeaway +- **Evidence**: Every claim backed by data or sourced information + +#### Step 3.3: Writing Standards + +| Principle | Implementation | +|-----------|---------------| +| **Scannable** | Use headers, bold text, bullet points, and short paragraphs | +| **Engaging** | Lead with the most interesting angle, not chronological order | +| **Concise** | Every sentence earns its place โ€” cut filler ruthlessly | +| **Accurate** | Every fact is sourced, every number is verified | +| **Attributive** | Always credit original sources with inline links | +| **Human** | Write like a knowledgeable friend, not a press release | + +**Tone Calibration by Audience**: + +| Audience | Tone | Example | +|----------|------|---------| +| **Technical** | Precise, no jargon explanations, assumed expertise | "The new API supports gRPC streaming with backpressure handling via flow control windows." | +| **Executive** | Impact-focused, bottom-line, strategic | "This acquisition gives Company X a 40% market share in the enterprise segment, directly threatening Incumbent Y's pricing power." | +| **General** | Accessible, analogies, explains concepts | "Think of it like a universal translator for data โ€” it lets any app talk to any database without learning a new language." | + +### Phase 4: Assembly & Polish + +#### Step 4.1: Assemble the Newsletter + +Combine all sections into the final document following the chosen structure template. + +#### Step 4.2: Footer + +Every newsletter ends with: + +```markdown +--- + +*[Newsletter Name] is [description of what it is].* +*[How to subscribe/share/give feedback]* + +*Sources: All links are provided inline. This newsletter curates and summarizes +publicly available information with original commentary.* +``` + +#### Step 4.3: Quality Checklist + +Before finalizing, verify: + +- [ ] **Every factual claim has a source link** โ€” No unsourced assertions +- [ ] **All links are functional** โ€” Verified URLs from search results +- [ ] **Date references use the actual current date** โ€” No hardcoded or assumed dates +- [ ] **Content is current** โ€” All major items are from within the expected timeframe +- [ ] **No duplicate stories** โ€” Each item appears only once +- [ ] **Consistent formatting** โ€” Headers, bullets, links use the same style throughout +- [ ] **Balanced coverage** โ€” Not dominated by a single source or perspective +- [ ] **Appropriate length** โ€” Matches the specified length target +- [ ] **Engaging opening** โ€” The first 2 sentences make the reader want to continue +- [ ] **Clear closing** โ€” The newsletter ends with a memorable or actionable note +- [ ] **Proofread** โ€” No typos, broken formatting, or incomplete sentences + +## Newsletter Output Template + +```markdown +# [Newsletter Name] + +*[Tagline] โ€” [Full date, e.g., April 4, 2026]* + +--- + +[Preview sentence: "This week: [topic 1], [topic 2], and [topic 3]."] + +## ๐Ÿ”ฅ Top Stories + +### [Headline 1] + +[Hook โ€” why this matters in 1-2 sentences.] + +[Body โ€” 2-4 paragraphs covering key facts, context, and implications.] + +**Why it matters:** [1 paragraph connecting to reader's interests or industry impact.] + +๐Ÿ“Ž [Source: Publication Name](URL) + +### [Headline 2] + +[Same structure as above] + +## ๐Ÿ“Š Trends & Analysis + +### [Trend Title] + +[Original commentary on an emerging trend, backed by data from research.] + +[Key data points presented clearly โ€” consider inline stats or a brief comparison.] + +**The bottom line:** [One-sentence takeaway.] + +## โšก Quick Bites + +- **[Headline]** โ€” [2-3 sentence summary with key takeaway.] [Source](URL) +- **[Headline]** โ€” [2-3 sentence summary.] [Source](URL) +- **[Headline]** โ€” [2-3 sentence summary.] [Source](URL) +- **[Headline]** โ€” [2-3 sentence summary.] [Source](URL) + +## ๐Ÿ› ๏ธ Tools & Resources + +- **[Tool/Resource Name]** โ€” [What it does and why it's useful.] [Link](URL) +- **[Tool/Resource Name]** โ€” [Description.] [Link](URL) + +## ๐Ÿ’ฌ One More Thing + +[Closing thought, insightful quote, or forward-looking statement.] + +--- + +*[Newsletter Name] curates the most important [topic] news and analysis.* +*Found this useful? Share it with a colleague.* + +*All sources are linked inline. Views and commentary are original.* +``` + +## Adaptation Examples + +### Technology Newsletter +- Emoji usage: โœ… Moderate (section headers) +- Sections: Top Stories, Deep Dive, Quick Bites, Open Source Spotlight, Dev Tools +- Tone: Technical-conversational + +### Business/Finance Newsletter +- Emoji usage: โŒ Minimal to none +- Sections: Market Overview, Deal Flow, Company News, Data Corner, Outlook +- Tone: Professional-analytical + +### Industry-Specific Newsletter +- Emoji usage: Moderate +- Sections: Regulatory Updates, Market Data, Innovation Watch, People Moves, Events +- Tone: Expert-authoritative + +### Creative/Marketing Newsletter +- Emoji usage: โœ… Liberal +- Sections: Campaign Spotlight, Trend Watch, Viral This Week, Tools We Love, Inspiration +- Tone: Enthusiastic-professional + +## Output Handling + +After generation: + +- Save the newsletter to `/mnt/user-data/outputs/newsletter-{topic}-{date}.md` +- Present the newsletter to the user using the `present_files` tool +- Offer to adjust sections, tone, length, or focus areas +- If the user wants HTML output, note that the Markdown can be converted using standard tools + +## Notes + +- This skill works best in combination with the `deep-research` skill for comprehensive topic coverage โ€” load both for newsletters requiring deep analysis +- Always use `<current_date>` for temporal context in searches and date references in the newsletter +- For recurring newsletters, suggest maintaining a consistent structure so readers develop expectations +- When curating, quality beats quantity โ€” 5 excellent items beat 15 mediocre ones +- Attribute all content properly โ€” newsletters build trust through transparent sourcing +- Avoid summarizing paywalled content that the reader cannot access +- If the user provides specific URLs or articles to include, incorporate them alongside your curated findings +- The newsletter should provide enough value in the summaries that readers benefit even without clicking through to every link