Skip to content
Closed
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
120 changes: 98 additions & 22 deletions backend/app/controller/tool_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import asyncio
import logging
import os
import socket
import subprocess
import time

from fastapi import APIRouter, HTTPException
Expand All @@ -38,6 +41,27 @@ class LinkedInTokenRequest(BaseModel):
logger = logging.getLogger("tool_controller")
router = APIRouter()

_login_browser_process: "subprocess.Popen | None" = None


async def _wait_for_cdp_ready(
process: subprocess.Popen,
port: int,
timeout: float = 10,
interval: float = 0.3,
) -> bool:
"""Poll until CDP port is open or process exits. Returns True if ready."""
elapsed = 0.0
while elapsed < timeout:
if process.poll() is not None:
return False # process already exited
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
if s.connect_ex(("localhost", port)) == 0:
return True
await asyncio.sleep(interval)
elapsed += interval
return False


@router.post("/install/tool/{tool}", name="install tool")
async def install_tool(tool: str):
Expand Down Expand Up @@ -658,10 +682,9 @@ async def open_browser_login():
Returns:
Browser session information
"""
try:
import socket
import subprocess
global _login_browser_process

try:
# Use fixed profile name for persistent logins (no port suffix)
session_id = "user_login"
cdp_port = 9223
Expand Down Expand Up @@ -737,31 +760,72 @@ def is_port_in_use(port):
logger.info(f"[PROFILE USER LOGIN] Electron args: {electron_args}")

# Start process and capture output in real-time
process = subprocess.Popen(
electron_args,
cwd=app_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # Redirect stderr to stdout
text=True,
encoding="utf-8",
errors="replace", # Replace undecodable chars instead of crashing
bufsize=1, # Line buffered
)
try:
process = subprocess.Popen(
electron_args,
cwd=app_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1, # Line buffered
)
except FileNotFoundError:
logger.error(
f"[PROFILE USER LOGIN] '{electron_cmd}' not found on PATH"
)
return {
"success": False,
"error": f"'{electron_cmd}' is not installed or not on PATH."
" Please install Node.js / npm first.",
}

# Create async task to log Electron output
async def log_electron_output():
for line in iter(process.stdout.readline, ""):
if line:
logger.info(f"[ELECTRON OUTPUT] {line.strip()}")
_login_browser_process = process

import asyncio
# Log Electron output in a background task.
# readline() is blocking, so run it in an executor
# to avoid stalling the event loop.
async def log_electron_output():
loop = asyncio.get_event_loop()
while True:
line = await loop.run_in_executor(
None, process.stdout.readline
)
if not line:
break
logger.info(f"[ELECTRON OUTPUT] {line.strip()}")

asyncio.create_task(log_electron_output())

# Wait a bit for Electron to start
import asyncio
# Wait for CDP port to become available (or process to exit)
ready = await _wait_for_cdp_ready(process, cdp_port)

await asyncio.sleep(3)
if not ready:
exit_code = process.poll()
if exit_code is not None:
# Process crashed / exited early
logger.error(
"[PROFILE USER LOGIN] Electron"
" process exited with code:"
f" {exit_code}"
)
_login_browser_process = None
return {
"success": False,
"error": "Browser process exited"
" before CDP became ready."
f" Exit code: {exit_code}",
}
# Timeout — process is alive but CDP port not
# ready yet. The browser window is open so the
# user can proceed with login.
logger.warning(
"[PROFILE USER LOGIN] CDP port"
f" {cdp_port} not ready after"
" timeout, but process"
f" {process.pid} is still running"
)

logger.info(
"[PROFILE USER LOGIN] Electron"
Expand Down Expand Up @@ -793,6 +857,18 @@ async def log_electron_output():
)


@router.get("/browser/status", name="browser status")
async def browser_status():
"""Check if the login browser is still running."""
global _login_browser_process
if _login_browser_process is None:
return {"is_open": False}
if _login_browser_process.poll() is not None:
_login_browser_process = None
return {"is_open": False}
return {"is_open": True, "pid": _login_browser_process.pid}


@router.get("/browser/cookies", name="list cookie domains")
async def list_cookie_domains(search: str = None):
"""
Expand Down
44 changes: 23 additions & 21 deletions src/pages/Browser/Cookies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export default function Cookies() {
);

const response = await fetchPost('/browser/login');
if (response) {
if (response?.success) {
toast.success('Browser opened successfully for login');
const checkInterval = setInterval(async () => {
try {
Expand Down Expand Up @@ -124,6 +124,8 @@ export default function Cookies() {
await handleLoadCookies();
}
}, 500);
} else if (response) {
toast.error(response.error || 'Failed to open browser');
}
} catch (error: any) {
toast.error(error?.message || 'Failed to open browser');
Expand Down Expand Up @@ -220,39 +222,39 @@ export default function Cookies() {
confirmVariant="information"
/>

<div className="px-6 pb-6 pt-8 flex w-full items-center justify-between">
<div className="flex w-full items-center justify-between px-6 pb-6 pt-8">
<div className="text-heading-sm font-bold text-text-heading">
{t('layout.browser-cookie-management')}
</div>
</div>

<div className="gap-4 flex flex-col">
<div className="rounded-xl border-border-disabled bg-surface-secondary p-6 relative flex w-full flex-col border">
<div className="right-6 top-6 absolute">
<div className="flex flex-col gap-4">
<div className="relative flex w-full flex-col rounded-xl border border-border-disabled bg-surface-secondary p-6">
<div className="absolute right-6 top-6">
<Button
variant="information"
size="xs"
onClick={handleRestartApp}
className="gap-0 ease-in-out justify-center overflow-hidden rounded-full transition-all duration-300"
className="justify-center gap-0 overflow-hidden rounded-full transition-all duration-300 ease-in-out"
>
<RefreshCw className="flex-shrink-0" />
<span
className={`ease-in-out overflow-hidden transition-all duration-300 ${
className={`overflow-hidden transition-all duration-300 ease-in-out ${
hasUnsavedChanges
? 'pl-2 max-w-[150px] opacity-100'
? 'max-w-[150px] pl-2 opacity-100'
: 'ml-0 max-w-0 opacity-0'
}`}
>
{t('layout.restart-to-apply')}
</span>
</Button>
</div>
<div className="text-body-sm text-text-label max-w-[600px]">
<div className="max-w-[600px] text-body-sm text-text-label">
{t('layout.browser-cookies-description')}
</div>
<div className="mt-4 gap-3 border-border-secondary pt-3 flex w-full flex-col border-[0.5px] border-x-0 border-b-0 border-solid">
<div className="py-2 flex flex-row items-center justify-between">
<div className="gap-2 flex flex-row items-center justify-start">
<div className="mt-4 flex w-full flex-col gap-3 border-[0.5px] border-x-0 border-b-0 border-solid border-border-secondary pt-3">
<div className="flex flex-row items-center justify-between py-2">
<div className="flex flex-row items-center justify-start gap-2">
<div className="text-body-base font-bold text-text-body">
{t('layout.cookie-domains')}
</div>
Expand All @@ -263,14 +265,14 @@ export default function Cookies() {
)}
</div>

<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
{cookieDomains.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleDeleteAll}
disabled={deletingAll}
className="!text-text-cuation uppercase"
className="uppercase !text-text-cuation"
>
{deletingAll
? t('layout.deleting')
Expand Down Expand Up @@ -302,14 +304,14 @@ export default function Cookies() {
</div>

{cookieDomains.length > 0 ? (
<div className="gap-2 flex flex-col">
<div className="flex flex-col gap-2">
{groupDomainsByMain(cookieDomains).map((group, index) => (
<div
key={index}
className="rounded-xl border-border-disabled bg-surface-tertiary px-4 py-2 flex items-center justify-between border-solid"
className="flex items-center justify-between rounded-xl border-solid border-border-disabled bg-surface-tertiary px-4 py-2"
>
<div className="flex w-full flex-col items-start justify-start">
<span className="text-body-sm font-bold text-text-body truncate">
<span className="truncate text-body-sm font-bold text-text-body">
{group.mainDomain}
</span>
<span className="mt-1 text-label-xs text-text-label">
Expand All @@ -335,20 +337,20 @@ export default function Cookies() {
))}
</div>
) : (
<div className="px-4 py-8 flex flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center px-4 py-8">
<Cookie className="mb-4 h-12 w-12 text-icon-secondary opacity-50" />
<div className="text-body-base font-bold text-text-label text-center">
<div className="text-body-base text-center font-bold text-text-label">
{t('layout.no-cookies-saved-yet')}
</div>
<p className="text-label-xs font-medium text-text-label text-center">
<p className="text-center text-label-xs font-medium text-text-label">
{t('layout.no-cookies-saved-yet-description')}
</p>
</div>
)}
</div>
</div>

<div className="text-label-xs text-text-label w-full text-center">
<div className="w-full text-center text-label-xs text-text-label">
For more information, check out our
<a
href="https://www.eigent.ai/privacy-policy"
Expand Down