diff --git a/src/cdp-monitor.ts b/src/cdp-monitor.ts index 7188f3b0..2c843dbc 100644 --- a/src/cdp-monitor.ts +++ b/src/cdp-monitor.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn } from "child_process" +import { type ChildProcess, execSync, spawn, spawnSync } from "child_process" import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" import { tmpdir } from "os" import { dirname, join } from "path" @@ -72,82 +72,6 @@ export class CDPMonitor { } } - private async runCommand( - command: string, - args: string[] - ): Promise<{ stdout: string; stderr: string; code: number | null }> { - return await new Promise((resolve) => { - const proc = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }) - let stdout = "" - let stderr = "" - proc.stdout?.on("data", (data) => { - stdout += data.toString() - }) - proc.stderr?.on("data", (data) => { - stderr += data.toString() - }) - proc.on("error", (error) => { - resolve({ stdout, stderr: `${stderr}${error.message}`, code: 1 }) - }) - proc.on("close", (code) => { - resolve({ stdout, stderr, code }) - }) - }) - } - - private async listProcesses(): Promise> { - if (process.platform === "win32") { - const result = await this.runCommand("powershell", [ - "-NoProfile", - "-Command", - "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress" - ]) - if (result.code !== 0) { - this.debugLog(`Failed to list processes via PowerShell: ${result.stderr.trim()}`) - return [] - } - const raw = result.stdout.trim() - if (!raw) return [] - try { - const parsed = JSON.parse(raw) as - | Array<{ ProcessId?: number; CommandLine?: string }> - | { ProcessId?: number; CommandLine?: string } - const items = Array.isArray(parsed) ? parsed : [parsed] - return items - .map((item) => ({ - pid: Number(item.ProcessId), - command: item.CommandLine ?? "" - })) - .filter((item) => Number.isFinite(item.pid)) - } catch (error) { - this.debugLog(`Failed to parse PowerShell process list: ${String(error)}`) - return [] - } - } - - let result = await this.runCommand("ps", ["-ax", "-o", "pid=", "-o", "command="]) - if (result.code !== 0) { - result = await this.runCommand("ps", ["-eo", "pid=,command="]) - } - if (result.code !== 0) { - this.debugLog(`Failed to list processes via ps: ${result.stderr.trim()}`) - return [] - } - - const lines = result.stdout.split("\n") - const processes: Array<{ pid: number; command: string }> = [] - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - const match = trimmed.match(/^(\d+)\s+(.*)$/) - if (!match) continue - const pid = Number(match[1]) - if (!Number.isFinite(pid)) continue - processes.push({ pid, command: match[2] }) - } - return processes - } - /** * Check if a URL should be monitored (i.e., it's from the user's app server, not dev3000's tools service or external sites) */ @@ -211,8 +135,26 @@ export class CDPMonitor { private async discoverChromePids(): Promise { try { - const processes = await this.listProcesses() - const pids = processes.filter((proc) => proc.command.includes(this.profileDir)).map((proc) => proc.pid) + const { spawn } = await import("child_process") + + // Find all Chrome processes with our profile directory + const profileDirEscaped = this.profileDir.replace(/'/g, "'\\''") + const pidsOutput = await new Promise((resolve) => { + const proc = spawn("sh", ["-c", `pgrep -f '${profileDirEscaped}'`], { + stdio: "pipe" + }) + let output = "" + proc.stdout?.on("data", (data) => { + output += data.toString() + }) + proc.on("exit", () => resolve(output.trim())) + }) + + const pids = pidsOutput + .split("\n") + .filter(Boolean) + .map((pid) => parseInt(pid.trim(), 10)) + .filter((pid) => !Number.isNaN(pid)) // Add main browser PID if we have it if (this.browser?.pid) { @@ -319,28 +261,53 @@ export class CDPMonitor { * This prevents issues where Chrome defers to an existing instance * instead of starting a new one with CDP enabled. */ - private async killExistingChromeWithProfile(): Promise { + private killExistingChromeWithProfile(): void { try { - // Find Chrome processes using this profile directory - const processes = await this.listProcesses() - const pids = processes - .filter( - (proc) => - proc.command.includes(`--user-data-dir=${this.profileDir}`) || proc.command.includes(this.profileDir) - ) - .map((proc) => proc.pid) - .filter((pid) => pid !== this.browser?.pid) + // Find Chrome processes using this profile directory without invoking a shell + const psResult = spawnSync("ps", ["aux"], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"] + }) - for (const pid of pids) { - this.debugLog(`Killing existing Chrome process ${pid} using profile ${this.profileDir}`) - try { - process.kill(pid, "SIGTERM") - } catch { - // Process may have already exited + if (psResult.error || psResult.status !== 0 || !psResult.stdout) { + this.debugLog("Unable to list processes with ps") + return + } + + const searchToken = `user-data-dir=${this.profileDir}` + const lines = psResult.stdout.split("\n") + const pids: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + // Skip header line that typically starts with "USER" + if (trimmed.toUpperCase().startsWith("USER")) continue + + if (trimmed.includes(searchToken)) { + // ps aux format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND + const parts = trimmed.split(/\s+/) + if (parts.length > 1) { + const pid = parts[1] + if (pid && /^\d+$/.test(pid)) { + pids.push(pid) + } + } } } + if (pids.length > 0) { - await new Promise((resolve) => setTimeout(resolve, 500)) + for (const pid of pids) { + this.debugLog(`Killing existing Chrome process ${pid} using profile ${this.profileDir}`) + try { + process.kill(Number.parseInt(pid, 10), "SIGTERM") + } catch { + // Process may have already exited + } + } + // Give Chrome a moment to clean up + execSync("sleep 0.5") } } catch { // No existing Chrome found or ps/grep not available @@ -350,7 +317,7 @@ export class CDPMonitor { private async launchChrome(): Promise { // Kill any existing Chrome using this profile to prevent CDP conflicts - await this.killExistingChromeWithProfile() + this.killExistingChromeWithProfile() return new Promise((resolve, reject) => { // Use custom browser path if provided, otherwise try different Chrome executables based on platform diff --git a/src/dev-environment.ts b/src/dev-environment.ts index 7cbfaca6..e189c664 100644 --- a/src/dev-environment.ts +++ b/src/dev-environment.ts @@ -2355,10 +2355,15 @@ export class DevEnvironment { } // Final synchronous lsof kill - most reliable method - const result = spawnSync("sh", ["-c", `lsof -ti:${this.options.port} | xargs kill -9 2>/dev/null`], { - stdio: "pipe" - }) - this.debugLog(`Final lsof kill exit code: ${result.status}`) + const portNum = Number(this.options.port) + if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) { + this.debugLog(`Skipping final lsof kill due to invalid port: ${this.options.port}`) + } else { + const result = spawnSync("sh", ["-c", `lsof -ti:${portNum} | xargs kill -9 2>/dev/null`], { + stdio: "pipe" + }) + this.debugLog(`Final lsof kill exit code: ${result.status}`) + } } catch { // Ignore pkill errors } diff --git a/www/lib/workflow-storage.ts b/www/lib/workflow-storage.ts index 5682eff1..88db65a6 100644 --- a/www/lib/workflow-storage.ts +++ b/www/lib/workflow-storage.ts @@ -81,7 +81,7 @@ export async function listWorkflowRuns(userId: string): Promise { try { ;({ blobs } = await list({ prefix })) } catch (error) { - console.error(`[Workflow Storage] Failed to list blobs for ${prefix}:`, error) + console.error("[Workflow Storage] Failed to list blobs for prefix %s:", prefix, error) return [] } @@ -101,13 +101,17 @@ export async function listWorkflowRuns(userId: string): Promise { // First verify blob exists using authenticated head() call const blobInfo = await head(blob.url) if (!blobInfo) { - console.error(`[Workflow Storage] Blob not found: ${blob.url}`) + console.error("[Workflow Storage] Blob not found for URL %s", blob.url) return null } // Check content type from head - if not JSON, skip it if (!blobInfo.contentType?.includes("application/json")) { - console.error(`[Workflow Storage] Unexpected content type ${blobInfo.contentType} for ${blob.url}`) + console.error( + "[Workflow Storage] Unexpected content type %s for URL %s", + blobInfo.contentType, + blob.url + ) return null } @@ -118,7 +122,10 @@ export async function listWorkflowRuns(userId: string): Promise { // Check for non-OK responses (security checkpoint returns 200 but HTML) if (!response.ok) { console.error( - `[Workflow Storage] HTTP ${response.status} fetching ${fetchUrl} (content-type: ${response.headers.get("content-type")})` + "[Workflow Storage] HTTP %d fetching %s (content-type: %s)", + response.status, + fetchUrl, + response.headers.get("content-type") ) return null } @@ -127,7 +134,9 @@ export async function listWorkflowRuns(userId: string): Promise { const contentType = response.headers.get("content-type") if (contentType && !contentType.includes("application/json")) { console.error( - `[Workflow Storage] Response is not JSON: ${contentType} for ${fetchUrl} (likely Vercel Security Checkpoint)` + "[Workflow Storage] Response is not JSON: %s for %s (likely Vercel Security Checkpoint)", + contentType, + fetchUrl ) return null } @@ -135,7 +144,7 @@ export async function listWorkflowRuns(userId: string): Promise { const run: WorkflowRun = await response.json() return run } catch (error) { - console.error(`[Workflow Storage] Failed to fetch ${blob.url}:`, error) + console.error("[Workflow Storage] Failed to fetch blob URL %s:", blob.url, error) return null } })