From 67e45ae8405fd146aabba4d105de55c2d76dfad1 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sun, 19 Jul 2026 12:43:01 +0200 Subject: [PATCH 1/4] fix: Linux AppImage OAuth auth hang (#1525) - Add linuxProtocol.ts: writes ~/.local/share/applications/eigent-protocol-handler.desktop with Exec="" %u and MimeType=x-scheme-handler/eigent; on every packaged Linux launch - Integrate in setupProtocolHandlers() and second-instance handler (self-heal) - Add mimeTypes to electron-builder.json for AppImage bundled .desktop Closes #1525 --- electron-builder.json | 3 +- electron/main/index.ts | 20 +++- electron/main/utils/linuxProtocol.ts | 159 +++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 electron/main/utils/linuxProtocol.ts diff --git a/electron-builder.json b/electron-builder.json index d13b62ffc..31f2853bd 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -129,7 +129,8 @@ "linux": { "icon": "build/icon.png", "target": ["AppImage"], - "category": "Development" + "category": "Development", + "mimeTypes": ["x-scheme-handler/eigent"] }, "nsis": { "oneClick": false, diff --git a/electron/main/index.ts b/electron/main/index.ts index e6d3bc692..4a4e9d572 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -60,6 +60,10 @@ import { removeEnvKey, updateEnvBlock, } from './utils/envUtil'; +import { + registerLinuxProtocolHandler, + reRegisterLinuxProtocolHandler, +} from './utils/linuxProtocol'; import { zipFolder } from './utils/log'; import { addMcp, readMcpConfig, removeMcp, updateMcp } from './utils/mcpConfig'; import { @@ -451,6 +455,11 @@ const setupProtocolHandlers = () => { } } else { app.setAsDefaultProtocolClient('eigent'); + + // Linux: register protocol handler with proper .desktop file (%u for URL handling) + if (process.platform === 'linux') { + registerLinuxProtocolHandler(); + } } }; @@ -594,7 +603,16 @@ const setupSingleInstanceLock = () => { app.on('second-instance', (event, argv) => { log.info('second-instance', argv); const url = argv.find((arg) => arg.startsWith('eigent://')); - if (url) handleProtocolUrl(url); + if (url) { + handleProtocolUrl(url); + } else if (process.platform === 'linux') { + // Linux self-heal: second-instance fired without URL means the + // .desktop file may be missing %u. Re-register to fix it for next login attempt. + log.info( + '[LinuxProtocol] second-instance without URL, re-registering protocol handler' + ); + reRegisterLinuxProtocolHandler(); + } if (win) win.show(); }); diff --git a/electron/main/utils/linuxProtocol.ts b/electron/main/utils/linuxProtocol.ts new file mode 100644 index 000000000..80bce5863 --- /dev/null +++ b/electron/main/utils/linuxProtocol.ts @@ -0,0 +1,159 @@ +// ========= 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 { spawn } from 'child_process'; +import log from 'electron-log'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import { homedir } from 'os'; +import path from 'path'; + +const DESKTOP_FILE_NAME = 'eigent-protocol-handler.desktop'; +const MIME_TYPE = 'x-scheme-handler/eigent'; + +function getDesktopFilePath(): string { + return path.join( + homedir(), + '.local', + 'share', + 'applications', + DESKTOP_FILE_NAME + ); +} + +function getExecPath(): string { + // Prefer APPIMAGE env var so registration survives AppImage version bumps + if (process.env.APPIMAGE) { + return process.env.APPIMAGE; + } + // Fallback to process.execPath + return process.execPath; +} + +function generateDesktopEntry(execPath: string): string { + const escapedExec = execPath.replace(/"/g, '\\"'); + return `[Desktop Entry] +Type=Application +Name=Eigent Protocol Handler +Exec="${escapedExec}" %u +MimeType=${MIME_TYPE}; +NoDisplay=true +Terminal=false +Categories=Development; +StartupNotify=false +`; +} + +async function ensureDirectory(dir: string): Promise { + try { + await fsp.mkdir(dir, { recursive: true }); + } catch (e) { + log.warn(`[LinuxProtocol] Failed to create directory ${dir}: ${e}`); + } +} + +async function runCommand(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: 'ignore' }); + proc.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${code}`)); + }); + proc.on('error', reject); + }); +} + +async function updateDesktopDatabase(): Promise { + try { + await runCommand('update-desktop-database', [ + path.join(homedir(), '.local', 'share', 'applications'), + ]); + } catch (e) { + log.warn(`[LinuxProtocol] update-desktop-database failed: ${e}`); + } +} + +async function setDefaultMimeHandler(): Promise { + try { + await runCommand('xdg-mime', ['default', DESKTOP_FILE_NAME, MIME_TYPE]); + } catch (e) { + log.warn(`[LinuxProtocol] xdg-mime default failed: ${e}`); + } +} + +/** + * Registers the eigent:// protocol handler on Linux by writing a .desktop file + * to ~/.local/share/applications/ and updating the desktop database. + * Skipped in development mode (handled by setupProtocolHandlers dev branch). + */ +export async function registerLinuxProtocolHandler(): Promise { + if (process.env.NODE_ENV === 'development') { + log.info('[LinuxProtocol] Skipping registration in development mode'); + return; + } + + if (process.platform !== 'linux') { + log.info('[LinuxProtocol] Skipping registration (not on Linux)'); + return; + } + + try { + const execPath = getExecPath(); + const desktopPath = getDesktopFilePath(); + const desktopDir = path.dirname(desktopPath); + + log.info( + `[LinuxProtocol] Registering protocol handler with exec: ${execPath}` + ); + + await ensureDirectory(desktopDir); + + const desktopEntry = generateDesktopEntry(execPath); + await fsp.writeFile(desktopPath, desktopEntry, 'utf-8'); + log.info(`[LinuxProtocol] Wrote desktop file to ${desktopPath}`); + + await updateDesktopDatabase(); + await setDefaultMimeHandler(); + + log.info('[LinuxProtocol] Protocol handler registration complete'); + } catch (error) { + log.error(`[LinuxProtocol] Registration failed: ${error}`); + } +} + +/** + * Re-registers the protocol handler (used for self-heal when second-instance fires without URL). + */ +export async function reRegisterLinuxProtocolHandler(): Promise { + log.info('[LinuxProtocol] Re-registering protocol handler (self-heal)'); + await registerLinuxProtocolHandler(); +} + +/** + * Checks if the desktop file exists and has the correct %u placeholder. + * Returns true if registration appears valid. + */ +export async function isProtocolHandlerRegistered(): Promise { + if (process.platform !== 'linux') return true; + + try { + const desktopPath = getDesktopFilePath(); + if (!fs.existsSync(desktopPath)) return false; + + const content = await fsp.readFile(desktopPath, 'utf-8'); + return content.includes('%u') && content.includes(MIME_TYPE); + } catch { + return false; + } +} From e9f1889bdd6738dcde8e18fdb8cdc3c504bd2c03 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sun, 19 Jul 2026 13:16:37 +0200 Subject: [PATCH 2/4] fix: escape backslashes in .desktop Exec field (CodeQL CWE-116) Incomplete string escaping: backslashes in executable path were not escaped, which could lead to shell interpretation issues in the .desktop file. Escapes both backslashes and double quotes now. --- electron/main/utils/linuxProtocol.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/main/utils/linuxProtocol.ts b/electron/main/utils/linuxProtocol.ts index 80bce5863..6ba1a68f3 100644 --- a/electron/main/utils/linuxProtocol.ts +++ b/electron/main/utils/linuxProtocol.ts @@ -42,7 +42,7 @@ function getExecPath(): string { } function generateDesktopEntry(execPath: string): string { - const escapedExec = execPath.replace(/"/g, '\\"'); + const escapedExec = execPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return `[Desktop Entry] Type=Application Name=Eigent Protocol Handler From 9d760b411801aebcb86550a81ab016be52959f96 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sun, 19 Jul 2026 13:36:51 +0200 Subject: [PATCH 3/4] fix: prevent EROFS on Linux AppImage first launch (#1743) - getPrebuiltVenvPath(): Remove fixups (fixPyvenvCfgPlaceholder, ensureVenvPythonSymlink, fixVenvScriptShebangs) from prebuilt venv since it's read-only in AppImage mount - getPrebuiltTerminalVenvPath(): Same fix - don't run fixups on prebuilt terminal venv - Fixups already run on copied user venv in ensureBackendVenvAtUserPath() and ensureTerminalVenvAtUserPath() which write to ~/.eigent/venvs/ --- electron/main/utils/process.ts | 37 ++++------------------------------ 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/electron/main/utils/process.ts b/electron/main/utils/process.ts index b5e2489a8..5b645cc24 100644 --- a/electron/main/utils/process.ts +++ b/electron/main/utils/process.ts @@ -362,8 +362,6 @@ function fixVenvScriptShebangs(venvPath: string): boolean { } } -const PREBUILT_FIXED_MARKER = '.prebuilt_fixed'; - /** * Ensure venv/bin/python exists - create symlink if missing or broken. */ @@ -435,6 +433,8 @@ function ensureVenvPythonSymlink(venvPath: string): boolean { /** * Get path to prebuilt venv (if available in packaged app) * All platforms use prebuilt/venv directory. + * NOTE: Does NOT run fixups on prebuilt venv (it's read-only in AppImage). + * Fixups run on the copied user venv in ensureBackendVenvAtUserPath(). */ export function getPrebuiltVenvPath(): string | null { if (!app.isPackaged) { @@ -444,21 +444,8 @@ export function getPrebuiltVenvPath(): string | null { const prebuiltDir = path.join(process.resourcesPath, 'prebuilt'); const prebuiltVenvPath = path.join(prebuiltDir, 'venv'); const pyvenvCfgPath = path.join(prebuiltVenvPath, 'pyvenv.cfg'); - const fixedMarkerPath = path.join(prebuiltDir, PREBUILT_FIXED_MARKER); - const currentVersion = app.getVersion(); if (fs.existsSync(prebuiltVenvPath) && fs.existsSync(pyvenvCfgPath)) { - const needsFix = - !fs.existsSync(fixedMarkerPath) || - fs.readFileSync(fixedMarkerPath, 'utf-8').trim() !== currentVersion; - - if (needsFix) { - fixPyvenvCfgPlaceholder(pyvenvCfgPath); - ensureVenvPythonSymlink(prebuiltVenvPath); - fixVenvScriptShebangs(prebuiltVenvPath); - fs.writeFileSync(fixedMarkerPath, currentVersion, 'utf-8'); - } - const pythonExePath = getVenvPythonPath(prebuiltVenvPath); if (fs.existsSync(pythonExePath)) { return prebuiltVenvPath; @@ -766,6 +753,8 @@ export function ensureTerminalVenvAtUserPath(version: string): void { /** * Get path to prebuilt terminal venv (if available in packaged app) + * NOTE: Does NOT run fixups on prebuilt venv (it's read-only in AppImage). + * Fixups run on the copied user venv in ensureTerminalVenvAtUserPath(). */ export function getPrebuiltTerminalVenvPath(): string | null { if (!app.isPackaged) { @@ -790,24 +779,6 @@ export function getPrebuiltTerminalVenvPath(): string | null { return null; } - // Check if already fixed for this version (avoid repeated fixes) - const fixedMarkerPath = path.join( - process.resourcesPath, - 'prebuilt', - '.terminal_venv_fixed' - ); - const currentVersion = app.getVersion(); - const needsFix = - !fs.existsSync(fixedMarkerPath) || - fs.readFileSync(fixedMarkerPath, 'utf-8').trim() !== currentVersion; - - if (needsFix) { - fixPyvenvCfgPlaceholder(pyvenvCfgPath); - ensureVenvPythonSymlink(prebuiltTerminalVenvPath); - fixVenvScriptShebangs(prebuiltTerminalVenvPath); - fs.writeFileSync(fixedMarkerPath, currentVersion, 'utf-8'); - } - const pythonExePath = getVenvPythonPath(prebuiltTerminalVenvPath); if (fs.existsSync(pythonExePath)) { From 4976e048593f56a927d7c23e01ed09e56beb3d82 Mon Sep 17 00:00:00 2001 From: Carlos Date: Sun, 19 Jul 2026 13:39:35 +0200 Subject: [PATCH 4/4] feat: add DELETE project endpoint (#1699) - Add delete_project() method to SpaceService - Add DELETE /spaces/{space_id}/projects/{project_id} endpoint - Returns 204 on success, 404 if project not found - Enforces ownership check (user must own the space) --- .../app/domains/space/api/space_controller.py | 13 ++++++++++++ .../domains/space/service/space_service.py | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/server/app/domains/space/api/space_controller.py b/server/app/domains/space/api/space_controller.py index 99e644d0d..713d2360e 100644 --- a/server/app/domains/space/api/space_controller.py +++ b/server/app/domains/space/api/space_controller.py @@ -214,6 +214,19 @@ def update_space_project( raise HTTPException(status_code=404, detail=str(exc)) from exc +@router.delete("/{space_id}/projects/{project_id}", name="delete space project", status_code=204) +def delete_space_project( + space_id: str, + project_id: str, + db_session: Session = Depends(session), + auth: V1UserAuth = Depends(auth_must), +): + try: + SpaceService.delete_project(space_id, project_id, auth.id, db_session) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + @router.post("/{space_id}/projects/{project_id}/promote", name="promote project to folder space", response_model=ProjectOut) def promote_space_project( space_id: str, diff --git a/server/app/domains/space/service/space_service.py b/server/app/domains/space/service/space_service.py index 79ba47cde..dacf14b0b 100644 --- a/server/app/domains/space/service/space_service.py +++ b/server/app/domains/space/service/space_service.py @@ -645,6 +645,27 @@ def update_project( s.refresh(project) return project + @staticmethod + def delete_project( + space_id: str, + project_id: str, + user_id: int | str, + s: Session, + ) -> None: + canonical_user_id = SpaceService.canonical_user_id(user_id) + SpaceService._get_owned_space(space_id, canonical_user_id, s) + project = s.exec( + select(Project).where( + Project.id == project_id, + Project.user_id == canonical_user_id, + Project.space_id == space_id, + ) + ).first() + if not project: + raise ValueError("Project not found") + s.delete(project) + s.commit() + @staticmethod def promote_project( space_id: str,