hjworktree-cli 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="/assets/index-D8dr9mJa.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-De6xm4hO.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-CsixHL-D.css">
12
12
  </head>
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hjworktree-cli",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Web-based git worktree parallel AI coding agent runner",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
@@ -15,6 +15,7 @@
15
15
  "build:server": "tsc",
16
16
  "build:web": "vite build --config web/vite.config.ts",
17
17
  "start": "node dist/server/index.js",
18
+ "postinstall": "node scripts/fix-pty-permissions.js",
18
19
  "prepublishOnly": "npm run build"
19
20
  },
20
21
  "keywords": [
@@ -37,7 +38,7 @@
37
38
  "dependencies": {
38
39
  "cors": "^2.8.5",
39
40
  "express": "^4.21.0",
40
- "node-pty": "^1.0.0",
41
+ "node-pty": "^1.1.0",
41
42
  "open": "^10.1.0",
42
43
  "simple-git": "^3.27.0",
43
44
  "socket.io": "^4.7.5"
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Fix node-pty spawn-helper permissions on macOS/Linux
5
+ *
6
+ * This script addresses the posix_spawnp failed error that occurs when
7
+ * spawn-helper lacks execute permissions after npm install.
8
+ *
9
+ * Reference: https://github.com/microsoft/node-pty/issues/670
10
+ */
11
+
12
+ import { chmod, access, constants } from 'fs';
13
+ import { resolve, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+
18
+ // Skip on Windows
19
+ if (process.platform === 'win32') {
20
+ process.exit(0);
21
+ }
22
+
23
+ // Possible spawn-helper locations
24
+ const possiblePaths = [
25
+ // When installed as dependency
26
+ resolve(__dirname, '../node_modules/node-pty/build/Release/spawn-helper'),
27
+ // When this package is installed globally
28
+ resolve(__dirname, '../node_modules/.pnpm/node-pty@1.1.0/node_modules/node-pty/build/Release/spawn-helper'),
29
+ // Direct prebuild location
30
+ resolve(__dirname, '../node_modules/node-pty/prebuilds'),
31
+ ];
32
+
33
+ // Find and fix spawn-helper permissions
34
+ async function fixPermissions() {
35
+ for (const spawnHelperPath of possiblePaths) {
36
+ try {
37
+ await new Promise((resolve, reject) => {
38
+ access(spawnHelperPath, constants.F_OK, (err) => {
39
+ if (err) reject(err);
40
+ else resolve();
41
+ });
42
+ });
43
+
44
+ // File exists, set execute permission
45
+ await new Promise((resolve, reject) => {
46
+ chmod(spawnHelperPath, 0o755, (err) => {
47
+ if (err) reject(err);
48
+ else resolve();
49
+ });
50
+ });
51
+
52
+ console.log(`[fix-pty-permissions] Fixed: ${spawnHelperPath}`);
53
+ } catch {
54
+ // File doesn't exist at this path, try next
55
+ }
56
+ }
57
+
58
+ // Also try to find spawn-helper recursively in node_modules
59
+ try {
60
+ const { execSync } = await import('child_process');
61
+ const result = execSync(
62
+ 'find node_modules -name "spawn-helper" -type f 2>/dev/null || true',
63
+ { cwd: resolve(__dirname, '..'), encoding: 'utf-8' }
64
+ );
65
+
66
+ const files = result.trim().split('\n').filter(Boolean);
67
+ for (const file of files) {
68
+ try {
69
+ const fullPath = resolve(__dirname, '..', file);
70
+ await new Promise((resolve, reject) => {
71
+ chmod(fullPath, 0o755, (err) => {
72
+ if (err) reject(err);
73
+ else resolve();
74
+ });
75
+ });
76
+ console.log(`[fix-pty-permissions] Fixed: ${fullPath}`);
77
+ } catch {
78
+ // Ignore errors for individual files
79
+ }
80
+ }
81
+ } catch {
82
+ // find command failed, ignore
83
+ }
84
+ }
85
+
86
+ fixPermissions().catch(() => {
87
+ // Don't fail the install if this script fails
88
+ process.exit(0);
89
+ });
@@ -125,6 +125,13 @@ export function apiRouter(cwd: string): Router {
125
125
  return;
126
126
  }
127
127
 
128
+ // SECURITY: Never delete main worktree
129
+ if (worktree.isMainWorktree) {
130
+ console.warn(`[SECURITY] API blocked attempt to delete main worktree: ${name}`);
131
+ res.status(403).json({ error: 'Cannot delete main worktree' });
132
+ return;
133
+ }
134
+
128
135
  await worktreeService.removeWorktree(worktree.path);
129
136
  res.json({ success: true });
130
137
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { simpleGit, SimpleGit } from 'simple-git';
2
2
  import path from 'path';
3
3
  import fs from 'fs/promises';
4
+ import { realpathSync, statSync } from 'fs';
4
5
  import type { Worktree } from '../../shared/types/index.js';
5
6
 
6
7
  export class WorktreeService {
@@ -13,11 +14,56 @@ export class WorktreeService {
13
14
  this.git = simpleGit(cwd);
14
15
  }
15
16
 
17
+ /**
18
+ * Safely resolves a path to its real path, handling symlinks.
19
+ * Returns null if path cannot be resolved.
20
+ */
21
+ private safeRealpath(targetPath: string): string | null {
22
+ try {
23
+ return realpathSync(targetPath);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Checks if a path is a linked worktree (has .git file) vs main repo (has .git directory).
31
+ * Linked worktrees have a .git FILE that points to the main .git directory.
32
+ * Main repo has a .git DIRECTORY.
33
+ */
34
+ private isLinkedWorktree(worktreePath: string): boolean {
35
+ const gitPath = path.join(worktreePath, '.git');
36
+ try {
37
+ const stat = statSync(gitPath);
38
+ // .git is a directory = main repository
39
+ // .git is a file = linked worktree
40
+ return !stat.isDirectory();
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Checks if the given path is the main repository.
48
+ */
49
+ private isMainRepository(targetPath: string): boolean {
50
+ const resolvedTarget = this.safeRealpath(targetPath);
51
+ const resolvedRoot = this.safeRealpath(this.rootDir);
52
+
53
+ if (!resolvedTarget || !resolvedRoot) {
54
+ // If we can't resolve paths, assume it might be main repo (fail safe)
55
+ return true;
56
+ }
57
+
58
+ return resolvedTarget === resolvedRoot;
59
+ }
60
+
16
61
  async listWorktrees(): Promise<Worktree[]> {
17
62
  const result = await this.git.raw(['worktree', 'list', '--porcelain']);
18
63
  const worktrees: Worktree[] = [];
19
64
 
20
65
  const entries = result.trim().split('\n\n').filter(Boolean);
66
+ const resolvedRootDir = this.safeRealpath(this.rootDir);
21
67
 
22
68
  for (const entry of entries) {
23
69
  const lines = entry.split('\n');
@@ -26,10 +72,17 @@ export class WorktreeService {
26
72
  const branch = branchLine?.replace('branch refs/heads/', '');
27
73
 
28
74
  if (worktreePath) {
75
+ // Determine if this is the main worktree using realpath comparison
76
+ const resolvedWorktreePath = this.safeRealpath(worktreePath);
77
+ const isMainWorktree = resolvedWorktreePath !== null &&
78
+ resolvedRootDir !== null &&
79
+ resolvedWorktreePath === resolvedRootDir;
80
+
29
81
  worktrees.push({
30
82
  path: worktreePath,
31
83
  branch: branch || 'detached',
32
84
  name: path.basename(worktreePath),
85
+ isMainWorktree,
33
86
  });
34
87
  }
35
88
  }
@@ -72,6 +125,7 @@ export class WorktreeService {
72
125
  path: worktreePath,
73
126
  branch: newBranchName,
74
127
  name: worktreeName,
128
+ isMainWorktree: false, // Created worktrees are always linked, not main
75
129
  };
76
130
  }
77
131
 
@@ -103,28 +157,66 @@ export class WorktreeService {
103
157
  }
104
158
 
105
159
  async removeWorktree(worktreePath: string): Promise<void> {
160
+ // SECURITY GUARD 1: Use realpathSync for symlink resolution
161
+ const resolvedWorktreePath = this.safeRealpath(worktreePath);
162
+ const resolvedRootDir = this.safeRealpath(this.rootDir);
163
+
164
+ // If we cannot resolve paths, abort for safety
165
+ if (!resolvedWorktreePath) {
166
+ console.warn(`[SECURITY] Cannot resolve worktree path: ${worktreePath}. Aborting deletion.`);
167
+ return;
168
+ }
169
+
170
+ if (!resolvedRootDir) {
171
+ console.warn(`[SECURITY] Cannot resolve root directory path. Aborting deletion.`);
172
+ return;
173
+ }
174
+
175
+ // SECURITY GUARD 2: Check exact match with main repo
176
+ if (resolvedWorktreePath === resolvedRootDir) {
177
+ console.warn(`[SECURITY] Attempted to remove main repository: ${worktreePath}. Ignoring.`);
178
+ return;
179
+ }
180
+
181
+ // SECURITY GUARD 3: Check if path is inside main repo
182
+ if (resolvedWorktreePath.startsWith(resolvedRootDir + path.sep)) {
183
+ console.warn(`[SECURITY] Attempted to remove path inside main repository: ${worktreePath}. Ignoring.`);
184
+ return;
185
+ }
186
+
187
+ // SECURITY GUARD 4: Check if main repo is inside target path (catastrophic prevention)
188
+ if (resolvedRootDir.startsWith(resolvedWorktreePath + path.sep)) {
189
+ console.warn(`[SECURITY] Attempted to remove parent of main repository: ${worktreePath}. Ignoring.`);
190
+ return;
191
+ }
192
+
193
+ // SECURITY GUARD 5: Verify it's actually a linked worktree (has .git FILE, not directory)
194
+ const isLinked = this.isLinkedWorktree(worktreePath);
195
+ if (!isLinked) {
196
+ console.warn(`[SECURITY] Path is not a linked worktree (missing .git file or has .git directory): ${worktreePath}. Ignoring.`);
197
+ return;
198
+ }
199
+
106
200
  const branchName = path.basename(worktreePath);
107
201
 
202
+ // CRITICAL: Only use git worktree remove - NEVER use fs.rm() as fallback
108
203
  try {
109
- // 1. Force remove worktree
110
204
  await this.git.raw(['worktree', 'remove', worktreePath, '--force']);
111
- } catch {
112
- // 2. If worktree remove fails, try to remove directory manually
113
- try {
114
- await fs.rm(worktreePath, { recursive: true, force: true });
115
- } catch {
116
- // Ignore errors
117
- }
205
+ } catch (error) {
206
+ // Log the error but DO NOT fall back to fs.rm
207
+ console.error(`[SECURITY] Failed to remove worktree via git: ${worktreePath}`, error);
208
+ // Propagate error instead of dangerous fallback
209
+ throw new Error(`Failed to remove worktree: ${error instanceof Error ? error.message : 'Unknown error'}`);
118
210
  }
119
211
 
120
- // 3. CRITICAL: Prune orphan worktree references
212
+ // Prune orphan worktree references
121
213
  try {
122
214
  await this.git.raw(['worktree', 'prune']);
123
215
  } catch {
124
- // Ignore prune errors
216
+ // Ignore prune errors - non-critical
125
217
  }
126
218
 
127
- // 4. Now we can safely delete the branch
219
+ // Delete the branch
128
220
  try {
129
221
  await this.git.branch(['-D', branchName]);
130
222
  } catch {
@@ -137,8 +229,25 @@ export class WorktreeService {
137
229
  async removeAllWorktrees(): Promise<void> {
138
230
  const worktrees = await this.listWorktrees();
139
231
 
140
- // Filter out the main worktree (usually the first one / the main repo)
141
- const additionalWorktrees = worktrees.filter(wt => wt.path !== this.rootDir);
232
+ // Filter out the main worktree using the isMainWorktree flag and path comparison
233
+ const additionalWorktrees = worktrees.filter(wt => {
234
+ // Primary check: use isMainWorktree flag
235
+ if (wt.isMainWorktree) {
236
+ console.log(`[SECURITY] Skipping main worktree in removeAll: ${wt.path}`);
237
+ return false;
238
+ }
239
+
240
+ // Secondary check: realpath comparison as defense in depth
241
+ const resolvedPath = this.safeRealpath(wt.path);
242
+ const resolvedRoot = this.safeRealpath(this.rootDir);
243
+
244
+ if (resolvedPath && resolvedRoot && resolvedPath === resolvedRoot) {
245
+ console.warn(`[SECURITY] Detected main worktree via realpath (flag was false): ${wt.path}`);
246
+ return false;
247
+ }
248
+
249
+ return true;
250
+ });
142
251
 
143
252
  for (const worktree of additionalWorktrees) {
144
253
  try {
@@ -187,6 +296,13 @@ export class WorktreeService {
187
296
  continue;
188
297
  }
189
298
 
299
+ // SECURITY: Never delete main worktree
300
+ if (worktree.isMainWorktree) {
301
+ console.warn(`[SECURITY] Blocked attempt to delete main worktree by name: ${name}`);
302
+ results.failed.push({ name, error: 'Cannot delete main worktree' });
303
+ continue;
304
+ }
305
+
190
306
  try {
191
307
  await this.removeWorktree(worktree.path);
192
308
  results.deleted.push(name);
@@ -1,6 +1,6 @@
1
1
  import { Server, Socket } from 'socket.io';
2
2
  import * as pty from 'node-pty';
3
- import type { IPty } from 'node-pty';
3
+ import type { IPty, IPtyForkOptions } from 'node-pty';
4
4
  import type {
5
5
  TerminalCreateData,
6
6
  TerminalInputData,
@@ -9,6 +9,101 @@ import type {
9
9
  AgentId
10
10
  } from '../shared/types/index.js';
11
11
  import { AI_AGENTS } from '../shared/constants.js';
12
+ import fs, { realpathSync } from 'fs';
13
+ import path from 'path';
14
+ import { promisify } from 'util';
15
+
16
+ const access = promisify(fs.access);
17
+
18
+ // Environment and path utilities for macOS compatibility
19
+ function sanitizeEnv(env: NodeJS.ProcessEnv): Record<string, string> {
20
+ const sanitized: Record<string, string> = {};
21
+ for (const [key, value] of Object.entries(env)) {
22
+ if (typeof value === 'string' && value.length > 0) {
23
+ sanitized[key] = value;
24
+ }
25
+ }
26
+ return sanitized;
27
+ }
28
+
29
+ function normalizePath(targetPath: string): string {
30
+ try {
31
+ const absolutePath = path.resolve(targetPath);
32
+ return realpathSync(absolutePath);
33
+ } catch {
34
+ return path.resolve(targetPath);
35
+ }
36
+ }
37
+
38
+ // Validation utilities
39
+ async function validatePath(pathToCheck: string): Promise<{ valid: boolean; error?: string }> {
40
+ try {
41
+ await access(pathToCheck, fs.constants.R_OK | fs.constants.X_OK);
42
+ return { valid: true };
43
+ } catch (error) {
44
+ return {
45
+ valid: false,
46
+ error: error instanceof Error ? error.message : 'Path not accessible'
47
+ };
48
+ }
49
+ }
50
+
51
+ async function validateShell(shell: string): Promise<{ valid: boolean; error?: string }> {
52
+ if (process.platform === 'win32') {
53
+ return { valid: true };
54
+ }
55
+ try {
56
+ await access(shell, fs.constants.X_OK);
57
+ return { valid: true };
58
+ } catch (error) {
59
+ return {
60
+ valid: false,
61
+ error: `Shell not executable: ${shell}`
62
+ };
63
+ }
64
+ }
65
+
66
+ // PTY spawn with retry logic
67
+ async function spawnWithRetry(
68
+ shell: string,
69
+ args: string[],
70
+ options: IPtyForkOptions,
71
+ maxRetries: number = 3
72
+ ): Promise<IPty> {
73
+ let lastError: Error | null = null;
74
+
75
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
76
+ try {
77
+ const ptyProcess = pty.spawn(shell, args, options);
78
+ return ptyProcess;
79
+ } catch (error) {
80
+ lastError = error instanceof Error ? error : new Error(String(error));
81
+ console.error(`[PTY] Spawn attempt ${attempt + 1}/${maxRetries} failed:`, lastError.message);
82
+
83
+ if (attempt < maxRetries - 1) {
84
+ const delay = Math.pow(2, attempt) * 100;
85
+ await new Promise(resolve => setTimeout(resolve, delay));
86
+ }
87
+ }
88
+ }
89
+
90
+ throw lastError || new Error('Failed to spawn PTY after retries');
91
+ }
92
+
93
+ // Spawn interval control to prevent resource contention
94
+ let lastSpawnTime = 0;
95
+ const MIN_SPAWN_INTERVAL = 150;
96
+
97
+ async function waitForSpawnInterval(): Promise<void> {
98
+ const now = Date.now();
99
+ const elapsed = now - lastSpawnTime;
100
+
101
+ if (elapsed < MIN_SPAWN_INTERVAL) {
102
+ await new Promise(resolve => setTimeout(resolve, MIN_SPAWN_INTERVAL - elapsed));
103
+ }
104
+
105
+ lastSpawnTime = Date.now();
106
+ }
12
107
 
13
108
  interface TerminalSession {
14
109
  pty: IPty;
@@ -25,7 +120,20 @@ function getAgentCommand(agentType: AgentId): string {
25
120
  }
26
121
 
27
122
  function getShell(): string {
28
- return process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash';
123
+ if (process.platform === 'win32') {
124
+ return 'powershell.exe';
125
+ }
126
+
127
+ const shellPath = process.env.SHELL || '/bin/bash';
128
+
129
+ try {
130
+ const resolvedShell = realpathSync(shellPath);
131
+ fs.accessSync(resolvedShell, fs.constants.X_OK);
132
+ return resolvedShell;
133
+ } catch {
134
+ // Fallback: use shell name only (let PATH resolve it)
135
+ return path.basename(shellPath);
136
+ }
29
137
  }
30
138
 
31
139
  export function setupSocketHandlers(io: Server, cwd: string) {
@@ -42,17 +150,35 @@ export function setupSocketHandlers(io: Server, cwd: string) {
42
150
  const shell = getShell();
43
151
  const agentCommand = getAgentCommand(agentType);
44
152
 
45
- const ptyProcess = pty.spawn(shell, [], {
153
+ // Validate path before spawning
154
+ const pathValidation = await validatePath(worktreePath);
155
+ if (!pathValidation.valid) {
156
+ throw new Error(`Invalid worktree path: ${pathValidation.error}`);
157
+ }
158
+
159
+ // Validate shell before spawning
160
+ const shellValidation = await validateShell(shell);
161
+ if (!shellValidation.valid) {
162
+ throw new Error(`Invalid shell: ${shellValidation.error}`);
163
+ }
164
+
165
+ // Wait for spawn interval to prevent resource contention
166
+ await waitForSpawnInterval();
167
+
168
+ // Normalize cwd path (resolve symlinks like /tmp -> /private/tmp on macOS)
169
+ const normalizedCwd = normalizePath(worktreePath);
170
+
171
+ const ptyProcess = await spawnWithRetry(shell, [], {
46
172
  name: 'xterm-256color',
47
173
  cols: 120,
48
174
  rows: 30,
49
- cwd: worktreePath,
50
- env: {
175
+ cwd: normalizedCwd,
176
+ env: sanitizeEnv({
51
177
  ...process.env,
52
178
  TERM: 'xterm-256color',
53
179
  FORCE_COLOR: '1',
54
180
  COLORTERM: 'truecolor',
55
- } as Record<string, string>,
181
+ }),
56
182
  });
57
183
 
58
184
  sessions.set(sessionId, {
@@ -31,5 +31,5 @@ export const DEFAULT_PARALLEL_COUNT = 3;
31
31
  export const BRANCH_POLL_INTERVAL = 5000; // 5 seconds
32
32
 
33
33
  export const APP_NAME = 'hjWorktree CLI';
34
- export const APP_VERSION = '2.0.0';
34
+ export const APP_VERSION = '2.3.0';
35
35
  export const DEFAULT_PORT = 3847;
@@ -51,6 +51,7 @@ export interface Worktree {
51
51
  path: string;
52
52
  branch: string;
53
53
  name: string;
54
+ isMainWorktree: boolean;
54
55
  }
55
56
 
56
57
  // Socket.IO event types