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.
- package/.claude/settings.local.json +10 -5
- package/.context-snapshots/context-snapshot-20260106-180000.md +108 -0
- package/.context-snapshots/context-snapshot-20260106-235500.md +108 -0
- package/README.md +21 -0
- package/dist/server/routes/api.d.ts.map +1 -1
- package/dist/server/routes/api.js +6 -0
- package/dist/server/routes/api.js.map +1 -1
- package/dist/server/services/worktreeService.d.ts +15 -0
- package/dist/server/services/worktreeService.d.ts.map +1 -1
- package/dist/server/services/worktreeService.js +114 -14
- package/dist/server/services/worktreeService.js.map +1 -1
- package/dist/server/socketHandlers.d.ts.map +1 -1
- package/dist/server/socketHandlers.js +112 -5
- package/dist/server/socketHandlers.js.map +1 -1
- package/dist/shared/constants.d.ts +1 -1
- package/dist/shared/constants.js +1 -1
- package/dist/shared/types/index.d.ts +1 -0
- package/dist/shared/types/index.d.ts.map +1 -1
- package/dist/web/assets/{index-D8dr9mJa.js → index-De6xm4hO.js} +2 -2
- package/dist/web/assets/index-De6xm4hO.js.map +1 -0
- package/dist/web/index.html +1 -1
- package/package.json +3 -2
- package/scripts/fix-pty-permissions.js +89 -0
- package/server/routes/api.ts +7 -0
- package/server/services/worktreeService.ts +129 -13
- package/server/socketHandlers.ts +132 -6
- package/shared/constants.ts +1 -1
- package/shared/types/index.ts +1 -0
- package/dist/web/assets/index-D8dr9mJa.js.map +0 -1
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/server/routes/api.ts
CHANGED
|
@@ -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
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
141
|
-
const additionalWorktrees = worktrees.filter(wt =>
|
|
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);
|
package/server/socketHandlers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
}
|
|
181
|
+
}),
|
|
56
182
|
});
|
|
57
183
|
|
|
58
184
|
sessions.set(sessionId, {
|
package/shared/constants.ts
CHANGED