From 8127774ab8997457abcfdafb6babf79f6962f6ee Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 09:27:23 -0500 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20Add=20Ghostty=20search=20pat?= =?UTF-8?q?hs=20and=20ban=20sync=20fs=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add macOS-specific Ghostty detection for Homebrew and app bundle paths - Convert terminal detection to async operations using fs.stat() - Add ESLint rule to prevent synchronous filesystem operations - Check /opt/homebrew/bin/ghostty, /Applications/Ghostty.app, /usr/local/bin Fixes Ghostty detection when not in PATH. --- eslint.config.mjs | 68 +++++++++++++++++++++++++++++++++++++++-- src/services/ipcMain.ts | 55 ++++++++++++++++++++++++++++----- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index bb1a72f74..d478be12d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,8 +5,8 @@ import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; /** - * Custom ESLint plugin for zombie process prevention - * Enforces safe child_process patterns + * Custom ESLint plugin for safe Node.js patterns + * Enforces safe child_process and filesystem patterns */ const localPlugin = { rules: { @@ -41,6 +41,67 @@ const localPlugin = { }; }, }, + "no-sync-fs-methods": { + meta: { + type: "problem", + docs: { + description: "Prevent synchronous filesystem operations", + }, + messages: { + syncFsMethod: + "Do not use synchronous fs methods ({{method}}). Use async version instead: {{asyncMethod}}", + }, + }, + create(context) { + // Map of sync methods to their async equivalents + const syncMethods = { + statSync: "stat", + readFileSync: "readFile", + writeFileSync: "writeFile", + readdirSync: "readdir", + mkdirSync: "mkdir", + unlinkSync: "unlink", + rmdirSync: "rmdir", + existsSync: "access or stat", + accessSync: "access", + copyFileSync: "copyFile", + renameSync: "rename", + chmodSync: "chmod", + chownSync: "chown", + lstatSync: "lstat", + linkSync: "link", + symlinkSync: "symlink", + readlinkSync: "readlink", + realpathSync: "realpath", + truncateSync: "truncate", + fstatSync: "fstat", + appendFileSync: "appendFile", + }; + + return { + MemberExpression(node) { + // Only flag if it's a property access on 'fs' or imported fs methods + if ( + node.property && + node.property.type === "Identifier" && + syncMethods[node.property.name] && + node.object && + node.object.type === "Identifier" && + (node.object.name === "fs" || node.object.name === "fsPromises") + ) { + context.report({ + node, + messageId: "syncFsMethod", + data: { + method: node.property.name, + asyncMethod: syncMethods[node.property.name], + }, + }); + } + }, + }; + }, + }, }, }; @@ -178,8 +239,9 @@ export default defineConfig([ "react/react-in-jsx-scope": "off", "react/prop-types": "off", - // Zombie process prevention + // Safe Node.js patterns "local/no-unsafe-child-process": "error", + "local/no-sync-fs-methods": "error", // Allow console for this app (it's a dev tool) "no-console": "off", diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index ce2115f5d..224ac9f78 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -652,11 +652,11 @@ export class IpcMain { } ); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, (_event, workspacePath: string) => { + ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspacePath: string) => { try { if (process.platform === "darwin") { // macOS - try Ghostty first, fallback to Terminal.app - const terminal = this.findAvailableCommand(["ghostty", "terminal"]); + const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { const child = spawn("open", ["-a", "Ghostty", workspacePath], { detached: true, @@ -693,7 +693,7 @@ export class IpcMain { { cmd: "xterm", args: [], cwd: workspacePath }, ]; - const availableTerminal = terminals.find((t) => this.isCommandAvailable(t.cmd)); + const availableTerminal = await this.findAvailableTerminal(terminals); if (availableTerminal) { const child = spawn(availableTerminal.cmd, availableTerminal.args, { @@ -1036,9 +1036,31 @@ export class IpcMain { } /** - * Check if a command is available in the system PATH + * Check if a command is available in the system PATH or known locations */ - private isCommandAvailable(command: string): boolean { + private async isCommandAvailable(command: string): Promise { + // Special handling for ghostty on macOS - check common installation paths + if (command === "ghostty" && process.platform === "darwin") { + const ghosttyPaths = [ + "/opt/homebrew/bin/ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty", + "/usr/local/bin/ghostty", + ]; + + for (const ghosttyPath of ghosttyPaths) { + try { + const stats = await fsPromises.stat(ghosttyPath); + // Check if any executable bit is set (owner, group, or other) + if ((stats.mode & 0o111) !== 0) { + return true; + } + } catch { + // Try next path + } + } + // If none of the known paths work, fall through to which check + } + try { const result = spawnSync("which", [command], { encoding: "utf8" }); return result.status === 0; @@ -1050,7 +1072,26 @@ export class IpcMain { /** * Find the first available command from a list of commands */ - private findAvailableCommand(commands: string[]): string | null { - return commands.find((cmd) => this.isCommandAvailable(cmd)) ?? null; + private async findAvailableCommand(commands: string[]): Promise { + for (const cmd of commands) { + if (await this.isCommandAvailable(cmd)) { + return cmd; + } + } + return null; + } + + /** + * Find the first available terminal from a list of terminal configurations + */ + private async findAvailableTerminal( + terminals: Array<{ cmd: string; args: string[]; cwd?: string }> + ): Promise<{ cmd: string; args: string[]; cwd?: string } | null> { + for (const terminal of terminals) { + if (await this.isCommandAvailable(terminal.cmd)) { + return terminal; + } + } + return null; } } From 01e6aa5453032ae6d591dab06f451279ff8611a7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 09:29:42 -0500 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A4=96=20Exempt=20existing=20files=20?= =?UTF-8?q?from=20sync=20fs=20lint=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporarily exempt files with existing sync fs usage to avoid breaking the build. The rule will still prevent new sync fs usage in other files. Files exempted: - src/config.ts (initialization code) - src/debug/**/*.ts (CLI tools) - src/git.ts, src/main.ts (bootstrap code) - src/services/* (existing services) New code in other locations will be required to use async fs operations. --- eslint.config.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index d478be12d..58d5b8f27 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -321,6 +321,26 @@ export default defineConfig([ "no-restricted-syntax": "off", }, }, + { + // Temporarily allow sync fs methods in files with existing usage + // TODO: Gradually migrate these to async operations + files: [ + "src/config.ts", + "src/debug/**/*.ts", + "src/git.ts", + "src/main.ts", + "src/services/gitService.ts", + "src/services/log.ts", + "src/services/streamManager.ts", + "src/services/tempDir.ts", + "src/services/tools/bash.ts", + "src/services/tools/bash.test.ts", + "src/services/tools/testHelpers.ts", + ], + rules: { + "local/no-sync-fs-methods": "off", + }, + }, { // Frontend architectural boundary - prevent services and tokenizer imports files: ["src/components/**", "src/contexts/**", "src/hooks/**", "src/App.tsx"], From b3211ee436145537548d379e57e216f49f826c62 Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 09:32:36 -0500 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A4=96=20Fix=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/ipcMain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 224ac9f78..9a8fcc363 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -1046,7 +1046,7 @@ export class IpcMain { "/Applications/Ghostty.app/Contents/MacOS/ghostty", "/usr/local/bin/ghostty", ]; - + for (const ghosttyPath of ghosttyPaths) { try { const stats = await fsPromises.stat(ghosttyPath); From 21f0a54e36bc79367531963f25bbc69059ae189f Mon Sep 17 00:00:00 2001 From: Ammar Date: Thu, 16 Oct 2025 10:27:31 -0500 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=A4=96=20Add=20isFile()=20check=20to?= =?UTF-8?q?=20prevent=20directory=20false-positive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensure Ghostty path detection only matches actual files, not directories with execute permission (which most directories have for traversal). --- src/services/ipcMain.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 9a8fcc363..a9d70d887 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -1050,8 +1050,8 @@ export class IpcMain { for (const ghosttyPath of ghosttyPaths) { try { const stats = await fsPromises.stat(ghosttyPath); - // Check if any executable bit is set (owner, group, or other) - if ((stats.mode & 0o111) !== 0) { + // Check if it's a file and any executable bit is set (owner, group, or other) + if (stats.isFile() && (stats.mode & 0o111) !== 0) { return true; } } catch {