diff --git a/.env.development b/.env.development index 81b3be797..cbab83245 100644 --- a/.env.development +++ b/.env.development @@ -1,8 +1,18 @@ VITE_BASE_URL=/api +# =======Production environment variables======= +SERVER_URL=https://dev.eigent.ai VITE_PROXY_URL=https://dev.eigent.ai +VITE_SITE_URL=https://www.eigent.ai VITE_USE_LOCAL_PROXY=false +# =======Test environment variables======= +# SERVER_URL=https://test-dev.eigent.ai +# VITE_PROXY_URL=https://test-dev.eigent.ai +# VITE_SITE_URL=https://test.eigent.ai +# VITE_USE_LOCAL_PROXY=false + +# =======Local environment variables======= # VITE_PROXY_URL=http://localhost:3001 # VITE_USE_LOCAL_PROXY=true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d39e22b2c..5f4615bb1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -347,7 +347,7 @@ jobs: files: | gh-release-assets/* - # Extract version from tag (e.g., v0.0.85 -> 0.0.90) + # Extract version from tag (e.g., v0.0.89 -> 0.0.89) - name: Extract version if: startsWith(github.ref, 'refs/tags/') id: version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36c729b0a..0ed8f3d11 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,64 @@ permissions: contents: read jobs: + web-local-smoke: + name: Run Web + Local Brain Smoke + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install frontend dependencies + run: npm install --ignore-scripts + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.11 + + - name: Install backend dependencies + run: | + cd backend + uv sync + + - name: Run web + local brain smoke + run: bash scripts/smoke-web-local-brain.sh + + frontend-quality: + name: Run Frontend Guardrails + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install frontend dependencies + run: npm install --ignore-scripts + + - name: Run type check + run: npm run type-check + + - name: Check Electron Access Guard + run: bash scripts/check-electron-access.sh + + - name: Design tokens (engine + no hard-coded colors in UI) + run: npm run check:design-tokens + pytest: name: Run Python Tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 371f89626..a77e24f08 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node-compile-cache node_modules dist +dist-web !package/**/dist dist-ssr dist-electron diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 3f024a1db..a9efd3733 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,6 +1,7 @@ { "*.{ts,tsx}": [ "eslint --fix --no-warn-ignored", + "node scripts/check-design-token-usage.mjs", "prettier --write", "node licenses/update_license.js" ], diff --git a/backend/README.md b/backend/README.md index 237ae8dde..1a6127113 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,5 +1,40 @@ ```bash +# Option 1: Start with uvicorn directly uv run uvicorn main:api --port 5001 + +# Option 2: Standalone mode (no Electron dependency) +uv run python main.py + +# Option 3: If uv run hangs, delete lock files and retry, or use venv directly: +.venv/bin/python main.py +# or +.venv/bin/uvicorn main:api --port 5001 --host 0.0.0.0 + +# If uv hangs, delete lock files first: rm -f uv_installing.lock uv_installed.lock +``` + +### Environment Variables (Standalone) + +| Variable | Default | Description | +| ---------------------------------- | ------------------- | ----------------------------------------------------------------------------------------- | +| `EIGENT_BRAIN_PORT` | 5001 | Listening port | +| `EIGENT_BRAIN_HOST` | 0.0.0.0 | Listening address | +| `EIGENT_DEBUG` | - | Set to 1/true to enable reload | +| `EIGENT_WORKSPACE` | ~/.eigent/workspace | Working directory | +| `EIGENT_DEPLOYMENT_TYPE` | (auto) | `local` / `cloud_vm` / `sandbox` / `docker`; determines Hands capabilities (see ADR-0006) | +| `EIGENT_HANDS_MODE` | - | Set to `remote` to enable `RemoteHands` (remote cluster resource mode) | +| `EIGENT_HANDS_CLUSTER_CONFIG_FILE` | - | Path to `RemoteHands` config file (TOML); **recommended** | +| `EIGENT_HANDS_TERMINAL` | - | Override terminal hand: `1`/`true`/`yes` or `0`/`false`/`no` | +| `EIGENT_HANDS_BROWSER` | - | Override browser hand | +| `EIGENT_HANDS_FILESYSTEM` | - | Override filesystem scope: `full` / `workspace_only` | +| `EIGENT_HANDS_MCP` | - | Override MCP mode: `all` / `allowlist` | + +RemoteHands config file example: + +```bash +cp backend/config/hands_clusters.example.toml ~/.eigent/hands_clusters.toml +export EIGENT_HANDS_MODE=remote +export EIGENT_HANDS_CLUSTER_CONFIG_FILE=~/.eigent/hands_clusters.toml ``` i18n operation process: https://github.com/Anbarryprojects/fastapi-babel diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 06f0364bf..66abb74a7 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -12,17 +12,72 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.base import BaseHTTPMiddleware # Initialize FastAPI with title api = FastAPI(title="Eigent Multi-Agent System API") -# Add CORS middleware + +@api.get("/") +def root(): + """Root endpoint - confirms this is the Brain backend.""" + return {"service": "eigent-brain", "docs": "/docs", "health": "/health"} + + +_cors_raw = os.environ.get("EIGENT_CORS_ORIGINS", "") +_allowed_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()] +_default_frame_ancestors = [ + "'self'", + "http://localhost:*", + "http://127.0.0.1:*", + "https://localhost:*", + "https://127.0.0.1:*", +] +_frame_ancestors = " ".join( + dict.fromkeys( + [ + *_default_frame_ancestors, + *[origin for origin in _allowed_origins if origin != "*"], + ] + ) +) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + path = request.url.path + is_file_preview = path.startswith( + "/files/preview/" + ) or path.startswith("/api/v1/files/preview/") + if is_file_preview: + if "X-Frame-Options" in response.headers: + del response.headers["X-Frame-Options"] + response.headers["Content-Security-Policy"] = ( + f"frame-ancestors {_frame_ancestors};" + ) + else: + response.headers["X-Frame-Options"] = "DENY" + return response + + api.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=_allowed_origins or ["*"], + allow_credentials=bool(_allowed_origins), allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Session-ID"], ) +api.add_middleware(SecurityHeadersMiddleware) + +# Phase 2: Channel/Session header parsing (X-Channel, X-Session-ID, X-User-ID) +from app.router_layer import ChannelSessionMiddleware + +api.add_middleware(ChannelSessionMiddleware) diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index cf9a8724f..c6cb0e02e 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -15,6 +15,7 @@ import platform import threading import uuid +from urllib.parse import urlparse from camel.messages import BaseMessage from camel.toolkits import ToolkitMessageIntegration @@ -33,14 +34,35 @@ from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.utils import NOW_STR from app.component.environment import env +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents +from app.utils.browser_launcher import normalize_cdp_url from app.utils.file_utils import get_working_directory def _get_browser_port(browser: dict) -> int: """Extract port from a browser config dict, with fallback to env default.""" - return int(browser.get("port", env("browser_port", "9222"))) + raw_port = browser.get("port") + if raw_port is not None: + return int(raw_port) + + raw_endpoint = browser.get("endpoint") or browser.get("cdp_url") + if raw_endpoint: + _, _, port = normalize_cdp_url(str(raw_endpoint)) + return port + + return int(env("browser_port", "9222")) + + +def _get_browser_endpoint(browser: dict) -> str: + """Extract a normalized CDP endpoint from a browser config dict.""" + raw_endpoint = browser.get("endpoint") or browser.get("cdp_url") + if raw_endpoint: + endpoint, _, _ = normalize_cdp_url(str(raw_endpoint)) + return endpoint + + return f"http://localhost:{_get_browser_port(browser)}" class CdpBrowserPoolManager: @@ -148,7 +170,10 @@ def get_occupied_ports(self) -> list[int]: _cdp_pool_manager = CdpBrowserPoolManager() -def browser_agent(options: Chat): +def browser_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating browser agent for project: {options.project_id} " @@ -160,17 +185,23 @@ def browser_agent(options: Chat): ).send_message_to_user ) - # Acquire CDP browser from pool or use default port + use_browser = hands is None or hands.can_use_browser() + use_terminal = hands is None or hands.can_execute_terminal() + + # Acquire CDP browser from pool or use default port (only when browser enabled) toolkit_session_id = str(uuid.uuid4())[:8] selected_port = None selected_is_external = False + cdp_url = None + cdp_owned_by_hands = False - if options.cdp_browsers: + if use_browser and options.cdp_browsers: selected_browser = _cdp_pool_manager.acquire_browser( options.cdp_browsers, toolkit_session_id, options.task_id ) if selected_browser: selected_port = _get_browser_port(selected_browser) + cdp_url = _get_browser_endpoint(selected_browser) selected_is_external = selected_browser.get("isExternal", False) logger.info( f"Acquired CDP browser from pool (initial): " @@ -178,62 +209,90 @@ def browser_agent(options: Chat): f"session_id={toolkit_session_id}" ) else: - selected_port = _get_browser_port(options.cdp_browsers[0]) - selected_is_external = options.cdp_browsers[0].get( - "isExternal", False - ) + fallback_browser = options.cdp_browsers[0] + selected_port = _get_browser_port(fallback_browser) + cdp_url = _get_browser_endpoint(fallback_browser) + selected_is_external = fallback_browser.get("isExternal", False) logger.warning( f"No available browsers in pool (initial), using first: " f"port={selected_port}, session_id={toolkit_session_id}" ) - else: + elif use_browser: + existing_cdp_url = env("EIGENT_CDP_URL", "").strip() selected_port = env("browser_port", "9222") + cdp_url = f"http://localhost:{selected_port}" + + if existing_cdp_url: + cdp_url = existing_cdp_url + try: + parsed = urlparse(existing_cdp_url) + if parsed.port is not None: + selected_port = parsed.port + except Exception: + selected_port = env("browser_port", "9222") + elif hands is not None: + try: + cdp_url = hands.acquire_resource( + "browser", toolkit_session_id, port=selected_port + ) + cdp_owned_by_hands = True + except (NotImplementedError, ValueError): + cdp_url = f"http://localhost:{selected_port}" + + # Web mode (no Electron): cdp_keep_current_page=False so toolkit can create + # pages when browser has 0 tabs. Electron mode: True to reuse user's page. + cdp_keep_current = bool(options.cdp_browsers) if use_browser else False + default_start_url = None if cdp_keep_current else "about:blank" + + web_toolkit_custom = None + web_toolkit_for_agent_registration = None + if use_browser: + web_toolkit_custom = HybridBrowserToolkit( + options.project_id, + cdp_keep_current_page=cdp_keep_current, + default_start_url=default_start_url, + headless=False, + browser_log_to_file=True, + stealth=True, + session_id=toolkit_session_id, + cdp_url=cdp_url, + enabled_tools=[ + "browser_click", + "browser_type", + "browser_back", + "browser_forward", + "browser_select", + "browser_console_exec", + "browser_console_view", + "browser_switch_tab", + "browser_enter", + "browser_visit_page", + "browser_scroll", + "browser_sheet_read", + "browser_sheet_input", + "browser_get_page_snapshot", + "browser_open", + "browser_upload_file", + "browser_download_file", + ], + ) + web_toolkit_for_agent_registration = web_toolkit_custom + web_toolkit_custom = message_integration.register_toolkits( + web_toolkit_custom + ) - web_toolkit_custom = HybridBrowserToolkit( - options.project_id, - cdp_keep_current_page=True, - headless=False, - browser_log_to_file=True, - stealth=True, - session_id=toolkit_session_id, - cdp_url=f"http://localhost:{selected_port}", - enabled_tools=[ - "browser_click", - "browser_type", - "browser_back", - "browser_forward", - "browser_select", - "browser_console_exec", - "browser_console_view", - "browser_switch_tab", - "browser_enter", - "browser_visit_page", - "browser_scroll", - "browser_sheet_read", - "browser_sheet_input", - "browser_get_page_snapshot", - "browser_open", - "browser_upload_file", - "browser_download_file", - ], - ) - - # Save reference before registering for toolkits_to_register_agent - web_toolkit_for_agent_registration = web_toolkit_custom - web_toolkit_custom = message_integration.register_toolkits( - web_toolkit_custom - ) - - terminal_toolkit = TerminalToolkit( - options.project_id, - Agents.browser_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_functions( - [terminal_toolkit.shell_exec] - ) + terminal_toolkit = None + if use_terminal: + terminal_toolkit = TerminalToolkit( + options.project_id, + Agents.browser_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_functions( + [terminal_toolkit.shell_exec] + ) note_toolkit = NoteTakingToolkit( options.project_id, @@ -272,13 +331,33 @@ def browser_agent(options: Chat): *HumanToolkit.get_can_use_tools( options.project_id, Agents.browser_agent ), - *web_toolkit_custom.get_tools(), - *terminal_toolkit, *note_toolkit.get_tools(), *screenshot_toolkit.get_tools(), *search_tools, *skill_toolkit.get_tools(), ] + tool_names = [ + SearchToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] + if use_browser and web_toolkit_custom: + tools = [ + *HumanToolkit.get_can_use_tools( + options.project_id, Agents.browser_agent + ), + *web_toolkit_custom.get_tools(), + *note_toolkit.get_tools(), + *screenshot_toolkit.get_tools(), + *search_tools, + *skill_toolkit.get_tools(), + ] + tool_names.insert(1, HybridBrowserToolkit.toolkit_name()) + if use_terminal and terminal_toolkit: + tools.extend(terminal_toolkit) + tool_names.append(TerminalToolkit.toolkit_name()) # Build external browser notice external_browser_notice = "" @@ -310,18 +389,14 @@ def browser_agent(options: Chat): options, tools, prune_tool_calls_from_memory=True, - tool_names=[ - SearchToolkit.toolkit_name(), - HybridBrowserToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ - web_toolkit_for_agent_registration, - screenshot_toolkit_for_agent_registration, + t + for t in ( + web_toolkit_for_agent_registration, + screenshot_toolkit_for_agent_registration, + ) + if t is not None ], enable_snapshot_clean=True, ) @@ -351,19 +426,42 @@ def release_cdp_from_agent(agent_instance): """Release CDP browser back to pool.""" port = getattr(agent_instance, "_cdp_port", None) session_id = getattr(agent_instance, "_cdp_session_id", None) - if port is not None and session_id is not None: + if ( + port is not None + and session_id is not None + and options.cdp_browsers + ): _cdp_pool_manager.release_browser(port, session_id) logger.info( f"Released CDP for agent {agent_instance.agent_id}: " f"port={port}, session={session_id}" ) + elif ( + session_id is not None + and hands is not None + and getattr(agent_instance, "_cdp_owned_by_hands", False) + ): + try: + hands.release_resource("browser", session_id) + except Exception as exc: + logger.warning( + "Failed to release browser resource for session %s: %s", + session_id, + exc, + ) - agent._cdp_acquire_callback = acquire_cdp_for_agent - agent._cdp_release_callback = release_cdp_from_agent + agent._cdp_acquire_callback = ( + acquire_cdp_for_agent if use_browser else None + ) + agent._cdp_release_callback = ( + release_cdp_from_agent if use_browser else None + ) agent._cdp_port = selected_port + agent._cdp_url = cdp_url agent._cdp_session_id = toolkit_session_id agent._cdp_task_id = options.task_id agent._cdp_options = options agent._browser_toolkit = web_toolkit_for_agent_registration + agent._cdp_owned_by_hands = cdp_owned_by_hands return agent diff --git a/backend/app/agent/factory/developer.py b/backend/app/agent/factory/developer.py index c52ce3e14..811c26622 100644 --- a/backend/app/agent/factory/developer.py +++ b/backend/app/agent/factory/developer.py @@ -30,12 +30,16 @@ from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.toolkit.web_deploy_toolkit import WebDeployToolkit from app.agent.utils import NOW_STR +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory -async def developer_agent(options: Chat): +async def developer_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating developer agent for project: {options.project_id} " @@ -66,16 +70,6 @@ async def developer_agent(options: Chat): screenshot_toolkit = message_integration.register_toolkits( screenshot_toolkit ) - - terminal_toolkit = TerminalToolkit( - options.project_id, - Agents.developer_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_toolkits(terminal_toolkit) - skill_toolkit = SkillToolkit( options.project_id, Agents.developer_agent, @@ -98,11 +92,33 @@ async def developer_agent(options: Chat): ), *note_toolkit.get_tools(), *web_deploy_toolkit.get_tools(), - *terminal_toolkit.get_tools(), *screenshot_toolkit.get_tools(), *skill_toolkit.get_tools(), *search_tools, ] + tool_names = [ + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + WebDeployToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] + if search_tools: + tool_names.append(SearchToolkit.toolkit_name()) + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + Agents.developer_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_toolkits( + terminal_toolkit + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(TerminalToolkit.toolkit_name()) + system_message = DEVELOPER_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), @@ -118,15 +134,7 @@ async def developer_agent(options: Chat): ), options, tools, - tool_names=[ - HumanToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - WebDeployToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/factory/document.py b/backend/app/agent/factory/document.py index edabefc70..0e1827808 100644 --- a/backend/app/agent/factory/document.py +++ b/backend/app/agent/factory/document.py @@ -33,12 +33,16 @@ from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.utils import NOW_STR +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory -async def document_agent(options: Chat): +async def document_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating document agent for project: {options.project_id} " @@ -82,14 +86,39 @@ async def document_agent(options: Chat): screenshot_toolkit ) - terminal_toolkit = TerminalToolkit( - options.project_id, - Agents.document_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_toolkits(terminal_toolkit) + tools = [ + *file_write_toolkit.get_tools(), + *pptx_toolkit.get_tools(), + *HumanToolkit.get_can_use_tools( + options.project_id, Agents.document_agent + ), + *mark_it_down_toolkit.get_tools(), + *excel_toolkit.get_tools(), + *note_toolkit.get_tools(), + *screenshot_toolkit.get_tools(), + ] + tool_names = [ + FileToolkit.toolkit_name(), + PPTXToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + MarkItDownToolkit.toolkit_name(), + ExcelToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + ] + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + Agents.document_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_toolkits( + terminal_toolkit + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(TerminalToolkit.toolkit_name()) google_drive_tools = await GoogleDriveMCPToolkit.get_can_use_tools( options.project_id, options.get_bun_env() @@ -102,30 +131,20 @@ async def document_agent(options: Chat): user_id=options.skill_config_user_id(), ) skill_toolkit = message_integration.register_toolkits(skill_toolkit) - + tools.extend(google_drive_tools) + if google_drive_tools: + tool_names.append(GoogleDriveMCPToolkit.toolkit_name()) + tools.extend(skill_toolkit.get_tools()) + tool_names.append(SkillToolkit.toolkit_name()) search_tools = SearchToolkit.get_can_use_tools( options.project_id, agent_name=Agents.document_agent ) if search_tools: search_tools = message_integration.register_functions(search_tools) + tools.extend(search_tools) + tool_names.append(SearchToolkit.toolkit_name()) else: search_tools = [] - - tools = [ - *file_write_toolkit.get_tools(), - *pptx_toolkit.get_tools(), - *HumanToolkit.get_can_use_tools( - options.project_id, Agents.document_agent - ), - *mark_it_down_toolkit.get_tools(), - *excel_toolkit.get_tools(), - *note_toolkit.get_tools(), - *terminal_toolkit.get_tools(), - *screenshot_toolkit.get_tools(), - *google_drive_tools, - *skill_toolkit.get_tools(), - *search_tools, - ] system_message = DOCUMENT_SYS_PROMPT.format( platform_system=platform.system(), platform_machine=platform.machine(), @@ -141,19 +160,7 @@ async def document_agent(options: Chat): ), options, tools, - tool_names=[ - FileToolkit.toolkit_name(), - PPTXToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - MarkItDownToolkit.toolkit_name(), - ExcelToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - GoogleDriveMCPToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/factory/multi_modal.py b/backend/app/agent/factory/multi_modal.py index f01756f57..00329c24d 100644 --- a/backend/app/agent/factory/multi_modal.py +++ b/backend/app/agent/factory/multi_modal.py @@ -33,12 +33,16 @@ from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.toolkit.video_download_toolkit import VideoDownloaderToolkit from app.agent.utils import NOW_STR +from app.hands.interface import IHands from app.model.chat import Chat from app.service.task import Agents from app.utils.file_utils import get_working_directory -def multi_modal_agent(options: Chat): +def multi_modal_agent( + options: Chat, + hands: IHands | None = None, +): working_directory = get_working_directory(options) logger.info( f"Creating multi-modal agent for project: {options.project_id} " @@ -67,15 +71,6 @@ def multi_modal_agent(options: Chat): screenshot_toolkit ) - terminal_toolkit = TerminalToolkit( - options.project_id, - agent_name=Agents.multi_modal_agent, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - terminal_toolkit = message_integration.register_toolkits(terminal_toolkit) - note_toolkit = NoteTakingToolkit( options.project_id, Agents.multi_modal_agent, @@ -105,13 +100,33 @@ def multi_modal_agent(options: Chat): *HumanToolkit.get_can_use_tools( options.project_id, Agents.multi_modal_agent ), - *terminal_toolkit.get_tools(), *note_toolkit.get_tools(), *skill_toolkit.get_tools(), *search_tools, ] + tool_names = [ + VideoDownloaderToolkit.toolkit_name(), + ScreenshotToolkit.toolkit_name(), + HumanToolkit.toolkit_name(), + NoteTakingToolkit.toolkit_name(), + SkillToolkit.toolkit_name(), + ] + if search_tools: + tool_names.append(SearchToolkit.toolkit_name()) + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + agent_name=Agents.multi_modal_agent, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + terminal_toolkit = message_integration.register_toolkits( + terminal_toolkit + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(TerminalToolkit.toolkit_name()) if options.is_cloud(): - # TODO: check llm has this model open_ai_image_toolkit = OpenAIImageToolkit( options.project_id, model="dall-e-3", @@ -125,10 +140,8 @@ def multi_modal_agent(options: Chat): open_ai_image_toolkit = message_integration.register_toolkits( open_ai_image_toolkit ) - tools = [ - *tools, - *open_ai_image_toolkit.get_tools(), - ] + tools.extend(open_ai_image_toolkit.get_tools()) + tool_names.append(OpenAIImageToolkit.toolkit_name()) # Convert string model_platform to enum for comparison try: model_platform_enum = ModelPlatformType(options.model_platform.lower()) @@ -148,6 +161,7 @@ def multi_modal_agent(options: Chat): audio_analysis_toolkit ) tools.extend(audio_analysis_toolkit.get_tools()) + tool_names.append(AudioAnalysisToolkit.toolkit_name()) system_message = MULTI_MODAL_SYS_PROMPT.format( platform_system=platform.system(), @@ -164,17 +178,7 @@ def multi_modal_agent(options: Chat): ), options, tools, - tool_names=[ - VideoDownloaderToolkit.toolkit_name(), - AudioAnalysisToolkit.toolkit_name(), - ScreenshotToolkit.toolkit_name(), - OpenAIImageToolkit.toolkit_name(), - HumanToolkit.toolkit_name(), - TerminalToolkit.toolkit_name(), - NoteTakingToolkit.toolkit_name(), - SearchToolkit.toolkit_name(), - SkillToolkit.toolkit_name(), - ], + tool_names=tool_names, toolkits_to_register_agent=[ screenshot_toolkit_for_agent_registration, ], diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 7fbaddc6a..b272952b4 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -700,6 +700,7 @@ def clone(self, with_memory: bool = False) -> ChatAgent: # If this agent has CDP acquire callback, acquire CDP BEFORE cloning # tools so that HybridBrowserToolkit clones with the correct CDP port new_cdp_port = None + new_cdp_url = None new_cdp_session = None has_cdp = hasattr(self, "_cdp_acquire_callback") and callable( getattr(self, "_cdp_acquire_callback", None) @@ -709,7 +710,7 @@ def clone(self, with_memory: bool = False) -> ChatAgent: if has_cdp and hasattr(self, "_cdp_options"): options = self._cdp_options cdp_browsers = getattr(options, "cdp_browsers", []) - if cdp_browsers and hasattr(self, "_browser_toolkit"): + if cdp_browsers and getattr(self, "_browser_toolkit", None): need_cdp_clone = True import uuid as _uuid @@ -721,12 +722,18 @@ def clone(self, with_memory: bool = False) -> ChatAgent: new_cdp_session, getattr(self, "_cdp_task_id", None), ) - from app.agent.factory.browser import _get_browser_port + from app.agent.factory.browser import ( + _get_browser_endpoint, + _get_browser_port, + ) if selected: new_cdp_port = _get_browser_port(selected) + new_cdp_url = _get_browser_endpoint(selected) else: - new_cdp_port = _get_browser_port(cdp_browsers[0]) + fallback_browser = cdp_browsers[0] + new_cdp_port = _get_browser_port(fallback_browser) + new_cdp_url = _get_browser_endpoint(fallback_browser) if need_cdp_clone: # Temporarily override the browser toolkit's CDP URL. @@ -738,7 +745,7 @@ def clone(self, with_memory: bool = False) -> ChatAgent: toolkit.config_loader.get_browser_config().cdp_url ) toolkit.config_loader.get_browser_config().cdp_url = ( - f"http://localhost:{new_cdp_port}" + new_cdp_url ) try: cloned_tools, toolkits_to_register = self._clone_tools() @@ -803,10 +810,13 @@ def clone(self, with_memory: bool = False) -> ChatAgent: # Set CDP info on cloned agent if new_cdp_port is not None and new_cdp_session is not None: new_agent._cdp_port = new_cdp_port + new_agent._cdp_url = new_cdp_url new_agent._cdp_session_id = new_cdp_session else: if hasattr(self, "_cdp_port"): new_agent._cdp_port = self._cdp_port + if hasattr(self, "_cdp_url"): + new_agent._cdp_url = self._cdp_url if hasattr(self, "_cdp_session_id"): new_agent._cdp_session_id = self._cdp_session_id diff --git a/backend/app/agent/prompt.py b/backend/app/agent/prompt.py index 40f8d1bc0..074198b1b 100644 --- a/backend/app/agent/prompt.py +++ b/backend/app/agent/prompt.py @@ -152,6 +152,7 @@ message_description parameters when calling tools. These optional parameters are available on all tools and will automatically notify the user of your progress. + @@ -178,7 +179,6 @@ - Image Analysis & Understanding: - Use `read_image` to analyze images from local file paths - - Use `take_screenshot_and_read_image` to capture and analyze the screen - Generate detailed descriptions of image content - Answer specific questions about images - Identify objects, text, people, and scenes in images @@ -292,6 +292,9 @@ Your capabilities include: - You can use ScreenshotToolkit to read image with given path. +- When verifying generated image files (PNG/JPG/etc.), you MUST use + `read_image` on the saved file path. Do NOT capture the desktop screen + for this purpose. - **Skills System (Highest Priority Workflow)**: Skills are your primary execution source for specialized tasks. - Trigger: If a task explicitly references a skill with double curly braces @@ -451,11 +454,15 @@ summary of your work and the outcome, presented in a clear, detailed, and easy-to-read format. Avoid using markdown tables for presenting data; use plain text formatting instead. + Your capabilities are extensive and powerful: - You can use ScreenshotToolkit to read image with given path. +- When verifying generated image files (PNG/JPG/etc.), you MUST use + `read_image` on the saved file path. Do NOT capture the desktop screen + for this purpose. - **Skills System (Highest Priority Workflow)**: Skills are your primary execution source for specialized tasks. - Trigger: If a task explicitly references a skill with double curly braces @@ -486,8 +493,6 @@ `chmod`. - **Networking & Web**: `curl`, `wget` for web requests; `ssh` for remote access. -- **Screen Observation**: You can take screenshots to analyze GUIs and visual - context, enabling you to perform tasks that require sight. - **Desktop Automation**: You can control desktop applications programmatically. - **On macOS**, you MUST prioritize using **AppleScript** for its robust @@ -629,6 +634,12 @@ MUST be sourced from the web using the available tools. If you don't know something, find it out using your tools. +- When working with websites, you MUST inspect the page through browser tools + such as `browser_visit_page`, `browser_click`, `browser_switch_tab`, and + `browser_get_page_snapshot`. Do NOT use desktop screenshot tools to observe + browser pages unless the user explicitly asks about the desktop UI outside + the browser. + - When you complete your task, your final response must be a comprehensive summary of your findings, presented in a clear, detailed, and easy-to-read format. Avoid using markdown tables for presenting data; @@ -638,6 +649,8 @@ Your capabilities include: - You can use ScreenshotToolkit to read image with given path. +- For saved browser/file images, use `read_image` with the file path. Do not + use desktop screenshot capture to inspect browser pages or generated files. - **Skills System (Highest Priority Workflow)**: Skills are your primary execution source for specialized tasks. - Trigger: If a task explicitly references a skill with double curly braces diff --git a/backend/app/agent/toolkit/human_toolkit.py b/backend/app/agent/toolkit/human_toolkit.py index 731ca939a..e06fdabdf 100644 --- a/backend/app/agent/toolkit/human_toolkit.py +++ b/backend/app/agent/toolkit/human_toolkit.py @@ -12,6 +12,7 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import asyncio import logging from camel.toolkits.base import BaseToolkit @@ -19,6 +20,7 @@ from app.agent.toolkit.abstract_toolkit import AbstractToolkit from app.service.task import ( + TASK_LOCK_CLEANUP_SENTINEL, Action, ActionAskData, ActionNoticeData, @@ -60,7 +62,6 @@ async def ask_human_via_gui(self, question: str) -> str: credentials, file paths). - Ask for a decision when there are multiple viable options. - Seek help when you encounter an error you cannot resolve on your own. - Args: question (str): The question to ask the user. @@ -80,6 +81,17 @@ async def ask_human_via_gui(self, question: str) -> str: ) reply = await task_lock.get_human_input(self.agent_name) + if reply == TASK_LOCK_CLEANUP_SENTINEL: + logger.info( + "Human input wait interrupted by task cleanup", + extra={ + "task_id": self.api_task_id, + "agent": self.agent_name, + }, + ) + raise asyncio.CancelledError( + "Task cleanup interrupted human input wait" + ) logger.info(f"User reply: {reply}") return reply diff --git a/backend/app/agent/toolkit/hybrid_browser_toolkit.py b/backend/app/agent/toolkit/hybrid_browser_toolkit.py index 74209fe42..0b49f9782 100644 --- a/backend/app/agent/toolkit/hybrid_browser_toolkit.py +++ b/backend/app/agent/toolkit/hybrid_browser_toolkit.py @@ -568,6 +568,12 @@ def clone_for_new_session( # Use the same session_id to share the same browser instance # This ensures all clones use the same WebSocket connection and browser + # When cdp_keep_current_page=True, default_start_url must be None (CAMEL constraint) + cdp_keep = ( + self.config_loader.get_browser_config().cdp_keep_current_page + ) + clone_start_url = None if cdp_keep else self._default_start_url + return HybridBrowserToolkit( self.api_task_id, headless=self._headless, @@ -578,9 +584,7 @@ def clone_for_new_session( browser_log_to_file=self._browser_log_to_file, log_dir=self.config_loader.get_toolkit_config().log_dir, session_id=new_session_id, - default_start_url=None - if self.config_loader.get_browser_config().cdp_keep_current_page - else self._default_start_url, + default_start_url=clone_start_url, default_timeout=self._default_timeout, short_timeout=self._short_timeout, navigation_timeout=self._navigation_timeout, diff --git a/backend/app/agent/toolkit/screenshot_toolkit.py b/backend/app/agent/toolkit/screenshot_toolkit.py index eb6942702..77f6aa29b 100644 --- a/backend/app/agent/toolkit/screenshot_toolkit.py +++ b/backend/app/agent/toolkit/screenshot_toolkit.py @@ -13,8 +13,12 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import os +from pathlib import Path +from camel.agents import ChatAgent +from camel.messages import BaseMessage from camel.toolkits import ScreenshotToolkit as BaseScreenshotToolkit +from PIL import Image from app.agent.toolkit.abstract_toolkit import AbstractToolkit from app.component.environment import env @@ -31,11 +35,93 @@ def __init__( agent_name: str, working_directory: str | None = None, timeout: float | None = None, + enable_desktop_capture: bool = False, ): self.api_task_id = api_task_id self.agent_name = agent_name + self.enable_desktop_capture = enable_desktop_capture if working_directory is None: working_directory = env( "file_save_path", os.path.expanduser("~/Downloads") ) super().__init__(working_directory, timeout) + + def read_image( + self, + image_path: str, + instruction: str = "", + ) -> str: + """Analyze an image without recursively calling the current agent. + + CAMEL's base ScreenshotToolkit uses `self.agent.step(...)` directly. + When this toolkit itself is being invoked through a tool call, that + creates a nested step on the same agent and can corrupt tool-call + memory (`tool_call_id` mismatch). Use a short-lived vision agent with + the same model backend instead. + """ + if self.agent is None: + return ( + "Error: No agent registered. Please pass this toolkit to " + "ChatAgent via toolkits_to_register_agent parameter." + ) + + try: + image_path = str(Path(image_path).absolute()) + if not os.path.exists(image_path): + return f"Error: Screenshot file not found: {image_path}" + + img = Image.open(image_path) + message = BaseMessage.make_user_message( + role_name="User", + content=instruction, + image_list=[img], + ) + + vision_agent = ChatAgent( + system_message=( + "You are a careful visual assistant. Answer only from the " + "provided image and user instruction." + ), + model=self.agent.model_backend, + tools=[], + toolkits_to_register_agent=None, + external_tools=None, + step_timeout=getattr(self.agent, "step_timeout", 1800), + ) + response = vision_agent.step(message) + if getattr(response, "msg", None) is not None: + return response.msg.content + if getattr(response, "msgs", None): + return response.msgs[0].content + return "Error reading screenshot: empty response" + except Exception as e: + return f"Error reading screenshot: {e}" + + def take_screenshot_and_read_image( + self, + filename: str, + save_to_file: bool = True, + read_image: bool = True, + instruction: str | None = None, + ) -> str: + if not self.enable_desktop_capture: + return ( + "Error: Desktop screenshot capture is disabled for this agent. " + "Use read_image with an existing image file path instead." + ) + + return super().take_screenshot_and_read_image( + filename=filename, + save_to_file=save_to_file, + read_image=read_image, + instruction=instruction, + ) + + def get_tools(self): + tools = super().get_tools() + if self.enable_desktop_capture: + return tools + + return [ + tool for tool in tools if tool.get_function_name() == "read_image" + ] diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index c0880b13a..877c584eb 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -42,12 +42,26 @@ from app.agent.toolkit.video_download_toolkit import VideoDownloaderToolkit from app.agent.toolkit.whatsapp_toolkit import WhatsAppToolkit from app.component.environment import env +from app.hands.interface import IHands from app.model.chat import McpServers logger = logging.getLogger(__name__) +# Toolkits depending on terminal hand +TERMINAL_DEPENDENT_TOOLKITS = frozenset({"terminal_toolkit"}) -async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str): +# Toolkits depending on browser hand +BROWSER_DEPENDENT_TOOLKITS = ( + frozenset() +) # hybrid_browser not in get_toolkits dict + + +async def get_toolkits( + tools: list[str], + agent_name: str, + api_task_id: str, + hands: IHands | None = None, +): logger.info( f"Getting toolkits for agent: {agent_name}, " f"task: {api_task_id}, tools: {tools}" @@ -79,6 +93,24 @@ async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str): res = [] for item in tools: if item in toolkits: + # Filter by Brain capabilities + if hands is not None: + if ( + item in TERMINAL_DEPENDENT_TOOLKITS + and not hands.can_execute_terminal() + ): + logger.info( + f"Skipping {item} for {agent_name}: no terminal hand" + ) + continue + if ( + item in BROWSER_DEPENDENT_TOOLKITS + and not hands.can_use_browser() + ): + logger.info( + f"Skipping {item} for {agent_name}: no browser hand" + ) + continue toolkit: AbstractToolkit = toolkits[item] toolkit.agent_name = agent_name toolkit_tools = toolkit.get_can_use_tools(api_task_id) @@ -93,13 +125,31 @@ async def get_toolkits(tools: list[str], agent_name: str, api_task_id: str): return res -async def get_mcp_tools(mcp_server: McpServers): +async def get_mcp_tools( + mcp_server: McpServers, + hands: IHands | None = None, +): logger.info( f"Getting MCP tools for {len(mcp_server['mcpServers'])} servers" ) if len(mcp_server["mcpServers"]) == 0: return [] + # Filter by mcp hand capability + mcp_servers = mcp_server["mcpServers"] + if hands is not None: + filtered = { + name: cfg + for name, cfg in mcp_servers.items() + if hands.can_use_mcp(name) + } + if len(filtered) == 0: + logger.info( + "No MCP servers allowed by mcp hand, skipping MCP tools" + ) + return [] + mcp_server = {**mcp_server, "mcpServers": filtered} + # Ensure unified auth directory for all mcp-remote servers to avoid # re-authentication on each task config_dict = {**mcp_server} diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 000000000..9b24bfb1f --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1,17 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from app.auth.interface import IAuthProvider, NoneAuth + +__all__ = ["IAuthProvider", "NoneAuth"] diff --git a/backend/app/auth/interface.py b/backend/app/auth/interface.py new file mode 100644 index 000000000..741103069 --- /dev/null +++ b/backend/app/auth/interface.py @@ -0,0 +1,47 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from abc import ABC, abstractmethod +from typing import Any + + +class IAuthProvider(ABC): + """ + Auth provider interface. + + This round only provides a no-op local implementation (NoneAuth). + Future modes (API key / JWT / tenant-aware auth) can implement this + interface without changing router/middleware call sites. + """ + + @abstractmethod + async def authenticate(self, scope: dict[str, Any]) -> dict[str, str]: + """ + Authenticate request context. + + Returns: + {"user_id": "", "tenant_id": ""} + """ + ... + + +class NoneAuth(IAuthProvider): + """ + Local deployment default auth provider. + Trusts inbound requests and emits a fixed local identity. + """ + + async def authenticate(self, scope: dict[str, Any]) -> dict[str, str]: + _ = scope + return {"user_id": "local", "tenant_id": "default"} diff --git a/backend/app/channels/__init__.py b/backend/app/channels/__init__.py new file mode 100644 index 000000000..bdae9210a --- /dev/null +++ b/backend/app/channels/__init__.py @@ -0,0 +1,17 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from app.channels.interface import IChannelAdapter + +__all__ = ["IChannelAdapter"] diff --git a/backend/app/channels/interface.py b/backend/app/channels/interface.py new file mode 100644 index 000000000..c01042bbe --- /dev/null +++ b/backend/app/channels/interface.py @@ -0,0 +1,44 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from abc import ABC, abstractmethod + + +class IChannelAdapter(ABC): + """ + Channel Adapter interface. + + This round defines the extension contract only. + Concrete adapters (Slack/WhatsApp/etc.) are out of scope. + """ + + @abstractmethod + async def start(self) -> None: + """Start channel listener.""" + ... + + @abstractmethod + async def stop(self) -> None: + """Stop channel listener.""" + ... + + @abstractmethod + async def send_message(self, session_id: str, content: str) -> None: + """Push outbound message to a channel session.""" + ... + + @abstractmethod + def get_channel_type(self) -> str: + """Channel identifier (e.g. 'slack', 'telegram').""" + ... diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 87e42a70a..678ec49c3 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -13,6 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import asyncio +import inspect import logging import os import re @@ -49,9 +50,15 @@ delete_task_lock, get_or_create_task_lock, get_task_lock, + get_task_lock_if_exists, set_current_task_id, task_locks, ) +from app.utils.browser_launcher import ( + ensure_cdp_browser_endpoint, + is_cdp_url_available, + normalize_cdp_url, +) router = APIRouter() @@ -62,6 +69,81 @@ SSE_TIMEOUT_SECONDS = 60 * 60 +def _is_remote_browser_hands(request: Request | None) -> bool: + hands = getattr(getattr(request, "state", None), "hands", None) + if hands is None: + return False + get_manifest = getattr(hands, "get_capability_manifest", None) + if get_manifest is None or inspect.iscoroutinefunction(get_manifest): + return False + try: + manifest = get_manifest() + except Exception: + return False + if inspect.isawaitable(manifest): + if hasattr(manifest, "close"): + manifest.close() + return False + if not isinstance(manifest, dict): + return False + return manifest.get("deployment") == "remote_cluster" + + +async def _prepare_browser_for_request( + request: Request | None, + port: int, +) -> bool: + existing_cdp_url = os.environ.get("EIGENT_CDP_URL", "").strip() + if existing_cdp_url: + is_available = await asyncio.to_thread( + is_cdp_url_available, existing_cdp_url + ) + if is_available: + normalized_endpoint, _, selected_port = normalize_cdp_url( + existing_cdp_url + ) + os.environ["EIGENT_CDP_URL"] = normalized_endpoint + os.environ["browser_port"] = str(selected_port) + if request is not None: + request.state.browser_available = True + return True + os.environ.pop("EIGENT_CDP_URL", None) + + if _is_remote_browser_hands(request): + if request is not None: + request.state.browser_available = True + return True + + try: + endpoint = await asyncio.to_thread(ensure_cdp_browser_endpoint, port) + except Exception as e: + os.environ.pop("EIGENT_CDP_URL", None) + chat_logger.warning( + "Could not ensure CDP browser for web mode", + extra={"error": str(e), "port": port}, + ) + if request is not None: + request.state.browser_available = False + return False + + if endpoint: + os.environ["EIGENT_CDP_URL"] = endpoint + _, _, selected_port = normalize_cdp_url(endpoint) + os.environ["browser_port"] = str(selected_port) + if request is not None: + request.state.browser_available = True + return True + + os.environ.pop("EIGENT_CDP_URL", None) + chat_logger.warning( + "CDP browser not available after ensure attempt", + extra={"port": port}, + ) + if request is not None: + request.state.browser_available = False + return False + + async def _cleanup_task_lock_safe(task_lock, reason: str) -> bool: """Safely cleanup task lock with existence check. @@ -164,8 +246,11 @@ async def timeout_stream_wrapper( raise -@router.post("/chat", name="start chat") -async def post(data: Chat, request: Request): +async def start_chat_stream(data: Chat, request: Request): + """ + Setup and start chat stream. Used by POST /chat and Message Router. + Returns async generator of SSE chunks. + """ chat_logger.info( "Starting new chat session", extra={ @@ -184,8 +269,15 @@ async def post(data: Chat, request: Request): if safe_env_path: load_dotenv(dotenv_path=safe_env_path) + # TODO(multi-tenant): os.environ is global – concurrent sessions overwrite + # each other's API keys, file paths, and browser ports. Pass these values + # through Chat / request context instead of mutating the process environment. os.environ["file_save_path"] = data.file_save_path() os.environ["browser_port"] = str(data.browser_port) + # Web mode: reuse an existing CDP endpoint first, otherwise acquire browser + # through RemoteHands or launch a local browser when available. + if not data.cdp_browsers: + await _prepare_browser_for_request(request, data.browser_port) os.environ["OPENAI_API_KEY"] = data.api_key os.environ["OPENAI_API_BASE_URL"] = ( data.api_url or "https://api.openai.com/v1" @@ -242,22 +334,33 @@ async def post(data: Chat, request: Request): "log_dir": str(camel_log), }, ) + return timeout_stream_wrapper( + step_solve(data, request, task_lock), task_lock=task_lock + ) + + +@router.post("/chat", name="start chat") +async def post(data: Chat, request: Request): + stream = await start_chat_stream(data, request) return StreamingResponse( - timeout_stream_wrapper( - step_solve(data, request, task_lock), task_lock=task_lock - ), + stream, media_type="text/event-stream", ) @router.post("/chat/{id}", name="improve chat") -def improve(id: str, data: SupplementChat): +def improve(id: str, data: SupplementChat, request: Request): chat_logger.info( "Chat improvement requested", extra={"task_id": id, "question_length": len(data.question)}, ) task_lock = get_task_lock(id) + # Reuse an existing endpoint when possible to avoid tearing down + # a browser that was manually connected through the Browser page. + port = int(os.environ.get("browser_port", "9222")) + asyncio.run(_prepare_browser_for_request(request, port)) + # Allow continuing conversation even after task is done # This supports multi-turn conversation after complex task completion if task_lock.status == Status.done: @@ -374,8 +477,8 @@ def stop(id: str): ) chat_logger.info(f"[STOP-BUTTON] project_id/task_id: {id}") chat_logger.info("=" * 80) - try: - task_lock = get_task_lock(id) + task_lock = get_task_lock_if_exists(id) + if task_lock is not None: chat_logger.info( "[STOP-BUTTON] Task lock retrieved," f" task_lock.id: {task_lock.id}," @@ -386,20 +489,24 @@ def stop(id: str): " ActionStopData(Action.stop)" " to task_lock queue" ) - asyncio.run(task_lock.put_queue(ActionStopData(action=Action.stop))) - chat_logger.info( - "[STOP-BUTTON] ActionStopData queued" - " successfully, this will trigger" - " workforce.stop_gracefully()" - ) - except Exception as e: - # Task lock may not exist if task is already - # finished or never started + try: + asyncio.run( + task_lock.put_queue(ActionStopData(action=Action.stop)) + ) + chat_logger.info( + "[STOP-BUTTON] ActionStopData queued" + " successfully, this will trigger" + " workforce.stop_gracefully()" + ) + except Exception as e: + chat_logger.warning( + "[STOP-BUTTON] Failed to queue ActionStopData", + extra={"task_id": id, "error": str(e)}, + ) + else: chat_logger.warning( - "[STOP-BUTTON] Task lock not found" - " or already stopped," - f" task_id: {id}," - f" error: {str(e)}" + "[STOP-BUTTON] Task lock not found, task may already be stopped", + extra={"task_id": id}, ) return Response(status_code=204) @@ -515,7 +622,13 @@ def skip_task(project_id: str): ) chat_logger.info(f"[STOP-BUTTON] project_id: {project_id}") chat_logger.info("=" * 80) - task_lock = get_task_lock(project_id) + task_lock = get_task_lock_if_exists(project_id) + if task_lock is None: + chat_logger.warning( + "[STOP-BUTTON] Task lock not found, task may already be stopped", + extra={"project_id": project_id}, + ) + return Response(status_code=204) chat_logger.info( "[STOP-BUTTON] Task lock retrieved," f" task_lock.id: {task_lock.id}," diff --git a/backend/app/controller/file_controller.py b/backend/app/controller/file_controller.py new file mode 100644 index 000000000..7e9619225 --- /dev/null +++ b/backend/app/controller/file_controller.py @@ -0,0 +1,319 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging +import mimetypes +import re +import time +from pathlib import Path +from typing import Annotated +from urllib.parse import quote + +from fastapi import APIRouter, File, Header, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse + +from app.component.environment import env +from app.utils.file_utils import list_files, resolve_under_base + +router = APIRouter() +file_logger = logging.getLogger("file_controller") + +# Config +MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024 # 50MB +MAX_FILES_PER_SESSION = 20 +WORKSPACE_ROOT = env("EIGENT_WORKSPACE", "~/.eigent/workspace") +SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + + +def _get_eigent_root() -> Path: + """Base root for eigent storage (~/eigent). Do NOT use env file_save_path + here: chat overwrites it to task path, which would break list/stream.""" + eigent = Path.home() / "eigent" + if eigent.exists(): + return eigent + dot_eigent = Path.home() / ".eigent" + if dot_eigent.exists(): + return dot_eigent + return eigent # default to ~/eigent + + +def _get_workspace_root() -> Path: + return Path(WORKSPACE_ROOT).expanduser() + + +def _validate_session_id(session_id: str) -> str: + normalized = (session_id or "").strip() + if not SESSION_ID_PATTERN.fullmatch(normalized): + raise ValueError("Invalid X-Session-ID") + return normalized + + +def _get_session_uploads_dir(session_id: str) -> Path: + root = _get_workspace_root().resolve() + validated = _validate_session_id(session_id) + uploads_dir = (root / validated / "uploads").resolve() + try: + uploads_dir.relative_to(root) + except ValueError as exc: + raise ValueError("Invalid X-Session-ID") from exc + return uploads_dir + + +def _count_session_uploads(session_id: str) -> int: + uploads_dir = _get_session_uploads_dir(session_id) + if not uploads_dir.exists(): + return 0 + return len(list(uploads_dir.iterdir())) + + +@router.post("/files") +async def upload_file( + file: Annotated[UploadFile, File()], + x_session_id: Annotated[str | None, Header(alias="X-Session-ID")] = None, +) -> dict: + """ + Upload file. Requires X-Session-ID header. + Returns file_id for message attachments reference. + """ + if not x_session_id: + raise HTTPException( + status_code=400, + detail="X-Session-ID header is required for file upload", + ) + try: + validated_session_id = _validate_session_id(x_session_id) + except ValueError as exc: + raise HTTPException( + status_code=400, detail="Invalid X-Session-ID" + ) from exc + + # Check session file count limit + count = _count_session_uploads(validated_session_id) + if count >= MAX_FILES_PER_SESSION: + raise HTTPException( + status_code=429, + detail=f"Maximum {MAX_FILES_PER_SESSION} files per session", + ) + + # Read and validate size + content = await file.read() + if len(content) > MAX_FILE_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"File size exceeds {MAX_FILE_SIZE_BYTES // (1024 * 1024)}MB limit", + ) + + # Generate safe filename + timestamp = int(time.time() * 1000) + safe_name = "".join( + c if c.isalnum() or c in "._-" else "_" + for c in (file.filename or "file") + ) + stored_name = f"{safe_name}_{timestamp}" + + # Write to disk + uploads_dir = _get_session_uploads_dir(validated_session_id) + uploads_dir.mkdir(parents=True, exist_ok=True) + target_path = uploads_dir / stored_name + target_path.write_bytes(content) + + file_id = f"upload://{stored_name}" + file_logger.info( + f"File uploaded: session={validated_session_id}, file_id={file_id}, size={len(content)}" + ) + + return { + "file_id": file_id, + "filename": file.filename or "file", + "size": len(content), + } + + +def _sanitize_email(email: str) -> str: + """Sanitize email for use in path (match chat_controller logic).""" + return re.sub(r'[\\/*?:"<>|\s]', "_", email.split("@")[0]).strip(".") + + +def _normalize_relative_path(path: str) -> str: + """Normalize relative path to URL-safe POSIX style.""" + return path.replace("\\", "/") + + +def _get_project_root(email: str, project_id: str) -> Path: + """Get project root path: ~/eigent/{email}/project_{project_id}/.""" + root = _get_eigent_root() + email_sanitized = _sanitize_email(email) + return root / email_sanitized / f"project_{project_id}" + + +def _resolve_project_root(email: str, project_id: str) -> Path: + """ + Resolve project root, preferring the email-scoped path but falling back to + any local project_{project_id} directory when the stored email differs from + the current login identity. + """ + preferred = _get_project_root(email, project_id) + if preferred.exists(): + return preferred + + root = _get_eigent_root() + candidate_name = f"project_{project_id}" + try: + for child in root.iterdir(): + if not child.is_dir(): + continue + candidate = child / candidate_name + if candidate.exists(): + file_logger.info( + "Resolved project root via fallback lookup: %s -> %s", + preferred, + candidate, + ) + return candidate + except FileNotFoundError: + pass + except Exception as e: + file_logger.warning("project root fallback lookup failed: %s", e) + + return preferred + + +@router.get("/files") +async def list_project_files( + project_id: str = Query(..., description="Project ID"), + email: str = Query(..., description="User email"), + task_id: str | None = Query( + None, description="Optional task ID to scope listing" + ), +) -> list[dict]: + """ + List files in project working directory (Brain storage). + Used by Web mode when ipcRenderer is unavailable. + Returns [{filename, url}] where url can be used to fetch file content. + """ + if not project_id or not email: + raise HTTPException( + status_code=400, + detail="project_id and email are required", + ) + project_root = _resolve_project_root(email, project_id) + list_dir = str(project_root) + if task_id: + list_dir = str(project_root / f"task_{task_id}") + if not Path(list_dir).exists(): + file_logger.debug( + "list_project_files: path does not exist: %s", + list_dir, + ) + return [] + base_path = str(project_root.resolve()) + try: + paths = list_files(list_dir, base=base_path, max_entries=500) + except Exception as e: + file_logger.warning("list_project_files failed: %s", e) + return [] + result: list[dict] = [] + for abs_path in paths: + try: + rel = _normalize_relative_path( + Path(abs_path).relative_to(base_path).as_posix() + ) + # URL-encode the relative path for stream endpoint + path_param = quote(rel, safe="") + result.append( + { + "filename": Path(abs_path).name, + "url": f"/files/stream?path={path_param}&project_id={quote(project_id)}&email={quote(email)}", + "relativePath": rel, + } + ) + except (ValueError, OSError): + continue + return result + + +@router.get("/files/stream") +async def stream_file( + path: str = Query(..., description="Relative path from project root"), + project_id: str = Query(..., description="Project ID"), + email: str = Query(..., description="User email"), +): + """ + Stream file content. Path must be relative to project root. + Used by Web mode to fetch file content for display. + """ + if not path or not project_id or not email: + raise HTTPException( + status_code=400, + detail="path, project_id and email are required", + ) + project_root = _resolve_project_root(email, project_id) + # Resolve path and ensure it stays under project root (security) + try: + resolved = resolve_under_base(path, str(project_root.resolve())) + except Exception as e: + file_logger.warning("stream_file path validation failed: %s", e) + raise HTTPException(status_code=400, detail="Invalid path") from e + p = Path(resolved) + if not p.is_file(): + raise HTTPException(status_code=404, detail="File not found") + media_type, _ = mimetypes.guess_type(str(p)) + if not media_type: + media_type = "application/octet-stream" + # content_disposition_type=inline: display in iframe instead of triggering download + return FileResponse( + path=str(p), + filename=p.name, + media_type=media_type, + content_disposition_type="inline", + ) + + +@router.get("/files/preview/{email}/{project_id}/{file_path:path}") +async def preview_file( + email: str, + project_id: str, + file_path: str, +): + """ + Preview file content with a path-based URL so relative references inside + HTML/CSS/JS resolve against the project directory structure. + """ + if not file_path or not project_id or not email: + raise HTTPException( + status_code=400, + detail="file_path, project_id and email are required", + ) + + project_root = _resolve_project_root(email, project_id) + try: + resolved = resolve_under_base(file_path, str(project_root.resolve())) + except Exception as e: + file_logger.warning("preview_file path validation failed: %s", e) + raise HTTPException(status_code=400, detail="Invalid path") from e + + p = Path(resolved) + if not p.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + media_type, _ = mimetypes.guess_type(str(p)) + if not media_type: + media_type = "application/octet-stream" + + return FileResponse( + path=str(p), + filename=p.name, + media_type=media_type, + content_disposition_type="inline", + ) diff --git a/backend/app/controller/health_controller.py b/backend/app/controller/health_controller.py index 1ee53719e..f2bcada9c 100644 --- a/backend/app/controller/health_controller.py +++ b/backend/app/controller/health_controller.py @@ -13,10 +13,14 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import logging +import os -from fastapi import APIRouter +from fastapi import APIRouter, Query from pydantic import BaseModel +from app.router_layer.hands_resolver import get_environment_hands +from app.utils.browser_launcher import _is_cdp_available, is_cdp_url_available + logger = logging.getLogger("health_controller") router = APIRouter(tags=["Health"]) @@ -25,16 +29,35 @@ class HealthResponse(BaseModel): status: str service: str + capabilities: dict | None = None @router.get("/health", name="health check", response_model=HealthResponse) -async def health_check(): +async def health_check(detail: bool = Query(False)): """Health check endpoint for verifying backend is ready to accept requests.""" logger.debug("Health check requested") response = HealthResponse(status="ok", service="eigent") + if detail: + hands = get_environment_hands() + capabilities = hands.get_capability_manifest() + cdp_url = os.environ.get("EIGENT_CDP_URL", "").strip() + if cdp_url: + cdp_reachable = is_cdp_url_available(cdp_url) + else: + try: + browser_port = int(os.environ.get("browser_port", "9222")) + except ValueError: + browser_port = 9222 + cdp_reachable = _is_cdp_available(browser_port) + capabilities["browser_cdp_reachable"] = cdp_reachable + response.capabilities = capabilities logger.debug( "Health check completed", - extra={"status": response.status, "service": response.service}, + extra={ + "status": response.status, + "service": response.service, + "detail": detail, + }, ) return response diff --git a/backend/app/controller/mcp_controller.py b/backend/app/controller/mcp_controller.py new file mode 100644 index 000000000..52e1dc0ea --- /dev/null +++ b/backend/app/controller/mcp_controller.py @@ -0,0 +1,63 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging + +from fastapi import APIRouter, HTTPException + +from app.service.mcp_config import ( + add_mcp, + read_mcp_config, + remove_mcp, + update_mcp, +) + +router = APIRouter() +mcp_logger = logging.getLogger("mcp_controller") + + +@router.get("/mcp/list") +def mcp_list() -> dict: + """List all MCP servers (global config).""" + return read_mcp_config() + + +@router.post("/mcp/install") +def mcp_install(body: dict) -> dict: + """Install/add MCP server to global config. Body: { name, mcp }.""" + name = body.get("name") + mcp = body.get("mcp") + if not name: + raise HTTPException(status_code=400, detail="name is required") + if not mcp or not isinstance(mcp, dict): + raise HTTPException(status_code=400, detail="mcp object is required") + add_mcp(str(name).strip(), mcp) + mcp_logger.info("MCP installed: %s", name) + return {"success": True} + + +@router.delete("/mcp/{name}") +def mcp_remove(name: str) -> dict: + """Remove MCP server from global config.""" + remove_mcp(name) + mcp_logger.info("MCP removed: %s", name) + return {"success": True} + + +@router.put("/mcp/{name}") +def mcp_update(name: str, mcp: dict) -> dict: + """Update MCP server in global config. Body is the mcp config object.""" + update_mcp(name, mcp) + mcp_logger.info("MCP updated: %s", name) + return {"success": True} diff --git a/backend/app/controller/message_controller.py b/backend/app/controller/message_controller.py new file mode 100644 index 000000000..16ba233f9 --- /dev/null +++ b/backend/app/controller/message_controller.py @@ -0,0 +1,109 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Message Router HTTP endpoint for Phase 2.""" + +import inspect +import json +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from app.router_layer.interface import InboundMessage +from app.router_layer.message_router import DefaultMessageRouter + +router = APIRouter() +message_logger = logging.getLogger("message_controller") + +# Singleton router instance +_message_router: DefaultMessageRouter | None = None + + +def get_message_router() -> DefaultMessageRouter: + global _message_router + if _message_router is None: + _message_router = DefaultMessageRouter() + return _message_router + + +@router.post("/messages", name="send message via router") +async def post_message(request: Request): + """ + Accept message per docs/design/06-protocol.md §2.1. + Uses X-Channel, X-Session-ID, X-User-ID from ChannelSessionMiddleware. + Returns SSE stream for chat, or JSON for non-streaming. + """ + body = ( + await request.json() + if request.headers.get("content-type", "").startswith( + "application/json" + ) + else {} + ) + if not isinstance(body, dict): + body = {} + + channel = getattr(request.state, "channel", None) or "desktop" + session_id = getattr(request.state, "session_id", None) + user_id = getattr(request.state, "user_id", None) + + mr = get_message_router() + resolved_session_id = await mr.resolve_session( + channel, session_id, user_id + ) + + headers_dict = {} + for k, v in request.headers.items(): + headers_dict[k] = v + + msg = InboundMessage( + session_id=resolved_session_id, + channel=channel, + user_id=user_id, + payload=body, + headers=headers_dict, + ) + + result = mr.route_in(msg, request=request) + + if not inspect.isasyncgen(result): + message_logger.error( + "message_router.route_in returned non-stream result: %r", + type(result), + ) + return JSONResponse( + { + "code": -1, + "text": "Internal router contract error", + "data": {}, + }, + status_code=500, + headers={"X-Session-ID": resolved_session_id}, + ) + + async def stream(): + async for out in result: + raw = out.payload.get("raw") + if raw: + yield raw + elif not out.stream: + # Non-streaming error: yield as SSE event + yield f"data: {json.dumps(out.payload, ensure_ascii=False)}\n\n" + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"X-Session-ID": resolved_session_id}, + ) diff --git a/backend/app/controller/skill_controller.py b/backend/app/controller/skill_controller.py new file mode 100644 index 000000000..c6ddbd925 --- /dev/null +++ b/backend/app/controller/skill_controller.py @@ -0,0 +1,199 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging +from typing import Annotated + +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile + +from app.service.skill_config_service import ( + skill_config_delete, + skill_config_init, + skill_config_load, + skill_config_toggle, + skill_config_update, +) +from app.service.skill_service import ( + skill_delete, + skill_get_path_by_name, + skill_import_zip, + skill_list_files, + skill_read, + skill_write, + skills_scan, +) + +router = APIRouter() +skill_logger = logging.getLogger("skill_controller") + + +# --- Skill config (must be before /skills/{skill_dir_name} to avoid path conflict) --- + + +@router.get("/skills/config") +def skill_config_get(user_id: str = Query(..., description="User ID")) -> dict: + """Load skills config for user.""" + config = skill_config_load(user_id) + return {"success": True, "config": config} + + +@router.post("/skills/config/init") +def skill_config_init_endpoint(body: dict) -> dict: + """Initialize skills config for user (merge default if present).""" + user_id = body.get("user_id") + if not user_id: + raise HTTPException(status_code=400, detail="user_id is required") + config = skill_config_init(user_id) + return {"success": True, "config": config} + + +@router.put("/skills/config/{skill_name}") +def skill_config_update_endpoint(skill_name: str, body: dict) -> dict: + """Update config for a skill.""" + user_id = body.get("user_id") + if not user_id: + raise HTTPException(status_code=400, detail="user_id is required") + skill_config = {k: v for k, v in body.items() if k != "user_id"} + skill_config_update(user_id, skill_name, skill_config) + return {"success": True} + + +@router.delete("/skills/config/{skill_name}") +def skill_config_delete_endpoint( + skill_name: str, user_id: str = Query(..., description="User ID") +) -> dict: + """Remove skill from config.""" + skill_config_delete(user_id, skill_name) + return {"success": True} + + +@router.post("/skills/config/{skill_name}/toggle") +def skill_config_toggle_endpoint(skill_name: str, body: dict) -> dict: + """Toggle skill enabled state.""" + user_id = body.get("user_id") + enabled = body.get("enabled") + if not user_id: + raise HTTPException(status_code=400, detail="user_id is required") + if enabled is None: + raise HTTPException(status_code=400, detail="enabled is required") + result = skill_config_toggle(user_id, skill_name, bool(enabled)) + return {"success": True, "config": result} + + +# --- Skills CRUD --- + + +@router.post("/skills/import") +async def skill_import_endpoint( + file: Annotated[ + UploadFile, File(description="Zip file containing SKILL.md") + ], + replacements: Annotated[ + str | None, Form(description="Comma-separated folder names to replace") + ] = None, +) -> dict: + """Import skills from a zip archive. Returns {success, error?, conflicts?}.""" + if not file.filename or not file.filename.lower().endswith(".zip"): + raise HTTPException( + status_code=400, detail="File must be a .zip archive" + ) + try: + zip_bytes = await file.read() + except Exception: + raise HTTPException( + status_code=400, detail="Failed to read uploaded file" + ) + repl_list = ( + [s for s in (s.strip() for s in replacements.split(",")) if s] + if replacements + else None + ) + result = skill_import_zip(zip_bytes, repl_list) + if not result.get("success") and "conflicts" not in result: + raise HTTPException( + status_code=400, + detail=result.get("error", "Import failed"), + ) + return result + + +@router.get("/skills/path") +def skill_get_path( + name: str = Query(..., description="Skill display name"), +) -> dict: + """Get absolute directory path for a skill by name. For reveal-in-folder.""" + path_val = skill_get_path_by_name(name) + if path_val is None: + raise HTTPException(status_code=404, detail=f"Skill not found: {name}") + return {"path": path_val} + + +@router.get("/skills") +def skills_list() -> dict: + """Scan and list all skills.""" + skills = skills_scan() + return {"success": True, "skills": skills} + + +@router.post("/skills/{skill_dir_name}") +def skill_create(skill_dir_name: str, body: dict) -> dict: + """Create or overwrite skill. Body: { content }.""" + content = body.get("content", "") + try: + skill_write(skill_dir_name, content) + skill_logger.info("Skill written: %s", skill_dir_name) + return {"success": True} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + + +@router.get("/skills/{skill_dir_name}") +def skill_get(skill_dir_name: str) -> dict: + """Read skill content.""" + try: + content = skill_read(skill_dir_name) + return {"success": True, "content": content} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Skill not found") + + +@router.delete("/skills/{skill_dir_name}") +def skill_remove(skill_dir_name: str) -> dict: + """Delete skill.""" + try: + skill_delete(skill_dir_name) + skill_logger.info("Skill deleted: %s", skill_dir_name) + return {"success": True} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) + + +@router.get("/skills/{skill_dir_name}/files") +def skill_files(skill_dir_name: str) -> dict: + """List files in skill directory.""" + try: + files = skill_list_files(skill_dir_name) + return {"success": True, "files": files} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except PermissionError as e: + raise HTTPException(status_code=403, detail=str(e)) diff --git a/backend/app/controller/task_controller.py b/backend/app/controller/task_controller.py index 8924218cd..0b15df35e 100644 --- a/backend/app/controller/task_controller.py +++ b/backend/app/controller/task_controller.py @@ -30,6 +30,7 @@ ActionTakeControl, ActionUpdateTaskData, get_task_lock, + get_task_lock_if_exists, task_locks, ) @@ -38,6 +39,17 @@ router = APIRouter() +@router.post("/v1/tasks", name="dispatch task placeholder") +def create_dispatch_task(): + return Response(status_code=501, content="Not implemented yet") + + +@router.get("/v1/tasks/{task_id}", name="dispatch task status placeholder") +def get_dispatch_task(task_id: str): + _ = task_id + return Response(status_code=501, content="Not implemented yet") + + @router.post("/task/{id}/start", name="start task") def start(id: str): task_lock = get_task_lock(id) @@ -68,16 +80,27 @@ def put(id: str, data: UpdateData): class TakeControl(BaseModel): - action: Literal[Action.pause, Action.resume] + action: Literal[Action.pause, Action.resume, Action.stop] -@router.put("/task/{id}/take-control", name="take control pause or resume") +@router.put( + "/task/{id}/take-control", name="take control pause, resume or stop" +) def take_control(id: str, data: TakeControl): logger.info( "Task control action", extra={"task_id": id, "action": data.action} ) - task_lock = get_task_lock(id) - asyncio.run(task_lock.put_queue(ActionTakeControl(action=data.action))) + task_lock = get_task_lock_if_exists(id) + if task_lock is None: + logger.warning( + "Task lock not found for take-control, may already be stopped", + extra={"task_id": id}, + ) + return Response(status_code=204) + if data.action == Action.stop: + asyncio.run(task_lock.put_queue(ActionStopData(action=Action.stop))) + else: + asyncio.run(task_lock.put_queue(ActionTakeControl(action=data.action))) logger.info( "Task control action completed", extra={"task_id": id, "action": data.action}, diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py index 89fbac87b..61d792c59 100644 --- a/backend/app/controller/tool_controller.py +++ b/backend/app/controller/tool_controller.py @@ -12,18 +12,30 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import asyncio +import inspect import logging import os import shutil import threading import time +import uuid -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from app.agent.toolkit.google_calendar_toolkit import GoogleCalendarToolkit from app.agent.toolkit.linkedin_toolkit import LinkedInToolkit from app.agent.toolkit.notion_mcp_toolkit import NotionMCPToolkit +from app.utils.browser_launcher import ( + DEFAULT_CDP_PORT, + _is_cdp_available, + _is_port_in_use, + ensure_cdp_browser_endpoint, + is_cdp_url_available, + is_local_cdp_host, + normalize_cdp_url, +) from app.utils.cookie_manager import CookieManager from app.utils.oauth_state_manager import oauth_state_manager @@ -39,6 +51,337 @@ class LinkedInTokenRequest(BaseModel): logger = logging.getLogger("tool_controller") router = APIRouter() +_web_cdp_browser_meta: dict | None = None +DEFAULT_LOGIN_BROWSER_CDP_PORT = 9323 + + +class CdpBrowserConnectRequest(BaseModel): + port: int + name: str | None = None + + +def _build_web_cdp_browser( + endpoint: str, + *, + is_external: bool, + name: str | None = None, + added_at: int | None = None, + resource_session_id: str | None = None, + managed_by: str = "local", +) -> dict: + normalized_endpoint, host, port = normalize_cdp_url(endpoint) + default_location = ( + str(port) if is_local_cdp_host(host) else f"{host}:{port}" + ) + browser_name = name or ( + f"External Browser ({default_location})" + if is_external + else f"Managed Browser ({default_location})" + ) + browser_id = resource_session_id or ( + f"web-cdp-{port}" + if is_local_cdp_host(host) + else f"web-cdp-{host.replace('.', '-')}-{port}" + ) + return { + "id": browser_id, + "port": port, + "endpoint": normalized_endpoint, + "host": host, + "isExternal": is_external, + "name": browser_name, + "addedAt": added_at or int(time.time() * 1000), + "resourceSessionId": resource_session_id, + "managedBy": managed_by, + } + + +def _get_connected_cdp_endpoint() -> str | None: + cdp_url = os.environ.get("EIGENT_CDP_URL") + if cdp_url: + return cdp_url + if _web_cdp_browser_meta: + return _web_cdp_browser_meta.get("endpoint") + return None + + +def _get_connected_cdp_port() -> int | None: + cdp_url = _get_connected_cdp_endpoint() + if not cdp_url: + return None + try: + _, _, port = normalize_cdp_url(cdp_url) + return port + except Exception: + logger.warning("Invalid EIGENT_CDP_URL: %s", cdp_url) + return None + + +def _get_login_browser_cdp_port() -> int: + """Dedicated CDP port for the user-login cookie browser. + + Keep this outside the Browser Agent fallback range (9223-9299), otherwise + Cookie Management can mistake a managed task browser for the login window. + """ + raw_port = os.environ.get("EIGENT_LOGIN_BROWSER_CDP_PORT") + if not raw_port: + return DEFAULT_LOGIN_BROWSER_CDP_PORT + + try: + port = int(raw_port) + except ValueError: + logger.warning( + "Invalid EIGENT_LOGIN_BROWSER_CDP_PORT=%s; using default %s", + raw_port, + DEFAULT_LOGIN_BROWSER_CDP_PORT, + ) + return DEFAULT_LOGIN_BROWSER_CDP_PORT + + if port <= 0 or port > 65535: + logger.warning( + "Out-of-range EIGENT_LOGIN_BROWSER_CDP_PORT=%s; using default %s", + raw_port, + DEFAULT_LOGIN_BROWSER_CDP_PORT, + ) + return DEFAULT_LOGIN_BROWSER_CDP_PORT + + return port + + +def _set_connected_cdp_browser( + endpoint: str, + *, + is_external: bool, + name: str | None = None, + resource_session_id: str | None = None, + managed_by: str = "local", +) -> dict: + global _web_cdp_browser_meta + normalized_endpoint, _, port = normalize_cdp_url(endpoint) + os.environ["EIGENT_CDP_URL"] = normalized_endpoint + os.environ["browser_port"] = str(port) + _web_cdp_browser_meta = _build_web_cdp_browser( + normalized_endpoint, + is_external=is_external, + name=name, + resource_session_id=resource_session_id, + managed_by=managed_by, + ) + return _web_cdp_browser_meta + + +def _clear_connected_cdp_browser() -> None: + global _web_cdp_browser_meta + os.environ.pop("EIGENT_CDP_URL", None) + _web_cdp_browser_meta = None + + +def _list_connected_cdp_browsers() -> list[dict]: + global _web_cdp_browser_meta + endpoint = _get_connected_cdp_endpoint() + if endpoint is None: + return [] + + if not _is_cdp_endpoint_available(endpoint): + _clear_connected_cdp_browser() + return [] + + if ( + _web_cdp_browser_meta + and _web_cdp_browser_meta.get("endpoint") == endpoint + ): + return [_web_cdp_browser_meta] + + inferred_browser = _build_web_cdp_browser(endpoint, is_external=True) + _web_cdp_browser_meta = inferred_browser + return [inferred_browser] + + +def _is_cdp_endpoint_available(endpoint: str) -> bool: + _, host, port = normalize_cdp_url(endpoint) + if is_local_cdp_host(host): + return _is_cdp_available(port) + + return is_cdp_url_available(endpoint) + + +def _is_remote_browser_hands(hands) -> bool: + if hands is None: + return False + get_manifest = getattr(hands, "get_capability_manifest", None) + if get_manifest is None or inspect.iscoroutinefunction(get_manifest): + return False + try: + manifest = get_manifest() + except Exception: + return False + if inspect.isawaitable(manifest): + if hasattr(manifest, "close"): + manifest.close() + return False + if not isinstance(manifest, dict): + return False + return manifest.get("deployment") == "remote_cluster" + + +async def _release_remote_browser_if_needed(request: Request | None) -> None: + meta = _web_cdp_browser_meta or {} + resource_session_id = meta.get("resourceSessionId") + if meta.get("managedBy") != "remote" or not resource_session_id: + return + + hands = getattr(getattr(request, "state", None), "hands", None) + if hands is None: + return + + try: + await asyncio.to_thread( + hands.release_resource, + "browser", + resource_session_id, + ) + except Exception as exc: + logger.warning( + "Failed to release remote browser session %s: %s", + resource_session_id, + exc, + ) + + +@router.get("/browser/cdp/list", name="list cdp browsers") +async def list_cdp_browsers(): + """List the currently connected CDP browser in web mode.""" + return _list_connected_cdp_browsers() + + +@router.post("/browser/cdp/launch", name="launch cdp browser") +async def launch_cdp_browser(request: Request): + """ + Launch or reuse a managed CDP browser for web mode. + + Returns: + Connection information for the managed browser. + """ + existing_browsers = _list_connected_cdp_browsers() + if existing_browsers: + browser = existing_browsers[0] + return { + "success": True, + "port": browser["port"], + "browser": browser, + "endpoint": browser.get("endpoint"), + "reused": True, + } + + hands = getattr(request.state, "hands", None) + if _is_remote_browser_hands(hands): + session_id = f"browser_ui_{uuid.uuid4().hex[:12]}" + try: + endpoint = await asyncio.to_thread( + hands.acquire_resource, + "browser", + session_id, + port=DEFAULT_CDP_PORT, + ) + except Exception: + logger.exception( + "Failed to acquire remote browser resource for session %s", + session_id, + ) + return { + "success": False, + "error": "Failed to acquire remote browser", + } + + browser = _set_connected_cdp_browser( + endpoint, + is_external=False, + resource_session_id=session_id, + managed_by="remote", + ) + return { + "success": True, + "port": browser["port"], + "browser": browser, + "endpoint": browser.get("endpoint"), + } + + endpoint = ensure_cdp_browser_endpoint(DEFAULT_CDP_PORT) + if not endpoint: + if _is_port_in_use(DEFAULT_CDP_PORT): + return { + "success": False, + "error": f"Port {DEFAULT_CDP_PORT} is already in use and is not exposing a compatible CDP browser.", + } + return { + "success": False, + "error": "Failed to launch browser. Ensure Chrome/Chromium is installed or run playwright install chromium.", + } + + browser = _set_connected_cdp_browser( + endpoint, + is_external=False, + ) + return { + "success": True, + "port": browser["port"], + "browser": browser, + "endpoint": browser.get("endpoint"), + } + + +@router.post("/browser/cdp/connect", name="connect cdp browser") +async def connect_cdp_browser(data: CdpBrowserConnectRequest): + """ + Connect an already-running browser that exposes CDP. + + Args: + data.port: CDP port exposed by the browser. + data.name: Optional custom display name. + """ + if data.port < 1 or data.port > 65535: + return {"success": False, "error": "Invalid port number."} + + if not _is_cdp_available(data.port): + return { + "success": False, + "error": f"No CDP browser found on port {data.port}.", + } + + browser = _set_connected_cdp_browser( + f"http://127.0.0.1:{data.port}", + is_external=True, + name=data.name, + ) + return { + "success": True, + "port": data.port, + "browser": browser, + } + + +@router.delete("/browser/cdp/{port}", name="disconnect cdp browser") +async def disconnect_cdp_browser(port: int, request: Request): + """ + Disconnect the current web-mode CDP browser reference. + + Note: + This does not terminate the browser process; it only clears + the backend's active CDP target. + """ + current_port = _get_connected_cdp_port() + if current_port is None: + return {"success": False, "error": "No connected browser to remove."} + + if current_port != port: + return { + "success": False, + "error": f"Browser on port {port} is not the active CDP connection.", + } + + await _release_remote_browser_if_needed(request) + _clear_connected_cdp_browser() + return {"success": True} @router.post("/install/tool/{tool}", name="install tool") @@ -661,12 +1004,11 @@ async def open_browser_login(): Browser session information """ try: - import socket import subprocess # Use fixed profile name for persistent logins (no port suffix) session_id = "user_login" - cdp_port = 9223 + cdp_port = _get_login_browser_cdp_port() # IMPORTANT: Use dedicated profile for tool_controller browser # This is the SOURCE OF TRUTH for login data @@ -687,12 +1029,7 @@ async def open_browser_login(): f" at: {user_data_dir}" ) - # Check if browser is already running on this port - def is_port_in_use(port): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex(("localhost", port)) == 0 - - if is_port_in_use(cdp_port): + if _is_port_in_use(cdp_port): logger.info(f"Browser already running on port {cdp_port}") return { "success": True, @@ -826,15 +1163,8 @@ def log_electron_output(): @router.get("/browser/status", name="browser status") async def browser_status(): """Check if the login browser is currently open.""" - import socket - - cdp_port = 9223 - - def is_port_in_use(port): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - return s.connect_ex(("localhost", port)) == 0 - - return {"is_open": is_port_in_use(cdp_port)} + cdp_port = _get_login_browser_cdp_port() + return {"is_open": _is_port_in_use(cdp_port), "cdp_port": cdp_port} @router.get("/browser/cookies", name="list cookie domains") diff --git a/backend/app/file_access/__init__.py b/backend/app/file_access/__init__.py new file mode 100644 index 000000000..1f21f1989 --- /dev/null +++ b/backend/app/file_access/__init__.py @@ -0,0 +1,19 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from app.file_access.interface import IFileAccess +from app.file_access.local_file_access import LocalFileAccess +from app.file_access.upload_file_access import UploadFileAccess + +__all__ = ["IFileAccess", "LocalFileAccess", "UploadFileAccess"] diff --git a/backend/app/file_access/interface.py b/backend/app/file_access/interface.py new file mode 100644 index 000000000..ab860af15 --- /dev/null +++ b/backend/app/file_access/interface.py @@ -0,0 +1,54 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from abc import ABC, abstractmethod + + +class IFileAccess(ABC): + """File access abstraction, implementation selected by client type""" + + @abstractmethod + def read_file(self, path: str) -> str: + """Read file content, return text""" + pass + + @abstractmethod + def read_file_binary(self, path: str) -> bytes: + """Read file as binary""" + pass + + @abstractmethod + def write_file(self, path: str, content: str | bytes) -> None: + """Write file""" + pass + + @abstractmethod + def exists(self, path: str) -> bool: + """Check if path exists""" + pass + + @abstractmethod + def list_dir(self, path: str) -> list[str]: + """List directory contents""" + pass + + @abstractmethod + def get_working_directory(self, session_id: str) -> str: + """Return session working directory absolute path""" + pass + + @abstractmethod + def resolve_path(self, path_or_id: str, session_id: str) -> str: + """Resolve path or file_id to actual path""" + pass diff --git a/backend/app/file_access/local_file_access.py b/backend/app/file_access/local_file_access.py new file mode 100644 index 000000000..2a63e4d89 --- /dev/null +++ b/backend/app/file_access/local_file_access.py @@ -0,0 +1,57 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pathlib import Path + +from app.file_access.interface import IFileAccess + + +class LocalFileAccess(IFileAccess): + """Direct local filesystem access (Desktop/CLI)""" + + def __init__(self, workspace_root: str = "~/.eigent/workspace") -> None: + self.workspace_root = Path(workspace_root).expanduser() + + def read_file(self, path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + def read_file_binary(self, path: str) -> bytes: + return Path(path).read_bytes() + + def write_file(self, path: str, content: str | bytes) -> None: + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, str): + p.write_text(content, encoding="utf-8") + else: + p.write_bytes(content) + + def exists(self, path: str) -> bool: + return Path(path).exists() + + def list_dir(self, path: str) -> list[str]: + return [p.name for p in Path(path).iterdir()] + + def get_working_directory(self, session_id: str) -> str: + return str(self.workspace_root / session_id) + + def resolve_path(self, path_or_id: str, session_id: str) -> str: + if path_or_id.startswith("upload://"): + return self._resolve_upload_id(path_or_id, session_id) + return path_or_id + + def _resolve_upload_id(self, path_or_id: str, session_id: str) -> str: + """Resolve upload://xxx to workspace/{session_id}/uploads/xxx""" + file_id = path_or_id.removeprefix("upload://") + return str(self.workspace_root / session_id / "uploads" / file_id) diff --git a/backend/app/file_access/upload_file_access.py b/backend/app/file_access/upload_file_access.py new file mode 100644 index 000000000..b571e3726 --- /dev/null +++ b/backend/app/file_access/upload_file_access.py @@ -0,0 +1,72 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pathlib import Path + +from app.file_access.interface import IFileAccess + + +class UploadFileAccess(IFileAccess): + """Only handle uploaded files, path restricted to workspace/{session_id}/ (Web/Channel)""" + + def __init__(self, workspace_root: str = "~/.eigent/workspace") -> None: + self.workspace_root = Path(workspace_root).expanduser() + + def read_file(self, path: str) -> str: + resolved = self._ensure_in_workspace(path) + return resolved.read_text(encoding="utf-8") + + def read_file_binary(self, path: str) -> bytes: + resolved = self._ensure_in_workspace(path) + return resolved.read_bytes() + + def write_file(self, path: str, content: str | bytes) -> None: + resolved = self._ensure_in_workspace(path) + resolved.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, str): + resolved.write_text(content, encoding="utf-8") + else: + resolved.write_bytes(content) + + def exists(self, path: str) -> bool: + try: + resolved = self._ensure_in_workspace(path) + return resolved.exists() + except PermissionError: + return False + + def list_dir(self, path: str) -> list[str]: + resolved = self._ensure_in_workspace(path) + return [p.name for p in resolved.iterdir()] + + def get_working_directory(self, session_id: str) -> str: + return str(self.workspace_root / session_id) + + def resolve_path(self, path_or_id: str, session_id: str) -> str: + if not path_or_id.startswith("upload://"): + raise PermissionError("Only uploaded files are accessible") + return self._resolve_upload_id(path_or_id, session_id) + + def _resolve_upload_id(self, path_or_id: str, session_id: str) -> str: + file_id = path_or_id.removeprefix("upload://") + return str(self.workspace_root / session_id / "uploads" / file_id) + + def _ensure_in_workspace(self, path: str) -> Path: + p = Path(path).resolve() + root = self.workspace_root.resolve() + try: + p.relative_to(root) + except ValueError: + raise PermissionError("Path outside workspace") + return p diff --git a/backend/app/hands/__init__.py b/backend/app/hands/__init__.py new file mode 100644 index 000000000..a48442a51 --- /dev/null +++ b/backend/app/hands/__init__.py @@ -0,0 +1,46 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from app.hands.capabilities import BrainCapabilities, detect_capabilities +from app.hands.cluster_config import ( + ClusterEndpointConfig, + HandsClusterConfigError, + HandsClusterRoutingConfig, + load_hands_cluster_config, +) +from app.hands.cluster_interface import IHandsCluster +from app.hands.environment_hands import EnvironmentHands +from app.hands.full_hands import FullHands +from app.hands.http_hands_cluster import HttpHandsCluster +from app.hands.interface import IHands +from app.hands.remote_hands import RemoteHands +from app.hands.routed_hands_cluster import RoutedHandsCluster +from app.hands.sandbox_hands import SandboxHands + +__all__ = [ + "BrainCapabilities", + "ClusterEndpointConfig", + "EnvironmentHands", + "FullHands", + "HandsClusterConfigError", + "HandsClusterRoutingConfig", + "HttpHandsCluster", + "IHandsCluster", + "IHands", + "RemoteHands", + "RoutedHandsCluster", + "SandboxHands", + "detect_capabilities", + "load_hands_cluster_config", +] diff --git a/backend/app/hands/capabilities.py b/backend/app/hands/capabilities.py new file mode 100644 index 000000000..eefd22677 --- /dev/null +++ b/backend/app/hands/capabilities.py @@ -0,0 +1,244 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +""" +BrainCapabilities — Brain capability set, determined by deployment env. Everything revolves around what the Brain can operate. + +Hand Types (capability dimensions — what the Brain can reach and control): +- filesystem: operate local files (scope: full | workspace_only | none) +- terminal: execute shell commands +- browser: control browser (CDP) +- mcp: use MCP tool protocol (all | allowlist) + +Design principles: +- Brain on local/cloud VM -> full capabilities (extensible: smart home, router, car, etc.) +- Brain in sandbox/Docker -> limited capabilities +- Channel only affects message display format (Markdown/plain/Block Kit), does not determine Brain capabilities +""" + +import logging +import os +import shutil +from dataclasses import dataclass, field +from pathlib import Path + +from app.component.environment import env + +logger = logging.getLogger("hands.capabilities") + +# Deployment determines Brain capabilities +DEPLOYMENT_FULL = ("local", "cloud_vm", "") # full capabilities +DEPLOYMENT_SANDBOX = ("sandbox", "docker", "container") # limited capabilities + + +def _is_running_in_docker() -> bool: + """Detect if Brain runs inside Docker/container.""" + if Path("/.dockerenv").exists(): + return True + try: + cgroup = Path("/proc/1/cgroup").read_text() + return ( + "docker" in cgroup + or "containerd" in cgroup + or "kubepods" in cgroup + ) + except (OSError, FileNotFoundError): + return False + + +def _probe_cdp_browser() -> bool: + """Check if CDP browser is configured/available.""" + if os.environ.get("EIGENT_CDP_URL"): + return True + cdp_json = Path.home() / ".eigent" / "cdp.json" + if cdp_json.exists(): + return True + # Electron persists CDP pool here; if present, browser capability is likely available. + cdp_pool = Path.home() / ".eigent" / "cdp-browsers.json" + return cdp_pool.exists() + + +def _is_electron_runtime() -> bool: + """Detect whether Brain is launched by Electron desktop host.""" + return env("EIGENT_RUNTIME", "").lower().strip() == "electron" + + +def _can_launch_local_cdp_browser() -> bool: + """Check if local runtime can provision a CDP browser on demand.""" + if os.environ.get("EIGENT_BRAIN_LAUNCH_BROWSER", "true").lower() in ( + "false", + "0", + "no", + ): + return False + try: + from app.utils.browser_launcher import _find_chrome_executable + + return _find_chrome_executable() is not None + except Exception as e: + logger.debug(f"Could not probe local browser executable: {e}") + return False + + +@dataclass +class BrainCapabilities: + """ + Brain capability set (detected + config), determined at startup, global singleton. + + Each field maps to a Hand Type: what the Brain can operate. + """ + + has_terminal: bool = True + """terminal hand: can execute shell""" + + has_browser: bool = False + """browser hand: can control CDP browser""" + + filesystem_scope: str = "full" + """filesystem hand: full | workspace_only | none""" + + mcp_mode: str = "all" + """mcp hand: all | allowlist""" + + mcp_allowlist: list[str] = field(default_factory=list) + """used when mcp_mode=allowlist""" + + workspace_root: Path = field( + default_factory=lambda: Path("~/.eigent/workspace").expanduser() + ) + """workspace root path""" + + deployment_type: str = "local" + """deployment type (for logging): local | cloud_vm | sandbox | docker""" + + @property + def mode(self) -> str: + """capability tier: full | sandbox — for IHands.mode compatibility""" + return "full" if self._is_full else "sandbox" + + @property + def _is_full(self) -> bool: + return self.filesystem_scope == "full" and self.has_terminal + + +def detect_capabilities(config: dict | None = None) -> BrainCapabilities: + """ + Detect Brain capabilities, two-layer decision: + 1. Deployment env: EIGENT_DEPLOYMENT_TYPE / Docker auto-detect + 2. Env var overrides: EIGENT_HANDS_* + """ + cfg = config or {} + + # 1. Deployment env determines base capabilities + deployment = env("EIGENT_DEPLOYMENT_TYPE") or "" + deployment = deployment.lower().strip() + + if deployment in DEPLOYMENT_FULL: + # local/cloud VM -> full capabilities + in_docker = _is_running_in_docker() + if in_docker: + logger.info("Brain running in Docker, using limited capabilities") + deployment = "docker" + caps = BrainCapabilities( + has_terminal=shutil.which("bash") is not None, + has_browser=False, + filesystem_scope="workspace_only", + mcp_mode="all", # MCP available in all deployment modes + workspace_root=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), + deployment_type="docker", + ) + else: + # local/desktop: browser hand when CDP is configured/reachable, + # Electron host is present, or local browser can be provisioned. + has_browser = _probe_cdp_browser() + if not has_browser and _is_electron_runtime(): + has_browser = True + if not has_browser: + has_browser = _can_launch_local_cdp_browser() + if not has_browser: + logger.warning( + "Browser capability disabled: no CDP config, " + "not running under Electron host, and no launchable browser found." + ) + caps = BrainCapabilities( + has_terminal=shutil.which("bash") is not None, + has_browser=has_browser, + filesystem_scope="full", + mcp_mode="all", + workspace_root=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), + deployment_type="cloud_vm" + if deployment == "cloud_vm" + else "local", + ) + else: + # sandbox / docker / container -> limited capabilities + caps = BrainCapabilities( + has_terminal=shutil.which("bash") is not None, + has_browser=False, + filesystem_scope="workspace_only", + mcp_mode="all", # MCP available in all deployment modes + workspace_root=Path( + env("EIGENT_WORKSPACE", "~/.eigent/workspace") + ).expanduser(), + deployment_type=deployment or "sandbox", + ) + + # 2. Env var overrides + if env("EIGENT_HANDS_TERMINAL") is not None: + caps.has_terminal = env("EIGENT_HANDS_TERMINAL", "true").lower() in ( + "1", + "true", + "yes", + ) + if env("EIGENT_HANDS_BROWSER") is not None: + caps.has_browser = env("EIGENT_HANDS_BROWSER", "false").lower() in ( + "1", + "true", + "yes", + ) + if env("EIGENT_HANDS_FILESYSTEM") is not None: + caps.filesystem_scope = env("EIGENT_HANDS_FILESYSTEM", "full") + if env("EIGENT_HANDS_MCP") is not None: + caps.mcp_mode = env("EIGENT_HANDS_MCP", "all") + if env("EIGENT_CDP_URL"): + caps.has_browser = True + + # 3. Config file overrides + if "terminal" in cfg: + caps.has_terminal = bool(cfg["terminal"]) + if "browser" in cfg: + caps.has_browser = bool(cfg["browser"]) + if "filesystem" in cfg: + caps.filesystem_scope = str(cfg["filesystem"]) + if "mcp" in cfg: + caps.mcp_mode = str(cfg["mcp"]) + if "mcp_allowlist" in cfg: + caps.mcp_allowlist = list(cfg["mcp_allowlist"]) + + logger.info( + "BrainCapabilities detected", + extra={ + "deployment": caps.deployment_type, + "mode": caps.mode, + "terminal": caps.has_terminal, + "browser": caps.has_browser, + "filesystem": caps.filesystem_scope, + "mcp": caps.mcp_mode, + }, + ) + return caps diff --git a/backend/app/hands/cluster_config.py b/backend/app/hands/cluster_config.py new file mode 100644 index 000000000..d1dc2505b --- /dev/null +++ b/backend/app/hands/cluster_config.py @@ -0,0 +1,321 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from __future__ import annotations + +import tomllib +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class HandsClusterConfigError(ValueError): + """Raised when cluster config file is invalid.""" + + +@dataclass(frozen=True, slots=True) +class ClusterEndpointConfig: + name: str + base_url: str + timeout_seconds: float + verify_tls: bool + acquire_path: str + release_path: str + health_path: str + auth_token: str | None + + +@dataclass(frozen=True, slots=True) +class HandsClusterRoutingConfig: + source_path: str + route_to_cluster: dict[str, ClusterEndpointConfig] + + +def load_hands_cluster_config( + config_file: str, + read_env: Callable[[str], str | None] | None = None, +) -> HandsClusterRoutingConfig: + env_reader = read_env or _default_read_env + source_path = _resolve_config_path(config_file) + data = _read_toml(source_path) + + defaults = _read_defaults(data.get("defaults")) + clusters = _read_clusters(data.get("clusters"), defaults, env_reader) + routes = _read_routes(data.get("routes"), clusters) + + return HandsClusterRoutingConfig( + source_path=str(source_path), + route_to_cluster=routes, + ) + + +def _default_read_env(name: str) -> str | None: + from app.component.environment import env + + return env(name) + + +def _resolve_config_path(config_file: str) -> Path: + raw = (config_file or "").strip() + if not raw: + raise HandsClusterConfigError("config file path is empty") + path = Path(raw).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + if not path.exists(): + raise HandsClusterConfigError( + f"cluster config file does not exist: {path}" + ) + if not path.is_file(): + raise HandsClusterConfigError( + f"cluster config path is not a file: {path}" + ) + return path + + +def _read_toml(path: Path) -> dict[str, Any]: + try: + with path.open("rb") as handle: + parsed = tomllib.load(handle) + except tomllib.TOMLDecodeError as exc: + raise HandsClusterConfigError( + f"invalid TOML in cluster config: {path}: {exc}" + ) from exc + except OSError as exc: + raise HandsClusterConfigError( + f"unable to read cluster config file: {path}: {exc}" + ) from exc + if not isinstance(parsed, dict): + raise HandsClusterConfigError( + f"cluster config root must be an object: {path}" + ) + return parsed + + +def _read_defaults(raw: Any) -> dict[str, Any]: + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise HandsClusterConfigError("[defaults] must be a TOML table/object") + return { + "timeout_seconds": _as_float( + raw.get("timeout_seconds"), "defaults.timeout_seconds", 10.0 + ), + "verify_tls": _as_bool( + raw.get("verify_tls"), "defaults.verify_tls", True + ), + "acquire_path": _as_path_segment( + raw.get("acquire_path"), "defaults.acquire_path", "/acquire" + ), + "release_path": _as_path_segment( + raw.get("release_path"), "defaults.release_path", "/release" + ), + "health_path": _as_path_segment( + raw.get("health_path"), "defaults.health_path", "/health" + ), + "auth_token": _as_optional_str(raw.get("auth_token")), + "auth_token_env": _as_optional_str(raw.get("auth_token_env")), + } + + +def _read_clusters( + raw: Any, + defaults: dict[str, Any], + read_env: Callable[[str], str | None], +) -> dict[str, ClusterEndpointConfig]: + if not isinstance(raw, dict) or not raw: + raise HandsClusterConfigError( + "[clusters] must be a non-empty TOML table/object" + ) + + clusters: dict[str, ClusterEndpointConfig] = {} + for raw_name, item in raw.items(): + if not isinstance(raw_name, str) or not raw_name.strip(): + raise HandsClusterConfigError( + "cluster name must be a non-empty string" + ) + name = raw_name.strip().lower() + if not isinstance(item, dict): + raise HandsClusterConfigError( + f"[clusters.{raw_name}] must be a TOML table/object" + ) + + base_url = _as_optional_str(item.get("base_url")) or _as_optional_str( + item.get("api") + ) + if not base_url: + raise HandsClusterConfigError( + f"[clusters.{raw_name}] requires base_url (or api)" + ) + + timeout_seconds = _as_float( + item.get("timeout_seconds"), + f"clusters.{raw_name}.timeout_seconds", + defaults["timeout_seconds"], + ) + verify_tls = _as_bool( + item.get("verify_tls"), + f"clusters.{raw_name}.verify_tls", + defaults["verify_tls"], + ) + acquire_path = _as_path_segment( + item.get("acquire_path"), + f"clusters.{raw_name}.acquire_path", + defaults["acquire_path"], + ) + release_path = _as_path_segment( + item.get("release_path"), + f"clusters.{raw_name}.release_path", + defaults["release_path"], + ) + health_path = _as_path_segment( + item.get("health_path"), + f"clusters.{raw_name}.health_path", + defaults["health_path"], + ) + auth_token = _resolve_auth_token( + item=item, + defaults=defaults, + read_env=read_env, + ) + + clusters[name] = ClusterEndpointConfig( + name=name, + base_url=base_url.strip(), + timeout_seconds=timeout_seconds, + verify_tls=verify_tls, + acquire_path=acquire_path, + release_path=release_path, + health_path=health_path, + auth_token=auth_token, + ) + return clusters + + +def _read_routes( + raw: Any, + clusters: dict[str, ClusterEndpointConfig], +) -> dict[str, ClusterEndpointConfig]: + if raw is None: + if len(clusters) == 1: + only = next(iter(clusters.values())) + return {"default": only} + return dict(clusters) + + if not isinstance(raw, dict): + raise HandsClusterConfigError("[routes] must be a TOML table/object") + + route_to_cluster: dict[str, ClusterEndpointConfig] = {} + for raw_route, raw_cluster in raw.items(): + if not isinstance(raw_route, str) or not raw_route.strip(): + raise HandsClusterConfigError( + "route key must be a non-empty string" + ) + if not isinstance(raw_cluster, str) or not raw_cluster.strip(): + raise HandsClusterConfigError( + f"route '{raw_route}' target must be a non-empty string" + ) + + route_key = _normalize_route_key(raw_route) + cluster_name = raw_cluster.strip().lower() + cluster = clusters.get(cluster_name) + if cluster is None: + raise HandsClusterConfigError( + f"route '{raw_route}' references unknown cluster '{raw_cluster}'" + ) + route_to_cluster[route_key] = cluster + + if not route_to_cluster: + raise HandsClusterConfigError("[routes] must not be empty") + return route_to_cluster + + +def _resolve_auth_token( + item: dict[str, Any], + defaults: dict[str, Any], + read_env: Callable[[str], str | None], +) -> str | None: + direct = _as_optional_str(item.get("auth_token")) + if direct: + return direct + + env_name = _as_optional_str(item.get("auth_token_env")) + if env_name: + return _as_optional_str(read_env(env_name)) + + default_direct = _as_optional_str(defaults.get("auth_token")) + if default_direct: + return default_direct + + default_env_name = _as_optional_str(defaults.get("auth_token_env")) + if default_env_name: + return _as_optional_str(read_env(default_env_name)) + + return None + + +def _normalize_route_key(raw: str) -> str: + key = raw.strip().lower() + if key in ("*", "fallback"): + return "default" + return key + + +def _as_optional_str(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + s = value.strip() + return s if s else None + return str(value).strip() or None + + +def _as_float(value: Any, field: str, default: float) -> float: + if value is None: + return default + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise HandsClusterConfigError( + f"{field} must be a number, got {value!r}" + ) from exc + if parsed <= 0: + raise HandsClusterConfigError(f"{field} must be > 0, got {parsed!r}") + return parsed + + +def _as_bool(value: Any, field: str, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("1", "true", "yes", "on"): + return True + if lowered in ("0", "false", "no", "off"): + return False + raise HandsClusterConfigError(f"{field} must be a boolean, got {value!r}") + + +def _as_path_segment(value: Any, field: str, default: str) -> str: + if value is None: + return default + if not isinstance(value, str): + raise HandsClusterConfigError(f"{field} must be a string path") + s = value.strip() + if not s: + raise HandsClusterConfigError(f"{field} must not be empty") + return s if s.startswith("/") else f"/{s}" diff --git a/backend/app/hands/cluster_interface.py b/backend/app/hands/cluster_interface.py new file mode 100644 index 000000000..de53d45e7 --- /dev/null +++ b/backend/app/hands/cluster_interface.py @@ -0,0 +1,41 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from abc import ABC, abstractmethod +from typing import Any + + +class IHandsCluster(ABC): + """Remote Hands worker cluster interface placeholder.""" + + @abstractmethod + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict[str, Any]: + """Acquire a worker resource and return its endpoint metadata.""" + ... + + @abstractmethod + async def release(self, session_id: str) -> None: + """Release the worker resource bound to the session.""" + ... + + @abstractmethod + async def health(self) -> dict[str, Any]: + """Return cluster health and pool availability.""" + ... diff --git a/backend/app/hands/environment_hands.py b/backend/app/hands/environment_hands.py new file mode 100644 index 000000000..d6d420cc5 --- /dev/null +++ b/backend/app/hands/environment_hands.py @@ -0,0 +1,110 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +""" +EnvironmentHands — IHands implementation driven by BrainCapabilities. + +Brain deployment env determines capability set; all Channels share one instance. +Channel only handles message display format adaptation. +""" + +from pathlib import Path + +from app.hands.capabilities import BrainCapabilities +from app.hands.interface import IHands + + +class EnvironmentHands(IHands): + """ + IHands implementation based on BrainCapabilities. + Initialized at Brain startup from deployment env; globally reused. + """ + + def __init__(self, caps: BrainCapabilities) -> None: + self._caps = caps + self.workspace_root = Path(caps.workspace_root).expanduser() + + @property + def mode(self) -> str: + """Capability tier: full | sandbox""" + return self._caps.mode + + def can_execute_terminal(self) -> bool: + return self._caps.has_terminal + + def can_access_filesystem(self, path: str) -> bool: + if self._caps.filesystem_scope == "full": + try: + resolved = Path(path).expanduser().resolve() + home = Path.home() + workspace = self.workspace_root.resolve() + try: + resolved.relative_to(home) + return True + except ValueError: + pass + try: + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + if self._caps.filesystem_scope == "workspace_only": + try: + resolved = Path(path).expanduser().resolve() + workspace = self.workspace_root.resolve() + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + return False # none + + def can_use_mcp(self, mcp_name: str) -> bool: + if self._caps.mcp_mode == "all": + return True + return mcp_name in self._caps.mcp_allowlist + + def can_use_browser(self) -> bool: + return self._caps.has_browser + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": self._caps.has_terminal, + "browser": self._caps.has_browser, + "filesystem": self._caps.filesystem_scope, + "mcp": self._caps.mcp_mode, + "mcp_allowlist": list(self._caps.mcp_allowlist), + "deployment": self._caps.deployment_type, + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + if resource_type == "browser": + port = kwargs.get("port", 9222) + return f"http://localhost:{port}" + raise ValueError(f"Unknown resource type: {resource_type}") + + def release_resource(self, resource_type: str, session_id: str) -> None: + return None diff --git a/backend/app/hands/full_hands.py b/backend/app/hands/full_hands.py new file mode 100644 index 000000000..c5af52b90 --- /dev/null +++ b/backend/app/hands/full_hands.py @@ -0,0 +1,84 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pathlib import Path + +from app.hands.interface import IHands + + +class FullHands(IHands): + """Full capabilities: terminal, filesystem, browser, MCP all available""" + + def __init__(self, workspace_root: str = "~/.eigent/workspace") -> None: + self.workspace_root = Path(workspace_root).expanduser() + + @property + def mode(self) -> str: + return "full" + + def can_execute_terminal(self) -> bool: + return True + + def can_access_filesystem(self, path: str) -> bool: + # Allow ~/ and workspace + try: + resolved = Path(path).expanduser().resolve() + home = Path.home() + workspace = self.workspace_root.resolve() + try: + resolved.relative_to(home) + return True + except ValueError: + pass + try: + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + + def can_use_mcp(self, mcp_name: str) -> bool: + return True + + def can_use_browser(self) -> bool: + return True + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": True, + "browser": True, + "filesystem": "full", + "mcp": "all", + "mcp_allowlist": [], + "deployment": "override_full", + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + if resource_type == "browser": + port = kwargs.get("port", 9222) + return f"http://localhost:{port}" + raise ValueError(f"Unknown resource type: {resource_type}") + + def release_resource(self, resource_type: str, session_id: str) -> None: + return None diff --git a/backend/app/hands/http_hands_cluster.py b/backend/app/hands/http_hands_cluster.py new file mode 100644 index 000000000..5ae92d642 --- /dev/null +++ b/backend/app/hands/http_hands_cluster.py @@ -0,0 +1,154 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging +from typing import Any + +import httpx + +from app.hands.cluster_interface import IHandsCluster + +logger = logging.getLogger("hands.cluster.http") + + +class HttpHandsCluster(IHandsCluster): + """HTTP-backed Hands cluster client.""" + + def __init__( + self, + base_url: str, + timeout_seconds: float = 10.0, + auth_token: str | None = None, + acquire_path: str = "/acquire", + release_path: str = "/release", + health_path: str = "/health", + verify_tls: bool = True, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + normalized = base_url.strip().rstrip("/") + if not normalized: + raise ValueError("base_url must not be empty") + self._base_url = normalized + self._timeout_seconds = timeout_seconds + self._auth_token = auth_token + self._acquire_path = acquire_path + self._release_path = release_path + self._health_path = health_path + self._verify_tls = verify_tls + self._transport = transport + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "type": resource_type, + "resource_type": resource_type, + "session_id": session_id, + "tenant_id": tenant_id, + } + payload.update(kwargs) + body = await self._request_json( + method="POST", + url=self._build_url(self._acquire_path), + payload=payload, + ) + data = self._unwrap_response(body) + endpoint = ( + data.get("endpoint") or data.get("cdp_url") or data.get("url") + ) + if not endpoint: + raise RuntimeError( + "Hands cluster acquire response missing endpoint" + ) + data["endpoint"] = str(endpoint) + return data + + async def release(self, session_id: str) -> None: + payload = {"session_id": session_id} + try: + await self._request_json( + method="POST", + url=self._build_url(self._release_path), + payload=payload, + ) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + logger.warning( + "Hands cluster release returned 404 for session_id=%s", + session_id, + ) + return + raise + + async def health(self) -> dict[str, Any]: + body = await self._request_json( + method="GET", + url=self._build_url(self._health_path), + payload=None, + ) + return self._unwrap_response(body) + + def _build_url(self, path: str) -> str: + p = path.strip() + if p.startswith("http://") or p.startswith("https://"): + return p + if not p.startswith("/"): + p = f"/{p}" + return f"{self._base_url}{p}" + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self._auth_token: + headers["Authorization"] = f"Bearer {self._auth_token}" + return headers + + async def _request_json( + self, + method: str, + url: str, + payload: dict[str, Any] | None, + ) -> dict[str, Any]: + request_kwargs: dict[str, Any] = {"headers": self._headers()} + if payload is not None: + request_kwargs["json"] = payload + + async with httpx.AsyncClient( + timeout=self._timeout_seconds, + verify=self._verify_tls, + transport=self._transport, + ) as client: + response = await client.request(method, url, **request_kwargs) + response.raise_for_status() + + if not response.content: + return {} + + try: + body = response.json() + except ValueError: + return {} + if isinstance(body, dict): + return body + return {"result": body} + + def _unwrap_response(self, body: dict[str, Any]) -> dict[str, Any]: + if isinstance(body.get("data"), dict): + return dict(body["data"]) + if isinstance(body.get("result"), dict): + return dict(body["result"]) + return dict(body) diff --git a/backend/app/hands/interface.py b/backend/app/hands/interface.py new file mode 100644 index 000000000..77cdf7d5e --- /dev/null +++ b/backend/app/hands/interface.py @@ -0,0 +1,75 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from abc import ABC, abstractmethod +from typing import Any + + +class IHands(ABC): + """ + Brain capability interface — what the Brain can operate (Hand Types). + + mode: full | sandbox (capability tier) + can_*: whether each Hand Type is available + """ + + @property + @abstractmethod + def mode(self) -> str: + """Capability tier: full | sandbox""" + pass + + @abstractmethod + def can_execute_terminal(self) -> bool: + """terminal hand: can execute shell""" + pass + + @abstractmethod + def can_access_filesystem(self, path: str) -> bool: + """filesystem hand: whether path is within accessible scope""" + pass + + @abstractmethod + def can_use_mcp(self, mcp_name: str) -> bool: + """mcp hand: whether this MCP is available""" + pass + + @abstractmethod + def can_use_browser(self) -> bool: + """browser hand: can control CDP browser""" + pass + + @abstractmethod + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + """Return session working directory""" + pass + + @abstractmethod + def get_capability_manifest(self) -> dict[str, Any]: + """Return a serializable capability manifest for clients.""" + pass + + @abstractmethod + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + """Acquire a resource endpoint for the requested hand type.""" + pass + + @abstractmethod + def release_resource(self, resource_type: str, session_id: str) -> None: + """Release a previously acquired resource.""" + pass diff --git a/backend/app/hands/remote_hands.py b/backend/app/hands/remote_hands.py new file mode 100644 index 000000000..37834b597 --- /dev/null +++ b/backend/app/hands/remote_hands.py @@ -0,0 +1,136 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import asyncio +from pathlib import Path +from typing import Any + +from app.hands.cluster_interface import IHandsCluster +from app.hands.interface import IHands + + +class RemoteHands(IHands): + """ + Remote/cluster-backed Hands placeholder. + + This class is intentionally minimal for this refactor stage: + - exposes a concrete IHands implementation for remote cluster mode + - supports browser resource acquire/release via IHandsCluster when provided + - keeps safe local fallback endpoint when cluster is not wired yet + """ + + def __init__( + self, + cluster: IHandsCluster | None = None, + workspace_root: str = "~/.eigent/workspace", + ) -> None: + self._cluster = cluster + self.workspace_root = Path(workspace_root).expanduser() + self._acquired: dict[str, dict[str, Any]] = {} + + @property + def mode(self) -> str: + return "full" + + def can_execute_terminal(self) -> bool: + return True + + def can_access_filesystem(self, path: str) -> bool: + try: + resolved = Path(path).expanduser().resolve() + workspace = self.workspace_root.resolve() + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + + def can_use_mcp(self, mcp_name: str) -> bool: + _ = mcp_name + return True + + def can_use_browser(self) -> bool: + return True + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + _ = tenant_id + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": True, + "browser": True, + "filesystem": "workspace_only", + "mcp": "all", + "mcp_allowlist": [], + "deployment": "remote_cluster", + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + if self._cluster is None: + if resource_type == "browser": + port = int(kwargs.get("port", 9222)) + return f"http://localhost:{port}" + raise ValueError( + f"Unknown resource type without cluster configured: {resource_type}" + ) + + # IHands interface is sync; bridge to async cluster API here. + try: + _ = asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError( + "Cannot synchronously acquire remote resource while event loop is running" + ) + + acquired = asyncio.run( + self._cluster.acquire( + resource_type=resource_type, + session_id=session_id, + **kwargs, + ) + ) + self._acquired[session_id] = acquired + endpoint = acquired.get("endpoint") + if not endpoint: + raise RuntimeError( + "Remote cluster acquire() did not return endpoint" + ) + return str(endpoint) + + def release_resource(self, resource_type: str, session_id: str) -> None: + _ = resource_type + self._acquired.pop(session_id, None) + if self._cluster is None: + return + + # Best-effort release for sync interface. + try: + asyncio.run(self._cluster.release(session_id)) + except RuntimeError: + # If called from a running loop, schedule best-effort release. + try: + loop = asyncio.get_running_loop() + loop.create_task(self._cluster.release(session_id)) + except RuntimeError: + return diff --git a/backend/app/hands/routed_hands_cluster.py b/backend/app/hands/routed_hands_cluster.py new file mode 100644 index 000000000..28b8a7184 --- /dev/null +++ b/backend/app/hands/routed_hands_cluster.py @@ -0,0 +1,122 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging +from typing import Any + +from app.hands.cluster_interface import IHandsCluster + +logger = logging.getLogger("hands.cluster.routed") + + +class RoutedHandsCluster(IHandsCluster): + """ + Route resource requests to different cluster clients by resource type. + + Example keys: + - "browser" + - "terminal" + - "model" + - "default" + """ + + def __init__( + self, + clusters: dict[str, IHandsCluster], + default_key: str = "default", + ) -> None: + normalized = { + k.strip().lower(): v + for k, v in clusters.items() + if k and isinstance(k, str) + } + if not normalized: + raise ValueError("clusters must not be empty") + self._clusters = normalized + self._default_key = ( + default_key if default_key in self._clusters else None + ) + self._session_cluster_key: dict[str, str] = {} + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict[str, Any]: + key = self._select_cluster_key(resource_type) + cluster = self._clusters[key] + acquired = await cluster.acquire( + resource_type=resource_type, + session_id=session_id, + tenant_id=tenant_id, + **kwargs, + ) + self._session_cluster_key[session_id] = key + if "cluster_key" not in acquired: + acquired["cluster_key"] = key + return acquired + + async def release(self, session_id: str) -> None: + key = self._session_cluster_key.pop(session_id, None) + if key is not None and key in self._clusters: + await self._clusters[key].release(session_id) + return + + if self._default_key is not None: + await self._clusters[self._default_key].release(session_id) + return + + last_error: Exception | None = None + for cluster_key, cluster in self._clusters.items(): + try: + await cluster.release(session_id) + return + except Exception as exc: # pragma: no cover - best effort log path + last_error = exc + logger.warning( + "Release attempt failed on cluster key %s for session_id=%s: %s", + cluster_key, + session_id, + exc, + ) + if last_error is not None: + raise last_error + + async def health(self) -> dict[str, Any]: + clusters_health: dict[str, Any] = {} + for key, cluster in self._clusters.items(): + try: + clusters_health[key] = await cluster.health() + except Exception as exc: + clusters_health[key] = {"error": str(exc)} + return { + "mode": "routed", + "default_key": self._default_key, + "clusters": clusters_health, + } + + def _select_cluster_key(self, resource_type: str) -> str: + wanted = (resource_type or "").strip().lower() + if wanted in self._clusters: + return wanted + if self._default_key is not None: + return self._default_key + if len(self._clusters) == 1: + return next(iter(self._clusters.keys())) + raise ValueError( + "No cluster configured for " + f"resource_type={resource_type!r} and no default cluster" + ) diff --git a/backend/app/hands/sandbox_hands.py b/backend/app/hands/sandbox_hands.py new file mode 100644 index 000000000..002fea120 --- /dev/null +++ b/backend/app/hands/sandbox_hands.py @@ -0,0 +1,89 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pathlib import Path + +from app.hands.interface import IHands + + +class SandboxHands(IHands): + """Limited capabilities: workspace only, MCP allowed, no browser""" + + def __init__( + self, + workspace_root: str = "~/.eigent/workspace", + allowed_mcps: frozenset[str] | None = None, + ) -> None: + self.workspace_root = Path(workspace_root).expanduser() + # None = allow all MCP (default for debug override); frozenset() = allow none + self.allowed_mcps = allowed_mcps + + @property + def mode(self) -> str: + return "sandbox" + + def can_execute_terminal(self) -> bool: + return ( + True # Enabled; toolkit layer validates against command allowlist + ) + + def can_access_filesystem(self, path: str) -> bool: + try: + resolved = Path(path).expanduser().resolve() + workspace = self.workspace_root.resolve() + resolved.relative_to(workspace) + return True + except ValueError: + return False + except (OSError, RuntimeError): + return False + + def can_use_mcp(self, mcp_name: str) -> bool: + if self.allowed_mcps is None: + return True # MCP available in all cases + if not self.allowed_mcps: + return False + return mcp_name in self.allowed_mcps + + def can_use_browser(self) -> bool: + return False # No browser hand in limited mode + + def get_working_directory( + self, session_id: str, tenant_id: str = "default" + ) -> str: + return str(self.workspace_root / session_id) + + def get_capability_manifest(self) -> dict[str, str | bool | list[str]]: + return { + "mode": self.mode, + "terminal": self.can_execute_terminal(), + "browser": False, + "filesystem": "workspace_only", + "mcp": "all" if self.allowed_mcps is None else "allowlist", + "mcp_allowlist": [] + if self.allowed_mcps is None + else list(self.allowed_mcps), + "deployment": "override_sandbox", + "workspace_root": str(self.workspace_root), + } + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ) -> str: + raise ValueError( + f"Resource type {resource_type!r} is not available in sandbox mode" + ) + + def release_resource(self, resource_type: str, session_id: str) -> None: + return None diff --git a/backend/app/hardware/__init__.py b/backend/app/hardware/__init__.py new file mode 100644 index 000000000..9bd7e8fef --- /dev/null +++ b/backend/app/hardware/__init__.py @@ -0,0 +1,18 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from app.hardware.interface import IHardwareBridge +from app.hardware.null_bridge import NullHardwareBridge + +__all__ = ["IHardwareBridge", "NullHardwareBridge"] diff --git a/backend/app/hardware/interface.py b/backend/app/hardware/interface.py new file mode 100644 index 000000000..e51f30219 --- /dev/null +++ b/backend/app/hardware/interface.py @@ -0,0 +1,55 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from abc import ABC, abstractmethod +from typing import Any + + +class IHardwareBridge(ABC): + """Hardware capability bridge. Only Desktop has implementation, others use NullBridge""" + + @abstractmethod + def get_cdp_browsers(self) -> list[dict]: + """Get available CDP browser list""" + pass + + @abstractmethod + def add_cdp_browser(self, browser_id: str, **kwargs: Any) -> dict: + """Add CDP browser""" + pass + + @abstractmethod + def remove_cdp_browser(self, browser_id: str) -> bool: + """Remove CDP browser""" + pass + + @abstractmethod + def create_webview(self, id: str, url: str) -> None: + """Create WebView (Electron)""" + pass + + @abstractmethod + def hide_webview(self, id: str) -> None: + """Hide WebView""" + pass + + @abstractmethod + def show_webview(self, id: str) -> None: + """Show WebView""" + pass + + @abstractmethod + def set_webview_size(self, id: str, size: dict) -> None: + """Set WebView size""" + pass diff --git a/backend/app/hardware/null_bridge.py b/backend/app/hardware/null_bridge.py new file mode 100644 index 000000000..df9c232d4 --- /dev/null +++ b/backend/app/hardware/null_bridge.py @@ -0,0 +1,42 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from typing import Any + +from app.hardware.interface import IHardwareBridge + + +class NullHardwareBridge(IHardwareBridge): + """Null implementation when no hardware. All methods return empty or no-op""" + + def get_cdp_browsers(self) -> list[dict]: + return [] + + def add_cdp_browser(self, browser_id: str, **kwargs: Any) -> dict: + raise NotImplementedError("CDP not available in this environment") + + def remove_cdp_browser(self, browser_id: str) -> bool: + return False + + def create_webview(self, id: str, url: str) -> None: + raise NotImplementedError("WebView not available in this environment") + + def hide_webview(self, id: str) -> None: + pass + + def show_webview(self, id: str) -> None: + pass + + def set_webview_size(self, id: str, size: dict) -> None: + pass diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 1b9b25a8c..b8e59756d 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -78,6 +78,9 @@ class Chat(BaseModel): search_config: dict[str, str] | None = None # User identifier for user-specific skill configurations user_id: str | None = None + # Direct server API base URL (for example http://localhost:3001/api/v1) + # used by standalone Brain to sync replay steps without Electron env injection. + server_url: str | None = None @field_validator("model_type") @classmethod diff --git a/backend/app/router.py b/backend/app/router.py index 2909eaae0..fc58f25d6 100644 --- a/backend/app/router.py +++ b/backend/app/router.py @@ -23,8 +23,12 @@ from app.controller import ( chat_controller, + file_controller, health_controller, + mcp_controller, + message_controller, model_controller, + skill_controller, task_controller, tool_controller, ) @@ -51,11 +55,31 @@ def register_routers(app: FastAPI, prefix: str = "") -> None: "tags": ["Health"], "description": "Health check endpoint for service readiness", }, + { + "router": file_controller.router, + "tags": ["Files"], + "description": "File upload for Web/Channel clients", + }, + { + "router": mcp_controller.router, + "tags": ["MCP"], + "description": "MCP config (list, install, remove, update)", + }, + { + "router": skill_controller.router, + "tags": ["Skills"], + "description": "Skills scan, write, read, delete", + }, { "router": chat_controller.router, "tags": ["chat"], "description": "Chat session management, improvements, and human interactions", }, + { + "router": message_controller.router, + "tags": ["Message Router"], + "description": "Phase 2 Message Router - /messages endpoint (prefix-aware)", + }, { "router": model_controller.router, "tags": ["model"], diff --git a/backend/app/router_layer/__init__.py b/backend/app/router_layer/__init__.py new file mode 100644 index 000000000..84962ae14 --- /dev/null +++ b/backend/app/router_layer/__init__.py @@ -0,0 +1,39 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Message Router layer: Channel/Session binding, Hands selection.""" + +from app.router_layer.hands_resolver import ( + get_environment_hands, + get_hands_for_channel, + init_environment_hands, +) +from app.router_layer.interface import ( + InboundMessage, + IRouter, + OutboundMessage, +) +from app.router_layer.message_router import DefaultMessageRouter +from app.router_layer.middleware import ChannelSessionMiddleware + +__all__ = [ + "ChannelSessionMiddleware", + "DefaultMessageRouter", + "get_environment_hands", + "get_hands_for_channel", + "InboundMessage", + "init_environment_hands", + "IRouter", + "OutboundMessage", +] diff --git a/backend/app/router_layer/hands_resolver.py b/backend/app/router_layer/hands_resolver.py new file mode 100644 index 000000000..49b3606ae --- /dev/null +++ b/backend/app/router_layer/hands_resolver.py @@ -0,0 +1,206 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +""" +Hands = Brain capabilities, driven by deployment env, not by Channel. + +- Brain on local/cloud VM -> full capabilities (extensible: smart home, router, car, etc.) +- Brain in sandbox/Docker -> limited capabilities +- Channel only affects message display format +- MCP is available in all deployment modes +""" + +import logging + +from app.component.environment import env +from app.hands import ( + FullHands, + HandsClusterConfigError, + HandsClusterRoutingConfig, + HttpHandsCluster, + IHands, + IHandsCluster, + RemoteHands, + RoutedHandsCluster, + SandboxHands, + load_hands_cluster_config, +) +from app.hands.capabilities import detect_capabilities +from app.hands.environment_hands import EnvironmentHands + +logger = logging.getLogger(__name__) + +# Global EnvironmentHands singleton, initialized at startup +_environment_hands: IHands | None = None + + +def _is_truthy(raw: str | None) -> bool: + if raw is None: + return False + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _new_http_cluster( + cluster_api: str, + timeout_seconds: float, + verify_tls: bool, + auth_token: str | None, + acquire_path: str, + release_path: str, + health_path: str, +) -> HttpHandsCluster: + logger.info( + "Configured HttpHandsCluster", + extra={ + "cluster_api": cluster_api, + "acquire_path": acquire_path, + "release_path": release_path, + "health_path": health_path, + "verify_tls": verify_tls, + "has_auth_token": bool(auth_token), + "timeout_seconds": timeout_seconds, + }, + ) + return HttpHandsCluster( + base_url=cluster_api, + timeout_seconds=timeout_seconds, + verify_tls=verify_tls, + auth_token=auth_token, + acquire_path=acquire_path, + release_path=release_path, + health_path=health_path, + ) + + +def _build_remote_cluster() -> IHandsCluster | None: + config_file = env("EIGENT_HANDS_CLUSTER_CONFIG_FILE", "").strip() + if not config_file: + return None + + try: + routing = load_hands_cluster_config(config_file) + except HandsClusterConfigError as exc: + logger.warning( + "Failed to load hands cluster config file %r: %s", + config_file, + exc, + ) + return None + + logger.info( + "Loaded hands cluster config file", + extra={ + "config_file": routing.source_path, + "routes": sorted(routing.route_to_cluster.keys()), + }, + ) + return _build_cluster_from_routing(routing) + + +def _build_cluster_from_routing( + routing: HandsClusterRoutingConfig, +) -> IHandsCluster: + clusters_by_name: dict[str, IHandsCluster] = {} + route_clients: dict[str, IHandsCluster] = {} + + for route_key, endpoint in routing.route_to_cluster.items(): + client = clusters_by_name.get(endpoint.name) + if client is None: + client = _new_http_cluster( + endpoint.base_url, + timeout_seconds=endpoint.timeout_seconds, + verify_tls=endpoint.verify_tls, + auth_token=endpoint.auth_token, + acquire_path=endpoint.acquire_path, + release_path=endpoint.release_path, + health_path=endpoint.health_path, + ) + clusters_by_name[endpoint.name] = client + route_clients[route_key] = client + + if len(route_clients) == 1 and "default" in route_clients: + return route_clients["default"] + return RoutedHandsCluster(clusters=route_clients) + + +def _create_remote_hands(workspace_root: str) -> RemoteHands: + cluster = _build_remote_cluster() + if cluster is None: + logger.warning( + "RemoteHands enabled but EIGENT_HANDS_CLUSTER_CONFIG_FILE is missing/invalid; " + "browser resource acquisition will fallback to localhost endpoint" + ) + return RemoteHands(cluster=cluster, workspace_root=workspace_root) + + +def init_environment_hands(config: dict | None = None) -> IHands: + """Initialize global EnvironmentHands (capability set) at Brain startup""" + global _environment_hands + mode = env("EIGENT_HANDS_MODE", "").strip().lower() + remote_enabled = _is_truthy(env("EIGENT_HANDS_REMOTE", "false")) + + if mode == "remote" or remote_enabled: + workspace_root = env("EIGENT_WORKSPACE", "~/.eigent/workspace") + logger.info( + "Initializing RemoteHands from env switch", + extra={"mode": mode, "remote_enabled": remote_enabled}, + ) + _environment_hands = _create_remote_hands(workspace_root) + return _environment_hands + + caps = detect_capabilities(config) + _environment_hands = EnvironmentHands(caps) + return _environment_hands + + +def get_environment_hands() -> IHands: + """Return global EnvironmentHands, shared by all Channels. Auto-detect if not initialized.""" + global _environment_hands + if _environment_hands is None: + init_environment_hands() + return _environment_hands + + +def _reset_environment_hands_for_testing() -> None: + """Testing only: reset global Hands so it can be re-initialized with new env.""" + global _environment_hands + _environment_hands = None + + +def get_hands_for_channel( + _channel: str, + hands_override: str | None = None, + workspace_root: str | None = None, +) -> IHands: + """ + Return Hands (Brain capability) instance. Capabilities driven by deployment env; Channel not involved. + + - _channel: Kept for API compatibility; not used (Hands are env-driven per ADR-0006) + - hands_override: For debugging; force full/sandbox/remote + - workspace_root: Override workspace root (optional) + """ + root = workspace_root or env("EIGENT_WORKSPACE", "~/.eigent/workspace") + + if hands_override: + if hands_override in ("full", "sandbox", "remote"): + if hands_override == "remote": + return _create_remote_hands(root) + cls = {"full": FullHands, "sandbox": SandboxHands}[hands_override] + return cls(workspace_root=root) + logger.warning( + "Ignoring invalid X-Hands-Override: %r, expected full, sandbox or remote", + hands_override, + ) + + return get_environment_hands() diff --git a/backend/app/router_layer/interface.py b/backend/app/router_layer/interface.py new file mode 100644 index 000000000..3eac0c418 --- /dev/null +++ b/backend/app/router_layer/interface.py @@ -0,0 +1,72 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""IRouter interface and message types for Phase 2 Message Router.""" + +from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from fastapi import Request + + +@dataclass +class InboundMessage: + """Standardized inbound message format.""" + + session_id: str + channel: str # desktop | web | cli | whatsapp | telegram | slack | ... + user_id: str | None + payload: dict[str, Any] + headers: dict[str, str] + + +@dataclass +class OutboundMessage: + """Outbound message for routing back to Client.""" + + session_id: str + payload: dict[str, Any] + stream: bool = False + + +class IRouter(ABC): + """Message Router interface.""" + + @abstractmethod + def route_in( + self, + msg: InboundMessage, + *, + request: "Request | None" = None, + ) -> AsyncGenerator[OutboundMessage, None]: + """Inbound: dispatch to Core and return streaming outbound messages.""" + pass + + @abstractmethod + async def route_out(self, session_id: str, msg: OutboundMessage) -> None: + """Outbound: route back to Client (for WebSocket push).""" + pass + + @abstractmethod + async def resolve_session( + self, + channel: str, + session_id: str | None, + user_id: str | None, + ) -> str: + """Resolve or create Session, return session_id.""" + pass diff --git a/backend/app/router_layer/message_router.py b/backend/app/router_layer/message_router.py new file mode 100644 index 000000000..8875c92cd --- /dev/null +++ b/backend/app/router_layer/message_router.py @@ -0,0 +1,190 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Default Message Router implementation for Phase 2.""" + +import logging +import os +import time +import uuid +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING + +from app.router_layer.interface import ( + InboundMessage, + IRouter, + OutboundMessage, +) +from app.router_layer.session_store import ISessionStore, MemorySessionStore + +if TYPE_CHECKING: + from fastapi import Request + +logger = logging.getLogger("router_layer") + +# Session TTL: 24 hours per docs/design/06-protocol.md §1.2 +SESSION_TTL_SECONDS = 86400 + + +def _now_ts() -> float: + return time.time() + + +class DefaultMessageRouter(IRouter): + """ + Default Message Router with in-memory session store. + Implements resolve_session per docs/design/06-protocol.md §1.5. + """ + + def __init__( + self, + session_ttl: int = SESSION_TTL_SECONDS, + session_store: ISessionStore | None = None, + ): + self._session_ttl = session_ttl + self._session_store: ISessionStore = ( + session_store or MemorySessionStore() + ) + + async def resolve_session( + self, + channel: str, + session_id: str | None, + user_id: str | None, + ) -> str: + """ + Resolve or create Session per docs/design/06-protocol.md §1.5. + Uses channel isolation: same user_id on different channels get different sessions. + """ + # 1. If session_id provided, check store + if session_id: + entry = await self._session_store.get(session_id) + if ( + isinstance(entry, dict) + and entry.get("channel") == channel + and not self._is_expired(entry) + ): + entry["last_activity"] = _now_ts() + await self._session_store.set( + session_id, entry, ttl=self._session_ttl + ) + return session_id + # Expired or not found → treat as new, fall through + await self._session_store.delete(session_id) + + # 2. Generate new session_id + new_id = f"sess_{uuid.uuid4().hex[:16]}" + + # 3. Channel isolation: always create new session (per §1.3 recommendation) + now = _now_ts() + entry = { + "session_id": new_id, + "channel": channel, + "user_id": user_id, + "created_at": now, + "last_activity": now, + } + await self._session_store.set(new_id, entry, ttl=self._session_ttl) + + return new_id + + def _is_expired(self, entry: dict) -> bool: + last_activity = entry.get("last_activity") + if not isinstance(last_activity, (int, float)): + return True + return (_now_ts() - float(last_activity)) > self._session_ttl + + async def route_in( + self, + msg: InboundMessage, + *, + request: "Request | None" = None, + ) -> AsyncGenerator[OutboundMessage, None]: + """ + Inbound: dispatch to Core. + For chat payload (content present), forwards to step_solve. + request is required for chat dispatch (disconnect detection, hands). + """ + payload = msg.payload + content = payload.get("content") if isinstance(payload, dict) else None + + if content is not None and request is not None: + # Chat message: build Chat and stream from step_solve + async for out in self._route_chat(msg, request): + yield out + else: + # Unknown or unsupported payload + yield OutboundMessage( + session_id=msg.session_id, + payload={ + "code": -1, + "text": "Unsupported message type or missing content", + "data": {}, + }, + stream=False, + ) + + async def _route_chat( + self, + msg: InboundMessage, + request: "Request", + ) -> AsyncGenerator[OutboundMessage, None]: + """Dispatch chat payload to step_solve.""" + from app.controller.chat_controller import start_chat_stream + from app.model.chat import Chat + + payload = msg.payload or {} + project_id = payload.get("project_id") or msg.session_id + task_id = payload.get("task_id") or str(uuid.uuid4()) + content = payload.get("content", "") + attachments = payload.get("attachments") or [] + # Map design doc attachments [{type, file_id}] -> attaches (paths/ids) + attaches = [] + for a in attachments: + if isinstance(a, dict) and "file_id" in a: + attaches.append(a["file_id"]) + elif isinstance(a, str): + attaches.append(a) + + user_id = msg.user_id or "user" + email = f"{user_id}@local" if "@" not in user_id else user_id + + # Build Chat with defaults + chat = Chat( + task_id=task_id, + project_id=project_id, + question=content, + email=email, + attaches=attaches, + model_platform=payload.get("model_platform") or "openai", + model_type=payload.get("model_type") or "gpt-4o", + # TODO(multi-tenant): falling back to os.environ inherits whatever + # the last /chat request wrote – unsafe under concurrent sessions. + api_key=payload.get("api_key") + or os.environ.get("OPENAI_API_KEY", ""), + api_url=payload.get("api_url"), + user_id=msg.user_id, + ) + + stream = await start_chat_stream(chat, request) + async for sse_chunk in stream: + yield OutboundMessage( + session_id=msg.session_id, + payload={"raw": sse_chunk}, + stream=True, + ) + + async def route_out(self, session_id: str, msg: OutboundMessage) -> None: + """Outbound: route back to Client. Empty for now (WebSocket push later).""" + pass diff --git a/backend/app/router_layer/middleware.py b/backend/app/router_layer/middleware.py new file mode 100644 index 000000000..39ac2580f --- /dev/null +++ b/backend/app/router_layer/middleware.py @@ -0,0 +1,117 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Channel/Session header middleware for Phase 2 Message Router.""" + +import logging +import uuid + +from app.component.environment import env +from app.router_layer.hands_resolver import get_hands_for_channel + +logger = logging.getLogger("router_layer") + +DEFAULT_CHANNEL = "desktop" +CHANNELS = frozenset( + { + "desktop", + "web", + "cli", + "whatsapp", + "telegram", + "slack", + "discord", + "lark", + "browser_extension", + } +) + + +def _is_truthy(raw: str | None) -> bool: + if raw is None: + return False + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _get_header( + scope: dict, name: str, default: str | None = None +) -> str | None: + name_lower = name.lower().encode() + for k, v in scope.get("headers", []): + if k.lower() == name_lower: + return v.decode() if v else default + return default + + +class ChannelSessionMiddleware: + """ + Parse X-Channel, X-Session-ID, X-User-ID headers and store in request.state. + Add X-Session-ID to response for clients. + Uses plain ASGI for reliable response header injection. + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + channel = ( + _get_header(scope, "X-Channel", DEFAULT_CHANNEL) or DEFAULT_CHANNEL + ) + if channel not in CHANNELS: + logger.warning( + "Invalid X-Channel header %r, falling back to %r", + channel, + DEFAULT_CHANNEL, + ) + channel = DEFAULT_CHANNEL + + session_id = _get_header(scope, "X-Session-ID") + user_id = _get_header(scope, "X-User-ID") + hands_override = _get_header(scope, "X-Hands-Override") + debug_override_enabled = _is_truthy(env("EIGENT_DEBUG", "false")) + + if hands_override and not debug_override_enabled: + logger.warning( + "Ignoring X-Hands-Override because EIGENT_DEBUG is disabled" + ) + hands_override = None + + if not session_id: + session_id = f"sess_{uuid.uuid4().hex[:16]}" + + hands = get_hands_for_channel(channel, hands_override) + + if "state" not in scope: + scope["state"] = {} + scope["state"]["channel"] = channel + scope["state"]["session_id"] = session_id + scope["state"]["user_id"] = user_id + scope["state"]["hands_override"] = hands_override + scope["state"]["hands"] = hands + + session_id_bytes = session_id.encode() + + async def send_wrapper(message): + if message["type"] == "http.response.start" and session_id: + headers = list(message.get("headers", [])) + if not any(h[0].lower() == b"x-session-id" for h in headers): + headers.append((b"x-session-id", session_id_bytes)) + message["headers"] = headers + await send(message) + + await self.app(scope, receive, send_wrapper) diff --git a/backend/app/router_layer/session_store.py b/backend/app/router_layer/session_store.py new file mode 100644 index 000000000..c8ac4ba87 --- /dev/null +++ b/backend/app/router_layer/session_store.py @@ -0,0 +1,87 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import time +from abc import ABC, abstractmethod +from typing import Any + + +class ISessionStore(ABC): + @abstractmethod + async def get(self, session_id: str) -> dict[str, Any] | None: ... + + @abstractmethod + async def set( + self, session_id: str, entry: dict[str, Any], ttl: int = 86400 + ) -> None: ... + + @abstractmethod + async def delete(self, session_id: str) -> None: ... + + +class MemorySessionStore(ISessionStore): + _CLEANUP_INTERVAL_SET_CALLS = 128 + _CLEANUP_BATCH_SIZE = 512 + + def __init__(self) -> None: + self._sessions: dict[str, dict[str, Any]] = {} + self._expires_at: dict[str, float] = {} + self._set_calls = 0 + + def _is_expired(self, session_id: str) -> bool: + expires_at = self._expires_at.get(session_id) + if expires_at is None: + return False + return time.time() > expires_at + + def _cleanup_if_expired(self, session_id: str) -> None: + if self._is_expired(session_id): + self._sessions.pop(session_id, None) + self._expires_at.pop(session_id, None) + + def _cleanup_expired_entries(self, max_entries: int) -> None: + if not self._expires_at: + return + now = time.time() + cleaned = 0 + for session_id, expires_at in list(self._expires_at.items()): + if expires_at > now: + continue + self._sessions.pop(session_id, None) + self._expires_at.pop(session_id, None) + cleaned += 1 + if cleaned >= max_entries: + break + + async def get(self, session_id: str) -> dict[str, Any] | None: + self._cleanup_if_expired(session_id) + return self._sessions.get(session_id) + + async def set( + self, session_id: str, entry: dict[str, Any], ttl: int = 86400 + ) -> None: + self._set_calls += 1 + if ( + self._set_calls % self._CLEANUP_INTERVAL_SET_CALLS == 0 + ): # lazy GC for never-read sessions + self._cleanup_expired_entries(self._CLEANUP_BATCH_SIZE) + self._sessions[session_id] = entry + if ttl > 0: + self._expires_at[session_id] = time.time() + ttl + else: + self._expires_at.pop(session_id, None) + + async def delete(self, session_id: str) -> None: + self._sessions.pop(session_id, None) + self._expires_at.pop(session_id, None) diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index b0ea4e606..5ca228847 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -16,6 +16,7 @@ import datetime import logging import platform +from functools import partial from pathlib import Path from typing import Any @@ -43,6 +44,7 @@ from app.agent.toolkit.skill_toolkit import SkillToolkit from app.agent.toolkit.terminal_toolkit import TerminalToolkit from app.agent.tools import get_mcp_tools, get_toolkits +from app.hands.interface import IHands from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json from app.service.task import ( Action, @@ -335,6 +337,9 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): event_loop = asyncio.get_running_loop() sub_tasks: list[Task] = [] + # Phase 4: hands from ChannelSessionMiddleware (desktop=full, web=sandbox, etc.) + hands = getattr(request.state, "hands", None) + logger.info("=" * 80) logger.info( "🚀 [LIFECYCLE] step_solve STARTED", @@ -533,10 +538,13 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): ) try: - simple_resp = question_agent.step(simple_answer_prompt) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[0].content - else: + simple_resp = await question_agent.astep( + simple_answer_prompt + ) + answer_content = _extract_agent_response_content( + simple_resp + ) + if not answer_content: answer_content = ( "I understand your " "question, but I'm " @@ -633,11 +641,15 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): logger.info( "[NEW-QUESTION] Creating NEW workforce instance" ) - (workforce, mcp) = await construct_workforce(options) + (workforce, mcp) = await construct_workforce( + options, hands=hands + ) for new_agent in options.new_agents: workforce.add_single_agent_worker( format_agent_description(new_agent), - await new_agent_model(new_agent, options), + await new_agent_model( + new_agent, options, hands=hands + ), ) task_lock.status = Status.confirmed @@ -1224,14 +1236,15 @@ async def run_decomposition(): ) try: - simple_resp = question_agent.step( + simple_resp = await question_agent.astep( simple_answer_prompt ) - if simple_resp and simple_resp.msgs: - answer_content = simple_resp.msgs[ - 0 - ].content - else: + answer_content = ( + _extract_agent_response_content( + simple_resp + ) + ) + if not answer_content: answer_content = ( "I understand your " "question, but I'm " @@ -1563,7 +1576,7 @@ def on_stream_text(chunk): workforce.pause() workforce.add_single_agent_worker( format_agent_description(item), - await new_agent_model(item, options), + await new_agent_model(item, options, hands=hands), ) workforce.resume() elif item.action == Action.timeout: @@ -1958,18 +1971,12 @@ async def question_confirm( Is this a complex task? (yes/no):""" try: - resp = agent.step(full_prompt) - - if not resp or not resp.msgs or len(resp.msgs) == 0: - logger.warning( - "No response from agent, defaulting to complex task" - ) - return True + resp = await _run_agent_step(agent, full_prompt) - content = resp.msgs[0].content + content = _extract_agent_response_content(resp) if not content: logger.warning( - "Empty content from agent, defaulting to complex task" + "No response from agent, defaulting to complex task" ) return True @@ -2004,8 +2011,8 @@ async def summary_task(agent: ListenChatAgent, task: Task) -> str: """ logger.debug("Generating task summary", extra={"task_id": task.id}) try: - res = agent.step(prompt) - summary = res.msgs[0].content + res = await _run_agent_step(agent, prompt) + summary = _extract_agent_response_content(res) or "" logger.info("Task summary generated", extra={"summary": summary}) return summary except Exception as e: @@ -2056,8 +2063,8 @@ async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: Summary: """ - res = agent.step(prompt) - summary = res.msgs[0].content + res = await _run_agent_step(agent, prompt) + summary = _extract_agent_response_content(res) or "" logger.info( "Generated subtasks summary for " @@ -2068,6 +2075,47 @@ async def summary_subtasks_result(agent: ListenChatAgent, task: Task) -> str: return summary +def _extract_agent_response_content(resp) -> str | None: + if resp is None: + return None + + msg = getattr(resp, "msg", None) + if msg is not None: + content = getattr(msg, "content", None) + if content: + return content + + msgs = getattr(resp, "msgs", None) + if msgs: + first = msgs[0] + content = getattr(first, "content", None) + if content: + return content + + return None + + +async def _run_agent_step(agent: ListenChatAgent, prompt: str): + """Run one model step with backward-compatible priority. + + Some call sites and tests still stub synchronous ``step`` while newer paths + provide ``astep``. Prefer ``step`` when available to preserve existing + behavior, and fall back to ``astep``. + """ + step_fn = getattr(agent, "step", None) + if callable(step_fn): + result = step_fn(prompt) + if asyncio.iscoroutine(result): + return await result + return result + + astep_fn = getattr(agent, "astep", None) + if callable(astep_fn): + return await astep_fn(prompt) + + raise AttributeError("Agent has neither step nor astep") + + async def get_task_result_with_optional_summary( task: Task, options: Chat ) -> str: @@ -2083,6 +2131,24 @@ async def get_task_result_with_optional_summary( """ result = str(task.result or "") + def _is_failed_state(state) -> bool: + if state is None: + return False + state_name = getattr(state, "name", str(state)) + return "fail" in str(state_name).lower() + + has_failed_subtask = any( + _is_failed_state(getattr(subtask, "state", None)) + for subtask in (task.subtasks or []) + ) + + if has_failed_subtask: + logger.info( + "Task %s has failed subtasks, skipping LLM summary to finish quickly", + task.id, + ) + return result + if task.subtasks and len(task.subtasks) > 1: logger.info( f"Task {task.id} has " @@ -2110,12 +2176,16 @@ async def get_task_result_with_optional_summary( async def construct_workforce( options: Chat, + hands: IHands | None = None, ) -> tuple[Workforce, ListenChatAgent]: """Construct a workforce with all required agents. This function creates all agents in PARALLEL to minimize startup time. Sync functions are run in thread pool, async functions are awaited concurrently. + + When hands is passed, base agents add tools based on Brain capabilities: + hands.can_execute_terminal(), hands.can_use_browser(), etc. determine whether terminal/browser hands are enabled. """ logger.debug( "construct_workforce started", @@ -2226,10 +2296,12 @@ def _create_new_worker_agent() -> ListenChatAgent: results = await asyncio.gather( asyncio.to_thread(_create_coordinator_and_task_agents), asyncio.to_thread(_create_new_worker_agent), - asyncio.to_thread(browser_agent, options), - developer_agent(options), - document_agent(options), - asyncio.to_thread(multi_modal_agent, options), + asyncio.to_thread(partial(browser_agent, options, hands=hands)), + developer_agent(options, hands=hands), + document_agent(options, hands=hands), + asyncio.to_thread( + partial(multi_modal_agent, options, hands=hands) + ), mcp_agent(options), ) except Exception as e: @@ -2237,13 +2309,6 @@ def _create_new_worker_agent() -> ListenChatAgent: f"Failed to create agents in parallel: {e}", exc_info=True ) raise - finally: - # Always clear event loop reference after - # parallel agent creation completes. - # This prevents stale references and - # potential cross-request interference - set_main_event_loop(None) - # Unpack results ( coord_task_agents, @@ -2347,7 +2412,11 @@ def format_agent_description(agent_data: NewAgent | ActionNewAgent) -> str: return " ".join(description_parts) -async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): +async def new_agent_model( + data: NewAgent | ActionNewAgent, + options: Chat, + hands: IHands | None = None, +): logger.info( "Creating new agent", extra={ @@ -2361,21 +2430,26 @@ async def new_agent_model(data: NewAgent | ActionNewAgent, options: Chat): ) working_directory = get_working_directory(options) tool_names = [] - tools = [*await get_toolkits(data.tools, data.name, options.project_id)] + tools = [ + *await get_toolkits( + data.tools, data.name, options.project_id, hands=hands + ) + ] for item in data.tools: tool_names.append(titleize(item)) - # Always include terminal_toolkit with proper working directory - terminal_toolkit = TerminalToolkit( - options.project_id, - agent_name=data.name, - working_directory=working_directory, - safe_mode=True, - clone_current_env=True, - ) - tools.extend(terminal_toolkit.get_tools()) - tool_names.append(titleize("terminal_toolkit")) + # Add terminal_toolkit when terminal hand is available + if hands is None or hands.can_execute_terminal(): + terminal_toolkit = TerminalToolkit( + options.project_id, + agent_name=data.name, + working_directory=working_directory, + safe_mode=True, + clone_current_env=True, + ) + tools.extend(terminal_toolkit.get_tools()) + tool_names.append(titleize("terminal_toolkit")) if data.mcp_tools is not None: - tools = [*tools, *await get_mcp_tools(data.mcp_tools)] + tools = [*tools, *await get_mcp_tools(data.mcp_tools, hands=hands)] for item in data.mcp_tools["mcpServers"].keys(): tool_names.append(titleize(item)) for item in tools: diff --git a/backend/app/service/mcp_config.py b/backend/app/service/mcp_config.py new file mode 100644 index 000000000..8658dae30 --- /dev/null +++ b/backend/app/service/mcp_config.py @@ -0,0 +1,105 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import json +import logging +from pathlib import Path + +logger = logging.getLogger("mcp_config") + +MCP_CONFIG_DIR = Path.home() / ".eigent" +MCP_CONFIG_PATH = MCP_CONFIG_DIR / "mcp.json" + + +def _normalize_args(args) -> list[str]: + """Normalize args to list of strings.""" + if args is None: + return [] + if isinstance(args, str): + try: + parsed = json.loads(args) + return ( + [str(x) for x in parsed] + if isinstance(parsed, list) + else [args] + ) + except json.JSONDecodeError: + return [x.strip() for x in args.split(",") if x.strip()] + if isinstance(args, list): + return [str(x) for x in args] + return [] + + +def _normalize_mcp(mcp: dict) -> dict: + """Normalize MCP server config.""" + out = dict(mcp) + if "args" in out: + out["args"] = _normalize_args(out["args"]) + return out + + +def get_mcp_config_path() -> Path: + return MCP_CONFIG_PATH + + +def read_mcp_config() -> dict: + """Read MCP config from ~/.eigent/mcp.json.""" + if not MCP_CONFIG_PATH.exists(): + default = {"mcpServers": {}} + write_mcp_config(default) + return default + try: + data = MCP_CONFIG_PATH.read_text(encoding="utf-8") + parsed = json.loads(data) + if not isinstance(parsed.get("mcpServers"), dict): + return {"mcpServers": {}} + for name, server in parsed["mcpServers"].items(): + if isinstance(server, dict): + parsed["mcpServers"][name] = _normalize_mcp(server) + return parsed + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read MCP config: %s, using default", e) + return {"mcpServers": {}} + + +def write_mcp_config(config: dict) -> None: + """Write MCP config to ~/.eigent/mcp.json.""" + MCP_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + MCP_CONFIG_PATH.write_text( + json.dumps(config, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +def add_mcp(name: str, mcp: dict) -> None: + """Add MCP server to config.""" + config = read_mcp_config() + if name not in config["mcpServers"]: + config["mcpServers"][name] = _normalize_mcp(mcp) + write_mcp_config(config) + + +def remove_mcp(name: str) -> None: + """Remove MCP server from config.""" + config = read_mcp_config() + if name in config["mcpServers"]: + del config["mcpServers"][name] + write_mcp_config(config) + + +def update_mcp(name: str, mcp: dict) -> None: + """Update MCP server in config.""" + config = read_mcp_config() + config["mcpServers"][name] = _normalize_mcp(mcp) + write_mcp_config(config) diff --git a/backend/app/service/skill_config_service.py b/backend/app/service/skill_config_service.py new file mode 100644 index 000000000..933256856 --- /dev/null +++ b/backend/app/service/skill_config_service.py @@ -0,0 +1,131 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +"""Skills config: ~/.eigent//skills-config.json.""" + +import json +import logging +import time +from pathlib import Path + +logger = logging.getLogger("skill_config") + +EIGENT_ROOT = Path.home() / ".eigent" + + +def _config_path(user_id: str) -> Path: + return EIGENT_ROOT / str(user_id) / "skills-config.json" + + +def _load_config(user_id: str) -> dict: + path = _config_path(user_id) + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + default = {"version": 1, "skills": {}} + path.write_text(json.dumps(default, indent=2, ensure_ascii=False)) + return default + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load skill config: %s", e) + return {"version": 1, "skills": {}} + + +def _save_config(user_id: str, config: dict) -> None: + path = _config_path(user_id) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config, indent=2, ensure_ascii=False)) + + +def _ensure_skills_key(config: dict) -> dict: + if "skills" not in config: + config["skills"] = {} + return config + + +def skill_config_load(user_id: str) -> dict: + """Load skills config for user.""" + config = _load_config(user_id) + return _ensure_skills_key(config) + + +def skill_config_init(user_id: str) -> dict: + """Load or create config, merge default from example-skills if present.""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + + # Try to merge default-config.json from example-skills (same as Electron) + try: + backend_root = Path(__file__).resolve().parent.parent.parent + default_path = ( + backend_root.parent + / "resources" + / "example-skills" + / "default-config.json" + ) + if default_path.exists(): + default = json.loads(default_path.read_text(encoding="utf-8")) + if default.get("skills"): + for skill_name, skill_cfg in default["skills"].items(): + if skill_name not in config["skills"]: + config["skills"][skill_name] = { + **skill_cfg, + "addedAt": int(time.time() * 1000), + } + logger.info( + "Initialized config for example skill: %s", + skill_name, + ) + _save_config(user_id, config) + except Exception as e: + logger.warning("Failed to merge default config: %s", e) + + return config + + +def skill_config_update( + user_id: str, skill_name: str, skill_config: dict +) -> None: + """Update config for a skill (merge with existing, don't replace entirely).""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + existing = config["skills"].get(skill_name, {}) + config["skills"][skill_name] = {**existing, **skill_config} + _save_config(user_id, config) + + +def skill_config_delete(user_id: str, skill_name: str) -> None: + """Remove skill from config.""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + if skill_name in config["skills"]: + del config["skills"][skill_name] + _save_config(user_id, config) + + +def skill_config_toggle(user_id: str, skill_name: str, enabled: bool) -> dict: + """Toggle skill enabled state.""" + config = _load_config(user_id) + config = _ensure_skills_key(config) + if skill_name not in config["skills"]: + config["skills"][skill_name] = { + "enabled": enabled, + "scope": {"isGlobal": True, "selectedAgents": []}, + "addedAt": int(time.time() * 1000), + "isExample": False, + } + else: + config["skills"][skill_name]["enabled"] = enabled + _save_config(user_id, config) + return config["skills"][skill_name] diff --git a/backend/app/service/skill_service.py b/backend/app/service/skill_service.py new file mode 100644 index 000000000..f65f2f593 --- /dev/null +++ b/backend/app/service/skill_service.py @@ -0,0 +1,300 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import logging +import re +import shutil +import tempfile +import zipfile +from pathlib import Path + +SKILLS_ROOT = Path.home() / ".eigent" / "skills" +SKILL_FILE = "SKILL.md" +logger = logging.getLogger("skill_service") + + +def _parse_skill_frontmatter(content: str) -> dict | None: + """Parse name and description from SKILL.md frontmatter.""" + if not content.startswith("---"): + return None + end = content.find("\n---", 3) + block = content[4:end] if end > 0 else content[4:] + name_match = re.search(r"^\s*name\s*:\s*(.+)$", block, re.MULTILINE) + desc_match = re.search(r"^\s*description\s*:\s*(.+)$", block, re.MULTILINE) + name = name_match.group(1).strip().strip("'\"") if name_match else None + desc = desc_match.group(1).strip().strip("'\"") if desc_match else None + if name and desc: + return {"name": name, "description": desc} + return None + + +def _assert_under_skills_root(target: Path) -> Path: + """Ensure path is under SKILLS_ROOT (security).""" + root = SKILLS_ROOT.resolve() + resolved = target.resolve() + try: + resolved.relative_to(root) + except ValueError: + raise PermissionError("Path is outside skills directory") + return resolved + + +def skills_scan() -> list[dict]: + """Scan skills directory and return list of skills with metadata.""" + if not SKILLS_ROOT.exists(): + return [] + skills = [] + for entry in SKILLS_ROOT.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + skill_path = entry / SKILL_FILE + try: + raw = skill_path.read_text(encoding="utf-8") + meta = _parse_skill_frontmatter(raw) + if meta: + skills.append( + { + "name": meta["name"], + "description": meta["description"], + "path": str(skill_path), + "scope": "user", + "skillDirName": entry.name, + } + ) + except (OSError, UnicodeDecodeError): + pass + return skills + + +def skill_get_path_by_name(skill_name: str) -> str | None: + """Return the absolute directory path for a skill by its display name, or None if not found.""" + if not SKILLS_ROOT.exists(): + return None + name_lower = (skill_name or "").strip().lower() + if not name_lower: + return None + for entry in SKILLS_ROOT.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + skill_path = entry / SKILL_FILE + if not skill_path.exists(): + continue + try: + meta = _parse_skill_frontmatter( + skill_path.read_text(encoding="utf-8") + ) + if meta and meta.get("name", "").lower().strip() == name_lower: + return str(entry.resolve()) + except (OSError, UnicodeDecodeError): + pass + return None + + +def skill_write(skill_dir_name: str, content: str) -> None: + """Write SKILL.md for a skill.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + dir_path = _assert_under_skills_root(SKILLS_ROOT / name) + dir_path.mkdir(parents=True, exist_ok=True) + (dir_path / SKILL_FILE).write_text(content, encoding="utf-8") + + +def skill_read(skill_dir_name: str) -> str: + """Read SKILL.md content.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + skill_path = _assert_under_skills_root(SKILLS_ROOT / name / SKILL_FILE) + return skill_path.read_text(encoding="utf-8") + + +def skill_delete(skill_dir_name: str) -> None: + """Delete skill directory.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + dir_path = _assert_under_skills_root(SKILLS_ROOT / name) + if dir_path.exists(): + import shutil + + shutil.rmtree(dir_path) + + +def skill_list_files(skill_dir_name: str) -> list[str]: + """List files in skill directory.""" + name = (skill_dir_name or "").strip() + if not name: + raise ValueError("Skill folder name is required") + dir_path = _assert_under_skills_root(SKILLS_ROOT / name) + if not dir_path.exists(): + return [] + return [e.name for e in dir_path.iterdir()] + + +def _get_skill_name_from_file(skill_file_path: Path) -> str: + """Extract skill name from SKILL.md frontmatter.""" + try: + raw = skill_file_path.read_text(encoding="utf-8") + name_match = re.search(r"^\s*name\s*:\s*(.+)$", raw, re.MULTILINE) + parsed = ( + name_match.group(1).strip().strip("'\"") if name_match else None + ) + return parsed or skill_file_path.parent.name + except (OSError, UnicodeDecodeError): + return skill_file_path.parent.name + + +def _folder_name_from_skill_name(skill_name: str, fallback: str) -> str: + """Derive safe folder name from skill display name.""" + cleaned = ( + (skill_name or "") + .replace("\\", "-") + .replace("/", "-") + .replace("*", "-") + .replace("?", "-") + .replace(":", "-") + .replace('"', "-") + .replace("<", "-") + .replace(">", "-") + .replace("|", "-") + .replace(" ", "-") + ) + cleaned = re.sub(r"-+", "-", cleaned).strip("-") + return cleaned or fallback + + +def skill_import_zip( + zip_bytes: bytes, + replacements: list[str] | None = None, +) -> dict: + """ + Import skills from a zip archive. + Returns {success, error?, conflicts?} matching Electron IPC contract. + """ + replacements_set = set(replacements or []) + temp_dir = Path(tempfile.mkdtemp(prefix="eigent-skill-extract-")) + try: + SKILLS_ROOT.mkdir(parents=True, exist_ok=True) + + # Step 1: Extract zip into temp directory + with zipfile.ZipFile(__import__("io").BytesIO(zip_bytes), "r") as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + name = info.filename.replace("\\", "/").lstrip("/") + if ".." in name or name.startswith("/"): + return { + "success": False, + "error": "Zip archive contains unsafe paths", + } + dest = temp_dir / name + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(zf.read(info)) + + # Step 2: Find all SKILL.md files + skill_files: list[Path] = [] + + def find_skill_md(d: Path) -> None: + for entry in d.iterdir(): + if entry.name.startswith("."): + continue + if entry.is_dir(): + find_skill_md(entry) + elif entry.name == SKILL_FILE: + skill_files.append(entry) + + find_skill_md(temp_dir) + + if not skill_files: + return { + "success": False, + "error": "No SKILL.md files found in zip archive", + } + + # Step 3: Build existing skill names map + existing_names: dict[str, str] = {} + if SKILLS_ROOT.exists(): + for entry in SKILLS_ROOT.iterdir(): + if not entry.is_dir() or entry.name.startswith("."): + continue + skill_file = entry / SKILL_FILE + if not skill_file.exists(): + continue + try: + meta = _parse_skill_frontmatter( + skill_file.read_text(encoding="utf-8") + ) + if meta and meta.get("name"): + existing_names[meta["name"].lower()] = entry.name + except (OSError, UnicodeDecodeError): + pass + + conflicts: list[dict] = [] + + for skill_file in skill_files: + skill_dir = skill_file.parent + incoming_name = _get_skill_name_from_file(skill_file) + incoming_lower = incoming_name.lower() + + fallback = ( + "imported-skill" if skill_dir == temp_dir else skill_dir.name + ) + dest_folder = _folder_name_from_skill_name(incoming_name, fallback) + dest_path = SKILLS_ROOT / dest_folder + + existing_folder = existing_names.get(incoming_lower) + if existing_folder: + if replacements is None: + conflicts.append( + { + "folderName": existing_folder, + "skillName": incoming_name, + } + ) + continue + if existing_folder in replacements_set: + shutil.rmtree( + SKILLS_ROOT / existing_folder, ignore_errors=True + ) + else: + continue + + dest_path.mkdir(parents=True, exist_ok=True) + if skill_dir == temp_dir: + for item in temp_dir.iterdir(): + dest_item = dest_path / item.name + if item.is_dir(): + shutil.copytree(item, dest_item, dirs_exist_ok=True) + else: + shutil.copy2(item, dest_item) + else: + for item in skill_dir.iterdir(): + dest_item = dest_path / item.name + if item.is_dir(): + shutil.copytree(item, dest_item, dirs_exist_ok=True) + else: + shutil.copy2(item, dest_item) + + if conflicts and replacements is None: + return {"success": False, "conflicts": conflicts} + + return {"success": True} + except zipfile.BadZipFile: + return {"success": False, "error": "Invalid zip file"} + except Exception: + logger.exception("Failed to import skills from zip archive") + return {"success": False, "error": "Failed to import skills"} + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/backend/app/service/task.py b/backend/app/service/task.py index 604fbc717..1696535f3 100644 --- a/backend/app/service/task.py +++ b/backend/app/service/task.py @@ -36,6 +36,8 @@ logger = logging.getLogger("task_service") +TASK_LOCK_CLEANUP_SENTINEL = "__task_lock_cleanup__" + class Action(str, Enum): improve = "improve" # user -> backend @@ -444,6 +446,16 @@ async def cleanup(self): pass self.background_tasks.clear() + # Unblock agents waiting on human input so shutdown can proceed. + for agent, queue in self.human_input.items(): + try: + queue.put_nowait(TASK_LOCK_CLEANUP_SENTINEL) + except asyncio.QueueFull: + logger.debug( + "Human input queue already full during cleanup", + extra={"task_id": self.id, "agent": agent}, + ) + # Clean up registered toolkits (e.g., remove TerminalToolkit venvs) for toolkit in self.registered_toolkits: try: diff --git a/backend/app/utils/browser_launcher.py b/backend/app/utils/browser_launcher.py new file mode 100644 index 000000000..be0127068 --- /dev/null +++ b/backend/app/utils/browser_launcher.py @@ -0,0 +1,382 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +""" +Browser launcher for Web mode: ensures a CDP-capable browser is running +when Electron is not available (e.g. web + brain mode). + +Previously, Electron provided the CDP browser via remote-debugging-port. +In web mode, Brain launches Chrome/Chromium directly. +""" + +import json +import logging +import os +import platform +import socket +import subprocess +from pathlib import Path +from urllib.parse import urlparse + +logger = logging.getLogger("browser_launcher") + +# Default CDP port (must match browser_port in Chat model) +DEFAULT_CDP_PORT = 9222 +FALLBACK_CDP_PORT_START = 9223 +FALLBACK_CDP_PORT_END = 9299 +LOCAL_CDP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + + +def is_local_cdp_host(host: str | None) -> bool: + """Return whether the CDP endpoint host points at the local machine.""" + if not host: + return True + return host.lower() in LOCAL_CDP_HOSTS + + +def normalize_cdp_url( + cdp_url: str, + *, + default_host: str = "127.0.0.1", + default_port: int = DEFAULT_CDP_PORT, +) -> tuple[str, str, int]: + """Normalize a CDP endpoint into ``scheme://host:port`` form.""" + raw_url = cdp_url.strip() + if raw_url.isdigit(): + port = int(raw_url) + return f"http://{default_host}:{port}", default_host, port + + parsed = urlparse(raw_url if "://" in raw_url else f"http://{raw_url}") + scheme = parsed.scheme or "http" + host = parsed.hostname or default_host + port = parsed.port or default_port + return f"{scheme}://{host}:{port}", host, port + + +def is_cdp_url_available(cdp_url: str) -> bool: + """Check whether a CDP endpoint is reachable.""" + normalized, host, port = normalize_cdp_url(cdp_url) + if is_local_cdp_host(host): + return _is_cdp_available(port) + + try: + import httpx + + r = httpx.get(f"{normalized}/json/version", timeout=2.0) + if r.status_code != 200: + return False + return _is_supported_cdp_version(r.json(), normalized) + except Exception: + return False + + +def _is_port_in_use(port: int) -> bool: + """Check if a port is in use.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + return s.connect_ex(("127.0.0.1", port)) == 0 + + +def _is_cdp_available(port: int) -> bool: + """Check if a Playwright-compatible CDP browser is listening.""" + try: + import httpx + + r = httpx.get(f"http://127.0.0.1:{port}/json/version", timeout=2.0) + if r.status_code != 200: + return False + return _is_supported_cdp_version(r.json(), f"http://127.0.0.1:{port}") + except Exception: + return False + + +def _is_supported_cdp_version(data: dict, endpoint: str) -> bool: + """Reject CDP endpoints that Playwright cannot manage.""" + browser = str(data.get("Browser") or "") + user_agent = str(data.get("User-Agent") or "") + websocket_url = data.get("webSocketDebuggerUrl") + combined = f"{browser} {user_agent}" + + if not websocket_url: + logger.debug( + "[BROWSER LAUNCHER] CDP endpoint has no browser websocket" + ) + return False + + if "Electron" in combined: + logger.warning( + "[BROWSER LAUNCHER] Ignoring Electron DevTools endpoint at %s; " + "Browser Agent requires a managed Chrome/Chromium CDP browser.", + endpoint, + ) + return False + + if not any( + token in combined + for token in ("Chrome/", "Chromium", "HeadlessChrome/") + ): + logger.warning( + "[BROWSER LAUNCHER] Ignoring unsupported CDP endpoint at %s: %s", + endpoint, + browser or user_agent or "unknown browser", + ) + return False + + if not _supports_browser_context_management(str(websocket_url), endpoint): + return False + + return True + + +def _supports_browser_context_management( + websocket_url: str, + endpoint: str, +) -> bool: + """Probe the browser-level CDP socket for Playwright compatibility.""" + try: + from websockets.sync.client import connect + + command = { + "id": 1, + "method": "Browser.setDownloadBehavior", + "params": {"behavior": "default"}, + } + with connect(websocket_url, open_timeout=2, close_timeout=1) as ws: + ws.send(json.dumps(command)) + response = json.loads(ws.recv(timeout=2)) + except Exception as exc: + logger.warning( + "[BROWSER LAUNCHER] Could not validate CDP capabilities at %s: %s", + endpoint, + exc, + ) + return False + + error = response.get("error") + if error: + message = error.get("message") if isinstance(error, dict) else error + logger.warning( + "[BROWSER LAUNCHER] Ignoring CDP endpoint at %s; " + "it does not support browser context management: %s", + endpoint, + message, + ) + return False + + return True + + +def _candidate_ports(preferred_port: int): + yield preferred_port + for port in range(FALLBACK_CDP_PORT_START, FALLBACK_CDP_PORT_END + 1): + if port != preferred_port: + yield port + + +def _find_chrome_executable() -> str | None: + """Find Chrome or Chromium executable for launching with CDP.""" + system = platform.system() + + # 1. Try Playwright's Chromium (most reliable, cross-platform) + try: + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + path = p.chromium.executable_path + if path and Path(path).exists(): + logger.debug(f"Using Playwright Chromium: {path}") + return path + except Exception as e: + logger.debug(f"Playwright Chromium not available: {e}") + + # 2. Platform-specific paths + if system == "Darwin": + candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + ] + elif system == "Linux": + candidates = [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + ] + elif system == "Windows": + candidates = [ + os.path.expandvars( + r"%ProgramFiles%\Google\Chrome\Application\chrome.exe" + ), + os.path.expandvars( + r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe" + ), + ] + else: + candidates = [] + + for path in candidates: + if path and Path(path).exists(): + logger.debug(f"Using system Chrome: {path}") + return path + + # 3. Try executable from PATH + for name in ("google-chrome", "chromium", "chromium-browser", "chrome"): + exe = _which(name) + if exe: + return exe + + return None + + +def _which(name: str) -> str | None: + """Find executable in PATH.""" + for path in os.environ.get("PATH", "").split(os.pathsep): + exe = Path(path) / name + if exe.exists(): + return str(exe) + return None + + +def _launch_browser( + executable: str, port: int, user_data_dir: str +) -> subprocess.Popen | None: + """Launch browser with CDP enabled. Returns process or None on failure.""" + profile_dir = Path(user_data_dir).expanduser() + profile_dir.mkdir(parents=True, exist_ok=True) + + args = [ + executable, + f"--remote-debugging-port={port}", + f"--user-data-dir={profile_dir}", + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "about:blank", + ] + + try: + proc = subprocess.Popen( + args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + logger.info( + f"[BROWSER LAUNCHER] Launched browser on port {port} (PID={proc.pid})" + ) + return proc + except Exception as e: + logger.error( + f"[BROWSER LAUNCHER] Failed to launch: {e}", exc_info=True + ) + return None + + +def ensure_cdp_browser_available(port: int = DEFAULT_CDP_PORT) -> bool: + """ + Ensure a CDP-capable browser is running on the given port. + + If no browser is listening, attempts to launch Chrome/Chromium. + Used in web mode when Electron is not available to provide CDP. + + Returns: + True if CDP is available (either already running or newly launched), + False otherwise. + """ + # Check if auto-launch is disabled + if os.environ.get("EIGENT_BRAIN_LAUNCH_BROWSER", "true").lower() in ( + "false", + "0", + "no", + ): + logger.debug("[BROWSER LAUNCHER] Auto-launch disabled by env") + return _is_cdp_available(port) + + # Already available + if _is_cdp_available(port): + logger.debug( + f"[BROWSER LAUNCHER] CDP already available on port {port}" + ) + return True + + # Port in use but not CDP (e.g. another service) + if _is_port_in_use(port): + logger.warning( + f"[BROWSER LAUNCHER] Port {port} in use but not CDP. " + "Another service may be using it." + ) + return False + + # Launch browser + executable = _find_chrome_executable() + if not executable: + logger.error( + "[BROWSER LAUNCHER] No Chrome/Chromium found. " + "Run: playwright install chromium" + ) + return False + + user_data_dir = os.path.expanduser( + f"~/.eigent/browser_profiles/cdp_brain_{port}" + ) + proc = _launch_browser(executable, port, user_data_dir) + if not proc: + return False + + # Poll for readiness (max 10s) + import time + + for _ in range(20): + time.sleep(0.5) + if _is_cdp_available(port): + logger.info(f"[BROWSER LAUNCHER] CDP ready on port {port}") + return True + + logger.warning( + "[BROWSER LAUNCHER] Browser launched but CDP not ready after 10s" + ) + return False + + +def ensure_cdp_browser_endpoint( + preferred_port: int = DEFAULT_CDP_PORT, +) -> str | None: + """ + Ensure a managed CDP browser exists and return its endpoint. + + If the preferred port is occupied by Electron's own DevTools endpoint, use + the next available local port instead of handing that endpoint to + Playwright. + """ + for port in _candidate_ports(preferred_port): + if _is_cdp_available(port): + return f"http://127.0.0.1:{port}" + + if _is_port_in_use(port): + logger.warning( + "[BROWSER LAUNCHER] Port %s is occupied by a non-managed " + "or unsupported CDP endpoint; trying another port.", + port, + ) + continue + + if ensure_cdp_browser_available(port): + return f"http://127.0.0.1:{port}" + + logger.error( + "[BROWSER LAUNCHER] No available CDP browser port in %s-%s", + preferred_port, + FALLBACK_CDP_PORT_END, + ) + return None diff --git a/backend/app/utils/event_loop_utils.py b/backend/app/utils/event_loop_utils.py index 13207054e..8d4a97c99 100644 --- a/backend/app/utils/event_loop_utils.py +++ b/backend/app/utils/event_loop_utils.py @@ -51,7 +51,7 @@ def _schedule_async_task(coro): try: # Try to get the running loop (works in main event loop thread) loop = asyncio.get_running_loop() - loop.create_task(coro) + return loop.create_task(coro) except RuntimeError: # No running loop in this thread (we're in a worker thread) # First try contextvars, then fallback to global reference @@ -60,10 +60,14 @@ def _schedule_async_task(coro): with _GLOBAL_MAIN_LOOP_LOCK: main_loop = _GLOBAL_MAIN_LOOP if main_loop is not None and main_loop.is_running(): - asyncio.run_coroutine_threadsafe(coro, main_loop) + return asyncio.run_coroutine_threadsafe(coro, main_loop) else: # This should not happen in normal operation - log error and skip + close = getattr(coro, "close", None) + if callable(close): + close() logging.error( "No event loop available for async task scheduling, task skipped. " "Ensure set_main_event_loop() is called before parallel agent creation." ) + return None diff --git a/backend/app/utils/listen/toolkit_listen.py b/backend/app/utils/listen/toolkit_listen.py index 1c2c9bbc6..f9db611d4 100644 --- a/backend/app/utils/listen/toolkit_listen.py +++ b/backend/app/utils/listen/toolkit_listen.py @@ -231,7 +231,7 @@ def run_in_thread(): logger.error(f"[SAFE_PUT_QUEUE] Thread failed: {e}") result_queue.put(("error", e)) - thread = threading.Thread(target=run_in_thread, daemon=False) + thread = threading.Thread(target=run_in_thread, daemon=True) thread.start() # Wait briefly for completion diff --git a/backend/app/utils/server/sync_step.py b/backend/app/utils/server/sync_step.py index ec2d8f514..aec4a6d98 100644 --- a/backend/app/utils/server/sync_step.py +++ b/backend/app/utils/server/sync_step.py @@ -18,14 +18,13 @@ High-frequency events (decompose_text) are batched to reduce API calls. Config (~/.eigent/.env): - SERVER_URL=https://dev.eigent.ai/api + SERVER_URL=https://dev.eigent.ai/api/v1 """ import asyncio import json import logging import time -from functools import lru_cache import httpx @@ -39,27 +38,54 @@ # Buffer storage: task_id -> accumulated text _text_buffers: dict[str, str] = {} +_warned_missing_auth_projects: set[str] = set() +_warned_missing_server_url_projects: set[str] = set() +_logged_sync_targets: set[str] = set() +_logged_first_sync_tasks: set[str] = set() -@lru_cache(maxsize=1) -def _get_config(): - server_url = env("SERVER_URL", "") +def _normalize_server_url(server_url: str | None) -> str: + if not server_url: + return "" + + trimmed = server_url.rstrip("/") + if trimmed.endswith("/api/v1"): + return trimmed + return f"{trimmed}/api/v1" + + +def _get_config(args): + server_url = ( + getattr(args[0], "server_url", None) + if args and hasattr(args[0], "server_url") + else None + ) + + if not server_url: + server_url = env("SERVER_URL", "") + + server_url = _normalize_server_url(server_url) if not server_url: return None - return f"{server_url.rstrip('/')}/chat/steps" + return f"{server_url}/chat/steps" def sync_step(func): async def wrapper(*args, **kwargs): - config = _get_config() + config = _get_config(args) if not config: + _warn_missing_server_url(args) async for value in func(*args, **kwargs): yield value return + if config not in _logged_sync_targets: + _logged_sync_targets.add(config) + logger.info("Cloud step sync enabled: %s", config) + async for value in func(*args, **kwargs): _try_sync(args, value, config) yield value @@ -76,18 +102,23 @@ def _try_sync(args, value, sync_url): if not task_id: return + headers = _get_auth_headers(args) + if headers is None: + _warn_missing_auth(args) + return + step = data.get("step") # Batch decompose_text events to reduce API calls if step == "decompose_text": _buffer_text(task_id, data["data"].get("content", "")) if _should_flush(task_id): - _flush_buffer(task_id, sync_url) + _flush_buffer(task_id, sync_url, headers) return # Flush any buffered text before sending other events (preserves order) if task_id in _text_buffers: - _flush_buffer(task_id, sync_url) + _flush_buffer(task_id, sync_url, headers) payload = { "task_id": task_id, @@ -96,7 +127,16 @@ def _try_sync(args, value, sync_url): "timestamp": time.time_ns() / 1_000_000_000, } - asyncio.create_task(_send(sync_url, payload)) + if task_id not in _logged_first_sync_tasks: + _logged_first_sync_tasks.add(task_id) + logger.info( + "Scheduling first cloud step sync: task_id=%s, step=%s, url=%s", + task_id, + step, + sync_url, + ) + + asyncio.create_task(_send(sync_url, payload, headers)) def _buffer_text(task_id: str, content: str): @@ -113,7 +153,11 @@ def _should_flush(task_id: str) -> bool: return word_count >= BATCH_WORD_THRESHOLD -def _flush_buffer(task_id: str, sync_url: str): +def _flush_buffer( + task_id: str, + sync_url: str, + headers: dict[str, str], +): """Send buffered text and clear buffer.""" text = _text_buffers.pop(task_id, "") if not text: @@ -126,7 +170,7 @@ def _flush_buffer(task_id: str, sync_url: str): "timestamp": time.time_ns() / 1_000_000_000, } - asyncio.create_task(_send(sync_url, payload)) + asyncio.create_task(_send(sync_url, payload, headers)) def _parse_value(value): @@ -162,9 +206,58 @@ def _get_task_id(args): return chat.task_id -async def _send(url, data): +def _get_auth_headers(args) -> dict[str, str] | None: + if len(args) < 2: + return None + + request = args[1] + headers = getattr(request, "headers", None) + if not headers: + return None + + auth_header = headers.get("authorization") + if not auth_header: + return None + + return {"Authorization": auth_header} + + +def _warn_missing_auth(args) -> None: + project_id = getattr(args[0], "project_id", None) if args else None + if not project_id or project_id in _warned_missing_auth_projects: + return + + _warned_missing_auth_projects.add(project_id) + logger.info( + "Skipping cloud step sync because Authorization header is missing " + "for project_id=%s. Replay will be unavailable for this run.", + project_id, + ) + + +def _warn_missing_server_url(args) -> None: + project_id = getattr(args[0], "project_id", None) if args else None + if not project_id or project_id in _warned_missing_server_url_projects: + return + + _warned_missing_server_url_projects.add(project_id) + logger.info( + "Skipping cloud step sync because SERVER_URL is empty for " + "project_id=%s. Replay will be unavailable for this run.", + project_id, + ) + + +async def _send(url, data, headers: dict[str, str]): try: async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(url, json=data) + response = await client.post(url, json=data, headers=headers) + if response.is_error: + logger.error( + "Failed to sync step to %s: HTTP %s: %s", + url, + response.status_code, + response.text[:500], + ) except Exception as e: logger.error(f"Failed to sync step to {url}: {type(e).__name__}: {e}") diff --git a/backend/app/utils/telemetry/workforce_metrics.py b/backend/app/utils/telemetry/workforce_metrics.py index 550357789..6944e6acf 100644 --- a/backend/app/utils/telemetry/workforce_metrics.py +++ b/backend/app/utils/telemetry/workforce_metrics.py @@ -183,6 +183,25 @@ def get_tracer_provider() -> TracerProvider | None: return _GLOBAL_TRACER_PROVIDER +def shutdown_tracer_provider() -> None: + """Shutdown the global TracerProvider and release background threads. + + Call during FastAPI shutdown to ensure BatchSpanProcessor worker threads + are properly released, preventing process hang on exit. + """ + global _GLOBAL_TRACER_PROVIDER + if _GLOBAL_TRACER_PROVIDER is None: + return + try: + _GLOBAL_TRACER_PROVIDER.force_flush(timeout_millis=2000) + _GLOBAL_TRACER_PROVIDER.shutdown() + logger.info("TracerProvider shutdown completed") + except Exception as e: + logger.warning(f"TracerProvider shutdown failed: {e}") + finally: + _GLOBAL_TRACER_PROVIDER = None + + def _create_langfuse_endpoint(base_url: str) -> str: """Create Langfuse OTLP endpoint URL. diff --git a/backend/app/utils/workforce.py b/backend/app/utils/workforce.py index e8d749b51..31efa9cb4 100644 --- a/backend/app/utils/workforce.py +++ b/backend/app/utils/workforce.py @@ -52,6 +52,7 @@ get_camel_task, get_task_lock, ) +from app.utils.event_loop_utils import _schedule_async_task from app.utils.single_agent_worker import SingleAgentWorker from app.utils.telemetry.workforce_metrics import WorkforceMetricsCallback @@ -790,6 +791,10 @@ async def _handle_failed_task(self, task: Task) -> bool: if task.failure_count < max_retries: return result + # Keep parent.subtasks in sync for terminal failures as well so + # downstream result summarization can see that a subtask failed. + self._sync_subtask_to_parent(task) + error_message = "" # Use proper CAMEL pattern for metrics logging metrics_callbacks = [ @@ -907,8 +912,11 @@ def stop(self) -> None: f"new state: {self._state.name}" ) task_lock = get_task_lock(self.api_task_id) - task = asyncio.create_task(task_lock.put_queue(ActionEndData())) - task_lock.add_background_task(task) + # Use thread-safe scheduling: stop() may be called from worker thread + # when task fails (e.g. max retries exceeded) + task = _schedule_async_task(task_lock.put_queue(ActionEndData())) + if task is not None: + task_lock.add_background_task(task) logger.info("[WF-LIFECYCLE] ✅ ActionEndData queued") def stop_gracefully(self) -> None: diff --git a/backend/config/hands_clusters.example.toml b/backend/config/hands_clusters.example.toml new file mode 100644 index 000000000..a93388b01 --- /dev/null +++ b/backend/config/hands_clusters.example.toml @@ -0,0 +1,26 @@ +[defaults] +timeout_seconds = 10 +verify_tls = true +acquire_path = "/acquire" +release_path = "/release" +health_path = "/health" +# auth_token_env = "EIGENT_HANDS_CLUSTER_AUTH_TOKEN" + +[routes] +browser = "browser_pool" +terminal = "terminal_pool" +model = "model_pool" +default = "gateway" + +[clusters.gateway] +base_url = "http://hands-gateway:8080" + +[clusters.browser_pool] +base_url = "http://browser-cluster:8080" +# auth_token_env = "EIGENT_BROWSER_CLUSTER_TOKEN" + +[clusters.terminal_pool] +base_url = "http://terminal-cluster:8080" + +[clusters.model_pool] +base_url = "http://model-cluster:8080" diff --git a/backend/main.py b/backend/main.py index 00321d984..df13432c7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ import pathlib import signal import sys +import threading # Add project root to Python path to import shared utils _project_root = pathlib.Path(__file__).parent.parent @@ -43,6 +44,7 @@ from app import api from app.component.environment import env from app.router import register_routers +from app.utils.event_loop_utils import set_main_event_loop os.environ["PYTHONIOENCODING"] = "utf-8" @@ -55,6 +57,9 @@ prefix = env("url_prefix", "") app_logger.info(f"Loading routers with prefix: '{prefix}'") +app_logger.info( + f"MCP will be at: {prefix}/mcp/list, health at: {prefix}/health" +) register_routers(api, prefix) app_logger.info("All routers loaded successfully") @@ -101,9 +106,16 @@ async def write_pid_file(): @api.on_event("startup") async def startup_event(): global pid_task + set_main_event_loop(asyncio.get_running_loop()) pid_task = asyncio.create_task(write_pid_file()) app_logger.info("PID write task created") + # Initialize EnvironmentHands from Brain deployment (full on local/cloud_vm, sandbox in Docker) + from app.router_layer.hands_resolver import init_environment_hands + + hands = init_environment_hands() + app_logger.info(f"EnvironmentHands initialized: mode={hands.mode}") + # Initialize telemetry tracer provider from app.utils.telemetry.workforce_metrics import ( initialize_tracer_provider, @@ -113,8 +125,10 @@ async def startup_event(): app_logger.info("Telemetry tracer provider initialized") -# Graceful shutdown handler -shutdown_event = asyncio.Event() +@api.on_event("shutdown") +async def shutdown_event_handler(): + r"""Run cleanup when uvicorn receives SIGINT/SIGTERM and shuts down.""" + await cleanup_resources() async def cleanup_resources(): @@ -143,18 +157,43 @@ async def cleanup_resources(): if pid_file.exists(): pid_file.unlink() - app_logger.info("All resources cleaned up successfully") + # Shutdown OpenTelemetry tracer (releases BatchSpanProcessor worker threads) + try: + from app.utils.telemetry.workforce_metrics import ( + shutdown_tracer_provider, + ) + shutdown_tracer_provider() + except Exception as e: + app_logger.warning(f"Telemetry shutdown failed: {e}") -def signal_handler(signum, frame): - r"""Handle shutdown signals""" - app_logger.warning(f"Received shutdown signal: {signum}") - asyncio.create_task(cleanup_resources()) - shutdown_event.set() + # Shutdown TerminalToolkit thread pool (prevents non-daemon threads blocking exit) + try: + from app.agent.toolkit.terminal_toolkit import TerminalToolkit + if TerminalToolkit._thread_pool is not None: + TerminalToolkit._thread_pool.shutdown(wait=False) + TerminalToolkit._thread_pool = None + except Exception as e: + app_logger.warning(f"TerminalToolkit shutdown failed: {e}") -signal.signal(signal.SIGTERM, signal_handler) -signal.signal(signal.SIGINT, signal_handler) + # Best-effort close Browser toolkit WebSocket/Node connections. + # Use a timeout so shutdown stays responsive even if a wrapper is stuck. + try: + from app.agent.toolkit.hybrid_browser_toolkit import ( + websocket_connection_pool, + ) + + await asyncio.wait_for( + websocket_connection_pool.close_all(), timeout=3.0 + ) + except TimeoutError: + app_logger.warning("Browser WebSocket pool shutdown timed out") + except Exception as e: + app_logger.warning(f"Browser WebSocket pool shutdown failed: {e}") + + set_main_event_loop(None) + app_logger.info("All resources cleaned up successfully") # Register cleanup on exit with safe synchronous wrapper @@ -174,3 +213,86 @@ def sync_cleanup(): # Log successful initialization app_logger.info("Application initialization completed successfully") + + +def run_standalone(): + """Run Brain in standalone mode (no Electron dependency).""" + import uvicorn + + port = int(env("EIGENT_BRAIN_PORT", "5001")) + host = env("EIGENT_BRAIN_HOST", "0.0.0.0") # nosec B104 - bind all for Docker/dev + reload = os.environ.get("EIGENT_DEBUG", "").lower() in ("1", "true", "yes") + + app_logger.info( + f"Starting Brain in standalone mode: {host}:{port} (reload={reload})" + ) + if reload: + uvicorn.run( + "main:api", + host=host, + port=port, + reload=reload, + timeout_graceful_shutdown=5, + ) + return + + config = uvicorn.Config( + "main:api", + host=host, + port=port, + reload=False, + timeout_graceful_shutdown=5, + ) + server = uvicorn.Server(config) + server.install_signal_handlers = lambda: None + + force_exit_timer = None + signal_count = {"count": 0} + old_sigint = signal.getsignal(signal.SIGINT) + old_sigterm = signal.getsignal(signal.SIGTERM) + + def _force_exit(signum: int): + signame = signal.Signals(signum).name + app_logger.error( + "Force exiting Brain after %s because graceful shutdown did not finish", + signame, + ) + os._exit(128 + signum) + + def _handle_signal(signum, _frame): + nonlocal force_exit_timer + signame = signal.Signals(signum).name + signal_count["count"] += 1 + + if signal_count["count"] == 1: + app_logger.warning( + "%s received, requesting graceful shutdown. Press Ctrl+C again to force exit.", + signame, + ) + server.should_exit = True + if force_exit_timer is None: + force_exit_timer = threading.Timer( + 5.0, _force_exit, args=(signum,) + ) + force_exit_timer.daemon = True + force_exit_timer.start() + return + + app_logger.error( + "%s received again, force exiting Brain immediately", signame + ) + _force_exit(signum) + + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + try: + server.run() + finally: + if force_exit_timer is not None: + force_exit_timer.cancel() + signal.signal(signal.SIGINT, old_sigint) + signal.signal(signal.SIGTERM, old_sigterm) + + +if __name__ == "__main__": + run_standalone() diff --git a/backend/tests/app/agent/factory/test_browser.py b/backend/tests/app/agent/factory/test_browser.py index aab7aaadf..6182600ad 100644 --- a/backend/tests/app/agent/factory/test_browser.py +++ b/backend/tests/app/agent/factory/test_browser.py @@ -12,6 +12,7 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import os from unittest.mock import MagicMock, patch import pytest @@ -91,3 +92,117 @@ def test_browser_agent_creation(sample_chat_data): assert ( "search" in str(system_message).lower() ) # system_prompt contains search + + +def test_browser_agent_prefers_preconnected_cdp_url(sample_chat_data): + """A browser connected from the Browser page should be reused by the agent.""" + options = Chat(**sample_chat_data) + + from app.service.task import task_locks + + mock_task_lock = MagicMock() + task_locks[options.task_id] = mock_task_lock + + _mod = "app.agent.factory.browser" + with ( + patch(f"{_mod}.agent_model") as mock_agent_model, + patch( + f"{_mod}.get_working_directory", return_value="/tmp/test_workdir" + ), + patch("asyncio.create_task"), + patch(f"{_mod}.HumanToolkit") as mock_human_toolkit, + patch(f"{_mod}.HybridBrowserToolkit") as mock_browser_toolkit, + patch(f"{_mod}.TerminalToolkit") as mock_terminal_toolkit, + patch(f"{_mod}.NoteTakingToolkit") as mock_note_toolkit, + patch(f"{_mod}.ScreenshotToolkit") as mock_screenshot_toolkit, + patch(f"{_mod}.SearchToolkit") as mock_search_toolkit, + patch(f"{_mod}.ToolkitMessageIntegration"), + patch("uuid.uuid4") as mock_uuid, + patch.dict( + os.environ, + {"EIGENT_CDP_URL": "http://worker-17:9222"}, + clear=True, + ), + ): + mock_human_toolkit.get_can_use_tools.return_value = [] + mock_browser_toolkit.return_value.get_tools.return_value = [] + mock_terminal_instance = MagicMock() + mock_terminal_instance.shell_exec = MagicMock() + mock_terminal_toolkit.return_value = mock_terminal_instance + mock_note_toolkit.return_value.get_tools.return_value = [] + mock_screenshot_toolkit.return_value.get_tools.return_value = [] + mock_search_instance = MagicMock() + mock_search_instance.search_google = MagicMock() + mock_search_toolkit.return_value = mock_search_instance + mock_uuid.return_value.__getitem__ = lambda self, key: "test_session" + + mock_agent = MagicMock() + mock_agent_model.return_value = mock_agent + + browser_agent(options) + + assert mock_browser_toolkit.call_args.kwargs["cdp_url"] == ( + "http://worker-17:9222" + ) + + +def test_browser_agent_uses_cdp_browser_endpoint(sample_chat_data): + """Browser pool entries may point at remote Hands endpoints.""" + sample_chat_data["cdp_browsers"] = [ + { + "id": "remote-browser", + "port": 9222, + "endpoint": "http://worker-17:9222", + "isExternal": False, + "name": "Remote Browser", + } + ] + options = Chat(**sample_chat_data) + + from app.agent.factory import browser as browser_factory + from app.service.task import task_locks + + mock_task_lock = MagicMock() + task_locks[options.task_id] = mock_task_lock + + _mod = "app.agent.factory.browser" + try: + with ( + patch(f"{_mod}.agent_model") as mock_agent_model, + patch( + f"{_mod}.get_working_directory", + return_value="/tmp/test_workdir", + ), + patch("asyncio.create_task"), + patch(f"{_mod}.HumanToolkit") as mock_human_toolkit, + patch(f"{_mod}.HybridBrowserToolkit") as mock_browser_toolkit, + patch(f"{_mod}.TerminalToolkit") as mock_terminal_toolkit, + patch(f"{_mod}.NoteTakingToolkit") as mock_note_toolkit, + patch(f"{_mod}.ScreenshotToolkit") as mock_screenshot_toolkit, + patch(f"{_mod}.SearchToolkit") as mock_search_toolkit, + patch(f"{_mod}.ToolkitMessageIntegration"), + patch("uuid.uuid4") as mock_uuid, + ): + mock_human_toolkit.get_can_use_tools.return_value = [] + mock_browser_toolkit.return_value.get_tools.return_value = [] + mock_terminal_instance = MagicMock() + mock_terminal_instance.shell_exec = MagicMock() + mock_terminal_toolkit.return_value = mock_terminal_instance + mock_note_toolkit.return_value.get_tools.return_value = [] + mock_screenshot_toolkit.return_value.get_tools.return_value = [] + mock_search_toolkit.return_value = MagicMock() + mock_uuid.return_value.__getitem__ = ( + lambda self, key: "test_session" + ) + + mock_agent = MagicMock() + mock_agent_model.return_value = mock_agent + + browser_agent(options) + + assert mock_browser_toolkit.call_args.kwargs["cdp_url"] == ( + "http://worker-17:9222" + ) + finally: + browser_factory._cdp_pool_manager.release_by_task(options.task_id) + task_locks.pop(options.task_id, None) diff --git a/backend/tests/app/controller/test_chat_controller.py b/backend/tests/app/controller/test_chat_controller.py index 5366b6dc3..79efd560f 100644 --- a/backend/tests/app/controller/test_chat_controller.py +++ b/backend/tests/app/controller/test_chat_controller.py @@ -13,6 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import os +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -90,6 +91,10 @@ async def test_post_chat_sets_environment_variables( ) as mock_step_solve, patch("app.controller.chat_controller.load_dotenv"), patch("app.controller.chat_controller.set_current_task_id"), + patch( + "app.controller.chat_controller._prepare_browser_for_request", + return_value=True, + ), patch("pathlib.Path.mkdir"), patch("pathlib.Path.home", return_value=MagicMock()), patch.dict(os.environ, {}, clear=True), @@ -111,7 +116,139 @@ async def mock_generator(): assert os.environ.get("CAMEL_MODEL_LOG_ENABLED") == "true" assert os.environ.get("browser_port") == "8080" - def test_improve_chat_success(self, mock_task_lock): + @pytest.mark.asyncio + async def test_post_chat_sets_cdp_url_when_browser_ready( + self, sample_chat_data, mock_request, mock_task_lock + ): + """Web mode should set EIGENT_CDP_URL after successful browser ensure.""" + chat_data = Chat(**sample_chat_data) + mock_request.state = SimpleNamespace() + + with ( + patch( + "app.controller.chat_controller.get_or_create_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.controller.chat_controller.step_solve" + ) as mock_step_solve, + patch( + "app.controller.chat_controller.is_cdp_url_available", + return_value=False, + ), + patch( + "app.controller.chat_controller.ensure_cdp_browser_endpoint", + return_value="http://127.0.0.1:8080", + ), + patch("app.controller.chat_controller.load_dotenv"), + patch("app.controller.chat_controller.set_current_task_id"), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.home", return_value=MagicMock()), + patch.dict(os.environ, {}, clear=True), + ): + + async def mock_generator(): + yield "data: test_response\n\n" + + mock_step_solve.return_value = mock_generator() + + await post(chat_data, mock_request) + + assert os.environ.get("EIGENT_CDP_URL") == "http://127.0.0.1:8080" + assert os.environ.get("browser_port") == "8080" + assert mock_request.state.browser_available is True + + @pytest.mark.asyncio + async def test_post_chat_clears_cdp_url_when_browser_unavailable( + self, sample_chat_data, mock_request, mock_task_lock + ): + """Web mode should mark browser unavailable and clear EIGENT_CDP_URL.""" + chat_data = Chat(**sample_chat_data) + mock_request.state = SimpleNamespace() + + with ( + patch( + "app.controller.chat_controller.get_or_create_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.controller.chat_controller.step_solve" + ) as mock_step_solve, + patch( + "app.controller.chat_controller.is_cdp_url_available", + return_value=False, + ), + patch( + "app.controller.chat_controller.ensure_cdp_browser_endpoint", + return_value=None, + ), + patch("app.controller.chat_controller.load_dotenv"), + patch("app.controller.chat_controller.set_current_task_id"), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.home", return_value=MagicMock()), + patch.dict( + os.environ, + {"EIGENT_CDP_URL": "http://127.0.0.1:9222"}, + clear=True, + ), + ): + + async def mock_generator(): + yield "data: test_response\n\n" + + mock_step_solve.return_value = mock_generator() + + await post(chat_data, mock_request) + + assert "EIGENT_CDP_URL" not in os.environ + assert mock_request.state.browser_available is False + + @pytest.mark.asyncio + async def test_post_chat_preserves_existing_cdp_url( + self, sample_chat_data, mock_request, mock_task_lock + ): + chat_data = Chat(**sample_chat_data) + mock_request.state = SimpleNamespace() + + with ( + patch( + "app.controller.chat_controller.get_or_create_task_lock", + return_value=mock_task_lock, + ), + patch( + "app.controller.chat_controller.step_solve" + ) as mock_step_solve, + patch( + "app.controller.chat_controller.is_cdp_url_available", + return_value=True, + ), + patch( + "app.controller.chat_controller.ensure_cdp_browser_endpoint", + ) as mock_ensure_browser, + patch("app.controller.chat_controller.load_dotenv"), + patch("app.controller.chat_controller.set_current_task_id"), + patch("pathlib.Path.mkdir"), + patch("pathlib.Path.home", return_value=MagicMock()), + patch.dict( + os.environ, + {"EIGENT_CDP_URL": "http://worker-17:9222"}, + clear=True, + ), + ): + + async def mock_generator(): + yield "data: test_response\n\n" + + mock_step_solve.return_value = mock_generator() + + await post(chat_data, mock_request) + + assert os.environ.get("EIGENT_CDP_URL") == "http://worker-17:9222" + assert os.environ.get("browser_port") == "9222" + assert mock_request.state.browser_available is True + mock_ensure_browser.assert_not_called() + + def test_improve_chat_success(self, mock_task_lock, mock_request): """Test successful chat improvement.""" task_id = "test_task_123" supplement_data = SupplementChat(question="Improve this code") @@ -124,15 +261,18 @@ def test_improve_chat_success(self, mock_task_lock): ), patch("asyncio.run") as mock_run, ): - response = improve(task_id, supplement_data) + mock_run.side_effect = lambda coro: coro.close() + response = improve(task_id, supplement_data, mock_request) assert isinstance(response, Response) assert response.status_code == 201 - mock_run.assert_called_once() + assert mock_run.call_count == 2 # put_queue is invoked when creating the coroutine passed to asyncio.run mock_task_lock.put_queue.assert_called_once() - def test_improve_chat_task_done_resets_to_confirming(self, mock_task_lock): + def test_improve_chat_task_done_resets_to_confirming( + self, mock_task_lock, mock_request + ): """Test improvement when task is done resets status to confirming.""" task_id = "test_task_123" supplement_data = SupplementChat(question="Improve this code") @@ -145,7 +285,8 @@ def test_improve_chat_task_done_resets_to_confirming(self, mock_task_lock): ), patch("asyncio.run") as mock_run, ): - response = improve(task_id, supplement_data) + mock_run.side_effect = lambda coro: coro.close() + response = improve(task_id, supplement_data, mock_request) assert mock_task_lock.status == Status.confirming assert isinstance(response, Response) @@ -189,11 +330,12 @@ def test_stop_chat_success(self, mock_task_lock): with ( patch( - "app.controller.chat_controller.get_task_lock", + "app.controller.chat_controller.get_task_lock_if_exists", return_value=mock_task_lock, ), patch("asyncio.run") as mock_run, ): + mock_run.side_effect = lambda coro: coro.close() response = stop(task_id) assert isinstance(response, Response) @@ -428,13 +570,14 @@ def test_improve_with_nonexistent_task(self): """Test improve endpoint with nonexistent task.""" task_id = "nonexistent_task" supplement_data = SupplementChat(question="Improve this code") + request = SimpleNamespace() with patch( "app.controller.chat_controller.get_task_lock", side_effect=KeyError("Task not found"), ): with pytest.raises(KeyError): - improve(task_id, supplement_data) + improve(task_id, supplement_data, request) def test_supplement_with_empty_question(self, mock_task_lock): """Test supplement endpoint with empty question.""" diff --git a/backend/tests/app/controller/test_health_controller.py b/backend/tests/app/controller/test_health_controller.py new file mode 100644 index 000000000..514b597f6 --- /dev/null +++ b/backend/tests/app/controller/test_health_controller.py @@ -0,0 +1,51 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from unittest.mock import patch + +import pytest + +from app.controller import health_controller + +pytestmark = pytest.mark.unit + + +class _FakeHands: + def get_capability_manifest(self): + return {"deployment": "remote_cluster"} + + +@pytest.mark.asyncio +async def test_health_detail_prefers_configured_cdp_url(monkeypatch): + monkeypatch.setenv("EIGENT_CDP_URL", "http://worker-17:9222") + monkeypatch.setenv("browser_port", "9222") + + with ( + patch( + "app.controller.health_controller.get_environment_hands", + return_value=_FakeHands(), + ), + patch( + "app.controller.health_controller.is_cdp_url_available", + return_value=True, + ) as is_cdp_url_available, + patch( + "app.controller.health_controller._is_cdp_available" + ) as is_cdp_available, + ): + response = await health_controller.health_check(detail=True) + + assert response.capabilities["browser_cdp_reachable"] is True + is_cdp_url_available.assert_called_once_with("http://worker-17:9222") + is_cdp_available.assert_not_called() diff --git a/backend/tests/app/controller/test_message_controller.py b/backend/tests/app/controller/test_message_controller.py new file mode 100644 index 000000000..451381253 --- /dev/null +++ b/backend/tests/app/controller/test_message_controller.py @@ -0,0 +1,63 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.router import register_routers +from app.router_layer import ChannelSessionMiddleware + + +def _build_app(prefix: str) -> FastAPI: + app = FastAPI() + register_routers(app, prefix=prefix) + app.add_middleware(ChannelSessionMiddleware) + return app + + +@pytest.mark.unit +def test_message_endpoint_is_prefix_aware(): + app = _build_app("/api/v1") + paths = {route.path for route in app.routes if hasattr(route, "path")} + assert "/api/v1/messages" in paths + assert "/api/v1/v1/messages" not in paths + + +@pytest.mark.unit +def test_message_endpoint_streams_via_start_chat_stream(monkeypatch): + async def fake_start_chat_stream(_chat, _request): + async def _stream(): + yield 'data: {"text":"ok"}\n\n' + + return _stream() + + monkeypatch.setattr( + "app.controller.chat_controller.start_chat_stream", + fake_start_chat_stream, + ) + + app = _build_app("/api/v1") + with TestClient(app) as client: + with client.stream( + "POST", + "/api/v1/messages", + json={"content": "hello"}, + headers={"X-Channel": "web"}, + ) as response: + assert response.status_code == 200 + body = "".join(response.iter_text()) + assert '"text":"ok"' in body + sid = response.headers.get("x-session-id") + assert sid and sid.startswith("sess_") diff --git a/backend/tests/app/controller/test_tool_controller.py b/backend/tests/app/controller/test_tool_controller.py index 22b78e8e8..f814870a9 100644 --- a/backend/tests/app/controller/test_tool_controller.py +++ b/backend/tests/app/controller/test_tool_controller.py @@ -12,12 +12,15 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import os +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from fastapi.testclient import TestClient +from app.controller import tool_controller from app.controller.tool_controller import install_tool @@ -139,6 +142,88 @@ async def test_install_notion_tool_with_complex_tools(self): mock_toolkit.connect.assert_called_once() mock_toolkit.disconnect.assert_called_once() + @pytest.mark.asyncio + async def test_launch_cdp_browser_uses_remote_hands_endpoint( + self, monkeypatch: pytest.MonkeyPatch + ): + class _FakeRemoteHands: + def get_capability_manifest(self): + return {"deployment": "remote_cluster"} + + def acquire_resource( + self, resource_type: str, session_id: str, **kwargs + ): + _ = (resource_type, session_id, kwargs) + return "http://worker-17:9222" + + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + request = SimpleNamespace( + state=SimpleNamespace(hands=_FakeRemoteHands()) + ) + + response = await tool_controller.launch_cdp_browser(request) + + assert response["success"] is True + assert response["endpoint"] == "http://worker-17:9222" + assert response["browser"]["managedBy"] == "remote" + assert response["browser"]["host"] == "worker-17" + assert os.environ["EIGENT_CDP_URL"] == "http://worker-17:9222" + assert os.environ["browser_port"] == "9222" + + tool_controller._clear_connected_cdp_browser() + + @pytest.mark.asyncio + async def test_open_browser_login_uses_dedicated_cookie_port_when_existing( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_LOGIN_BROWSER_CDP_PORT", raising=False) + + with patch( + "app.controller.tool_controller._is_port_in_use", + return_value=True, + ): + response = await tool_controller.open_browser_login() + + assert response["success"] is True + assert response["cdp_port"] == 9323 + assert response["session_id"] == "user_login" + + @pytest.mark.asyncio + async def test_browser_status_uses_dedicated_cookie_port( + self, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_LOGIN_BROWSER_CDP_PORT", raising=False) + + with patch( + "app.controller.tool_controller._is_port_in_use", + return_value=True, + ) as is_port_in_use: + response = await tool_controller.browser_status() + + assert response == {"is_open": True, "cdp_port": 9323} + is_port_in_use.assert_called_once_with(9323) + + def test_remote_browser_hands_rejects_async_manifest(self): + class _AsyncManifestHands: + async def get_capability_manifest(self): + return {"deployment": "remote_cluster"} + + assert not tool_controller._is_remote_browser_hands( + _AsyncManifestHands() + ) + + def test_remote_cdp_endpoint_uses_shared_validation(self): + with patch( + "app.controller.tool_controller.is_cdp_url_available", + return_value=True, + ) as is_cdp_url_available: + assert tool_controller._is_cdp_endpoint_available( + "http://worker-17:9222" + ) + + is_cdp_url_available.assert_called_once_with("http://worker-17:9222") + @pytest.mark.integration class TestToolControllerIntegration: @@ -198,6 +283,74 @@ def test_install_notion_tool_endpoint_with_connection_error( assert data["tools"] == [] assert "warning" in data + def test_launch_cdp_browser_endpoint_integration( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + + with patch( + "app.controller.tool_controller.ensure_cdp_browser_endpoint", + return_value="http://127.0.0.1:9222", + ): + response = client.post("/browser/cdp/launch") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["port"] == 9222 + assert data["browser"]["id"] == "web-cdp-9222" + assert tool_controller._get_connected_cdp_port() == 9222 + + tool_controller._clear_connected_cdp_browser() + + def test_connect_list_and_disconnect_cdp_browser_endpoints( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + + with patch( + "app.controller.tool_controller._is_cdp_available", + return_value=True, + ): + connect_response = client.post( + "/browser/cdp/connect", + json={"port": 9333, "name": "External Browser (9333)"}, + ) + + assert connect_response.status_code == 200 + connect_data = connect_response.json() + assert connect_data["success"] is True + assert connect_data["browser"]["port"] == 9333 + assert connect_data["browser"]["isExternal"] is True + + list_response = client.get("/browser/cdp/list") + assert list_response.status_code == 200 + assert list_response.json() == [connect_data["browser"]] + + disconnect_response = client.delete("/browser/cdp/9333") + assert disconnect_response.status_code == 200 + assert disconnect_response.json()["success"] is True + assert tool_controller._get_connected_cdp_port() is None + + def test_connect_cdp_browser_endpoint_returns_error_when_unreachable( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("EIGENT_CDP_URL", raising=False) + tool_controller._web_cdp_browser_meta = None + + with patch( + "app.controller.tool_controller._is_cdp_available", + return_value=False, + ): + response = client.post("/browser/cdp/connect", json={"port": 9555}) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "9555" in data["error"] + @pytest.mark.model_backend class TestToolControllerWithRealMCP: diff --git a/backend/tests/app/hands/test_cluster_config.py b/backend/tests/app/hands/test_cluster_config.py new file mode 100644 index 000000000..d84b16604 --- /dev/null +++ b/backend/tests/app/hands/test_cluster_config.py @@ -0,0 +1,133 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +from pathlib import Path + +import pytest + +from app.hands.cluster_config import ( + HandsClusterConfigError, + load_hands_cluster_config, +) + + +@pytest.mark.unit +def test_load_cluster_config_with_routes_and_env_token(tmp_path: Path): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[defaults] +timeout_seconds = 12 +verify_tls = true +acquire_path = "/acquire" +release_path = "/release" +health_path = "/health" + +[routes] +browser = "browser_pool" +terminal = "terminal_pool" +default = "gateway" + +[clusters.gateway] +base_url = "http://hands-gateway.local" + +[clusters.browser_pool] +base_url = "http://browser-cluster.local" +auth_token_env = "BROWSER_CLUSTER_TOKEN" + +[clusters.terminal_pool] +api = "http://terminal-cluster.local" +verify_tls = false +""".strip(), + encoding="utf-8", + ) + routing = load_hands_cluster_config( + str(config), + read_env=lambda name: "token_browser" + if name == "BROWSER_CLUSTER_TOKEN" + else None, + ) + + assert set(routing.route_to_cluster.keys()) == { + "browser", + "terminal", + "default", + } + assert ( + routing.route_to_cluster["browser"].base_url + == "http://browser-cluster.local" + ) + assert routing.route_to_cluster["browser"].auth_token == "token_browser" + assert routing.route_to_cluster["terminal"].verify_tls is False + assert routing.route_to_cluster["default"].timeout_seconds == 12 + + +@pytest.mark.unit +def test_load_cluster_config_without_routes_single_cluster_defaults_to_default( + tmp_path: Path, +): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[clusters.default] +base_url = "http://hands-gateway.local" +""".strip(), + encoding="utf-8", + ) + routing = load_hands_cluster_config(str(config)) + assert set(routing.route_to_cluster.keys()) == {"default"} + assert ( + routing.route_to_cluster["default"].base_url + == "http://hands-gateway.local" + ) + + +@pytest.mark.unit +def test_load_cluster_config_normalizes_fallback_route(tmp_path: Path): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[routes] +fallback = "gateway" + +[clusters.gateway] +base_url = "http://hands-gateway.local" +""".strip(), + encoding="utf-8", + ) + routing = load_hands_cluster_config(str(config)) + assert set(routing.route_to_cluster.keys()) == {"default"} + + +@pytest.mark.unit +def test_load_cluster_config_invalid_route_target_raises(tmp_path: Path): + config = tmp_path / "hands_clusters.toml" + config.write_text( + """ +[routes] +browser = "missing_cluster" + +[clusters.gateway] +base_url = "http://hands-gateway.local" +""".strip(), + encoding="utf-8", + ) + with pytest.raises(HandsClusterConfigError): + load_hands_cluster_config(str(config)) + + +@pytest.mark.unit +def test_load_cluster_config_missing_file_raises(): + with pytest.raises(HandsClusterConfigError): + load_hands_cluster_config("/tmp/non-existent-hands-cluster.toml") diff --git a/backend/tests/app/hands/test_http_hands_cluster.py b/backend/tests/app/hands/test_http_hands_cluster.py new file mode 100644 index 000000000..8bbb3a403 --- /dev/null +++ b/backend/tests/app/hands/test_http_hands_cluster.py @@ -0,0 +1,103 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import asyncio +import json + +import httpx +import pytest + +from app.hands.http_hands_cluster import HttpHandsCluster + + +@pytest.mark.unit +def test_http_hands_cluster_acquire_with_wrapped_data(): + def _handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == "http://hands-cluster.local/acquire" + assert request.headers.get("authorization") == "Bearer token_abc" + payload = json.loads(request.content.decode("utf-8")) + assert payload["type"] == "browser" + assert payload["resource_type"] == "browser" + assert payload["session_id"] == "sess_11" + assert payload["tenant_id"] == "tenant_1" + assert payload["port"] == 9444 + return httpx.Response( + 200, + json={ + "data": { + "endpoint": "http://worker-11:9222", + "container_id": "abc123", + } + }, + ) + + cluster = HttpHandsCluster( + base_url="http://hands-cluster.local", + auth_token="token_abc", + transport=httpx.MockTransport(_handler), + ) + acquired = asyncio.run( + cluster.acquire( + resource_type="browser", + session_id="sess_11", + tenant_id="tenant_1", + port=9444, + ) + ) + assert acquired["endpoint"] == "http://worker-11:9222" + assert acquired["container_id"] == "abc123" + + +@pytest.mark.unit +def test_http_hands_cluster_release_404_is_ignored(): + def _handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert str(request.url) == "http://hands-cluster.local/release" + payload = json.loads(request.content.decode("utf-8")) + assert payload["session_id"] == "sess_missing" + return httpx.Response(404, json={"detail": "not found"}) + + cluster = HttpHandsCluster( + base_url="http://hands-cluster.local", + transport=httpx.MockTransport(_handler), + ) + asyncio.run(cluster.release("sess_missing")) + + +@pytest.mark.unit +def test_http_hands_cluster_health_with_custom_path(): + def _handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert str(request.url) == "http://hands-cluster.local/api/v1/healthz" + return httpx.Response( + 200, + json={ + "browser_workers": {"total": 3, "available": 2, "in_use": 1} + }, + ) + + cluster = HttpHandsCluster( + base_url="http://hands-cluster.local", + health_path="/api/v1/healthz", + transport=httpx.MockTransport(_handler), + ) + health = asyncio.run(cluster.health()) + assert health["browser_workers"]["available"] == 2 + + +@pytest.mark.unit +def test_http_hands_cluster_requires_base_url(): + with pytest.raises(ValueError): + HttpHandsCluster(base_url=" ") diff --git a/backend/tests/app/hands/test_path_scope_checks.py b/backend/tests/app/hands/test_path_scope_checks.py new file mode 100644 index 000000000..60de91cee --- /dev/null +++ b/backend/tests/app/hands/test_path_scope_checks.py @@ -0,0 +1,62 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import pytest + +from app.file_access.upload_file_access import UploadFileAccess +from app.hands.full_hands import FullHands +from app.hands.sandbox_hands import SandboxHands + + +@pytest.mark.unit +def test_sandbox_hands_blocks_workspace_prefix_bypass(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + inside = workspace / "safe.txt" + inside.write_text("safe", encoding="utf-8") + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + hands = SandboxHands(workspace_root=str(workspace)) + assert hands.can_access_filesystem(str(inside)) is True + assert hands.can_access_filesystem(str(outside)) is False + + +@pytest.mark.unit +def test_full_hands_blocks_workspace_prefix_bypass(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + hands = FullHands(workspace_root=str(workspace)) + assert hands.can_access_filesystem(str(outside)) is False + + +@pytest.mark.unit +def test_upload_file_access_blocks_workspace_prefix_bypass(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + file_access = UploadFileAccess(workspace_root=str(workspace)) + with pytest.raises(PermissionError): + file_access.read_file(str(outside)) diff --git a/backend/tests/app/hands/test_remote_hands.py b/backend/tests/app/hands/test_remote_hands.py new file mode 100644 index 000000000..6432617ba --- /dev/null +++ b/backend/tests/app/hands/test_remote_hands.py @@ -0,0 +1,93 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import pytest + +from app.hands.remote_hands import RemoteHands + + +class _FakeCluster: + def __init__(self) -> None: + self.released: list[str] = [] + self.acquired: list[tuple[str, str]] = [] + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict: + _ = (tenant_id, kwargs) + self.acquired.append((resource_type, session_id)) + return {"endpoint": "http://worker-17:9222", "container_id": "abc123"} + + async def release(self, session_id: str) -> None: + self.released.append(session_id) + + async def health(self) -> dict: + return {"browser_workers": {"total": 1, "available": 1, "in_use": 0}} + + +@pytest.mark.unit +def test_remote_hands_browser_fallback_endpoint(): + hands = RemoteHands(cluster=None) + endpoint = hands.acquire_resource("browser", "sess_1", port=9444) + assert endpoint == "http://localhost:9444" + + +@pytest.mark.unit +def test_remote_hands_cluster_acquire_and_release(): + cluster = _FakeCluster() + hands = RemoteHands(cluster=cluster) + + endpoint = hands.acquire_resource("browser", "sess_2") + assert endpoint == "http://worker-17:9222" + assert ("browser", "sess_2") in cluster.acquired + + hands.release_resource("browser", "sess_2") + assert "sess_2" in cluster.released + + +@pytest.mark.unit +def test_remote_hands_cluster_allows_non_browser_resource(): + cluster = _FakeCluster() + hands = RemoteHands(cluster=cluster) + + endpoint = hands.acquire_resource("terminal", "sess_terminal") + assert endpoint == "http://worker-17:9222" + assert ("terminal", "sess_terminal") in cluster.acquired + + +@pytest.mark.unit +def test_remote_hands_unknown_resource_raises(): + hands = RemoteHands(cluster=None) + with pytest.raises(ValueError): + hands.acquire_resource("terminal", "sess_3") + + +@pytest.mark.unit +def test_remote_hands_workspace_prefix_bypass_blocked(tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + sibling = tmp_path / "workspace_evil" + sibling.mkdir() + inside = workspace / "ok.txt" + inside.write_text("ok", encoding="utf-8") + outside = sibling / "evil.txt" + outside.write_text("evil", encoding="utf-8") + + hands = RemoteHands(cluster=None, workspace_root=str(workspace)) + assert hands.can_access_filesystem(str(inside)) is True + assert hands.can_access_filesystem(str(outside)) is False diff --git a/backend/tests/app/hands/test_routed_hands_cluster.py b/backend/tests/app/hands/test_routed_hands_cluster.py new file mode 100644 index 000000000..caaffc2b3 --- /dev/null +++ b/backend/tests/app/hands/test_routed_hands_cluster.py @@ -0,0 +1,88 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import asyncio + +import pytest + +from app.hands.routed_hands_cluster import RoutedHandsCluster + + +class _FakeCluster: + def __init__(self, endpoint: str) -> None: + self.endpoint = endpoint + self.acquired: list[tuple[str, str]] = [] + self.released: list[str] = [] + + async def acquire( + self, + resource_type: str, + session_id: str, + tenant_id: str = "default", + **kwargs, + ) -> dict: + _ = (tenant_id, kwargs) + self.acquired.append((resource_type, session_id)) + return {"endpoint": self.endpoint} + + async def release(self, session_id: str) -> None: + self.released.append(session_id) + + async def health(self) -> dict: + return {"endpoint": self.endpoint} + + +@pytest.mark.unit +def test_routed_cluster_routes_by_resource_type(): + browser = _FakeCluster("http://browser-cluster:9222") + terminal = _FakeCluster("http://terminal-cluster:7001") + routed = RoutedHandsCluster({"browser": browser, "terminal": terminal}) + + acquired = asyncio.run( + routed.acquire("terminal", "sess_terminal", tenant_id="default") + ) + assert acquired["endpoint"] == "http://terminal-cluster:7001" + assert acquired["cluster_key"] == "terminal" + assert ("terminal", "sess_terminal") in terminal.acquired + assert browser.acquired == [] + + +@pytest.mark.unit +def test_routed_cluster_release_uses_same_cluster_as_acquire(): + browser = _FakeCluster("http://browser-cluster:9222") + terminal = _FakeCluster("http://terminal-cluster:7001") + routed = RoutedHandsCluster({"browser": browser, "terminal": terminal}) + + asyncio.run(routed.acquire("browser", "sess_browser")) + asyncio.run(routed.release("sess_browser")) + assert "sess_browser" in browser.released + assert terminal.released == [] + + +@pytest.mark.unit +def test_routed_cluster_uses_default_when_resource_missing(): + default_cluster = _FakeCluster("http://default-cluster:8100") + routed = RoutedHandsCluster( + {"default": default_cluster, "browser": _FakeCluster("http://b:1")} + ) + + acquired = asyncio.run(routed.acquire("model", "sess_model")) + assert acquired["endpoint"] == "http://default-cluster:8100" + assert acquired["cluster_key"] == "default" + + +@pytest.mark.unit +def test_routed_cluster_requires_non_empty_mapping(): + with pytest.raises(ValueError): + RoutedHandsCluster({}) diff --git a/backend/tests/app/router_layer/test_message_router.py b/backend/tests/app/router_layer/test_message_router.py new file mode 100644 index 000000000..32e7925a0 --- /dev/null +++ b/backend/tests/app/router_layer/test_message_router.py @@ -0,0 +1,109 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import pytest + +from app.router_layer.message_router import DefaultMessageRouter +from app.router_layer.session_store import MemorySessionStore + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_resolve_session_reuses_valid_session(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.message_router._now_ts", lambda: current["now"] + ) + + router = DefaultMessageRouter(session_ttl=60) + session_id = await router.resolve_session("web", None, "alice") + + current["now"] = 1020.0 + reused = await router.resolve_session("web", session_id, "alice") + + assert reused == session_id + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_resolve_session_channel_mismatch_creates_new(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.message_router._now_ts", lambda: current["now"] + ) + + router = DefaultMessageRouter(session_ttl=60) + session_id = await router.resolve_session("web", None, "alice") + + current["now"] = 1010.0 + new_session = await router.resolve_session("desktop", session_id, "alice") + + assert new_session != session_id + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_resolve_session_expired_creates_new(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.message_router._now_ts", lambda: current["now"] + ) + + router = DefaultMessageRouter(session_ttl=10) + session_id = await router.resolve_session("web", None, "alice") + + current["now"] = 1015.0 + new_session = await router.resolve_session("web", session_id, "alice") + + assert new_session != session_id + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_memory_session_store_respects_ttl(monkeypatch): + current = {"now": 2000.0} + monkeypatch.setattr( + "app.router_layer.session_store.time.time", + lambda: current["now"], + ) + + store = MemorySessionStore() + await store.set("sess_1", {"session_id": "sess_1"}, ttl=10) + assert await store.get("sess_1") == {"session_id": "sess_1"} + + current["now"] = 2011.0 + assert await store.get("sess_1") is None + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_memory_session_store_lazy_cleanup_on_set(monkeypatch): + current = {"now": 1000.0} + monkeypatch.setattr( + "app.router_layer.session_store.time.time", + lambda: current["now"], + ) + + store = MemorySessionStore() + for i in range(127): + await store.set( + f"sess_old_{i}", {"session_id": f"sess_old_{i}"}, ttl=1 + ) + + current["now"] = 2000.0 + await store.set("sess_live", {"session_id": "sess_live"}, ttl=60) + + # GC should run on the 128th set call and purge expired entries even if never read. + assert len(store._sessions) == 1 + assert await store.get("sess_live") == {"session_id": "sess_live"} diff --git a/backend/tests/app/utils/test_browser_launcher.py b/backend/tests/app/utils/test_browser_launcher.py new file mode 100644 index 000000000..732bd74e4 --- /dev/null +++ b/backend/tests/app/utils/test_browser_launcher.py @@ -0,0 +1,108 @@ +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import json + +import pytest + +from app.utils.browser_launcher import ( + _is_supported_cdp_version, + normalize_cdp_url, +) + + +class _FakeWebSocket: + def __init__(self, response: dict): + self.response = response + self.sent: dict | None = None + + def __enter__(self): + return self + + def __exit__(self, *args): + return None + + def send(self, message: str): + self.sent = json.loads(message) + + def recv(self, timeout: int): + assert timeout == 2 + return json.dumps(self.response) + + +@pytest.mark.unit +def test_normalize_cdp_url_accepts_host_port_without_scheme(): + assert normalize_cdp_url("worker-17:9333") == ( + "http://worker-17:9333", + "worker-17", + 9333, + ) + + +@pytest.mark.unit +def test_normalize_cdp_url_accepts_bare_port(): + assert normalize_cdp_url("9333") == ( + "http://127.0.0.1:9333", + "127.0.0.1", + 9333, + ) + + +@pytest.mark.unit +def test_supported_cdp_version_requires_context_management(monkeypatch): + fake_ws = _FakeWebSocket({"id": 1, "result": {}}) + monkeypatch.setattr( + "websockets.sync.client.connect", + lambda *args, **kwargs: fake_ws, + ) + + assert _is_supported_cdp_version( + { + "Browser": "Chrome/147.0.7727.102", + "User-Agent": "Chrome/147.0.7727.102", + "webSocketDebuggerUrl": "ws://127.0.0.1:9223/devtools/browser/id", + }, + "http://127.0.0.1:9223", + ) + assert fake_ws.sent == { + "id": 1, + "method": "Browser.setDownloadBehavior", + "params": {"behavior": "default"}, + } + + +@pytest.mark.unit +def test_supported_cdp_version_rejects_context_management_error(monkeypatch): + fake_ws = _FakeWebSocket( + { + "id": 1, + "error": { + "code": -32000, + "message": "Browser context management is not supported.", + }, + } + ) + monkeypatch.setattr( + "websockets.sync.client.connect", + lambda *args, **kwargs: fake_ws, + ) + + assert not _is_supported_cdp_version( + { + "Browser": "Chrome/147.0.7727.102", + "User-Agent": "Chrome/147.0.7727.102", + "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/id", + }, + "http://127.0.0.1:9222", + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc27f27ed..ef58488a0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,6 +17,7 @@ import tempfile from collections.abc import AsyncGenerator, Generator from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -193,6 +194,7 @@ def mock_request(): """Mock FastAPI Request object.""" request = AsyncMock() request.is_disconnected = AsyncMock(return_value=False) + request.state = SimpleNamespace() return request diff --git a/backend/uv.lock b/backend/uv.lock index 856027d5c..e9b316c5a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.11.*" resolution-markers = [ "sys_platform == 'win32'", diff --git a/docs/core/brain-architecture.md b/docs/core/brain-architecture.md new file mode 100644 index 000000000..2f6139033 --- /dev/null +++ b/docs/core/brain-architecture.md @@ -0,0 +1,146 @@ +--- +title: Brain Architecture +description: Understand how Eigent separates clients, Brain, and runtime capabilities across desktop and web deployments. +icon: brain +--- + +## Overview + +Eigent is evolving toward a Brain-centered architecture. + +Instead of treating the desktop app as the system boundary, Eigent treats the Brain as the primary runtime. Clients such as Desktop and Web connect to the same Brain over HTTP and SSE, while runtime capabilities are determined by where the Brain is deployed. + +This shift makes it easier to: + +- support both Desktop and Web as first-class clients +- run Brain independently from Electron +- keep capability boundaries consistent across local and remote deployments +- prepare for future CLI, channel, and remote resource integrations + +## Core Building Blocks + +### Brain + +The Brain is the central runtime for Eigent. It is responsible for: + +- task and chat orchestration +- agent and workforce coordination +- file, tool, MCP, and skill APIs +- session-aware request handling +- runtime capability resolution + +In practice, the Brain is the part of the system that reasons, routes work, and executes actions through the available runtime capabilities. + +### Clients + +Eigent supports multiple client shapes around the same Brain: + +- Desktop +- Web +- future CLI and channel-based clients + +Clients are responsible for presentation and interaction. They do not define what the system is allowed to do. They only define how users connect to and interact with the Brain. + +### Hands + +Hands represent what the Brain can actually operate in its current environment. + +Examples include: + +- terminal execution +- browser control +- filesystem access +- MCP usage + +This is an important architectural choice: Hands are determined by the Brain deployment environment, not by the client type. + +That means a Web client connected to a full local or VM-hosted Brain can still access browser and terminal capabilities, while a client connected to a restricted Brain will see a reduced capability set. + +### Host + +For Desktop, Electron acts as a host layer. It provides native integrations such as: + +- window controls +- file picking +- CDP and webview-related integrations +- backend lifecycle support + +The host is intentionally kept separate from Brain logic so shared frontend code can work across Desktop and Web. + +## High-Level Architecture + +```text +Clients + ├─ Desktop + ├─ Web + └─ Future CLI / Channels + │ + │ HTTP / SSE + ▼ + Brain + ├─ Router layer + ├─ Chat / Task / Tool / File APIs + ├─ MCP / Skills services + └─ Hands + ├─ terminal + ├─ browser + ├─ filesystem + └─ MCP +``` + +## Request Flow + +### Desktop + +In Desktop mode, Electron starts and hosts the local Brain. The frontend resolves the local Brain endpoint through the host layer, then uses shared Brain HTTP and SSE APIs for most business flows. + +### Web + +In Web mode, the frontend connects directly to a Brain endpoint. Session metadata is carried through headers, file attachments are uploaded through Brain APIs, and task streaming uses shared SSE transport. + +This makes Web a first-class entry point instead of a limited fallback path. + +## Why Hands Are Environment-Driven + +A common pitfall in multi-client systems is tying capability boundaries to the client type. + +Eigent avoids that by separating: + +- **channel**: how a client connects and how responses should be adapted +- **hands**: what the Brain can actually do in its runtime environment + +This enables a cleaner model: + +- Desktop does not automatically mean full capability +- Web does not automatically mean restricted capability +- the Brain environment remains the source of truth for runtime power + +## Deployment Modes + +The architecture supports multiple deployment shapes: + +- **Desktop + Local Brain** + - best for local development and full machine access +- **Web + Local Brain** + - useful for frontend/backend separation and local web usage +- **Web + Cloud or VM Brain** + - allows browser-based access to a remotely hosted Brain +- **Brain + Remote resource pools** + - enables future remote browser or terminal acquisition patterns + +## What This Architecture Enables + +This architecture lays the foundation for: + +- stronger separation between UI and runtime +- better Web support without breaking Desktop +- clearer capability modeling +- future remote execution and multi-client expansion + +It also reduces the amount of client-specific branching required in the product by moving more system behavior into shared Brain-side abstractions. + +## Current Direction + +The architecture is being rolled out incrementally. Desktop remains supported while Web and standalone Brain flows are being strengthened around the same core abstractions. + +That incremental approach helps Eigent evolve toward a more portable and extensible system without requiring a full rewrite. diff --git a/docs/docs.json b/docs/docs.json index d20352a07..99c71db89 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -59,6 +59,7 @@ "icon": "key", "pages": [ "/core/concepts", + "/core/brain-architecture", "/core/workforce", { "group": "Models", diff --git a/docs/troubleshooting/bug.md b/docs/troubleshooting/bug.md index 17cac16e6..8a190eac7 100644 --- a/docs/troubleshooting/bug.md +++ b/docs/troubleshooting/bug.md @@ -10,23 +10,23 @@ width="100%" height="auto" /> -### Step 1: Access the Bug Report Feature +### Step 1: Open Report a bug -1. Locate the **Bug Report** button in the top section of the desktop interface, positioned to the right of your project name -1. Click the **Bug Report** button to initiate the reporting process +1. In the top bar, click the **Support** (help) icon +1. Choose **Report a bug** -### Step 2: Download Log Files +### Step 2: Describe the issue and save diagnostics -- Upon clicking the Bug Report button, log files will be automatically downloaded to your device -- These log files contain technical information that helps our development team diagnose issues more effectively +1. Enter what went wrong. Optionally add **steps to reproduce** +1. Click **Save diagnostics and open email** +1. Choose where to save the **diagnostics ZIP** (it includes app logs and a `bug_report.txt` file) -### Step 3: Complete the Bug Report Form +Your default email app opens addressed to **info@eigent.ai** with a pre-filled message. -- A bug report web form will automatically open in your default browser -- Please provide the following information: - - **Bug Description**: Write a clear description of the issue you encountered - - **Screenshot Upload**: Attach relevant screenshots that demonstrate the problem - - **Log File Upload**: Upload the log files that were downloaded in Step 2 +### Step 3: Send the email + +1. **Attach the ZIP** you just saved to the message (the mail app cannot add this automatically) +1. Add screenshots or other files if they help, then send ### Step 4: Join Our Community for Real-time Support @@ -47,9 +47,11 @@ Developers and technical users are welcome to report issues directly through our - **Repository URL**: https://github.com/eigent-ai/eigent - Submit detailed issues with reproduction steps +**Optional:** In the same **Report a bug** dialog, use **Download logs** to save a single log file (without the full diagnostics ZIP). + ## Important Notes -- Always include log files when reporting bugs for faster resolution +- Always include the diagnostics ZIP (or log export) when reporting bugs for faster resolution - Provide as much detail as possible in your bug description - Screenshots help our team understand visual issues more quickly - Our community channels provide the fastest response times for urgent issues diff --git a/electron/main/index.ts b/electron/main/index.ts index e6d3bc692..7ef8765c6 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -36,7 +36,6 @@ import os, { homedir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import kill from 'tree-kill'; -import * as unzipper from 'unzipper'; import { copyBrowserData } from './copy'; import { FileReader } from './fileReader'; import { @@ -60,7 +59,7 @@ import { removeEnvKey, updateEnvBlock, } from './utils/envUtil'; -import { zipFolder } from './utils/log'; +import { createDiagnosticsZip, zipFolder } from './utils/log'; import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig'; import { checkVenvExistsForPreCheck, @@ -413,13 +412,8 @@ protocol.registerSchemesAsPrivileged([ process.env.APP_ROOT = MAIN_DIST; process.env.VITE_PUBLIC = VITE_PUBLIC; -// Respect system theme on Windows, keep light theme on macOS for consistency -const isWindows = process.platform === 'win32'; -if (isWindows) { - nativeTheme.themeSource = 'system'; // Respect Windows dark/light mode -} else { - nativeTheme.themeSource = 'light'; // Keep existing behavior for macOS -} +// Always follow OS appearance so renderer `prefers-color-scheme` stays accurate. +nativeTheme.themeSource = 'system'; // Set log level log.transports.console.level = 'info'; @@ -1117,6 +1111,100 @@ function registerIpcHandlers() { } }); + ipcMain.handle('get-diagnostics-info', async () => { + return { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + }; + }); + + ipcMain.handle( + 'export-diagnostics-zip', + async ( + _event, + payload: { description: string; steps?: string } | undefined + ) => { + try { + const description = + typeof payload?.description === 'string' + ? payload.description.trim() + : ''; + if (!description) { + return { success: false, error: 'Description is required' }; + } + const steps = + typeof payload?.steps === 'string' ? payload.steps.trim() : ''; + + const logFiles: { src: string; destName: string }[] = []; + if (fs.existsSync(logPath)) { + logFiles.push({ src: logPath, destName: 'electron-main.log' }); + } + const backupResolved = getBackupLogPath(); + if ( + fs.existsSync(backupResolved) && + path.resolve(backupResolved) !== path.resolve(logPath) + ) { + logFiles.push({ + src: backupResolved, + destName: 'electron-userdata-logs.log', + }); + } + if (logFiles.length === 0) { + return { success: false, error: 'no log file' }; + } + + const appVersion = app.getVersion(); + const platform = process.platform; + const arch = process.arch; + const bugReportText = [ + 'Eigent bug report', + '=================', + '', + `App version: ${appVersion}`, + `OS: ${platform} (${arch})`, + '', + 'Description', + '-----------', + description, + '', + ...(steps + ? ['Steps to reproduce', '-------------------', steps, ''] + : []), + ].join('\n'); + + const defaultFileName = `eigent-diagnostics-${appVersion}-${Date.now()}.zip`; + const { canceled, filePath } = await dialog.showSaveDialog({ + title: 'Save diagnostics', + defaultPath: defaultFileName, + filters: [{ name: 'ZIP archive', extensions: ['zip'] }], + }); + + if (canceled || !filePath) { + return { success: false, error: '' }; + } + + await createDiagnosticsZip(filePath, bugReportText, logFiles); + return { success: true, savedPath: filePath }; + } catch (error: any) { + log.error('export-diagnostics-zip failed:', error); + return { success: false, error: error.message }; + } + } + ); + + ipcMain.handle('open-mailto', async (_event, url: string) => { + try { + if (typeof url !== 'string' || !url.startsWith('mailto:')) { + return { success: false, error: 'Invalid mailto URL' }; + } + await shell.openExternal(url); + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } + }); + ipcMain.handle( 'upload-log', async ( @@ -1405,413 +1493,10 @@ function registerIpcHandlers() { } }); - // ======================== skills ======================== - // SKILLS_ROOT, SKILL_FILE, seedDefaultSkillsIfEmpty are defined at module level (used at startup too). - function parseSkillFrontmatter( - content: string - ): { name: string; description: string } | null { - if (!content.startsWith('---')) return null; - const end = content.indexOf('\n---', 3); - const block = end > 0 ? content.slice(4, end) : content.slice(4); - const nameMatch = block.match(/^\s*name\s*:\s*(.+)$/m); - const descMatch = block.match(/^\s*description\s*:\s*(.+)$/m); - const name = nameMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - const desc = descMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - if (name && desc) return { name, description: desc }; - return null; - } - - const normalizePathForCompare = (value: string) => - process.platform === 'win32' ? value.toLowerCase() : value; - - function assertPathUnderSkillsRoot(targetPath: string): string { - const resolvedRoot = path.resolve(SKILLS_ROOT); - const resolvedTarget = path.resolve(targetPath); - const rootCmp = normalizePathForCompare(resolvedRoot); - const targetCmp = normalizePathForCompare(resolvedTarget); - const rootWithSep = rootCmp.endsWith(path.sep) - ? rootCmp - : `${rootCmp}${path.sep}`; - if (targetCmp !== rootCmp && !targetCmp.startsWith(rootWithSep)) { - throw new Error('Path is outside skills directory'); - } - return resolvedTarget; - } - - function resolveSkillDirPath(skillDirName: string): string { - const name = String(skillDirName || '').trim(); - if (!name) { - throw new Error('Skill folder name is required'); - } - return assertPathUnderSkillsRoot(path.join(SKILLS_ROOT, name)); - } - - ipcMain.handle('get-skills-dir', async () => { - try { - if (!existsSync(SKILLS_ROOT)) { - await fsp.mkdir(SKILLS_ROOT, { recursive: true }); - } - await seedDefaultSkillsIfEmpty(); - return { success: true, path: SKILLS_ROOT }; - } catch (error: any) { - log.error('get-skills-dir failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle('skills-scan', async () => { - try { - if (!existsSync(SKILLS_ROOT)) { - return { success: true, skills: [] }; - } - await seedDefaultSkillsIfEmpty(); - const entries = await fsp.readdir(SKILLS_ROOT, { withFileTypes: true }); - const exampleSkillsDir = getExampleSkillsSourceDir(); - const skills: Array<{ - name: string; - description: string; - path: string; - scope: string; - skillDirName: string; - isExample: boolean; - }> = []; - for (const e of entries) { - if (!e.isDirectory() || e.name.startsWith('.')) continue; - const skillPath = path.join(SKILLS_ROOT, e.name, SKILL_FILE); - try { - const raw = await fsp.readFile(skillPath, 'utf-8'); - const meta = parseSkillFrontmatter(raw); - if (meta) { - const isExample = existsSync( - path.join(exampleSkillsDir, e.name, SKILL_FILE) - ); - skills.push({ - name: meta.name, - description: meta.description, - path: skillPath, - scope: 'user', - skillDirName: e.name, - isExample, - }); - } - } catch (_) { - // skip invalid or unreadable skill - } - } - return { success: true, skills }; - } catch (error: any) { - log.error('skills-scan failed', error); - return { success: false, error: error?.message, skills: [] }; - } - }); - - ipcMain.handle( - 'skill-write', - async (_event, skillDirName: string, content: string) => { - try { - const dir = resolveSkillDirPath(skillDirName); - await fsp.mkdir(dir, { recursive: true }); - await fsp.writeFile(path.join(dir, SKILL_FILE), content, 'utf-8'); - return { success: true }; - } catch (error: any) { - log.error('skill-write failed', error); - return { success: false, error: error?.message }; - } - } - ); - - ipcMain.handle('skill-delete', async (_event, skillDirName: string) => { - try { - const dir = resolveSkillDirPath(skillDirName); - if (!existsSync(dir)) return { success: true }; - await fsp.rm(dir, { recursive: true, force: true }); - return { success: true }; - } catch (error: any) { - log.error('skill-delete failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle('skill-read', async (_event, filePath: string) => { - try { - const fullPath = path.isAbsolute(filePath) - ? assertPathUnderSkillsRoot(filePath) - : assertPathUnderSkillsRoot( - path.join(SKILLS_ROOT, filePath, SKILL_FILE) - ); - const content = await fsp.readFile(fullPath, 'utf-8'); - return { success: true, content }; - } catch (error: any) { - log.error('skill-read failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle('skill-list-files', async (_event, skillDirName: string) => { - try { - const dir = resolveSkillDirPath(skillDirName); - if (!existsSync(dir)) - return { success: false, error: 'Skill folder not found', files: [] }; - const entries = await fsp.readdir(dir, { withFileTypes: true }); - const files = entries.map((e) => - e.isDirectory() ? `${e.name}/` : e.name - ); - return { success: true, files }; - } catch (error: any) { - log.error('skill-list-files failed', error); - return { success: false, error: error?.message, files: [] }; - } - }); - - ipcMain.handle('open-skill-folder', async (_event, skillName: string) => { - try { - const name = String(skillName || '').trim(); - if (!name) return { success: false, error: 'Skill name is required' }; - if (!existsSync(SKILLS_ROOT)) - return { success: false, error: 'Skills dir not found' }; - const entries = await fsp.readdir(SKILLS_ROOT, { withFileTypes: true }); - const nameLower = name.toLowerCase(); - for (const e of entries) { - if (!e.isDirectory() || e.name.startsWith('.')) continue; - const skillPath = path.join(SKILLS_ROOT, e.name, SKILL_FILE); - try { - const raw = await fsp.readFile(skillPath, 'utf-8'); - const meta = parseSkillFrontmatter(raw); - if (meta && meta.name.toLowerCase().trim() === nameLower) { - const dirPath = path.join(SKILLS_ROOT, e.name); - await shell.openPath(dirPath); - return { success: true }; - } - } catch (_) { - continue; - } - } - return { success: false, error: `Skill not found: ${name}` }; - } catch (error: any) { - log.error('open-skill-folder failed', error); - return { success: false, error: error?.message }; - } - }); - - // ======================== skills-config.json handlers ======================== - - function getSkillConfigPath(userId: string): string { - return path.join(os.homedir(), '.eigent', userId, 'skills-config.json'); - } - - async function loadSkillConfig(userId: string): Promise { - const configPath = getSkillConfigPath(userId); - - // Auto-create config file if it doesn't exist - if (!existsSync(configPath)) { - const defaultConfig = { version: 1, skills: {} }; - try { - await fsp.mkdir(path.dirname(configPath), { recursive: true }); - await fsp.writeFile( - configPath, - JSON.stringify(defaultConfig, null, 2), - 'utf-8' - ); - log.info(`Auto-created skills config at ${configPath}`); - return defaultConfig; - } catch (error) { - log.error('Failed to create default skills config', error); - return defaultConfig; - } - } - - try { - const content = await fsp.readFile(configPath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - log.error('Failed to load skill config', error); - return { version: 1, skills: {} }; - } - } - - async function saveSkillConfig(userId: string, config: any): Promise { - const configPath = getSkillConfigPath(userId); - await fsp.mkdir(path.dirname(configPath), { recursive: true }); - await fsp.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); - } - - ipcMain.handle('skill-config-load', async (_event, userId: string) => { - try { - const config = await loadSkillConfig(userId); - return { success: true, config }; - } catch (error: any) { - log.error('skill-config-load failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle( - 'skill-config-toggle', - async (_event, userId: string, skillName: string, enabled: boolean) => { - try { - const config = await loadSkillConfig(userId); - if (!config.skills[skillName]) { - // Use SkillScope object format - config.skills[skillName] = { - enabled, - scope: { - isGlobal: true, - selectedAgents: [], - }, - addedAt: Date.now(), - isExample: false, - }; - } else { - config.skills[skillName].enabled = enabled; - } - await saveSkillConfig(userId, config); - return { success: true, config: config.skills[skillName] }; - } catch (error: any) { - log.error('skill-config-toggle failed', error); - return { success: false, error: error?.message }; - } - } - ); - - ipcMain.handle( - 'skill-config-update', - async (_event, userId: string, skillName: string, skillConfig: any) => { - try { - const config = await loadSkillConfig(userId); - config.skills[skillName] = { ...skillConfig }; - await saveSkillConfig(userId, config); - return { success: true }; - } catch (error: any) { - log.error('skill-config-update failed', error); - return { success: false, error: error?.message }; - } - } - ); - - ipcMain.handle( - 'skill-config-delete', - async (_event, userId: string, skillName: string) => { - try { - const config = await loadSkillConfig(userId); - delete config.skills[skillName]; - await saveSkillConfig(userId, config); - return { success: true }; - } catch (error: any) { - log.error('skill-config-delete failed', error); - return { success: false, error: error?.message }; - } - } - ); - - // Initialize skills config for a user (ensures config file exists) - ipcMain.handle('skill-config-init', async (_event, userId: string) => { - try { - log.info(`[SKILLS-CONFIG] Initializing config for user: ${userId}`); - const config = await loadSkillConfig(userId); - - try { - const exampleSkillsDir = getExampleSkillsSourceDir(); - const defaultConfigPath = path.join( - exampleSkillsDir, - 'default-config.json' - ); - - if (existsSync(defaultConfigPath)) { - const defaultConfigContent = await fsp.readFile( - defaultConfigPath, - 'utf-8' - ); - const defaultConfig = JSON.parse(defaultConfigContent); - - if (defaultConfig.skills) { - let addedCount = 0; - // Merge default skills config with user's existing config - for (const [skillName, skillConfig] of Object.entries( - defaultConfig.skills - )) { - if (!config.skills[skillName]) { - // Add new skill config with current timestamp - config.skills[skillName] = { - ...(skillConfig as any), - addedAt: Date.now(), - }; - addedCount++; - log.info( - `[SKILLS-CONFIG] Initialized config for example skill: ${skillName}` - ); - } - } - - if (addedCount > 0) { - await saveSkillConfig(userId, config); - log.info( - `[SKILLS-CONFIG] Added ${addedCount} example skill configs` - ); - } - } - } else { - log.warn( - `[SKILLS-CONFIG] Default config not found at: ${defaultConfigPath}` - ); - } - } catch (err) { - log.error( - '[SKILLS-CONFIG] Failed to load default config template:', - err - ); - // Continue anyway - user config is still valid - } - - log.info( - `[SKILLS-CONFIG] Config initialized with ${Object.keys(config.skills || {}).length} skills` - ); - return { success: true, config }; - } catch (error: any) { - log.error('skill-config-init failed', error); - return { success: false, error: error?.message }; - } - }); - - ipcMain.handle( - 'skill-import-zip', - async ( - _event, - zipPathOrBuffer: string | Buffer | ArrayBuffer | Uint8Array, - replacements?: string[] - ) => - withImportLock(async () => { - // Use typeof check instead of instanceof to handle cross-realm objects - // from Electron IPC (instanceof can fail across context boundaries) - const replacementsSet = replacements - ? new Set(replacements) - : undefined; - const isBufferLike = typeof zipPathOrBuffer !== 'string'; - if (isBufferLike) { - const buf = Buffer.isBuffer(zipPathOrBuffer) - ? zipPathOrBuffer - : Buffer.from( - zipPathOrBuffer instanceof ArrayBuffer - ? zipPathOrBuffer - : (zipPathOrBuffer as any) - ); - const tempPath = path.join( - os.tmpdir(), - `eigent-skill-import-${Date.now()}.zip` - ); - try { - await fsp.writeFile(tempPath, buf); - const result = await importSkillsFromZip(tempPath, replacementsSet); - return result; - } finally { - await fsp.unlink(tempPath).catch(() => {}); - } - } - return importSkillsFromZip(zipPathOrBuffer as string, replacementsSet); - }) - ); + // Skills: all operations via Brain REST API (backend). No IPC. // ==================== read file handler ==================== - ipcMain.handle('read-file', async (event, filePath: string) => { + ipcMain.handle('read-file', async (_event, filePath: string) => { try { log.info('Reading file:', filePath); @@ -1820,15 +1505,12 @@ function registerIpcHandlers() { log.error('File does not exist:', filePath); return { success: false, error: 'File does not exist' }; } - - // Check if it's a directory const stats = await fsp.stat(filePath); if (stats.isDirectory()) { log.error('Path is a directory, not a file:', filePath); return { success: false, error: 'Path is a directory, not a file' }; } - // Read file content const fileContent = await fsp.readFile(filePath); return { @@ -2473,239 +2155,6 @@ async function seedDefaultSkillsIfEmpty(): Promise { } } -/** Truncate a single path component to fit within the 255-byte filesystem limit. */ -function safePathComponent(name: string, maxBytes = 200): string { - // 200 leaves headroom for suffixes the OS or future logic may add - if (Buffer.byteLength(name, 'utf-8') <= maxBytes) return name; - // Trim from the end, character by character, until it fits - let trimmed = name; - while (Buffer.byteLength(trimmed, 'utf-8') > maxBytes) { - trimmed = trimmed.slice(0, -1); - } - return trimmed.replace(/-+$/, '') || 'skill'; -} - -// Simple mutex to prevent concurrent skill imports -let _importLock: Promise = Promise.resolve(); -function withImportLock(fn: () => Promise): Promise { - let release: () => void; - const next = new Promise((resolve) => { - release = resolve; - }); - const prev = _importLock; - _importLock = next; - return prev.then(fn).finally(() => release!()); -} - -async function importSkillsFromZip( - zipPath: string, - replacements?: Set -): Promise<{ - success: boolean; - error?: string; - conflicts?: Array<{ folderName: string; skillName: string }>; -}> { - // Extract to a temp directory, then find SKILL.md files and copy their - // parent skill directories into SKILLS_ROOT. This handles any zip - // structure: wrapping directories, SKILL.md at root, or multiple skills. - const tempDir = path.join(os.tmpdir(), `eigent-skill-extract-${Date.now()}`); - try { - if (!existsSync(zipPath)) { - return { success: false, error: 'Zip file does not exist' }; - } - const ext = path.extname(zipPath).toLowerCase(); - if (ext !== '.zip') { - return { success: false, error: 'Only .zip files are supported' }; - } - if (!existsSync(SKILLS_ROOT)) { - await fsp.mkdir(SKILLS_ROOT, { recursive: true }); - } - - // Step 1: Extract zip into temp directory - await fsp.mkdir(tempDir, { recursive: true }); - const directory = await unzipper.Open.file(zipPath); - const resolvedTempDir = path.resolve(tempDir); - const comparePath = (value: string) => - process.platform === 'win32' ? value.toLowerCase() : value; - const resolvedTempDirCmp = comparePath(resolvedTempDir); - const resolvedTempDirWithSep = resolvedTempDirCmp.endsWith(path.sep) - ? resolvedTempDirCmp - : `${resolvedTempDirCmp}${path.sep}`; - for (const file of directory.files as any[]) { - if (file.type === 'Directory') continue; - const normalizedArchivePath = path - .normalize(String(file.path)) - .replace(/^([/\\])+/, ''); - const destPath = path.join(tempDir, normalizedArchivePath); - const resolvedDestPathCmp = comparePath(path.resolve(destPath)); - // Protect against zip-slip (e.g. entries containing ../) - if ( - !normalizedArchivePath || - (resolvedDestPathCmp !== resolvedTempDirCmp && - !resolvedDestPathCmp.startsWith(resolvedTempDirWithSep)) - ) { - return { success: false, error: 'Zip archive contains unsafe paths' }; - } - const destDir = path.dirname(destPath); - await fsp.mkdir(destDir, { recursive: true }); - const content = await file.buffer(); - await fsp.writeFile(destPath, content); - } - - // Step 2: Recursively find all SKILL.md files - const skillFiles: string[] = []; - async function findSkillMdFiles(dir: string) { - const entries = await fsp.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.name.startsWith('.')) continue; - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - await findSkillMdFiles(fullPath); - } else if (entry.name === SKILL_FILE) { - skillFiles.push(fullPath); - } - } - } - await findSkillMdFiles(tempDir); - - if (skillFiles.length === 0) { - return { - success: false, - error: 'No SKILL.md files found in zip archive', - }; - } - - // Step 3: Copy each skill directory into SKILLS_ROOT - - // Helper function to extract skill name from SKILL.md - async function getSkillName(skillFilePath: string): Promise { - try { - const raw = await fsp.readFile(skillFilePath, 'utf-8'); - const nameMatch = raw.match(/^\s*name\s*:\s*(.+)$/m); - const parsed = nameMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - return parsed || path.basename(path.dirname(skillFilePath)); - } catch { - return path.basename(path.dirname(skillFilePath)); - } - } - - // Helper: derive a safe folder name from a skill display name - function folderNameFromSkillName( - skillName: string, - fallback: string - ): string { - return safePathComponent( - skillName - .replace(/[\\/*?:"<>|\s]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') || fallback - ); - } - - // Step 3a: Scan existing skills to build a name→folderName map for - // name-based duplicate detection (case-insensitive). - const existingSkillNames = new Map(); // lower-case name → folder name on disk - if (existsSync(SKILLS_ROOT)) { - const rootEntries = await fsp.readdir(SKILLS_ROOT, { - withFileTypes: true, - }); - for (const entry of rootEntries) { - if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - const existingSkillFile = path.join( - SKILLS_ROOT, - entry.name, - SKILL_FILE - ); - if (!existsSync(existingSkillFile)) continue; - try { - const raw = await fsp.readFile(existingSkillFile, 'utf-8'); - const nameMatch = raw.match(/^\s*name\s*:\s*(.+)$/m); - const name = nameMatch?.[1]?.trim()?.replace(/^['"]|['"]$/g, ''); - if (name) existingSkillNames.set(name.toLowerCase(), entry.name); - } catch { - // skip unreadable skill - } - } - } - - // Collect conflicts if replacements not provided - const conflicts: Array<{ folderName: string; skillName: string }> = []; - const replacementsSet = replacements || new Set(); - - for (const skillFilePath of skillFiles) { - const skillDir = path.dirname(skillFilePath); - - // Read the incoming skill's display name from SKILL.md frontmatter. - const incomingName = await getSkillName(skillFilePath); - const incomingNameLower = incomingName.toLowerCase(); - - // Determine where this skill will be written on disk. - // Both root-level and nested skills use the skill name to derive the - // folder, so that detection and storage are consistent. - const fallbackFolderName = - skillDir === tempDir - ? path.basename(zipPath, path.extname(zipPath)) - : path.basename(skillDir); - const destFolderName = folderNameFromSkillName( - incomingName, - fallbackFolderName - ); - const dest = path.join(SKILLS_ROOT, destFolderName); - - // Name-based duplicate detection: check if any existing skill already - // has this display name, regardless of what folder it lives in. - const existingFolder = existingSkillNames.get(incomingNameLower); - if (existingFolder) { - if (!replacements) { - // First pass — report conflict using the existing skill's folder as - // the key so the frontend can confirm the right replacement. - conflicts.push({ - folderName: existingFolder, - skillName: incomingName, - }); - continue; - } - if (replacementsSet.has(existingFolder)) { - // User confirmed — remove the existing skill folder before importing. - await fsp.rm(path.join(SKILLS_ROOT, existingFolder), { - recursive: true, - force: true, - }); - } else { - // User cancelled for this skill — skip it. - continue; - } - } - - // Import the skill (no conflict, or conflict was resolved). - await fsp.mkdir(dest, { recursive: true }); - if (skillDir === tempDir) { - // SKILL.md at zip root — copy all root-level entries. - await copyDirRecursive(tempDir, dest); - } else { - // SKILL.md inside a subdirectory — copy that directory. - await copyDirRecursive(skillDir, dest); - } - } - - // Return conflicts if any were found and replacements not provided - if (conflicts.length > 0 && !replacements) { - return { success: false, conflicts }; - } - - log.info( - `Imported ${skillFiles.length} skill(s) from zip into ~/.eigent/skills:`, - zipPath - ); - return { success: true }; - } catch (error: any) { - log.error('importSkillsFromZip failed', error); - return { success: false, error: error?.message || String(error) }; - } finally { - await fsp.rm(tempDir, { recursive: true, force: true }).catch(() => {}); - } -} - // ==================== Shared backend startup logic ==================== // Starts backend after installation completes // Used by both initial startup and retry flows @@ -2728,6 +2177,7 @@ let installationLock: Promise = Promise.resolve({ // ==================== window create ==================== async function createWindow() { const isMac = process.platform === 'darwin'; + const isWindows = process.platform === 'win32'; // Ensure .eigent directories exist before anything else ensureEigentDirectories(); @@ -2754,10 +2204,10 @@ async function createWindow() { // Windows: native frame and solid background. macOS/Linux: frameless; macOS corner radius via native hook. win = new BrowserWindow({ title: 'Eigent', - width: 1200, - height: 800, - minWidth: 1050, - minHeight: 650, + width: 1280, + height: 960, + minWidth: 1100, + minHeight: 700, // Use native frame on Windows for better native integration frame: isWindows ? true : false, show: false, // Don't show until content is ready to avoid white screen diff --git a/electron/main/init.ts b/electron/main/init.ts index 57fb26c87..90d0ec5a7 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -40,7 +40,7 @@ import { const execAsync = promisify(exec); -const DEFAULT_SERVER_URL = 'https://dev.eigent.ai/api'; +const DEFAULT_SERVER_URL = 'https://dev.eigent.ai'; function readEnvValue(filePath: string, key: string): string | undefined { try { @@ -81,9 +81,8 @@ function buildLocalServerUrl(proxyUrl: string | undefined): string | undefined { if (!proxyUrl) return undefined; const trimmed = proxyUrl.trim().replace(/\/+$/, ''); if (!trimmed) return undefined; - // Avoid double /api suffix - if (trimmed.endsWith('/api')) return trimmed; - return `${trimmed}/api`; + // Keep SERVER_URL as host/base only; API version belongs to concrete endpoints. + return trimmed.replace(/\/api\/v[12]$/i, ''); } // helper function to get main window @@ -271,9 +270,8 @@ export async function startBackend( } } - const devServerUrl = process.env.VITE_DEV_SERVER_URL; - if (!resolvedServerUrl && devServerUrl) { - const devEnvPath = path.join(app.getAppPath(), '.env.development'); + const devEnvPath = path.join(app.getAppPath(), '.env.development'); + if (!resolvedServerUrl && fs.existsSync(devEnvPath)) { const devProxyEnabled = readEnvValue(devEnvPath, 'VITE_USE_LOCAL_PROXY') === 'true'; const devProxyUrl = readEnvValue(devEnvPath, 'VITE_PROXY_URL'); @@ -289,6 +287,15 @@ export async function startBackend( } } + // In dev mode, also check .env.development for SERVER_URL + if (!resolvedServerUrl && fs.existsSync(devEnvPath)) { + const devFileServerUrl = readEnvValue(devEnvPath, 'SERVER_URL'); + if (devFileServerUrl) { + resolvedServerUrl = devFileServerUrl; + resolvedSource = `dev env file SERVER_URL (${devEnvPath})`; + } + } + if (!resolvedServerUrl && process.env.SERVER_URL) { resolvedServerUrl = process.env.SERVER_URL; resolvedSource = 'process.env SERVER_URL'; @@ -329,6 +336,7 @@ export async function startBackend( ...uvEnv, ...proxyEnv, SERVER_URL: serverUrl, + EIGENT_RUNTIME: 'electron', PYTHONIOENCODING: 'utf-8', PYTHONUNBUFFERED: '1', npm_config_cache: npmCacheDir, @@ -354,8 +362,10 @@ export async function startBackend( }; const pythonPath = getVenvPythonPath(venvPath); - // Dev mode: use uv run (ensures sync); Packaged: use venv's python directly (prebuilt has deps) - const useDirectPython = app.isPackaged; + // Use venv's python directly when venv exists (avoids uv run hang in some terminals/Electron spawn). + // Packaged: always direct. Dev: use direct when venv exists, else uv run for first-time sync. + const venvExists = fs.existsSync(path.join(venvPath, 'pyvenv.cfg')); + const useDirectPython = app.isPackaged || venvExists; return new Promise(async (resolve, reject) => { const spawnCmd = useDirectPython diff --git a/electron/main/utils/log.ts b/electron/main/utils/log.ts index bf7007874..495576cb4 100644 --- a/electron/main/utils/log.ts +++ b/electron/main/utils/log.ts @@ -12,7 +12,11 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; // @ts-ignore import archiver from 'archiver'; import log from 'electron-log'; @@ -37,3 +41,42 @@ export function zipFolder( archive.finalize(); }); } + +export type DiagnosticsLogFile = { src: string; destName: string }; + +/** + * Stages log files and bug_report.txt into a temp directory, zips to outputZipPath, then removes the staging dir. + */ +export async function createDiagnosticsZip( + outputZipPath: string, + bugReportText: string, + logFiles: DiagnosticsLogFile[] +): Promise { + if (logFiles.length === 0) { + throw new Error('no log files to include'); + } + const id = randomBytes(8).toString('hex'); + const staging = path.join(os.tmpdir(), `eigent-diagnostics-${id}`); + await fsp.mkdir(staging, { recursive: true }); + try { + for (const f of logFiles) { + if (!fs.existsSync(f.src)) { + log.warn(`[diagnostics] skip missing log: ${f.src}`); + continue; + } + await fsp.copyFile(f.src, path.join(staging, f.destName)); + } + await fsp.writeFile( + path.join(staging, 'bug_report.txt'), + bugReportText, + 'utf-8' + ); + const entries = await fsp.readdir(staging); + if (entries.length === 0) { + throw new Error('no log files to include'); + } + await zipFolder(staging, outputZipPath); + } finally { + await fsp.rm(staging, { recursive: true, force: true }); + } +} diff --git a/electron/main/webview.ts b/electron/main/webview.ts index d7ddb02e5..1575cf0bb 100644 --- a/electron/main/webview.ts +++ b/electron/main/webview.ts @@ -37,6 +37,20 @@ export class WebViewManager { private maxInactiveWebviews = 5; private lastCleanupTime = Date.now(); + private getHiddenBounds(id: string, width = 100, height = 100) { + const numericId = Number(id); + const idOffset = Number.isFinite(numericId) ? (numericId % 20) * 20 : 0; + const safeWidth = Math.max(width, 100); + const safeHeight = Math.max(height, 100); + + return { + x: -10000 - safeWidth - idOffset, + y: -10000 - safeHeight - idOffset, + width: safeWidth, + height: safeHeight, + }; + } + constructor(window: BrowserWindow) { this.win = window; } @@ -45,11 +59,52 @@ export class WebViewManager { // IPC handlers should be registered once in the main process public async captureWebview(webviewId: string) { - const webContents = this.webViews.get(webviewId); - if (!webContents) return null; + const webViewInfo = this.webViews.get(webviewId); + if (!webViewInfo) return null; + + const targetContents = webViewInfo.view.webContents; + if (!targetContents || targetContents.isDestroyed()) { + return null; + } + + const debuggerApi = targetContents.debugger; + let attachedHere = false; + + try { + if (!debuggerApi.isAttached()) { + debuggerApi.attach('1.3'); + attachedHere = true; + } + + const result = (await debuggerApi.sendCommand('Page.captureScreenshot', { + format: 'jpeg', + quality: 60, + fromSurface: true, + })) as { data?: string }; - const image = await webContents.view.webContents.capturePage(); - const jpegBuffer = image.toJPEG(10); + if (result?.data) { + return 'data:image/jpeg;base64,' + result.data; + } + } catch (error) { + console.warn( + `CDP screenshot failed for webview ${webviewId}, falling back to capturePage:`, + error + ); + } finally { + if (attachedHere && debuggerApi.isAttached()) { + try { + debuggerApi.detach(); + } catch (detachError) { + console.warn( + `Failed to detach debugger for webview ${webviewId}:`, + detachError + ); + } + } + } + + const image = await targetContents.capturePage(); + const jpegBuffer = image.toJPEG(60); return 'data:image/jpeg;base64,' + jpegBuffer.toString('base64'); } @@ -204,13 +259,7 @@ export class WebViewManager { // Set to muted state when created view.webContents.audioMuted = true; - let newId = Number(id); - view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: 100, - height: 100, - }); + view.setBounds(this.getHiddenBounds(id)); view.setBorderRadius(16); await view.webContents.loadURL(url); @@ -257,12 +306,7 @@ export class WebViewManager { this.win?.webContents.send('url-updated', navigationUrl); return; } - webViewInfo.view.setBounds({ - x: -1919, - y: -1079, - width: 1920, - height: 1080, - }); + webViewInfo.view.setBounds(this.getHiddenBounds(id, 1920, 1080)); const activeSize = this.getActiveWebview().length; const allSize = Array.from(this.webViews.values()).length; const inactiveSize = allSize - activeSize; @@ -333,13 +377,7 @@ export class WebViewManager { height: Math.max(height, 100), }); } else { - let newId = Number(id); - webViewInfo.view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: Math.max(width, 100), - height: Math.max(height, 100), - }); + webViewInfo.view.setBounds(this.getHiddenBounds(id, width, height)); } return { success: true }; @@ -354,13 +392,7 @@ export class WebViewManager { if (!webViewInfo) { return { success: false, error: `Webview with id ${id} not found` }; } - let newId = Number(id); - webViewInfo.view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: 100, - height: 100, - }); + webViewInfo.view.setBounds(this.getHiddenBounds(id)); webViewInfo.isShow = false; if ( @@ -374,13 +406,7 @@ export class WebViewManager { } public hideAllWebview() { this.webViews.forEach((webview) => { - let newId = Number(webview.id); - webview.view.setBounds({ - x: -9999 + newId * 100, - y: -9999 + newId * 100, - width: 100, - height: 100, - }); + webview.view.setBounds(this.getHiddenBounds(webview.id)); webview.isShow = false; if (webview.view.webContents && !webview.view.webContents.isDestroyed()) { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 910a670d7..2547c038a 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -76,6 +76,10 @@ contextBridge.exposeInMainWorld('electronAPI', { webviewDestroy: (webviewId: string) => ipcRenderer.invoke('webview-destroy', webviewId), exportLog: () => ipcRenderer.invoke('export-log'), + getDiagnosticsInfo: () => ipcRenderer.invoke('get-diagnostics-info'), + exportDiagnosticsZip: (payload: { description: string; steps?: string }) => + ipcRenderer.invoke('export-diagnostics-zip', payload), + openMailto: (url: string) => ipcRenderer.invoke('open-mailto', url), uploadLog: (email: string, taskId: string, baseUrl: string, token: string) => ipcRenderer.invoke('upload-log', email, taskId, baseUrl, token), // mcp @@ -175,32 +179,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.off(channel, listener); }; }, - // Skills - getSkillsDir: () => ipcRenderer.invoke('get-skills-dir'), - skillsScan: () => ipcRenderer.invoke('skills-scan'), - skillWrite: (skillDirName: string, content: string) => - ipcRenderer.invoke('skill-write', skillDirName, content), - skillDelete: (skillDirName: string) => - ipcRenderer.invoke('skill-delete', skillDirName), - skillRead: (filePath: string) => ipcRenderer.invoke('skill-read', filePath), - skillListFiles: (skillDirName: string) => - ipcRenderer.invoke('skill-list-files', skillDirName), - skillImportZip: ( - zipPathOrBuffer: string | ArrayBuffer, - replacements?: string[] - ) => ipcRenderer.invoke('skill-import-zip', zipPathOrBuffer, replacements), - openSkillFolder: (skillName: string) => - ipcRenderer.invoke('open-skill-folder', skillName), - skillConfigInit: (userId: string) => - ipcRenderer.invoke('skill-config-init', userId), - skillConfigLoad: (userId: string) => - ipcRenderer.invoke('skill-config-load', userId), - skillConfigToggle: (userId: string, skillName: string, enabled: boolean) => - ipcRenderer.invoke('skill-config-toggle', userId, skillName, enabled), - skillConfigUpdate: (userId: string, skillName: string, skillConfig: any) => - ipcRenderer.invoke('skill-config-update', userId, skillName, skillConfig), - skillConfigDelete: (userId: string, skillName: string) => - ipcRenderer.invoke('skill-config-delete', userId, skillName), + // Skills: all operations via Brain REST API, no IPC }); // --------- Preload scripts loading --------- diff --git a/eslint.config.js b/eslint.config.js index fa8839466..66f196871 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,6 +58,7 @@ export default [ '.vite/**', // Config files 'vite.config.ts', + 'vite.config.*.ts', 'vitest.config.ts', 'tailwind.config.js', 'postcss.config.cjs', @@ -70,6 +71,8 @@ export default [ '**/.venv/**', // Prebuilt resources 'resources/prebuilt/**', + // Archive (pre-refactor snapshots) + 'archive/**', ], }, @@ -140,6 +143,34 @@ export default [ ], }, }, + // Guardrail: in src code, always use Host abstraction instead of direct window Electron APIs + { + files: ['src/**/*.{ts,tsx,js,jsx}'], + rules: { + 'no-restricted-properties': [ + 'error', + { + object: 'window', + property: 'electronAPI', + message: + 'Use Host abstraction (useHost/createHost) instead of window.electronAPI', + }, + { + object: 'window', + property: 'ipcRenderer', + message: + 'Use Host abstraction (useHost/createHost) instead of window.ipcRenderer', + }, + ], + }, + }, + // Single allowed bridge for reading global Electron APIs + { + files: ['src/host/createHost.ts'], + rules: { + 'no-restricted-properties': 'off', + }, + }, // Prettier config (must be last to override conflicting rules) prettier, ]; diff --git a/index.html b/index.html index e000c9cd4..bde6b479d 100644 --- a/index.html +++ b/index.html @@ -46,7 +46,7 @@ https://registry.npmmirror.com; worker-src 'self' blob:; child-src 'self' blob:; - frame-src 'self' localfile: blob: data:; + frame-src 'self' localfile: blob: data: http://localhost:5001 http://127.0.0.1:5001; " /> diff --git a/package.json b/package.json index c9b1f4480..53d0c2863 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "prepare": "husky", "clean-cache": "rimraf node_modules/.vite", "dev": "npm run clean-cache && vite", + "dev:web": "npm run clean-cache && vite --config vite.config.web.ts", "preinstall-deps": "node scripts/preinstall-deps.js", "fix-venv-paths": "node scripts/fix-venv-paths.js", "fix-symlinks": "node scripts/fix-symlinks.js", @@ -35,13 +36,19 @@ "build:linux": "npm run prebuild && electron-builder --linux --publish never", "build:all": "npm run prebuild && electron-builder --mac --win --linux --publish never", "preview": "vite preview", + "build:web": "vite build --config vite.config.web.ts", + "preview:web": "vite preview --config vite.config.web.ts", "pretest": "vite build --mode=test", "test": "vitest run", "test:watch": "vitest", "test:e2e": "vitest run --config vitest.config.ts", "test:coverage": "vitest run --coverage", + "check:i18n": "node scripts/check-i18n-locale-parity.js", + "check:design-token-usage": "node scripts/check-design-token-usage.mjs", + "check:design-tokens": "npm run verify:theme && npm run check:design-token-usage", + "verify:theme": "vite-node scripts/verify-theme-tokens.ts", "type-check": "tsc -p tsconfig.build.json --noEmit", - "lint": "eslint . --no-warn-ignored", + "lint": "eslint . --no-warn-ignored && npm run check:design-token-usage", "lint:fix": "eslint . --fix --no-warn-ignored", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\"", @@ -50,6 +57,7 @@ }, "dependencies": { "@electron/notarize": "^2.5.0", + "@emotion/is-prop-valid": "^1.3.1", "@fontsource/inter": "^5.2.5", "@gsap/react": "^2.1.2", "@microsoft/fetch-event-source": "^2.0.1", @@ -95,12 +103,12 @@ "koffi": "^2.14.1", "lodash-es": "^4.17.21", "lottie-web": "^5.13.0", - "lucide-react": "^0.509.0", + "lucide-react": "^0.548.0", "mammoth": "^1.9.1", "marked": "^17.0.1", "mime": "^4.1.0", "monaco-editor": "^0.52.2", - "motion": "^12.23.24", + "motion": "^12.38.0", "next-themes": "^0.4.6", "papaparse": "^5.5.3", "postprocessing": "^6.37.8", diff --git a/scripts/check-design-token-usage.mjs b/scripts/check-design-token-usage.mjs new file mode 100644 index 000000000..26ae1260d --- /dev/null +++ b/scripts/check-design-token-usage.mjs @@ -0,0 +1,196 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Fails if UI source contains hard-coded colors instead of design tokens + * (CSS variables such as var(--ds-...), var(--colors-...), component vars, or + * Tailwind classes that map to those vars — not raw #hex / rgb() / hsl()). + * + * Usage: + * node scripts/check-design-token-usage.mjs + * node scripts/check-design-token-usage.mjs src/a.tsx # lint-staged (one or more files) + * + * Exemptions: + * - End-of-line comment: // ds:allow-hardcoded-color + * - scripts/design-token-usage.allowlist — repo-relative paths, one per line (# comments ok) + */ + +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..'); + +const EXT = new Set(['.ts', '.tsx', '.js', '.jsx']); + +const SKIP_PREFIXES = ['src/lib/themeTokens/']; + +const SKIP_FILE_RE = + /\.(test|spec)\.(ts|tsx|js|jsx)$|vite-env\.d\.ts$|\.stories\.(ts|tsx)$/; + +const HEX_RE = /#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g; + +const RGB_NUM_RE = /\brgba?\(\s*[\d.]/; +const HSL_NUM_RE = /\bhsla?\(\s*[\d.]/; + +const ARBITRARY_HEX_RE = + /\[[^\]]*#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b[^\]]*\]/; +const ARBITRARY_RGB_RE = /\[[^\]]*rgba?\([^\]]*\]/; + +function loadAllowlist() { + const path = join(REPO_ROOT, 'scripts/design-token-usage.allowlist'); + const set = new Set(); + if (!existsSync(path)) return set; + const raw = readFileSync(path, 'utf8'); + for (const line of raw.split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#')) continue; + set.add(t.replaceAll('\\', '/')); + } + return set; +} + +function shouldSkipPath(relPosix, allowlist) { + const norm = relPosix.replaceAll('\\', '/'); + if (allowlist.has(norm)) return true; + for (const prefix of SKIP_PREFIXES) { + if (norm.startsWith(prefix)) return true; + } + if (SKIP_FILE_RE.test(norm)) return true; + return false; +} + +function* walkSrcFiles(dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const p = join(dir, e.name); + if (e.isDirectory()) { + if (e.name === 'node_modules' || e.name === 'dist') continue; + yield* walkSrcFiles(p); + } else { + const ext = e.name.slice(e.name.lastIndexOf('.')); + if (EXT.has(ext)) yield p; + } + } +} + +function stripTrailingLineComment(line) { + const idx = line.indexOf('//'); + if (idx === -1) return line; + return line.slice(0, idx); +} + +function lineHasExemption(line) { + return line.includes('//') && line.includes('ds:allow-hardcoded-color'); +} + +function checkLine(rawLine, lineNum, fileRel, out) { + if (lineHasExemption(rawLine)) return; + const line = stripTrailingLineComment(rawLine); + + if (RGB_NUM_RE.test(line) || HSL_NUM_RE.test(line)) { + out.push({ + file: fileRel, + line: lineNum, + message: + 'Use design tokens (e.g. var(--ds-...), Tailwind semantic colors) instead of raw rgb/hsl.', + snippet: rawLine.trim(), + }); + return; + } + + HEX_RE.lastIndex = 0; + let m; + while ((m = HEX_RE.exec(line)) !== null) { + const start = m.index; + const before = line.slice(Math.max(0, start - 4), start); + if (/url\s*\(\s*$/i.test(before)) continue; + out.push({ + file: fileRel, + line: lineNum, + message: `Hard-coded hex "${m[0]}" — use a design token or Tailwind color that maps to var(--...).`, + snippet: rawLine.trim(), + }); + return; + } + + if (ARBITRARY_HEX_RE.test(line) || ARBITRARY_RGB_RE.test(line)) { + out.push({ + file: fileRel, + line: lineNum, + message: + 'Tailwind arbitrary color value ([#...] / [rgb(...)]) — use ds-* or semantic utilities.', + snippet: rawLine.trim(), + }); + } +} + +function checkFile(absPath, allowlist) { + const rel = relative(REPO_ROOT, absPath); + const relPosix = rel.replaceAll('\\', '/'); + if (shouldSkipPath(relPosix, allowlist)) return []; + + const text = readFileSync(absPath, 'utf8'); + const lines = text.split(/\r?\n/); + const out = []; + lines.forEach((ln, i) => checkLine(ln, i + 1, relPosix, out)); + return out; +} + +function resolveCliFiles(argv) { + const files = []; + for (const a of argv) { + if (a.startsWith('-')) continue; + const abs = resolve(REPO_ROOT, a); + if (existsSync(abs) && statSync(abs).isFile()) files.push(abs); + } + return files; +} + +function main() { + const allowlist = loadAllowlist(); + const argv = process.argv.slice(2).filter((a) => a !== '--'); + const explicit = resolveCliFiles(argv); + + const targets = + explicit.length > 0 + ? explicit + : [...walkSrcFiles(join(REPO_ROOT, 'src'))]; + + const violations = []; + for (const abs of targets) { + violations.push(...checkFile(abs, allowlist)); + } + + if (violations.length === 0) { + console.log('check-design-token-usage: OK (no hard-coded colors found).'); + process.exit(0); + } + + console.error('check-design-token-usage: hard-coded colors detected:\n'); + for (const v of violations) { + console.error(` ${v.file}:${v.line}`); + console.error(` ${v.message}`); + console.error( + ` ${v.snippet.slice(0, 200)}${v.snippet.length > 200 ? '…' : ''}\n` + ); + } + console.error( + `Total: ${violations.length} finding(s). Fix or add // ds:allow-hardcoded-color on the line, or list the file in scripts/design-token-usage.allowlist (one path per line).` + ); + process.exit(1); +} + +main(); diff --git a/scripts/check-electron-access.sh b/scripts/check-electron-access.sh new file mode 100644 index 000000000..9c061ba20 --- /dev/null +++ b/scripts/check-electron-access.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Guardrail for web separation: +# only src/host/createHost.ts may read window.electronAPI/window.ipcRenderer. +violations="$( + rg -n \ + -e 'window\s*(\?\.)?\s*\.\s*(electronAPI|ipcRenderer)' \ + -e '\(window\s+as\s+any\)\s*\.\s*(electronAPI|ipcRenderer)' \ + -e 'window\s*\[\s*["'\''](electronAPI|ipcRenderer)["'\'']\s*\]' \ + --glob '*.{ts,tsx,js,jsx}' \ + --glob '!src/host/createHost.ts' \ + src || true +)" + +if [[ -n "${violations}" ]]; then + echo "Found forbidden direct Electron window access outside Host bridge:" + echo "${violations}" + exit 1 +fi + +echo "Electron window access guard passed." diff --git a/scripts/check-i18n-locale-parity.js b/scripts/check-i18n-locale-parity.js new file mode 100644 index 000000000..b0e85e8b4 --- /dev/null +++ b/scripts/check-i18n-locale-parity.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/* global console, process */ + +/** + * Ensures every locale has the same JSON keys as `en-us` for every namespace JSON under `src/i18n/locales//`. + * Run from repo root: `node scripts/check-i18n-locale-parity.js` + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..'); +const localesDir = path.join(projectRoot, 'src', 'i18n', 'locales'); +const referenceLocale = 'en-us'; + +function collectJsonFiles(dir) { + const out = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + out.push(...collectJsonFiles(full)); + } else if (e.isFile() && e.name.endsWith('.json')) { + out.push(full); + } + } + return out; +} + +function flattenKeys(obj, prefix = '') { + const keys = []; + for (const [k, v] of Object.entries(obj)) { + const p = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + keys.push(...flattenKeys(v, p)); + } else { + keys.push(p); + } + } + return keys; +} + +function main() { + const refPath = path.join(localesDir, referenceLocale); + if (!fs.existsSync(refPath)) { + console.error(`Reference locale not found: ${refPath}`); + process.exit(1); + } + + const refFiles = collectJsonFiles(refPath); + const refRelative = refFiles.map((f) => path.relative(refPath, f)); + + const localeDirs = fs + .readdirSync(localesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((name) => name !== referenceLocale); + + let failed = false; + + for (const locale of localeDirs) { + const locPath = path.join(localesDir, locale); + for (const rel of refRelative) { + const refFile = path.join(refPath, rel); + const targetFile = path.join(locPath, rel); + if (!fs.existsSync(targetFile)) { + console.error(`Missing file [${locale}]: ${rel}`); + failed = true; + continue; + } + let refJson; + let tgtJson; + try { + refJson = JSON.parse(fs.readFileSync(refFile, 'utf8')); + tgtJson = JSON.parse(fs.readFileSync(targetFile, 'utf8')); + } catch (e) { + console.error(`Invalid JSON (${locale}/${rel}):`, e.message); + failed = true; + continue; + } + const refKeys = new Set(flattenKeys(refJson)); + const tgtKeys = new Set(flattenKeys(tgtJson)); + for (const k of refKeys) { + if (!tgtKeys.has(k)) { + console.error(`Missing key [${locale}] ${rel}: ${k}`); + failed = true; + } + } + for (const k of tgtKeys) { + if (!refKeys.has(k)) { + console.error(`Extra key [${locale}] ${rel}: ${k}`); + failed = true; + } + } + } + } + + if (failed) { + console.error('\ncheck-i18n-locale-parity: FAILED'); + process.exit(1); + } + console.log('check-i18n-locale-parity: OK'); +} + +main(); diff --git a/scripts/design-token-usage.allowlist b/scripts/design-token-usage.allowlist new file mode 100644 index 000000000..fa84573ba --- /dev/null +++ b/scripts/design-token-usage.allowlist @@ -0,0 +1,15 @@ +# Whole-file skips for scripts/check-design-token-usage.mjs (run: npm run check:design-token-usage). +# Prefer design tokens or a line-level // ds:allow-hardcoded-color comment. +# List a path only when the file must contain raw colors by design (whole module is the exception). +# +# WordCarousel — default marketing gradient uses fixed brand hex; pass `gradient` for token-based styling. +src/components/ui/WordCarousel/WordCarousel.tsx +# +# Terminal — @xterm/xterm ITheme requires hex strings for theme colors. +src/components/Terminal/index.tsx +# +# Install progress bar (src/components/ui/progress-install.tsx) and similar: use CSS vars only — no entry needed. +# +# Electron shell surfaces use fixed native colors in preload HTML and BrowserWindow options. +electron/main/index.ts +electron/preload/index.ts diff --git a/scripts/smoke-web-local-brain.sh b/scripts/smoke-web-local-brain.sh new file mode 100644 index 000000000..9a4943050 --- /dev/null +++ b/scripts/smoke-web-local-brain.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +BACKEND_LOG="${TMP_DIR}/backend.log" +FRONTEND_LOG="${TMP_DIR}/frontend.log" +BACKEND_PID="" +FRONTEND_PID="" + +BRAIN_HOST="${BRAIN_HOST:-127.0.0.1}" +BRAIN_PORT="${BRAIN_PORT:-5001}" +WEB_HOST="${WEB_HOST:-127.0.0.1}" +WEB_PORT="${WEB_PORT:-5173}" + +cleanup() { + local exit_code=$? + if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then + kill "${FRONTEND_PID}" >/dev/null 2>&1 || true + wait "${FRONTEND_PID}" 2>/dev/null || true + fi + if [[ -n "${BACKEND_PID}" ]] && kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then + kill "${BACKEND_PID}" >/dev/null 2>&1 || true + wait "${BACKEND_PID}" 2>/dev/null || true + fi + if [[ ${exit_code} -ne 0 ]]; then + echo + echo "[smoke] backend log:" + cat "${BACKEND_LOG}" || true + echo + echo "[smoke] frontend log:" + cat "${FRONTEND_LOG}" || true + fi + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT INT TERM + +wait_http() { + local url="$1" + local label="$2" + local timeout_seconds="${3:-120}" + local started_at + started_at="$(date +%s)" + + while true; do + if curl --silent --show-error --output /dev/null --fail "${url}"; then + return 0 + fi + if (( "$(date +%s)" - started_at > timeout_seconds )); then + echo "[smoke] timeout waiting for ${label}: ${url}" >&2 + return 1 + fi + sleep 1 + done +} + +assert_html_doc() { + local file="$1" + local label="$2" + if ! grep -Eiq "|&2 + return 1 + fi +} + +echo "[smoke] starting backend on ${BRAIN_HOST}:${BRAIN_PORT}" +( + cd "${ROOT_DIR}/backend" + EIGENT_BRAIN_HOST="${BRAIN_HOST}" \ + EIGENT_BRAIN_PORT="${BRAIN_PORT}" \ + EIGENT_DEBUG="false" \ + uv run python main.py >"${BACKEND_LOG}" 2>&1 +) & +BACKEND_PID=$! + +wait_http "http://${BRAIN_HOST}:${BRAIN_PORT}/health" "backend health" + +echo "[smoke] checking session header + health detail" +curl --silent --show-error \ + --header "X-Channel: web" \ + --dump-header "${TMP_DIR}/health_headers.txt" \ + --output "${TMP_DIR}/health.json" \ + "http://${BRAIN_HOST}:${BRAIN_PORT}/health" + +if ! grep -qi '^x-session-id:' "${TMP_DIR}/health_headers.txt"; then + echo "[smoke] missing X-Session-ID header in /health response" >&2 + exit 1 +fi + +curl --silent --show-error \ + --header "X-Channel: web" \ + --output "${TMP_DIR}/health_detail.json" \ + "http://${BRAIN_HOST}:${BRAIN_PORT}/health?detail=true" + +python3 - "${TMP_DIR}/health_detail.json" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert isinstance(payload, dict), "health detail payload must be object" +assert "capabilities" in payload, "health detail missing capabilities" +assert isinstance(payload["capabilities"], dict), "capabilities must be object" +PY + +echo "[smoke] starting web frontend on ${WEB_HOST}:${WEB_PORT}" +( + cd "${ROOT_DIR}" + npm run dev:web -- --host "${WEB_HOST}" --port "${WEB_PORT}" >"${FRONTEND_LOG}" 2>&1 +) & +FRONTEND_PID=$! + +wait_http "http://${WEB_HOST}:${WEB_PORT}/" "frontend root" + +curl --silent --show-error \ + --output "${TMP_DIR}/web_root.html" \ + "http://${WEB_HOST}:${WEB_PORT}/" +assert_html_doc "${TMP_DIR}/web_root.html" "web root" + +status_code="$(curl --silent --show-error \ + --output "${TMP_DIR}/web_route.html" \ + --write-out "%{http_code}" \ + "http://${WEB_HOST}:${WEB_PORT}/project/smoke-route")" +if [[ "${status_code}" != "200" ]]; then + echo "[smoke] browser-router route fallback failed: status=${status_code}" >&2 + exit 1 +fi +assert_html_doc "${TMP_DIR}/web_route.html" "web route fallback" + +echo "[smoke] PASS: web + local brain smoke checks completed" diff --git a/scripts/verify-theme-tokens.ts b/scripts/verify-theme-tokens.ts new file mode 100644 index 000000000..da70a1255 --- /dev/null +++ b/scripts/verify-theme-tokens.ts @@ -0,0 +1,195 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +// Standalone V2 design-token verification CLI. +// +// Runs the verifier over every registered theme/mode/contrast variant and +// prints a human-readable report. Exits with a non-zero code if any `error` +// findings are produced. Auxiliary contrast warnings do not fail the run +// unless `--strict` is passed. +// +// Usage: +// npm run verify:theme +// npm run verify:theme -- --strict # auxiliary warnings fail too +// npm run verify:theme -- --json # machine-readable output +// npm run verify:theme -- --contrast 0,50,100 + +import { + getDefaultContrastGrid, + listRegisteredThemes, + verifyThemeEngine, + type VerifyFinding, +} from '../src/lib/themeTokens/verifier'; + +type CliFlags = { + strict: boolean; + json: boolean; + contrastGrid: number[]; +}; + +function parseArgs(argv: string[]): CliFlags { + const flags: CliFlags = { + strict: false, + json: false, + contrastGrid: getDefaultContrastGrid(), + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--strict') flags.strict = true; + else if (arg === '--json') flags.json = true; + else if (arg === '--contrast') { + const raw = argv[++i]; + if (raw) { + flags.contrastGrid = raw + .split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + } + } else if (arg === '--help' || arg === '-h') { + process.stdout.write( + [ + 'Usage: verify-theme-tokens [options]', + '', + 'Options:', + ' --strict Treat auxiliary contrast warnings as errors', + ' --json Emit JSON report on stdout', + ' --contrast a,b,c Override contrast grid (default: 0,25,43,75,100)', + ' -h, --help Show this help', + '', + ].join('\n') + ); + process.exit(0); + } + } + return flags; +} + +const COLORS = { + reset: '\x1b[0m', + red: '\x1b[31m', + yellow: '\x1b[33m', + green: '\x1b[32m', + dim: '\x1b[2m', + bold: '\x1b[1m', + cyan: '\x1b[36m', +}; + +function colorize(text: string, code: string): string { + if (!process.stdout.isTTY) return text; + return `${code}${text}${COLORS.reset}`; +} + +function groupFindings( + findings: VerifyFinding[] +): Map { + const groups = new Map(); + for (const f of findings) { + const key = `${f.mode} / ${f.themeId} / contrast=${f.contrast}`; + const bucket = groups.get(key); + if (bucket) bucket.push(f); + else groups.set(key, [f]); + } + return groups; +} + +function printHumanReport( + themes: Array<{ mode: string; id: string }>, + flags: CliFlags, + report: ReturnType +): void { + const { summary, findings } = report; + + process.stdout.write( + `\n${colorize('Design Token Engine Verification (V2)', COLORS.bold)}\n` + ); + process.stdout.write( + colorize( + ` Registered themes: ${themes.map((t) => `${t.mode}/${t.id}`).join(', ')}\n`, + COLORS.dim + ) + ); + process.stdout.write( + colorize( + ` Contrast grid: ${flags.contrastGrid.join(', ')}\n`, + COLORS.dim + ) + ); + process.stdout.write( + colorize(` Variants checked: ${summary.variantsChecked}\n\n`, COLORS.dim) + ); + + if (findings.length === 0) { + process.stdout.write( + `${colorize('PASS', COLORS.green)} No findings — engine is clean.\n\n` + ); + return; + } + + const groups = groupFindings(findings); + for (const [variant, bucket] of groups) { + process.stdout.write(`${colorize(variant, COLORS.cyan)}\n`); + for (const f of bucket) { + const badge = + f.severity === 'error' + ? colorize('ERROR', COLORS.red) + : colorize('WARN ', COLORS.yellow); + const ratioSuffix = + f.ratio !== undefined && f.threshold !== undefined + ? colorize( + ` (ratio ${f.ratio.toFixed(2)} / threshold ${f.threshold})`, + COLORS.dim + ) + : ''; + process.stdout.write( + ` ${badge} [${f.code}] ${f.message}${ratioSuffix}\n` + ); + if (f.tokenKey && f.value) { + process.stdout.write( + colorize(` ↳ ${f.tokenKey} = ${f.value}\n`, COLORS.dim) + ); + } + } + process.stdout.write('\n'); + } + + const errBadge = + summary.errors === 0 + ? colorize(`${summary.errors} errors`, COLORS.green) + : colorize(`${summary.errors} errors`, COLORS.red); + const warnBadge = + summary.warnings === 0 + ? colorize(`${summary.warnings} warnings`, COLORS.green) + : colorize(`${summary.warnings} warnings`, COLORS.yellow); + process.stdout.write(`Summary: ${errBadge}, ${warnBadge}\n\n`); +} + +function main() { + const flags = parseArgs(process.argv.slice(2)); + const themes = listRegisteredThemes(); + const report = verifyThemeEngine({ + contrastGrid: flags.contrastGrid, + strictAuxContrast: flags.strict, + }); + + if (flags.json) { + process.stdout.write(JSON.stringify({ themes, ...report }, null, 2) + '\n'); + } else { + printHumanReport(themes, flags, report); + } + + const failed = report.summary.errors > 0; + process.exit(failed ? 1 : 0); +} + +main(); diff --git a/server/app/domains/chat/api/share_controller.py b/server/app/domains/chat/api/share_controller.py index 674c72226..6877dde3f 100644 --- a/server/app/domains/chat/api/share_controller.py +++ b/server/app/domains/chat/api/share_controller.py @@ -70,6 +70,7 @@ async def event_generator(): "task_id": s.task_id, "step": s.step, "data": s.data, + "timestamp": s.timestamp, "created_at": s.created_at.isoformat() if s.created_at else None, } yield f"data: {json.dumps(step_data)}\n\n" diff --git a/server/app/domains/chat/api/step_controller.py b/server/app/domains/chat/api/step_controller.py index d14c84ad0..e8f58fbe7 100644 --- a/server/app/domains/chat/api/step_controller.py +++ b/server/app/domains/chat/api/step_controller.py @@ -83,6 +83,7 @@ async def event_generator(): "task_id": s.task_id, "step": s.step, "data": s.data, + "timestamp": s.timestamp, "created_at": s.created_at.isoformat() if s.created_at else None, } yield f"data: {json.dumps(step_data)}\n\n" diff --git a/src/App.tsx b/src/App.tsx index ebfca2717..fceb87b1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { useHost } from '@/host'; import { queryClient } from '@/lib/queryClient'; import AppRoutes from '@/routers/index'; import { stackClientApp } from '@/stack/client'; @@ -29,6 +30,7 @@ import { useAuthStore } from './store/authStore'; const HAS_STACK_KEYS = hasStackKeys(); function App() { + const host = useHost(); const navigate = useNavigate(); const { setInitState } = useAuthStore(); const { token } = useAuthStore(); @@ -69,14 +71,14 @@ function App() { } }; - window.ipcRenderer?.on('auth-share-token-received', handleShareCode); - window.electronAPI?.onUpdateNotification(handleUpdateNotification); + host?.ipcRenderer?.on('auth-share-token-received', handleShareCode); + host?.electronAPI?.onUpdateNotification(handleUpdateNotification); return () => { - window.ipcRenderer?.off('auth-share-token-received', handleShareCode); - window.electronAPI?.removeAllListeners('update-notification'); + host?.ipcRenderer?.off('auth-share-token-received', handleShareCode); + host?.electronAPI?.removeAllListeners('update-notification'); }; - }, [navigate, setInitState]); + }, [host, navigate, setInitState]); // render wrapper const renderWrapper = (children: React.ReactNode) => { diff --git a/src/api/brain.ts b/src/api/brain.ts new file mode 100644 index 000000000..46b85725d --- /dev/null +++ b/src/api/brain.ts @@ -0,0 +1,179 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Brain REST API - MCP and Skills endpoints. + * All calls go through Brain HTTP API (getBaseURL). No IPC fallback. + */ + +import { + fetchDelete, + fetchGet, + fetchPost, + fetchPostForm, + fetchPut, +} from './http'; + +export async function mcpList(): Promise<{ + mcpServers: Record; +}> { + const res = await fetchGet('/mcp/list'); + return res && typeof res.mcpServers === 'object' ? res : { mcpServers: {} }; +} + +export async function mcpInstall( + name: string, + mcp: Record +): Promise<{ success: boolean }> { + return fetchPost('/mcp/install', { name, mcp }); +} + +export async function mcpRemove(name: string): Promise<{ success: boolean }> { + return fetchDelete(`/mcp/${encodeURIComponent(name)}`); +} + +export async function mcpUpdate( + name: string, + mcp: Record +): Promise<{ success: boolean }> { + return fetchPut(`/mcp/${encodeURIComponent(name)}`, mcp); +} + +export async function skillsScan(): Promise<{ + success: boolean; + skills: Array<{ + name: string; + description: string; + path: string; + scope: string; + skillDirName: string; + isExample: boolean; + }>; +}> { + const res = await fetchGet('/skills'); + return res?.skills !== undefined ? res : { success: true, skills: [] }; +} + +export async function skillWrite( + skillDirName: string, + content: string +): Promise<{ success: boolean }> { + return fetchPost(`/skills/${encodeURIComponent(skillDirName)}`, { content }); +} + +export async function skillImportZip( + zipBuffer: ArrayBuffer, + replacements?: string[] +): Promise<{ + success: boolean; + error?: string; + conflicts?: Array<{ folderName: string; skillName: string }>; +}> { + const formData = new FormData(); + formData.append('file', new Blob([zipBuffer]), 'skill.zip'); + if (replacements?.length) { + formData.append('replacements', replacements.join(',')); + } + const res = await fetchPostForm('/skills/import', formData); + return (res ?? { success: false, error: 'Import failed' }) as { + success: boolean; + error?: string; + conflicts?: Array<{ folderName: string; skillName: string }>; + }; +} + +export async function skillRead( + skillDirName: string +): Promise<{ success: boolean; content: string }> { + return fetchGet(`/skills/${encodeURIComponent(skillDirName)}`); +} + +export async function skillDelete( + skillDirName: string +): Promise<{ success: boolean }> { + return fetchDelete(`/skills/${encodeURIComponent(skillDirName)}`); +} + +export async function skillListFiles( + skillDirName: string +): Promise<{ success: boolean; files: string[] }> { + const res = await fetchGet( + `/skills/${encodeURIComponent(skillDirName)}/files` + ); + return res?.files !== undefined ? res : { success: true, files: [] }; +} + +export async function skillGetPathByName( + skillName: string +): Promise<{ path: string } | null> { + const res = await fetchGet( + `/skills/path?name=${encodeURIComponent(skillName)}` + ); + return res?.path ? { path: res.path } : null; +} + +// --- Skill config (REST API, no Electron coupling) --- + +export async function skillConfigLoad( + userId: string +): Promise<{ success: boolean; config?: Record }> { + const res = await fetchGet( + `/skills/config?user_id=${encodeURIComponent(userId)}` + ); + return res?.config !== undefined ? res : { success: false }; +} + +export async function skillConfigInit( + userId: string +): Promise<{ success: boolean; config?: Record }> { + const res = await fetchPost('/skills/config/init', { user_id: userId }); + return res?.config !== undefined ? res : { success: false }; +} + +export async function skillConfigUpdate( + userId: string, + skillName: string, + config: { + enabled?: boolean; + scope?: { isGlobal?: boolean; selectedAgents?: string[] }; + addedAt?: number; + isExample?: boolean; + } +): Promise<{ success: boolean }> { + return fetchPut(`/skills/config/${encodeURIComponent(skillName)}`, { + user_id: userId, + ...config, + }); +} + +export async function skillConfigDelete( + userId: string, + skillName: string +): Promise<{ success: boolean }> { + return fetchDelete( + `/skills/config/${encodeURIComponent(skillName)}?user_id=${encodeURIComponent(userId)}` + ); +} + +export async function skillConfigToggle( + userId: string, + skillName: string, + enabled: boolean +): Promise<{ success: boolean; config?: Record }> { + const res = await fetchPost( + `/skills/config/${encodeURIComponent(skillName)}/toggle`, + { user_id: userId, enabled } + ); + return res ?? { success: false }; +} diff --git a/src/api/http.ts b/src/api/http.ts index 70fa418ce..0c523df6e 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -15,20 +15,93 @@ import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import { showTrafficToast } from '@/components/Toast/trafficToast'; +import { createHost } from '@/host/createHost'; import { getAuthStore } from '@/store/authStore'; +import { + getConnectionConfig, + setConnectionConfig, +} from '@/store/connectionStore'; +import { + EventSourceMessage, + fetchEventSource, +} from '@microsoft/fetch-event-source'; const defaultHeaders = { 'Content-Type': 'application/json', }; -let baseUrl = ''; +export function getDefaultBrainEndpoint(): string { + const envEndpoint = import.meta.env.VITE_BRAIN_ENDPOINT; + if (envEndpoint && typeof envEndpoint === 'string') { + return envEndpoint.replace(/\/$/, ''); + } + if (import.meta.env.DEV) { + return 'http://localhost:5001'; + } + return ''; +} + +function persistSessionIdFromResponse(response: Response): void { + const sessionId = response.headers.get('x-session-id'); + if (!sessionId) { + return; + } + const current = getConnectionConfig().sessionId; + if (current !== sessionId) { + setConnectionConfig({ sessionId }); + } +} + +function shouldAttachAuthHeader(url: string): boolean { + return !url.includes('http://') && !url.includes('https://'); +} + +function buildBrainHeaders( + url: string, + customHeaders: Record = {}, + includeContentType = true +): Record { + const { token } = getAuthStore(); + const conn = getConnectionConfig(); + const headers: Record = { + ...(includeContentType ? defaultHeaders : {}), + 'X-Channel': conn.channel, + ...customHeaders, + }; + if (conn.sessionId) { + headers['X-Session-ID'] = conn.sessionId; + } + if (token && shouldAttachAuthHeader(url)) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +/** Reset cached baseUrl (e.g. when backend restarts). */ +export function resetBaseURL(): void { + setConnectionConfig({ brainEndpoint: '' }); +} + export async function getBaseURL() { - if (baseUrl) { - return baseUrl; + const cfg = getConnectionConfig(); + if (cfg.brainEndpoint) { + return cfg.brainEndpoint.replace(/\/$/, ''); + } + // Electron: get port from IPC + const port = await createHost().ipcRenderer?.invoke('get-backend-port'); + if (port && port > 0) { + const resolved = `http://localhost:${port}`; + setConnectionConfig({ brainEndpoint: resolved }); + return resolved; } - const port = await window.ipcRenderer.invoke('get-backend-port'); - baseUrl = `http://localhost:${port}`; - return baseUrl; + // Pure Web: use VITE_BRAIN_ENDPOINT (dev default http://localhost:5001) + const envEndpoint = getDefaultBrainEndpoint(); + if (envEndpoint && typeof envEndpoint === 'string') { + const resolved = envEndpoint.replace(/\/$/, ''); // trim trailing slash + setConnectionConfig({ brainEndpoint: resolved }); + return resolved; + } + return ''; } async function fetchRequest( @@ -39,17 +112,7 @@ async function fetchRequest( ): Promise { const baseURL = await getBaseURL(); const fullUrl = `${baseURL}${url}`; - const { token } = getAuthStore(); - - const headers: Record = { - ...defaultHeaders, - ...customHeaders, - }; - - // Cases without token: url is a complete http:// path - if (!url.includes('http://') && token) { - headers['Authorization'] = `Bearer ${token}`; - } + const headers = buildBrainHeaders(url, customHeaders); const options: RequestInit = { method, @@ -82,23 +145,34 @@ async function handleResponse( ): Promise { try { const res = await responsePromise; + persistSessionIdFromResponse(res); if (res.status === 204) { return { code: 0, text: '' }; } const contentType = res.headers.get('content-type') || ''; - if (res.body && !contentType.includes('application/json')) { - return { - isStream: true, - body: res.body, - reader: res.body.getReader(), - }; + if (!contentType.includes('application/json')) { + if (!res.ok) { + const detail = await res.text().catch(() => ''); + const msg = detail?.trim() || `HTTP ${res.status}`; + const err = new Error(msg); + (err as any).status = res.status; + (err as any).response = res; + throw err; + } + if (res.body) { + return { + isStream: true, + body: res.body, + reader: res.body.getReader(), + }; + } + return null; } const resData = await res.json(); if (!resData) { return null; } - const { code, text } = resData; // showCreditsToast() if (code === 1 || code === 300) { @@ -123,9 +197,13 @@ async function handleResponse( } if (!res.ok) { - const err: any = new Error( - resData?.detail || resData?.message || `HTTP error ${res.status}` - ); + const detail = resData?.detail; + const msg = + (Array.isArray(detail) ? detail[0] : detail) || + resData?.message || + `HTTP ${res.status}`; + const err: any = new Error(typeof msg === 'string' ? msg : String(msg)); + err.status = res.status; err.response = { data: resData, status: res.status }; throw err; } @@ -163,6 +241,78 @@ export const fetchPut = (url: string, data?: any, headers?: any) => export const fetchDelete = (url: string, data?: any, headers?: any) => fetchRequest('DELETE', url, data, headers); +/** POST FormData to Brain base URL (for file uploads). */ +export async function fetchPostForm( + url: string, + formData: FormData, + customHeaders: Record = {} +): Promise { + const baseURL = await getBaseURL(); + const fullUrl = `${baseURL}${url}`; + const headers = buildBrainHeaders(url, customHeaders, false); + return handleResponse( + fetch(fullUrl, { method: 'POST', headers, body: formData }) + ); +} + +export async function uploadFileToBrain(file: globalThis.File): Promise<{ + file_id: string; + filename: string; + size: number; +}> { + const formData = new FormData(); + formData.append('file', file); + return fetchPostForm('/files', formData); +} + +export interface SSETransportOptions { + url: string; + method?: 'GET' | 'POST'; + body?: Record | string; + signal?: AbortSignal; + extraHeaders?: Record; + openWhenHidden?: boolean; + onmessage: (event: EventSourceMessage) => void | Promise; + onopen?: (response: Response) => void | Promise; + onerror?: (err: any) => number | null | undefined | void; + onclose?: () => void; +} + +export async function sseTransport( + options: SSETransportOptions +): Promise { + const baseURL = await getBaseURL(); + const fullUrl = + options.url.startsWith('http://') || options.url.startsWith('https://') + ? options.url + : `${baseURL}${options.url}`; + + const headers = buildBrainHeaders(options.url, options.extraHeaders); + const body = + typeof options.body === 'string' + ? options.body + : options.body + ? JSON.stringify(options.body) + : undefined; + + await fetchEventSource(fullUrl, { + method: options.method || 'POST', + openWhenHidden: options.openWhenHidden ?? true, + signal: options.signal, + headers, + body, + onmessage: options.onmessage, + async onopen(response) { + persistSessionIdFromResponse(response); + if (options.onopen) { + await options.onopen(response); + } + }, + onerror: options.onerror, + onclose: options.onclose, + }); +} + // =============== porxy =============== // get proxy base URL @@ -170,11 +320,9 @@ async function getProxyBaseURL() { const isDev = import.meta.env.DEV; if (isDev) { - const proxyUrl = import.meta.env.VITE_PROXY_URL; - if (!proxyUrl) { - return 'http://localhost:3001'; - } - return proxyUrl; + // Use empty base so request goes to same origin; Vite proxy forwards /api to VITE_PROXY_URL + // This avoids CORS when running dev:web (browser at 5173, server at 3001) + return ''; } else { const baseUrl = import.meta.env.VITE_BASE_URL; if (!baseUrl) { diff --git a/src/assets/dark.png b/src/assets/dark.png deleted file mode 100644 index d46a6a756..000000000 Binary files a/src/assets/dark.png and /dev/null differ diff --git a/src/assets/icon/cursor.svg b/src/assets/icon/cursor.svg new file mode 100644 index 000000000..2104885da --- /dev/null +++ b/src/assets/icon/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/src/assets/icon/github.svg b/src/assets/icon/github.svg new file mode 100644 index 000000000..bd1fe3b66 --- /dev/null +++ b/src/assets/icon/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/google_calendar.svg b/src/assets/icon/google_calendar.svg new file mode 100644 index 000000000..920991de6 --- /dev/null +++ b/src/assets/icon/google_calendar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/google_gmail.svg b/src/assets/icon/google_gmail.svg new file mode 100644 index 000000000..fadeff8f6 --- /dev/null +++ b/src/assets/icon/google_gmail.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icon/linkedin.svg b/src/assets/icon/linkedin.svg new file mode 100644 index 000000000..2cf6f301a --- /dev/null +++ b/src/assets/icon/linkedin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icon/notion.svg b/src/assets/icon/notion.svg new file mode 100644 index 000000000..070bcd78a --- /dev/null +++ b/src/assets/icon/notion.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icon/rag.svg b/src/assets/icon/rag.svg new file mode 100644 index 000000000..bee2d0fa1 --- /dev/null +++ b/src/assets/icon/rag.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/reddit.svg b/src/assets/icon/reddit.svg new file mode 100644 index 000000000..4e427bc7e --- /dev/null +++ b/src/assets/icon/reddit.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icon/slack.svg b/src/assets/icon/slack.svg index 73d69d013..53b24e829 100644 --- a/src/assets/icon/slack.svg +++ b/src/assets/icon/slack.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/src/assets/icon/vs-code.svg b/src/assets/icon/vs-code.svg new file mode 100644 index 000000000..24c1d67a9 --- /dev/null +++ b/src/assets/icon/vs-code.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icon/whatsapp.svg b/src/assets/icon/whatsapp.svg new file mode 100644 index 000000000..49f70f468 --- /dev/null +++ b/src/assets/icon/whatsapp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/icon/x.svg b/src/assets/icon/x.svg new file mode 100644 index 000000000..f414fb575 --- /dev/null +++ b/src/assets/icon/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/light.png b/src/assets/light.png deleted file mode 100644 index f0de3f5e5..000000000 Binary files a/src/assets/light.png and /dev/null differ diff --git a/src/assets/logo/icon_black.svg b/src/assets/logo/icon_black.svg index 752962fb5..a4a46348b 100644 --- a/src/assets/logo/icon_black.svg +++ b/src/assets/logo/icon_black.svg @@ -1,33 +1,32 @@ - - - - - - + + + + + + - + - - - - - - - - - + + + + + + + + - + diff --git a/src/assets/logo/icon_white.svg b/src/assets/logo/icon_white.svg index 4cefd0711..a66226908 100644 --- a/src/assets/logo/icon_white.svg +++ b/src/assets/logo/icon_white.svg @@ -1,33 +1,32 @@ - - - - - - + + + + + + - + - - - - - - - - - - + + + + + + + + + - - + + diff --git a/src/assets/transparent.png b/src/assets/transparent.png deleted file mode 100644 index 798523455..000000000 Binary files a/src/assets/transparent.png and /dev/null differ diff --git a/src/client/desktop/README.md b/src/client/desktop/README.md new file mode 100644 index 000000000..3f1662a7c --- /dev/null +++ b/src/client/desktop/README.md @@ -0,0 +1,6 @@ +# Desktop client (Electron) + +Desktop-only components and logic. Uses `useHost()` from `@/host`; when `host.electronAPI` is present. + +- WindowControls, HardwareBridge-related UI +- IPC handlers for window, file, CDP, etc. diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 000000000..85d04ef4e --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,23 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Client adapters: desktop, web, cli, browser_extension, channel adapters. +// See docs/design/04-client.md. + +export { + getClientType, + isDesktop, + isElectron, + isWeb, + type ClientType, +} from './platform'; diff --git a/src/client/platform.ts b/src/client/platform.ts new file mode 100644 index 000000000..269d073e3 --- /dev/null +++ b/src/client/platform.ts @@ -0,0 +1,48 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Client platform detection. See docs/design/04-client.md. + +import { createHost } from '@/host/createHost'; + +export type ClientType = + | 'desktop' + | 'web' + | 'cli' + | 'browser_extension' + | 'whatsapp' + | 'telegram' + | 'slack' + | 'discord' + | 'lark'; + +/** True when running inside Electron (desktop app). */ +export function isElectron(): boolean { + const host = createHost(); + return !!host.electronAPI && !!host.ipcRenderer; +} + +/** Current client type. Web build = 'web'; Electron = 'desktop'. */ +export function getClientType(): ClientType { + if (typeof window === 'undefined') return 'web'; + if (isElectron()) return 'desktop'; + return 'web'; +} + +export function isDesktop(): boolean { + return getClientType() === 'desktop'; +} + +export function isWeb(): boolean { + return getClientType() === 'web'; +} diff --git a/src/client/web/README.md b/src/client/web/README.md new file mode 100644 index 000000000..ed547c596 --- /dev/null +++ b/src/client/web/README.md @@ -0,0 +1,7 @@ +# Web client + +Web-specific components. Used when `host.electronAPI` is null (from `useHost()`). + +- No Electron API +- Brain endpoint from VITE_BRAIN_ENDPOINT or user config +- File upload UI (no local file picker) diff --git a/src/components/AddWorker/ToolSelect.tsx b/src/components/AddWorker/ToolSelect.tsx index 5bc863484..4d3247dc4 100644 --- a/src/components/AddWorker/ToolSelect.tsx +++ b/src/components/AddWorker/ToolSelect.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { mcpInstall } from '@/api/brain'; import { fetchGet, fetchPost, @@ -19,29 +20,31 @@ import { proxyFetchPost, proxyFetchPut, } from '@/api/http'; -import githubIcon from '@/assets/github.svg'; -import IntegrationList from '@/components/IntegrationList'; +import IntegrationList from '@/components/Dashboard/IntegrationList'; import { Badge } from '@/components/ui/badge'; +import { + useIntegrationManagement, + type IntegrationItem, +} from '@/hooks/useIntegrationManagement'; +import { useHost } from '@/host'; import { capitalizeFirstLetter, getProxyBaseURL } from '@/lib'; +import { cn } from '@/lib/utils'; import { useAuthStore } from '@/store/authStore'; -import { CircleAlert, Store, X } from 'lucide-react'; +import { CircleAlert, X } from 'lucide-react'; import { forwardRef, useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button } from '../ui/button'; +import { Checkbox } from '../ui/checkbox'; import { Textarea } from '../ui/textarea'; import { TooltipSimple } from '../ui/tooltip'; -import AnthropicIcon from '@/assets/mcp/Anthropic.svg?url'; -import CamelIcon from '@/assets/mcp/Camel.svg?url'; -import CommunityIcon from '@/assets/mcp/Community.svg?url'; -import OfficialIcon from '@/assets/mcp/Official.svg?url'; interface McpItem { id: number; name: string; @@ -66,25 +69,27 @@ const ToolSelect = forwardRef< { installMcp: (id: number, env?: any, activeMcp?: any) => Promise }, ToolSelectProps >(({ onShowEnvConfig, onSelectedToolsChange, initialSelectedTools }, ref) => { + const host = useHost(); + const electronAPI = host?.electronAPI; const { t } = useTranslation(); // state management - remove internal selected state, use parent passed initialSelectedTools const [keyword, setKeyword] = useState(''); - const [mcpList, setMcpList] = useState([]); - const [allMcpList, setAllMcpList] = useState([]); - const [customMcpList, setCustomMcpList] = useState([]); - const [isOpen, setIsOpen] = useState(false); - const [installed, setInstalled] = useState<{ [id: number]: boolean }>({}); - const [installing, setInstalling] = useState<{ [id: number]: boolean }>({}); - const [installedIds, setInstalledIds] = useState([]); const { email } = useAuthStore(); - // add: integration service list const [integrations, setIntegrations] = useState([]); + const [userMcpList, setUserMcpList] = useState([]); + + const integrationItems = integrations as IntegrationItem[]; + const { installed: webInstalled } = + useIntegrationManagement(integrationItems); + + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + const containerRef = useRef(null); + // select management const addOption = useCallback( (item: McpItem, isLocal?: boolean) => { - setKeyword(''); const currentSelected = initialSelectedTools || []; - console.log(currentSelected.find((i) => i.id === item.id)); if (isLocal) { if (!currentSelected.find((i) => i.key === item.key)) { const newSelected = [...currentSelected, { ...item, isLocal }]; @@ -294,86 +299,23 @@ const ToolSelect = forwardRef< [addOption, t] ); - // Refs - const inputRef = useRef(null); - const debounceTimerRef = useRef(null); - const containerRef = useRef(null); - - // constants - const categoryIconMap: Record = { - anthropic: 'Anthropic', - community: 'Community', - official: 'Official', - camel: 'Camel', - }; - - const svgIcons: Record = { - Anthropic: AnthropicIcon, - Community: CommunityIcon, - Official: OfficialIcon, - Camel: CamelIcon, - }; - - // data fetching - const fetchData = useCallback((keyword?: string) => { - proxyFetchGet('/api/v1/mcps', { - keyword: keyword || '', - page: 1, - size: 100, - }) - .then((res) => { - // Add defensive check for API errors - if (res && res.items && Array.isArray(res.items)) { - setAllMcpList(res.items); - } else { - console.error('Failed to fetch MCPs:', res); - setAllMcpList([]); - } - }) - .catch((error) => { - console.error('Error fetching MCPs:', error); - setAllMcpList([]); - }); - }, []); - const fetchInstalledMcps = useCallback(() => { proxyFetchGet('/api/v1/mcp/users') .then((res) => { - let dataList = []; - let ids: number[] = []; + let dataList: any[] = []; if (Array.isArray(res)) { - ids = res.map((item: any) => item.mcp_id); dataList = res; } else if (res && Array.isArray(res.items)) { - ids = res.items.map((item: any) => item.mcp_id); dataList = res.items; } - setInstalledIds(ids); - - const customMcpList = dataList.filter((item: any) => item.mcp_id === 0); - setCustomMcpList(customMcpList); + setUserMcpList(dataList); }) .catch((error) => { console.error('Error fetching installed MCPs:', error); - setInstalledIds([]); - setCustomMcpList([]); + setUserMcpList([]); }); }, []); - // only surface installed MCPs from the market list - useEffect(() => { - // Add defensive check and fix logic: should filter when installedIds has items - if (Array.isArray(allMcpList) && installedIds.length > 0) { - const filtered = allMcpList.filter((item) => - installedIds.includes(item.id) - ); - setMcpList(filtered); - } else if (Array.isArray(allMcpList)) { - // If no installed IDs, show empty list instead of all - setMcpList([]); - } - }, [allMcpList, installedIds]); - // public save env/config logic const saveEnvAndConfig = async ( provider: string, @@ -408,8 +350,8 @@ const ToolSelect = forwardRef< await proxyFetchPost('/api/v1/configs', configPayload); } - if (window.electronAPI?.envWrite) { - await window.electronAPI.envWrite(email, { key: envVarKey, value }); + if (electronAPI?.envWrite) { + await electronAPI.envWrite(email, { key: envVarKey, value }); } }; // MCP install related @@ -630,34 +572,32 @@ const ToolSelect = forwardRef< } return; } - setInstalling((prev) => ({ ...prev, [id]: true })); try { await proxyFetchPost('/api/v1/mcp/install?mcp_id=' + id); - setInstalled((prev) => ({ ...prev, [id]: true })); - const installedMcp = mcpList.find((mcp) => mcp.id === id); - if (window.ipcRenderer && installedMcp) { - const env: { [key: string]: string } = {}; + const listRes = await proxyFetchGet('/api/v1/mcps', { + page: 1, + size: 200, + keyword: '', + }); + const items = + listRes?.items && Array.isArray(listRes.items) ? listRes.items : []; + const installedMcp = items.find((mcp: McpItem) => mcp.id === id); + if (installedMcp?.install_command) { + const installCmd = { ...installedMcp.install_command }; if (envValue) { + const env: { [key: string]: string } = {}; Object.keys(envValue).map((key) => { env[key] = envValue[key]?.value; }); - installedMcp.install_command!.env = env; + installCmd.env = env; } - - await window.ipcRenderer.invoke( - 'mcp-install', - installedMcp.key, - installedMcp.install_command - ); + await mcpInstall(installedMcp.key, installCmd); } - // after install successfully, automatically add to selected list if (installedMcp) { addOption(installedMcp); } } catch (e) { console.error('Failed to install MCP:', e); - } finally { - setInstalling((prev) => ({ ...prev, [id]: false })); } }; @@ -666,57 +606,81 @@ const ToolSelect = forwardRef< installMcp, })); - const checkEnv = (id: number) => { - const mcp = mcpList.find((mcp) => mcp.id === id); - if (mcp && Object.keys(mcp?.install_command?.env || {}).length > 0) { - if (onShowEnvConfig) { - onShowEnvConfig(mcp); - } - } else { - installMcp(id); - } - }; - - const removeOption = (item: McpItem) => { - const currentSelected = initialSelectedTools || []; - const newSelected = currentSelected.filter((i) => i.id !== item.id); - onSelectedToolsChange?.(newSelected); - }; - - // tool functions - const getCategoryIcon = (categoryName?: string) => { - if (!categoryName) return ; + const removeOption = useCallback( + (item: McpItem) => { + const currentSelected = initialSelectedTools || []; + const newSelected = currentSelected.filter((i) => i.id !== item.id); + onSelectedToolsChange?.(newSelected); + }, + [initialSelectedTools, onSelectedToolsChange] + ); - const normalizedName = categoryName.toLowerCase(); - const iconKey = categoryIconMap[normalizedName]; - const iconUrl = iconKey ? svgIcons[iconKey] : undefined; + const buildLocalToolFromIntegration = useCallback( + (item: IntegrationItem): McpItem => { + const normalizedToolkit = + item.name === 'Notion' ? 'notion_mcp_toolkit' : item.toolkit; + return { + id: 0, + key: item.key, + name: item.name, + description: typeof item.desc === 'string' ? item.desc : '', + toolkit: normalizedToolkit, + isLocal: true, + }; + }, + [] + ); - return iconUrl ? ( - {categoryName} - ) : ( - - ); - }; + const isIntegrationInAgentSelection = useCallback( + (item: IntegrationItem) => + !!(initialSelectedTools || []).find( + (s) => s.isLocal && s.key === item.key + ), + [initialSelectedTools] + ); - const getGithubRepoName = (homePage?: string) => { - if (!homePage || !homePage.startsWith('https://github.com/')) return null; - const parts = homePage.split('/'); - return parts.length > 4 ? parts[4] : homePage; - }; + const handleToggleIntegrationForAgent = useCallback( + (item: IntegrationItem, selected: boolean) => { + if (selected) { + addOption(buildLocalToolFromIntegration(item), true); + } else { + const found = (initialSelectedTools || []).find( + (s) => s.isLocal && s.key === item.key + ); + if (found) removeOption(found); + } + }, + [ + addOption, + buildLocalToolFromIntegration, + initialSelectedTools, + removeOption, + ] + ); - const getInstallButtonText = (itemId: number) => { - if (installedIds.includes(itemId)) return t('layout.installed'); - if (installing[itemId]) return t('layout.installing'); - if (installed[itemId]) return t('layout.installed'); - return t('layout.install'); - }; + const handleToggleUserMcp = useCallback( + (row: any, selected: boolean) => { + if (selected) { + addOption({ + id: row.id, + key: row.mcp_key || String(row.id), + name: row.mcp_name || row.mcp_key, + description: String(row.mcp_desc || ''), + mcp_name: row.mcp_name, + } as McpItem); + } else { + const found = (initialSelectedTools || []).find((i) => i.id === row.id); + if (found) removeOption(found); + } + }, + [addOption, initialSelectedTools, removeOption] + ); // Effects useEffect(() => { - fetchData(); fetchIntegrationsData(); fetchInstalledMcps(); - }, [fetchData, fetchIntegrationsData, fetchInstalledMcps]); + }, [fetchIntegrationsData, fetchInstalledMcps]); useEffect(() => { if (debounceTimerRef.current) { @@ -724,7 +688,6 @@ const ToolSelect = forwardRef< } debounceTimerRef.current = setTimeout(() => { - fetchData(keyword); fetchIntegrationsData(keyword); }, 500); @@ -733,23 +696,56 @@ const ToolSelect = forwardRef< clearTimeout(debounceTimerRef.current); } }; - }, [keyword, fetchData, fetchIntegrationsData]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - containerRef.current && - !containerRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); + }, [keyword, fetchIntegrationsData]); + + const webConnectedItems = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return integrations + .filter((i: IntegrationItem) => webInstalled[i.key]) + .filter((i: IntegrationItem) => { + if (!kw) return true; + const descStr = typeof i.desc === 'string' ? i.desc.toLowerCase() : ''; + return ( + (i.key || '').toLowerCase().includes(kw) || + (i.name || '').toLowerCase().includes(kw) || + descStr.includes(kw) + ); + }); + }, [integrations, webInstalled, keyword]); + + const webNotConnectedItems = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return integrations + .filter((i: IntegrationItem) => !webInstalled[i.key]) + .filter((i: IntegrationItem) => { + if (!kw) return true; + const descStr = typeof i.desc === 'string' ? i.desc.toLowerCase() : ''; + return ( + (i.key || '').toLowerCase().includes(kw) || + (i.name || '').toLowerCase().includes(kw) || + descStr.includes(kw) + ); + }); + }, [integrations, webInstalled, keyword]); + + const ownPicks = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + return userMcpList.filter((opt) => { + if (!kw) return true; + const name = String(opt.mcp_name || '').toLowerCase(); + const desc = String(opt.mcp_desc || '').toLowerCase(); + const key = String(opt.mcp_key || '').toLowerCase(); + return name.includes(kw) || desc.includes(kw) || key.includes(kw); + }); + }, [userMcpList, keyword]); + + const listHasItems = + webConnectedItems.length > 0 || + webNotConnectedItems.length > 0 || + ownPicks.length > 0; + + const showSearchPlaceholder = + keyword.length === 0 && (initialSelectedTools?.length ?? 0) === 0; // render functions const renderSelectedItems = () => ( @@ -757,12 +753,14 @@ const ToolSelect = forwardRef< {(initialSelectedTools || []).map((item: any) => ( {item.name || item.mcp_name || item.key || `tool_${item.id}`} -
+
removeOption(item)} />
@@ -771,160 +769,132 @@ const ToolSelect = forwardRef< ); - const renderMcpItem = (item: McpItem) => ( -
{ - // check if already installed - const isAlreadyInstalled = - installedIds.includes(item.id) || installed[item.id]; - - if (isAlreadyInstalled) { - // if already installed, add to selected list directly - addOption(item); - setKeyword(''); - } else { - // if not installed, first check environment configuration, then install and add to selected list - checkEnv(item.id); - } - }} - className="flex cursor-pointer justify-between px-3 py-2 hover:bg-surface-hover-subtle" - > -
- {getCategoryIcon(item.category?.name)} -
- {item.name} -
- - e.stopPropagation()} - /> - -
-
- {getGithubRepoName(item.home_page) && ( -
- github - - {getGithubRepoName(item.home_page)} - -
- )} - + onClick={(e) => e.stopPropagation()} + aria-label={String(item.mcp_name || item.mcp_key || '')} + /> + + {capitalizeFirstLetter(item.mcp_name || '')} +
-
- ); + ); + }; - const renderCustomMcpItem = (item: any) => ( -
{ - addOption(item); - setKeyword(''); - }} - className="flex cursor-pointer justify-between px-3 py-2 hover:bg-surface-hover-subtle" - > -
- {/* {getCategoryIcon(item.category?.name)} */} -
- {item.mcp_name} -
- - e.stopPropagation()} - /> - -
-
- -
-
- ); return ( -
-
-
+
+
+
{t('workforce.agent-tool')} - +
{ - inputRef.current?.focus(); - setIsOpen(true); - }} - className="flex max-h-[120px] min-h-[60px] w-full flex-wrap justify-start gap-1 overflow-y-auto rounded-lg border border-solid border-input-border-default bg-input-bg-default px-[6px] py-1" + onMouseDown={() => inputRef.current?.focus()} + className={cn( + 'focus-within:ring-ds-border-brand-default-default/35 gap-1.5 rounded-lg border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default min-w-0 px-2 py-1.5 flex max-h-[120px] min-h-[40px] w-full flex-wrap content-center items-center justify-start border border-solid focus-within:ring-2' + )} > {renderSelectedItems()}