cli-tunnel 1.1.0 → 1.2.0-beta.10
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/README.md +48 -5
- package/dist/index.js +471 -85
- package/package.json +8 -6
- package/remote-ui/app.js +269 -19
- package/remote-ui/index.html +10 -10
- package/remote-ui/styles.css +44 -0
package/dist/index.js
CHANGED
|
@@ -20,8 +20,15 @@ import crypto from 'node:crypto';
|
|
|
20
20
|
import { execSync, execFileSync, spawn } from 'node:child_process';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import http from 'node:http';
|
|
23
|
+
import readline from 'node:readline';
|
|
23
24
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
24
25
|
import os from 'node:os';
|
|
26
|
+
function askUser(question) {
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer.trim().toLowerCase()); });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
25
32
|
const BOLD = '\x1b[1m';
|
|
26
33
|
const RESET = '\x1b[0m';
|
|
27
34
|
const DIM = '\x1b[2m';
|
|
@@ -29,40 +36,45 @@ const GREEN = '\x1b[32m';
|
|
|
29
36
|
const YELLOW = '\x1b[33m';
|
|
30
37
|
// ─── Parse args ─────────────────────────────────────────────
|
|
31
38
|
const args = process.argv.slice(2);
|
|
32
|
-
if (args.includes('--help') || args.includes('-h')
|
|
39
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
33
40
|
console.log(`
|
|
34
41
|
${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
|
|
35
42
|
|
|
36
43
|
${BOLD}Usage:${RESET}
|
|
37
44
|
cli-tunnel [options] <command> [args...]
|
|
45
|
+
cli-tunnel # hub mode — sessions dashboard only
|
|
38
46
|
|
|
39
47
|
${BOLD}Options:${RESET}
|
|
40
|
-
--
|
|
48
|
+
--local Disable devtunnel (localhost only)
|
|
41
49
|
--port <n> Bridge port (default: random)
|
|
42
50
|
--name <name> Session name (shown in dashboard)
|
|
51
|
+
--replay Enable replay buffer (off by default)
|
|
43
52
|
--help, -h Show this help
|
|
44
53
|
|
|
45
54
|
${BOLD}Examples:${RESET}
|
|
46
|
-
cli-tunnel copilot
|
|
47
|
-
cli-tunnel --
|
|
48
|
-
cli-tunnel
|
|
49
|
-
cli-tunnel
|
|
50
|
-
cli-tunnel --
|
|
51
|
-
cli-tunnel --
|
|
55
|
+
cli-tunnel copilot --yolo # tunnel + run copilot
|
|
56
|
+
cli-tunnel copilot --model claude-sonnet-4 --agent squad
|
|
57
|
+
cli-tunnel k9s # tunnel + run k9s
|
|
58
|
+
cli-tunnel python -i # tunnel + run python
|
|
59
|
+
cli-tunnel --name wizard copilot # named session
|
|
60
|
+
cli-tunnel --local copilot --yolo # localhost only, no devtunnel
|
|
61
|
+
cli-tunnel # hub: see all active sessions
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
app. cli-tunnel's own flags
|
|
55
|
-
before the command.
|
|
63
|
+
Devtunnel is enabled by default. All flags after the command name
|
|
64
|
+
pass through to the underlying app. cli-tunnel's own flags
|
|
65
|
+
(--local, --port, --name) must come before the command.
|
|
56
66
|
`);
|
|
57
67
|
process.exit(0);
|
|
58
68
|
}
|
|
59
|
-
const
|
|
69
|
+
const hasLocal = args.includes('--local');
|
|
70
|
+
const hasTunnel = !hasLocal;
|
|
71
|
+
const hasReplay = args.includes('--replay');
|
|
60
72
|
const portIdx = args.indexOf('--port');
|
|
61
73
|
const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
|
|
62
74
|
const nameIdx = args.indexOf('--name');
|
|
63
75
|
const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
|
|
64
76
|
// Everything that's not our flags is the command
|
|
65
|
-
const ourFlags = new Set(['--tunnel', '--port', '--name']);
|
|
77
|
+
const ourFlags = new Set(['--local', '--tunnel', '--port', '--name', '--replay']);
|
|
66
78
|
const cmdArgs = [];
|
|
67
79
|
let skip = false;
|
|
68
80
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -70,20 +82,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
70
82
|
skip = false;
|
|
71
83
|
continue;
|
|
72
84
|
}
|
|
73
|
-
if (
|
|
85
|
+
if (args[i] === '--port' || args[i] === '--name') {
|
|
74
86
|
skip = true;
|
|
75
87
|
continue;
|
|
76
88
|
}
|
|
77
|
-
if (args[i] === '--tunnel')
|
|
89
|
+
if (args[i] === '--local' || args[i] === '--tunnel' || args[i] === '--replay')
|
|
78
90
|
continue;
|
|
79
91
|
cmdArgs.push(args[i]);
|
|
80
92
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const command = cmdArgs[0];
|
|
86
|
-
const commandArgs = cmdArgs.slice(1);
|
|
93
|
+
// Hub mode — no command, just show sessions dashboard
|
|
94
|
+
const hubMode = cmdArgs.length === 0;
|
|
95
|
+
const command = hubMode ? '' : cmdArgs[0];
|
|
96
|
+
const commandArgs = hubMode ? [] : cmdArgs.slice(1);
|
|
87
97
|
const cwd = process.cwd();
|
|
88
98
|
// ─── Tunnel helpers ─────────────────────────────────────────
|
|
89
99
|
function sanitizeLabel(l) {
|
|
@@ -103,35 +113,199 @@ function getGitInfo() {
|
|
|
103
113
|
}
|
|
104
114
|
// ─── Security: Session token for WebSocket auth ────────────
|
|
105
115
|
const sessionToken = crypto.randomUUID();
|
|
116
|
+
// ─── Session file registry (IPC via filesystem) ────────────
|
|
117
|
+
const sessionsDir = path.join(os.homedir(), '.cli-tunnel', 'sessions');
|
|
118
|
+
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
let sessionFilePath = null;
|
|
120
|
+
function writeSessionFile(tunnelId, tunnelUrl, port) {
|
|
121
|
+
sessionFilePath = path.join(sessionsDir, `${tunnelId}.json`);
|
|
122
|
+
const data = JSON.stringify({
|
|
123
|
+
token: sessionToken, name: sessionName || command,
|
|
124
|
+
tunnelId, tunnelUrl, port, hubMode,
|
|
125
|
+
machine: os.hostname(), pid: process.pid,
|
|
126
|
+
createdAt: new Date().toISOString(),
|
|
127
|
+
});
|
|
128
|
+
fs.writeFileSync(sessionFilePath, data, { mode: 0o600 });
|
|
129
|
+
}
|
|
130
|
+
function removeSessionFile() {
|
|
131
|
+
if (sessionFilePath) {
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(sessionFilePath);
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function readLocalSessions() {
|
|
139
|
+
try {
|
|
140
|
+
return fs.readdirSync(sessionsDir)
|
|
141
|
+
.filter(f => f.endsWith('.json'))
|
|
142
|
+
.map(f => { try {
|
|
143
|
+
return JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf-8'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
} })
|
|
148
|
+
.filter((s) => s !== null && !s.hubMode);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// ─── F-18: Session TTL (4 hours) ───────────────────────────
|
|
155
|
+
const SESSION_TTL = 4 * 60 * 60 * 1000; // 4 hours
|
|
156
|
+
const sessionCreatedAt = Date.now();
|
|
157
|
+
// ─── F-02: One-time ticket store for WebSocket auth ────────
|
|
158
|
+
const tickets = new Map();
|
|
159
|
+
// #30: Ticket GC — clean expired tickets every 30s
|
|
160
|
+
setInterval(() => {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
for (const [id, t] of tickets) {
|
|
163
|
+
if (t.expires < now)
|
|
164
|
+
tickets.delete(id);
|
|
165
|
+
}
|
|
166
|
+
}, 30000);
|
|
106
167
|
// ─── Security: Redact secrets from replay events ────────────
|
|
107
168
|
function redactSecrets(text) {
|
|
108
|
-
return text
|
|
169
|
+
return text
|
|
170
|
+
// Generic patterns: key=value, key: value, key="value"
|
|
171
|
+
.replace(/(?:token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing)[\s:="']+\S{8,}/gi, '[REDACTED]')
|
|
172
|
+
// OpenAI keys
|
|
173
|
+
.replace(/sk-[a-zA-Z0-9]{20,}/g, '[REDACTED]')
|
|
174
|
+
// GitHub tokens
|
|
175
|
+
.replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, '[REDACTED]')
|
|
176
|
+
// AWS keys
|
|
177
|
+
.replace(/AKIA[A-Z0-9]{16}/g, '[REDACTED]')
|
|
178
|
+
// Azure connection strings
|
|
179
|
+
.replace(/DefaultEndpointsProtocol=[^;\s]{20,}/gi, '[REDACTED]')
|
|
180
|
+
.replace(/AccountKey=[^;\s]{20,}/gi, 'AccountKey=[REDACTED]')
|
|
181
|
+
// Database URLs
|
|
182
|
+
.replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
|
|
183
|
+
// Bearer tokens in headers
|
|
184
|
+
.replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]')
|
|
185
|
+
// JWT tokens
|
|
186
|
+
.replace(/eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, '[REDACTED]')
|
|
187
|
+
// Slack tokens
|
|
188
|
+
.replace(/xox[bpras]-[a-zA-Z0-9-]{10,}/g, '[REDACTED]')
|
|
189
|
+
// npm tokens
|
|
190
|
+
.replace(/npm_[a-zA-Z0-9]{20,}/g, '[REDACTED]')
|
|
191
|
+
// PEM private keys
|
|
192
|
+
.replace(/-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----/g, '[REDACTED]');
|
|
109
193
|
}
|
|
110
194
|
// ─── Bridge server ──────────────────────────────────────────
|
|
111
195
|
const acpEventLog = [];
|
|
112
196
|
const connections = new Map();
|
|
197
|
+
// #10: Session TTL enforcement — periodically close expired connections
|
|
198
|
+
setInterval(() => {
|
|
199
|
+
if (Date.now() - sessionCreatedAt > SESSION_TTL) {
|
|
200
|
+
for (const [id, ws] of connections) {
|
|
201
|
+
ws.close(1000, 'Session expired');
|
|
202
|
+
connections.delete(id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}, 60000);
|
|
206
|
+
// ─── F-8: Per-IP rate limiter ───────────────────────────────
|
|
207
|
+
const rateLimits = new Map();
|
|
208
|
+
const ticketRateLimits = new Map();
|
|
209
|
+
function checkRateLimit(ip, map, maxRequests) {
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
const entry = map.get(ip);
|
|
212
|
+
if (!entry || entry.resetAt < now) {
|
|
213
|
+
map.set(ip, { count: 1, resetAt: now + 60000 });
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
entry.count++;
|
|
217
|
+
return entry.count <= maxRequests;
|
|
218
|
+
}
|
|
219
|
+
// Clean up rate limit maps every 60s
|
|
220
|
+
setInterval(() => {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
for (const [ip, entry] of rateLimits) {
|
|
223
|
+
if (entry.resetAt < now)
|
|
224
|
+
rateLimits.delete(ip);
|
|
225
|
+
}
|
|
226
|
+
for (const [ip, entry] of ticketRateLimits) {
|
|
227
|
+
if (entry.resetAt < now)
|
|
228
|
+
ticketRateLimits.delete(ip);
|
|
229
|
+
}
|
|
230
|
+
}, 60000);
|
|
113
231
|
const server = http.createServer((req, res) => {
|
|
232
|
+
const clientIp = req.socket.remoteAddress || 'unknown';
|
|
233
|
+
// F-8: Rate limiting for HTTP endpoints
|
|
234
|
+
if (req.url?.startsWith('/api/')) {
|
|
235
|
+
const isTicket = req.url === '/api/auth/ticket';
|
|
236
|
+
if (isTicket && !checkRateLimit(clientIp, ticketRateLimits, 10)) {
|
|
237
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
238
|
+
res.end(JSON.stringify({ error: 'Too Many Requests' }));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!checkRateLimit(clientIp, rateLimits, 30)) {
|
|
242
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
243
|
+
res.end(JSON.stringify({ error: 'Too Many Requests' }));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// F-18: Session expiry check for API routes
|
|
248
|
+
if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
|
|
249
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
250
|
+
res.end(JSON.stringify({ error: 'Session expired' }));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// F-02: Ticket endpoint — exchange session token for one-time WS ticket
|
|
254
|
+
if (req.url === '/api/auth/ticket' && req.method === 'POST') {
|
|
255
|
+
const auth = req.headers.authorization?.replace('Bearer ', '');
|
|
256
|
+
if (auth !== sessionToken) {
|
|
257
|
+
res.writeHead(401);
|
|
258
|
+
res.end();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const ticket = crypto.randomUUID();
|
|
262
|
+
tickets.set(ticket, { expires: Date.now() + 60000 });
|
|
263
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
264
|
+
res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// F-01: Session token check for all API routes
|
|
268
|
+
if (req.url?.startsWith('/api/')) {
|
|
269
|
+
const reqUrl = new URL(req.url, `http://${req.headers.host}`);
|
|
270
|
+
const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
|
|
271
|
+
if (authToken !== sessionToken) {
|
|
272
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
273
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
114
277
|
// Sessions API
|
|
115
|
-
if (req.url === '/api/sessions' && req.method === 'GET') {
|
|
278
|
+
if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
|
|
116
279
|
try {
|
|
117
|
-
const output =
|
|
280
|
+
const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
118
281
|
const data = JSON.parse(output);
|
|
282
|
+
const localMachine = os.hostname();
|
|
283
|
+
const localSessions = hubMode ? readLocalSessions() : [];
|
|
284
|
+
const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
|
|
119
285
|
const sessions = (data.tunnels || []).map((t) => {
|
|
120
286
|
const labels = t.labels || [];
|
|
121
287
|
const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
|
|
122
288
|
const cluster = t.tunnelId?.split('.').pop() || 'euw';
|
|
123
289
|
const portLabel = labels.find((l) => l.startsWith('port-'));
|
|
124
290
|
const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
|
|
125
|
-
|
|
291
|
+
const machine = labels[4] || 'unknown';
|
|
292
|
+
const session = {
|
|
126
293
|
id, tunnelId: t.tunnelId,
|
|
127
294
|
name: labels[1] || 'unnamed',
|
|
128
295
|
repo: labels[2] || 'unknown',
|
|
129
296
|
branch: (labels[3] || 'unknown').replace(/_/g, '/'),
|
|
130
|
-
machine
|
|
297
|
+
machine,
|
|
131
298
|
online: (t.hostConnections || 0) > 0,
|
|
132
299
|
port: p,
|
|
133
300
|
url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
|
|
301
|
+
isLocal: machine === localMachine,
|
|
134
302
|
};
|
|
303
|
+
// Attach token from local session files (hub mode only)
|
|
304
|
+
const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
|
|
305
|
+
const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
|
|
306
|
+
if (token)
|
|
307
|
+
session.token = token;
|
|
308
|
+
return session;
|
|
135
309
|
});
|
|
136
310
|
res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
|
|
137
311
|
res.end(JSON.stringify({ sessions }));
|
|
@@ -163,7 +337,18 @@ const server = http.createServer((req, res) => {
|
|
|
163
337
|
}
|
|
164
338
|
// Static files
|
|
165
339
|
const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
|
|
166
|
-
|
|
340
|
+
// #18: Guard against malformed URI encoding
|
|
341
|
+
let decodedUrl;
|
|
342
|
+
try {
|
|
343
|
+
// Strip query string before resolving file path
|
|
344
|
+
const urlPath = (req.url || '/').split('?')[0];
|
|
345
|
+
decodedUrl = decodeURIComponent(urlPath);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
res.writeHead(400);
|
|
349
|
+
res.end();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
167
352
|
if (decodedUrl.includes('..')) {
|
|
168
353
|
res.writeHead(400);
|
|
169
354
|
res.end();
|
|
@@ -175,65 +360,154 @@ const server = http.createServer((req, res) => {
|
|
|
175
360
|
res.end();
|
|
176
361
|
return;
|
|
177
362
|
}
|
|
178
|
-
if
|
|
179
|
-
|
|
363
|
+
// #2: EISDIR guard — check if path is a directory before createReadStream
|
|
364
|
+
try {
|
|
365
|
+
const stat = fs.statSync(filePath);
|
|
366
|
+
if (stat.isDirectory()) {
|
|
367
|
+
filePath = path.join(filePath, 'index.html');
|
|
368
|
+
if (!fs.existsSync(filePath)) {
|
|
369
|
+
res.writeHead(404);
|
|
370
|
+
res.end();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
res.writeHead(404);
|
|
377
|
+
res.end();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
180
380
|
const ext = path.extname(filePath);
|
|
181
381
|
const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
|
|
182
382
|
const securityHeaders = {
|
|
183
383
|
'Content-Type': mimes[ext] || 'application/octet-stream',
|
|
184
384
|
'X-Frame-Options': 'DENY',
|
|
185
385
|
'X-Content-Type-Options': 'nosniff',
|
|
186
|
-
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self'
|
|
386
|
+
'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
|
|
387
|
+
'Referrer-Policy': 'no-referrer',
|
|
388
|
+
'Cache-Control': 'no-store',
|
|
389
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
187
390
|
};
|
|
188
391
|
res.writeHead(200, securityHeaders);
|
|
189
|
-
|
|
392
|
+
// #8: Handle createReadStream errors
|
|
393
|
+
const stream = fs.createReadStream(filePath);
|
|
394
|
+
stream.on('error', () => { if (!res.headersSent) {
|
|
395
|
+
res.writeHead(500);
|
|
396
|
+
} res.end(); });
|
|
397
|
+
stream.pipe(res);
|
|
190
398
|
});
|
|
191
399
|
const wss = new WebSocketServer({
|
|
192
400
|
server,
|
|
193
401
|
maxPayload: 1048576,
|
|
194
402
|
verifyClient: (info) => {
|
|
403
|
+
if (hubMode)
|
|
404
|
+
return true; // Hub mode doesn't need WS auth
|
|
405
|
+
// F-18: Session expiry
|
|
406
|
+
if (Date.now() - sessionCreatedAt > SESSION_TTL)
|
|
407
|
+
return false;
|
|
408
|
+
// F-3: Validate origin BEFORE ticket acceptance
|
|
409
|
+
const origin = info.req.headers.origin;
|
|
410
|
+
if (origin) {
|
|
411
|
+
try {
|
|
412
|
+
const originUrl = new URL(origin);
|
|
413
|
+
const host = originUrl.hostname;
|
|
414
|
+
if (host !== 'localhost' && host !== '127.0.0.1' && !host.endsWith('.devtunnels.ms')) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
195
422
|
const url = new URL(info.req.url, `http://${info.req.headers.host}`);
|
|
196
|
-
|
|
423
|
+
// F-02: Accept one-time ticket (only auth method for WS)
|
|
424
|
+
const ticket = url.searchParams.get('ticket');
|
|
425
|
+
if (ticket && tickets.has(ticket)) {
|
|
426
|
+
const t = tickets.get(ticket);
|
|
427
|
+
tickets.delete(ticket); // Single use
|
|
428
|
+
return t.expires > Date.now();
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
197
431
|
},
|
|
198
432
|
});
|
|
199
433
|
// ─── Security: Audit log for remote PTY input ──────────────
|
|
200
|
-
const
|
|
434
|
+
const auditDir = path.join(os.homedir(), '.cli-tunnel', 'audit');
|
|
435
|
+
fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
|
|
436
|
+
const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
|
|
201
437
|
const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
|
|
438
|
+
auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
|
|
202
439
|
wss.on('connection', (ws, req) => {
|
|
203
|
-
|
|
440
|
+
// F-10: Connection cap (global + per-IP)
|
|
441
|
+
if (connections.size >= 5) {
|
|
442
|
+
ws.close(1013, 'Max connections reached');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
204
445
|
const remoteAddress = req.socket.remoteAddress || 'unknown';
|
|
446
|
+
let perIpCount = 0;
|
|
447
|
+
for (const [, c] of connections) {
|
|
448
|
+
if (c._remoteAddress === remoteAddress)
|
|
449
|
+
perIpCount++;
|
|
450
|
+
}
|
|
451
|
+
if (perIpCount >= 2) {
|
|
452
|
+
ws.close(1013, 'Max connections per IP reached');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const id = crypto.randomUUID();
|
|
456
|
+
ws._remoteAddress = remoteAddress;
|
|
205
457
|
connections.set(id, ws);
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
458
|
+
// F-10: WS ping/pong heartbeat
|
|
459
|
+
ws._isAlive = true;
|
|
460
|
+
ws.on('pong', () => { ws._isAlive = true; });
|
|
461
|
+
// Replay history with secrets redacted (only if replay is enabled)
|
|
462
|
+
if (hasReplay) {
|
|
463
|
+
for (const event of acpEventLog) {
|
|
464
|
+
ws.send(JSON.stringify({ type: '_replay', data: redactSecrets(event) }));
|
|
465
|
+
}
|
|
466
|
+
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
209
467
|
}
|
|
210
|
-
ws.send(JSON.stringify({ type: '_replay_done' }));
|
|
211
468
|
ws.on('message', (data) => {
|
|
212
469
|
const raw = data.toString();
|
|
213
470
|
try {
|
|
214
471
|
const msg = JSON.parse(raw);
|
|
215
472
|
if (msg.type === 'pty_input' && ptyProcess) {
|
|
216
|
-
auditLog.write(
|
|
473
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), src: remoteAddress, type: 'pty_input', data: redactSecrets(JSON.stringify(msg.data)) }) + '\n');
|
|
217
474
|
ptyProcess.write(msg.data);
|
|
218
475
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
476
|
+
// #7: NaN guard on pty_resize
|
|
477
|
+
if (msg.type === 'pty_resize') {
|
|
478
|
+
const cols = Number(msg.cols);
|
|
479
|
+
const rows = Number(msg.rows);
|
|
480
|
+
if (Number.isFinite(cols) && Number.isFinite(rows) && ptyProcess) {
|
|
481
|
+
ptyProcess.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows)));
|
|
482
|
+
}
|
|
223
483
|
}
|
|
224
484
|
}
|
|
225
485
|
catch {
|
|
226
|
-
|
|
227
|
-
|
|
486
|
+
// #3: Log but do NOT write to PTY — only structured pty_input messages allowed
|
|
487
|
+
auditLog.write(JSON.stringify({ ts: new Date().toISOString(), type: 'rejected', reason: 'non-json', length: raw.length }) + '\n');
|
|
228
488
|
}
|
|
229
489
|
});
|
|
230
490
|
ws.on('close', () => connections.delete(id));
|
|
231
491
|
});
|
|
492
|
+
// F-10: WS heartbeat — ping every 30s, close unresponsive after 10s
|
|
493
|
+
setInterval(() => {
|
|
494
|
+
for (const [id, ws] of connections) {
|
|
495
|
+
if (ws._isAlive === false) {
|
|
496
|
+
ws.terminate();
|
|
497
|
+
connections.delete(id);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
ws._isAlive = false;
|
|
501
|
+
ws.ping();
|
|
502
|
+
}
|
|
503
|
+
}, 30000);
|
|
232
504
|
function broadcast(data) {
|
|
233
505
|
const msg = JSON.stringify({ type: 'pty', data });
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
506
|
+
if (hasReplay) {
|
|
507
|
+
acpEventLog.push(msg);
|
|
508
|
+
if (acpEventLog.length > 2000)
|
|
509
|
+
acpEventLog.splice(0, acpEventLog.length - 2000);
|
|
510
|
+
}
|
|
237
511
|
for (const [, ws] of connections) {
|
|
238
512
|
if (ws.readyState === WebSocket.OPEN)
|
|
239
513
|
ws.send(msg);
|
|
@@ -243,7 +517,7 @@ function broadcast(data) {
|
|
|
243
517
|
let ptyProcess = null;
|
|
244
518
|
async function main() {
|
|
245
519
|
const actualPort = await new Promise((resolve, reject) => {
|
|
246
|
-
server.listen(port, () => {
|
|
520
|
+
server.listen(port, '127.0.0.1', () => {
|
|
247
521
|
const addr = server.address();
|
|
248
522
|
resolve(typeof addr === 'object' ? addr.port : port);
|
|
249
523
|
});
|
|
@@ -252,60 +526,117 @@ async function main() {
|
|
|
252
526
|
const { repo, branch } = getGitInfo();
|
|
253
527
|
const machine = os.hostname();
|
|
254
528
|
const displayName = sessionName || command;
|
|
255
|
-
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
529
|
+
console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
|
|
530
|
+
if (hubMode) {
|
|
531
|
+
console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
|
|
532
|
+
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
533
|
+
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
|
|
537
|
+
console.log(` ${DIM}Name:${RESET} ${displayName}`);
|
|
538
|
+
console.log(` ${DIM}Port:${RESET} ${actualPort}`);
|
|
539
|
+
console.log(` ${DIM}Audit log:${RESET} ${auditLogPath}`);
|
|
540
|
+
console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}`);
|
|
541
|
+
console.log(` ${DIM}Session expires:${RESET} ${new Date(sessionCreatedAt + SESSION_TTL).toLocaleTimeString()}`);
|
|
542
|
+
}
|
|
260
543
|
// Tunnel
|
|
261
544
|
if (hasTunnel) {
|
|
262
545
|
// Check if devtunnel is installed
|
|
263
546
|
let devtunnelInstalled = false;
|
|
264
547
|
try {
|
|
265
|
-
|
|
548
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
266
549
|
devtunnelInstalled = true;
|
|
267
550
|
}
|
|
268
551
|
catch {
|
|
269
552
|
console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
|
|
270
|
-
|
|
553
|
+
let installCmd = '';
|
|
271
554
|
if (process.platform === 'win32') {
|
|
272
|
-
|
|
555
|
+
installCmd = 'winget install Microsoft.devtunnel';
|
|
273
556
|
}
|
|
274
557
|
else if (process.platform === 'darwin') {
|
|
275
|
-
|
|
558
|
+
installCmd = 'brew install --cask devtunnel';
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
|
|
562
|
+
}
|
|
563
|
+
const answer = await askUser(` Would you like to install it now? (${GREEN}${installCmd}${RESET}) [Y/n] `);
|
|
564
|
+
if (answer === '' || answer === 'y' || answer === 'yes') {
|
|
565
|
+
console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
|
|
566
|
+
try {
|
|
567
|
+
const installParts = installCmd.split(' ');
|
|
568
|
+
const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
|
|
569
|
+
await new Promise((resolve, reject) => {
|
|
570
|
+
installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
|
|
571
|
+
installProc.on('error', reject);
|
|
572
|
+
});
|
|
573
|
+
// Refresh PATH — winget updates the registry but current process has stale PATH
|
|
574
|
+
if (process.platform === 'win32') {
|
|
575
|
+
try {
|
|
576
|
+
const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
577
|
+
const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
578
|
+
const extractPath = (out) => out.split('\n').find(l => l.includes('REG_'))?.split('REG_EXPAND_SZ')[1]?.trim() || out.split('\n').find(l => l.includes('REG_'))?.split('REG_SZ')[1]?.trim() || '';
|
|
579
|
+
process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
|
|
580
|
+
}
|
|
581
|
+
catch { /* keep existing PATH */ }
|
|
582
|
+
}
|
|
583
|
+
// Verify installation
|
|
584
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
585
|
+
console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
|
|
586
|
+
devtunnelInstalled = true;
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
|
|
590
|
+
console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
|
|
591
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
592
|
+
}
|
|
276
593
|
}
|
|
277
594
|
else {
|
|
278
|
-
console.log(
|
|
595
|
+
console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
|
|
596
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
279
597
|
}
|
|
280
|
-
console.log(`\n Then authenticate once:\n`);
|
|
281
|
-
console.log(` ${GREEN}devtunnel user login${RESET}\n`);
|
|
282
|
-
console.log(` ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}\n`);
|
|
283
|
-
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
284
598
|
}
|
|
285
|
-
// Check if logged in
|
|
286
599
|
if (devtunnelInstalled) {
|
|
600
|
+
// Check if logged in before attempting tunnel creation
|
|
287
601
|
try {
|
|
288
|
-
const userInfo =
|
|
289
|
-
if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
|
|
602
|
+
const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
603
|
+
if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
|
|
290
604
|
throw new Error('not logged in');
|
|
291
605
|
}
|
|
292
606
|
}
|
|
293
607
|
catch {
|
|
294
|
-
console.log(`\n ${YELLOW}⚠ devtunnel not authenticated
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
608
|
+
console.log(`\n ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
|
|
609
|
+
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
610
|
+
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
611
|
+
try {
|
|
612
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
613
|
+
await new Promise((resolve, reject) => {
|
|
614
|
+
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
615
|
+
loginProc.on('error', reject);
|
|
616
|
+
});
|
|
617
|
+
console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
|
|
621
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
622
|
+
devtunnelInstalled = false;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
|
|
627
|
+
console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
|
|
628
|
+
devtunnelInstalled = false;
|
|
629
|
+
}
|
|
299
630
|
}
|
|
300
631
|
}
|
|
301
632
|
if (devtunnelInstalled) {
|
|
302
633
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
const createOut =
|
|
634
|
+
const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
|
|
635
|
+
const labelArgs = labelValues.flatMap(l => ['--labels', l]);
|
|
636
|
+
const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
306
637
|
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
307
638
|
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
308
|
-
|
|
639
|
+
execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
|
|
309
640
|
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
|
|
310
641
|
const url = await new Promise((resolve, reject) => {
|
|
311
642
|
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
@@ -320,28 +651,59 @@ async function main() {
|
|
|
320
651
|
});
|
|
321
652
|
hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
|
|
322
653
|
});
|
|
323
|
-
const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
|
|
654
|
+
const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
|
|
324
655
|
console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
|
|
656
|
+
// Write session file for hub discovery
|
|
657
|
+
writeSessionFile(tunnelId, url, actualPort);
|
|
325
658
|
try {
|
|
326
659
|
// @ts-ignore
|
|
327
660
|
const qr = (await import('qrcode-terminal'));
|
|
328
661
|
qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
|
|
329
662
|
}
|
|
330
663
|
catch { }
|
|
331
|
-
process.on('SIGINT', () => { hostProc.kill(); try {
|
|
332
|
-
|
|
664
|
+
process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
|
|
665
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
333
666
|
}
|
|
334
667
|
catch { } });
|
|
335
|
-
process.on('exit', () => { hostProc.kill(); try {
|
|
336
|
-
|
|
668
|
+
process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
|
|
669
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
337
670
|
}
|
|
338
671
|
catch { } });
|
|
339
672
|
}
|
|
340
673
|
catch (err) {
|
|
341
|
-
|
|
674
|
+
const errMsg = err.message || '';
|
|
675
|
+
// Detect auth failure at create time (expired token, anonymous, etc.)
|
|
676
|
+
if (errMsg.includes('Anonymous') || errMsg.includes('Unauthorized') || errMsg.includes('not permitted')) {
|
|
677
|
+
console.log(`\n ${YELLOW}⚠ devtunnel session expired or not authenticated.${RESET}\n`);
|
|
678
|
+
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
679
|
+
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
680
|
+
try {
|
|
681
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
682
|
+
await new Promise((resolve, reject) => {
|
|
683
|
+
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
684
|
+
loginProc.on('error', reject);
|
|
685
|
+
});
|
|
686
|
+
console.log(`\n ${GREEN}✓${RESET} Logged in! Please run cli-tunnel again to create the tunnel.\n`);
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${errMsg}\n`);
|
|
695
|
+
}
|
|
342
696
|
}
|
|
343
697
|
} // end if (devtunnelInstalled)
|
|
344
698
|
}
|
|
699
|
+
if (hubMode) {
|
|
700
|
+
// Hub mode — just serve the sessions dashboard, no PTY
|
|
701
|
+
console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
|
|
702
|
+
console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`);
|
|
703
|
+
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
704
|
+
// Keep process alive
|
|
705
|
+
await new Promise(() => { });
|
|
706
|
+
}
|
|
345
707
|
console.log(` ${DIM}Starting ${command}...${RESET}\n`);
|
|
346
708
|
// Spawn PTY
|
|
347
709
|
const nodePty = await import('node-pty');
|
|
@@ -351,7 +713,7 @@ async function main() {
|
|
|
351
713
|
let resolvedCmd = command;
|
|
352
714
|
if (process.platform === 'win32') {
|
|
353
715
|
try {
|
|
354
|
-
const wherePaths =
|
|
716
|
+
const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
|
|
355
717
|
// Prefer .exe or .cmd over .ps1 for node-pty compatibility
|
|
356
718
|
const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
|
|
357
719
|
if (exePath) {
|
|
@@ -365,11 +727,16 @@ async function main() {
|
|
|
365
727
|
}
|
|
366
728
|
catch { /* use as-is */ }
|
|
367
729
|
}
|
|
368
|
-
//
|
|
730
|
+
// F-07: Security — filter dangerous environment variables for PTY
|
|
731
|
+
// Blocklist approach: pass everything except known dangerous vars and secrets
|
|
732
|
+
const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
|
|
733
|
+
'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
|
|
734
|
+
'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
|
|
735
|
+
'SSH_AUTH_SOCK', 'GPG_TTY']);
|
|
736
|
+
const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
|
|
369
737
|
const safeEnv = {};
|
|
370
|
-
const sensitivePatterns = /token|secret|key|password|credential|api_key|private/i;
|
|
371
738
|
for (const [k, v] of Object.entries(process.env)) {
|
|
372
|
-
if (!
|
|
739
|
+
if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
|
|
373
740
|
safeEnv[k] = v;
|
|
374
741
|
}
|
|
375
742
|
}
|
|
@@ -378,6 +745,25 @@ async function main() {
|
|
|
378
745
|
cols, rows, cwd,
|
|
379
746
|
env: safeEnv,
|
|
380
747
|
});
|
|
748
|
+
// Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
|
|
749
|
+
let ptyExitedEarly = false;
|
|
750
|
+
const earlyExitCheck = new Promise((resolve) => {
|
|
751
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
752
|
+
if (exitCode === 134 || exitCode === 3221226505) {
|
|
753
|
+
ptyExitedEarly = true;
|
|
754
|
+
resolve();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
setTimeout(resolve, 2000);
|
|
758
|
+
});
|
|
759
|
+
await earlyExitCheck;
|
|
760
|
+
if (ptyExitedEarly) {
|
|
761
|
+
const nodeVer = process.version;
|
|
762
|
+
console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
|
|
763
|
+
console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
|
|
764
|
+
console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
381
767
|
ptyProcess.onData((data) => {
|
|
382
768
|
process.stdout.write(data);
|
|
383
769
|
broadcast(data);
|