Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 39 additions & 9 deletions backend/app/component/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,67 @@
import importlib
from typing import Any, overload
import threading
import re

traceroot_logger = traceroot.get_logger("env")

# Thread-local storage for user-specific environment
_thread_local = threading.local()

BASE_ENV_DIR = Path.home() / ".eigent"

# Default global environment path
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
default_env_path = str(BASE_ENV_DIR / ".env")
load_dotenv(dotenv_path=default_env_path)


def set_user_env_path(env_path: str | None = None):
def _sanitize_env_path(env_path: str | None) -> str | None:
"""
Limit env files to .eigent directory and simple .env-like filenames.
Prevents directory traversal and arbitrary file reads.
"""
if not env_path:
return None

# Only allow a filename (no path separators)
filename = os.path.basename(env_path)
if filename != env_path:
raise ValueError("env_path must not contain directories")

if not re.fullmatch(r"\.env(\.[A-Za-z0-9_-]+)?", filename):
raise ValueError("env_path filename is not allowed")

resolved = (BASE_ENV_DIR / filename).resolve()
return str(resolved)


def set_user_env_path(env_path: str | None = None) -> str:
"""
Set user-specific environment path for current thread.
If env_path is None, uses default global environment.
"""
traceroot_logger.info("Setting user environment path", extra={"env_path": env_path, "exists": env_path and os.path.exists(env_path) if env_path else None})
try:
sanitized = _sanitize_env_path(env_path)
except ValueError as e:
traceroot_logger.warning("Rejecting unsafe env_path", extra={"env_path": env_path, "error": str(e)})
sanitized = None

traceroot_logger.info("Setting user environment path", extra={"env_path": sanitized, "exists": sanitized and os.path.exists(sanitized) if sanitized else None})

if env_path and os.path.exists(env_path):
_thread_local.env_path = env_path
if sanitized and os.path.exists(sanitized):
_thread_local.env_path = sanitized
# Load user-specific environment variables
load_dotenv(dotenv_path=env_path, override=True)
traceroot_logger.info("User-specific environment loaded", extra={"env_path": env_path})
load_dotenv(dotenv_path=sanitized, override=True)
traceroot_logger.info("User-specific environment loaded", extra={"env_path": sanitized})
else:
# Clear thread-local env_path to fall back to global
if hasattr(_thread_local, 'env_path'):
delattr(_thread_local, 'env_path')
traceroot_logger.info("Reset to default global environment")

if env_path and not os.path.exists(env_path):
traceroot_logger.warning("User environment path does not exist, falling back to global", extra={"env_path": env_path})
if sanitized and not os.path.exists(sanitized):
traceroot_logger.warning("User environment path does not exist, falling back to global", extra={"env_path": sanitized})
return get_current_env_path()


def get_current_env_path() -> str:
Expand Down
4 changes: 2 additions & 2 deletions backend/app/controller/chat_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ async def post(data: Chat, request: Request):
task_lock = get_or_create_task_lock(data.project_id)

# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
load_dotenv(dotenv_path=data.env_path)
env_path = set_user_env_path(data.env_path)
load_dotenv(dotenv_path=env_path)

os.environ["file_save_path"] = data.file_save_path()
os.environ["browser_port"] = str(data.browser_port)
Expand Down
4 changes: 2 additions & 2 deletions backend/app/controller/task_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ def add_agent(id: str, data: NewAgent):
logger.info("Adding new agent to task", extra={"task_id": id, "agent_name": data.name})
logger.debug("New agent data", extra={"task_id": id, "agent_data": data.model_dump_json()})
# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
load_dotenv(dotenv_path=data.env_path)
env_path = set_user_env_path(data.env_path)
load_dotenv(dotenv_path=env_path)
asyncio.run(get_task_lock(id).put_queue(ActionNewAgent(**data.model_dump())))
logger.info("Agent added to task", extra={"task_id": id, "agent_name": data.name})
return Response(status_code=204)
Expand Down
44 changes: 41 additions & 3 deletions backend/app/service/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,40 @@
logger = traceroot.get_logger("chat_service")


def _safe_working_directory(path_value: str | None) -> str | None:
if not path_value:
return None

base_dir = os.path.join(os.path.expanduser("~"), "eigent")
normalized = path_value.replace("\\", "/")

if os.path.isabs(normalized):
try:
rel_path = os.path.relpath(normalized, base_dir)
except ValueError:
logger.warning(f"Rejected working directory outside base: {path_value}")
return None
else:
rel_path = normalized

if rel_path.startswith("..") or rel_path.startswith("../"):
logger.warning(f"Rejected working directory outside base: {path_value}")
return None

segments = [segment for segment in rel_path.split("/") if segment and segment != "."]
if not segments:
logger.warning(f"Invalid working directory path: {path_value}")
return None

for segment in segments:
if not segment.isascii() or not segment.replace("-", "").replace("_", "").replace(".", "").isalnum():
logger.warning(f"Invalid working directory segment: {segment}")
return None

safe_path = os.path.join(base_dir, *segments)
return safe_path


def format_task_context(task_data: dict, seen_files: set | None = None, skip_files: bool = False) -> str:
"""Format structured task data into a readable context string.

Expand All @@ -66,7 +100,7 @@ def format_task_context(task_data: dict, seen_files: set | None = None, skip_fil

# Skip file listing if requested
if not skip_files:
working_directory = task_data.get('working_directory')
working_directory = _safe_working_directory(task_data.get('working_directory'))
if working_directory:
try:
if os.path.exists(working_directory):
Expand Down Expand Up @@ -126,10 +160,11 @@ def collect_previous_task_context(working_directory: str, previous_task_content:
context_parts.append(f"Previous Task Result:\n{previous_task_result}\n")

# Collect generated files from working directory
safe_working_directory = _safe_working_directory(working_directory)
try:
if os.path.exists(working_directory):
if safe_working_directory and os.path.exists(safe_working_directory):
generated_files = []
for root, dirs, files in os.walk(working_directory):
for root, dirs, files in os.walk(safe_working_directory):
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['node_modules', '__pycache__', 'venv']]
for file in files:
if not file.startswith('.') and not file.endswith(('.pyc', '.tmp')):
Expand Down Expand Up @@ -203,6 +238,9 @@ def build_conversation_context(task_lock: TaskLock, header: str = "=== CONVERSAT
if working_directories:
all_generated_files = set() # Use set to avoid duplicates
for working_directory in working_directories:
working_directory = _safe_working_directory(working_directory)
if not working_directory:
continue
try:
if os.path.exists(working_directory):
for root, dirs, files in os.walk(working_directory):
Expand Down
2 changes: 1 addition & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,7 +1148,7 @@ async function createWindow() {
// Use a dedicated partition for main window to isolate from webviews
// This ensures main window's auth data (localStorage) is stored separately and persists across restarts
partition: 'persist:main_window',
webSecurity: false,
webSecurity: true,
preload,
nodeIntegration: true,
contextIsolation: true,
Expand Down
2 changes: 1 addition & 1 deletion resources/scripts/install-bun.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ function detectPlatformAndArch() {
function detectIsMusl() {
try {
// Simple check for Alpine Linux which uses MUSL
const output = execSync('cat /etc/os-release').toString()
const output = fs.readFileSync('/etc/os-release', 'utf8')
return output.toLowerCase().includes('alpine')
} catch (error) {
return false
Expand Down
2 changes: 1 addition & 1 deletion resources/scripts/install-uv.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function detectPlatformAndArch() {
function detectIsMusl() {
try {
// Simple check for Alpine Linux which uses MUSL
const output = execSync("cat /etc/os-release").toString();
const output = fs.readFileSync("/etc/os-release", "utf8");
return output.toLowerCase().includes("alpine");
} catch (error) {
return false;
Expand Down
2 changes: 1 addition & 1 deletion server/app/controller/mcp/proxy_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def google_search(query: str, search_type: str = "web", key: Key = Depends(key_m
logger.info("Google search completed", extra={"query": query, "search_type": search_type, "result_count": len(responses)})
else:
error_info = data.get("error", {})
logger.error("Google search API error", extra={"query": query, "api_error": error_info})
logger.error("Google search API error", extra={"query": query})
raise HTTPException(status_code=500, detail="Internal server error")

except Exception as e:
Expand Down
46 changes: 25 additions & 21 deletions server/app/controller/oauth/oauth_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,32 +35,36 @@ def oauth_login(app: str, request: Request, state: Optional[str] = None):
raise HTTPException(status_code=400, detail="OAuth login failed")


ALLOWED_OAUTH_PROVIDERS = {"slack", "notion", "x", "googlesuite"}

@router.get("/{app}/callback", name="OAuth Callback")
@traceroot.trace()
def oauth_callback(app: str, request: Request, code: Optional[str] = None, state: Optional[str] = None):
"""Handle OAuth provider callback and redirect to client app."""
if not code:
logger.warning("OAuth callback missing code", extra={"provider": app})
raise HTTPException(status_code=400, detail="Missing code parameter")

import re
CODE_STATE_REGEX = re.compile(r'^[A-Za-z0-9_\-]+$')
from starlette.datastructures import URL

if app not in ALLOWED_OAUTH_PROVIDERS:
logger.warning("Invalid OAuth provider", extra={"provider": app, "code": code})
raise HTTPException(status_code=400, detail="Invalid OAuth provider")
if not code or not CODE_STATE_REGEX.match(code):
logger.warning("OAuth callback missing or invalid code", extra={"provider": app, "code": code})
raise HTTPException(status_code=400, detail="Missing or invalid code parameter")
if state and not CODE_STATE_REGEX.match(state):
logger.warning("OAuth callback invalid state", extra={"provider": app, "state": state})
raise HTTPException(status_code=400, detail="Invalid state parameter")

logger.info("OAuth callback received", extra={"provider": app, "has_state": state is not None})

redirect_url = f"eigent://callback/oauth?provider={app}&code={code}&state={state}"
html_content = f"""
<html>
<head>
<title>OAuth Callback</title>
</head>
<body>
<script type='text/javascript'>
window.location.href = '{redirect_url}';
</script>
<p>Redirecting, please wait...</p>
<button onclick='window.close()'>Close this window</button>
</body>
</html>
"""
return HTMLResponse(content=html_content)

base_url = URL("eigent://callback/oauth")
redirect_url = base_url.include_query_params(
provider=app,
code=code,
state=state or "",
)

return RedirectResponse(str(redirect_url))


@router.post("/{app}/token", name="OAuth Fetch Token")
Expand Down
79 changes: 14 additions & 65 deletions server/app/controller/redirect_controller.py
Original file line number Diff line number Diff line change
@@ -1,72 +1,21 @@
import json
from fastapi import APIRouter, Depends, Request
from fastapi_babel import _
from fastapi.responses import HTMLResponse

import re
from fastapi import APIRouter, Request,HTTPException
from fastapi.responses import RedirectResponse
from utils import traceroot_wrapper as traceroot
logger = traceroot.get_logger("server_redirect_controller")

router = APIRouter(tags=["Redirect"])


@router.get("/redirect/callback")
def redirect_callback(code: str, request: Request):
cookies = request.cookies
cookies_json = json.dumps(cookies)
from starlette.datastructures import URL

if not re.match(r'^[A-Za-z0-9_-]+$', code):
logger.warning("redirect callback invalid code", extra={"code": code})
raise HTTPException(status_code=400, detail="Invalid state parameter")

base_url = URL("eigent://callback")
redirect_url = base_url.include_query_params(code=code)
return RedirectResponse(str(redirect_url))

html_content = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorization successful</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px 0;
background-color: #f4f4f9;
color: #333;
}}
.container {{
padding: 30px;
background-color: white;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
max-width: 600px;
width: 100%;
text-align: center;
}}
h1 {{
text-align: center;
}}
.loading {{
margin-top: 20px;
font-size: 16px;
color: #666;
}}
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>Redirecting to application...</p>
<div class="loading">Please wait...</div>
</div>
<script>
(function() {{
const allCookies = {cookies_json};
const baseUrl = "eigent://callback?code={code}";
let finalUrl = baseUrl;

// 自动跳转到应用
window.location.href = finalUrl;
}})();
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
Loading