diff --git a/packages/rpa-core/src/browser/external.ts b/packages/rpa-core/src/browser/external.ts index 7007bb9..8b98f6a 100644 --- a/packages/rpa-core/src/browser/external.ts +++ b/packages/rpa-core/src/browser/external.ts @@ -1,6 +1,7 @@ import { execFile, spawn, type ChildProcess } from "node:child_process"; import { promisify } from "node:util"; import { promises as fs } from "node:fs"; +import { randomUUID } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { RuntimeError } from "../engine/types"; @@ -214,22 +215,26 @@ export async function connectRemoteBrowser(options: ExternalRemoteOptions): Prom /** 清理占用专用 profile 的残留浏览器进程。 * 同一 user-data-dir 已有实例时,再次启动的 Chrome 会把请求转交旧实例并立即退出(代码 0), - * 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。 */ + * 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。 + * 匹配按完整 user-data-dir 路径字面量过滤(非通配),只会命中本模块自己拉起的实例。 */ async function killStaleBrowserProcesses(browserType: ExternalBrowserType, userDataDir: string): Promise { try { if (process.platform === "win32") { const names = browserType === "edge" ? "msedge.exe" : "chrome.exe"; - const marker = userDataDir.replace(/'/g, "''"); + // 单引号转义防注入;-like 用字面量路径(路径内无通配符语义字符时的精确匹配) + const marker = userDataDir.replace(/'/g, "''").replace(/`/g, "``").replace(/\[/g, "``["); const script = `Get-CimInstance Win32_Process -Filter "Name='${names}'" | ` + `Where-Object { $_.CommandLine -like '*${marker}*' } | ` + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"; await execAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 8000, windowsHide: true }); } else { + // pkill -f 用完整 profile 路径字面量(含 rpa-studio-external 前缀),不扩大清理范围 await execAsync("pkill", ["-f", userDataDir], { timeout: 5000 }); } - } catch { - // 清理失败不阻塞启动:可能本来就没有残留,或权限受限 + } catch (error) { + // 清理失败不阻塞启动:可能本来就没有残留,或权限受限;记 warning 供审计(整改:不再静默吞) + console.warn(`[browser] 清理残留 ${browserType} 进程失败(不影响启动):`, (error as Error).message); } } @@ -247,7 +252,10 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro } const userDataDir = - options.userDataDir?.trim() || path.join(os.tmpdir(), `rpa-studio-external-${browserType}`); + options.userDataDir?.trim() || + // 并发/多会话隔离(审查整改):默认 profile 加入随机后缀,进程级唯一, + // 避免多个运行共用同一 profile 互相挤掉登录态与调试实例。 + path.join(os.tmpdir(), `rpa-studio-external-${browserType}-${randomUUID().slice(0, 8)}`); await fs.mkdir(userDataDir, { recursive: true }); const extraArgs = (options.extraArgs ?? []).filter(Boolean); @@ -309,19 +317,30 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro }); } + // 就绪之后的接管链(/json/list → transport.connect):任一步失败都必须回收自己拉起的 + // 子进程(审查整改:此前失败路径会遗留浏览器进程)。成功路径 killOnClose=true 交由关闭逻辑。 const base = `http://127.0.0.1:${port}`; - const targets = await fetchJson(`${base}/json/list`, 5000); - const target = pickPageTarget(targets, "about:blank"); - const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`); - return { - transport, - sessionKey: `launch|${browserType}|${port}`, - label: `${label} (127.0.0.1:${port})`, - httpBase: base, - targetId: String(target.id ?? ""), - child, - killOnClose: true - }; + try { + const targets = await fetchJson(`${base}/json/list`, 5000); + const target = pickPageTarget(targets, "about:blank"); + const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`); + return { + transport, + sessionKey: `launch|${browserType}|${port}`, + label: `${label} (127.0.0.1:${port})`, + httpBase: base, + targetId: String(target.id ?? ""), + child, + killOnClose: true + }; + } catch (error) { + try { + child.kill(); + } catch { + // 进程可能已自行退出 + } + throw error; + } } /** 列出调试端点上的全部页面级 target(多标签枚举用);排除 chrome://、edge:// 内部页 */ diff --git a/packages/rpa-core/src/browser/transport.ts b/packages/rpa-core/src/browser/transport.ts index 62e1f8f..53a6899 100644 --- a/packages/rpa-core/src/browser/transport.ts +++ b/packages/rpa-core/src/browser/transport.ts @@ -92,11 +92,20 @@ export class ExternalTransport implements CdpTransport { commandTimeoutMs = 30_000 ): Promise { const transport = new ExternalTransport(wsUrl, description, commandTimeoutMs); - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs) - ); - await Promise.race([transport.openPromise, timeout]); - return transport; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs); + }); + try { + await Promise.race([transport.openPromise, timeout]); + return transport; + } catch (error) { + // 超时/失败路径必须关闭底层 WebSocket(审查整改:此前失败会泄漏挂起的 ws) + await transport.close().catch(() => {}); + throw error; + } finally { + if (timer !== undefined) clearTimeout(timer); + } } get closed(): boolean {