cli-tunnel 1.2.0-beta.1 → 1.2.0-beta.11

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 CHANGED
@@ -77,12 +77,55 @@ cli-tunnel copilot
77
77
 
78
78
  ## Security
79
79
 
80
- Tunnels are **private by default** — only the Microsoft/GitHub account that created the tunnel can connect. Auth is enforced at Microsoft's relay layer before traffic reaches your machine.
80
+ cli-tunnel uses a layered security model:
81
81
 
82
- - No inbound ports opened
83
- - No anonymous access
84
- - No central server
85
- - TLS encryption via devtunnel relay
82
+ **Network layer** — Microsoft Dev Tunnels are private by default. Only the Microsoft or GitHub account that created the tunnel can connect. TLS encryption is handled by Microsoft's relay infrastructure. No inbound ports are opened on your machine.
83
+
84
+ **Session authentication** Each session generates a unique token (cryptographic random UUID). All HTTP API and WebSocket connections require this token. The token is embedded in the URL you receive at startup — anyone without it cannot connect.
85
+
86
+ **WebSocket auth** — cli-tunnel uses a ticket-based handshake: the browser exchanges the session token for a single-use, short-lived ticket (60 seconds) to establish the WebSocket connection. This avoids keeping the long-lived token in WebSocket upgrade logs.
87
+
88
+ **Input validation** — Only structured JSON messages are accepted over WebSocket. Raw text is rejected and logged. Terminal resize commands are bounds-checked to prevent abuse.
89
+
90
+ **Environment isolation** — The child process receives a filtered set of environment variables (an allowlist of ~40 safe variables like PATH, HOME, TERM). Sensitive variables and NODE_OPTIONS are excluded to prevent code injection.
91
+
92
+ **Audit logging** — All remote keyboard input is logged to `~/.cli-tunnel/audit/` in JSONL format with timestamps and source addresses. Secrets are automatically redacted from audit entries.
93
+
94
+ **Connection limits** — Maximum 5 concurrent WebSocket connections. Sessions expire after 24 hours.
95
+
96
+ ## Terminal Size Behavior
97
+
98
+ cli-tunnel uses a single PTY (pseudo-terminal) shared between your local terminal and all remote viewers. When a phone or tablet connects, the PTY resizes to match the remote device's screen dimensions. This ensures the CLI app renders correctly on the device you're actively using to interact with it.
99
+
100
+ Because the PTY can only have one size at a time, the local terminal on your machine will reflect the remote device's dimensions while it's connected. This is by design — cli-tunnel prioritizes the remote viewing experience since the primary use case is controlling your CLI from another device.
101
+
102
+ **Tips for the best experience:**
103
+ - Rotate your phone to landscape for a wider terminal
104
+ - Use the key bar (↑↓←→ Tab Enter Esc Ctrl+C) at the bottom for navigation
105
+ - If multiple devices connect, the last one to resize wins
106
+
107
+ ## FAQ
108
+
109
+ **Can multiple devices connect to the same session?**
110
+ Yes, up to 5 devices simultaneously. All viewers see the same terminal output in real time. Input from any device goes to the same CLI session.
111
+
112
+ **What happens if my phone disconnects?**
113
+ The CLI session keeps running on your machine. When you reconnect, you'll see live output from that point forward. Use `--replay` to enable history replay so reconnecting devices catch up on what they missed.
114
+
115
+ **Does cli-tunnel work with any CLI app?**
116
+ Yes. Any command that runs in a terminal works — copilot, vim, htop, python, ssh, k9s, node, and more. cli-tunnel doesn't interpret the command's output; it streams raw terminal bytes.
117
+
118
+ **Is there a central server?**
119
+ No. cli-tunnel runs entirely on your machine. Microsoft Dev Tunnels provides the relay infrastructure, but no third-party server sees your terminal content.
120
+
121
+ **What about the anti-phishing page?**
122
+ The first time you open a devtunnel URL, Microsoft shows an interstitial warning page. This is a devtunnel security feature — it confirms you trust the tunnel. You only see it once per tunnel.
123
+
124
+ **Does the tool work without devtunnel?**
125
+ Yes. Use `--local` to skip tunnel creation. The terminal is available at `http://127.0.0.1:<port>` on your local network only.
126
+
127
+ **What's hub mode?**
128
+ Run `cli-tunnel` with no command to start hub mode — a sessions dashboard that shows all active cli-tunnel sessions on your machine. Tap any online session to connect to it.
86
129
 
87
130
  ## How It's Built
88
131
 
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';
@@ -106,8 +113,46 @@ function getGitInfo() {
106
113
  }
107
114
  // ─── Security: Session token for WebSocket auth ────────────
108
115
  const sessionToken = crypto.randomUUID();
109
- // ─── F-18: Session TTL (24 hours) ──────────────────────────
110
- const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
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
111
156
  const sessionCreatedAt = Date.now();
112
157
  // ─── F-02: One-time ticket store for WebSocket auth ────────
113
158
  const tickets = new Map();
@@ -136,7 +181,15 @@ function redactSecrets(text) {
136
181
  // Database URLs
137
182
  .replace(/(postgres|mongodb|mysql|redis):\/\/[^\s"']{10,}/gi, '[REDACTED]')
138
183
  // Bearer tokens in headers
139
- .replace(/Bearer\s+[a-zA-Z0-9._-]{20,}/gi, 'Bearer [REDACTED]');
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]');
140
193
  }
141
194
  // ─── Bridge server ──────────────────────────────────────────
142
195
  const acpEventLog = [];
@@ -150,7 +203,47 @@ setInterval(() => {
150
203
  }
151
204
  }
152
205
  }, 60000);
153
- const server = http.createServer((req, res) => {
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);
231
+ const server = http.createServer(async (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
+ }
154
247
  // F-18: Session expiry check for API routes
155
248
  if (!hubMode && req.url?.startsWith('/api/') && Date.now() - sessionCreatedAt > SESSION_TTL) {
156
249
  res.writeHead(401, { 'Content-Type': 'application/json' });
@@ -171,8 +264,8 @@ const server = http.createServer((req, res) => {
171
264
  res.end(JSON.stringify({ ticket, expires: Date.now() + 60000 }));
172
265
  return;
173
266
  }
174
- // F-01: Session token check for all API routes (skip in hub mode)
175
- if (!hubMode && req.url?.startsWith('/api/')) {
267
+ // F-01: Session token check for all API routes
268
+ if (req.url?.startsWith('/api/')) {
176
269
  const reqUrl = new URL(req.url, `http://${req.headers.host}`);
177
270
  const authToken = req.headers.authorization?.replace('Bearer ', '') || reqUrl.searchParams.get('token');
178
271
  if (authToken !== sessionToken) {
@@ -181,27 +274,72 @@ const server = http.createServer((req, res) => {
181
274
  return;
182
275
  }
183
276
  }
277
+ // Hub ticket proxy — fetch ticket from local session on behalf of grid client
278
+ if (hubMode && req.url?.startsWith('/api/proxy/ticket/') && req.method === 'POST') {
279
+ const targetPort = parseInt(req.url.replace('/api/proxy/ticket/', ''), 10);
280
+ if (!Number.isFinite(targetPort) || targetPort < 1 || targetPort > 65535) {
281
+ res.writeHead(400, { 'Content-Type': 'application/json' });
282
+ res.end(JSON.stringify({ error: 'Invalid port' }));
283
+ return;
284
+ }
285
+ // Find token for this port from session files
286
+ const localSessions = readLocalSessions();
287
+ const session = localSessions.find(s => s.port === targetPort);
288
+ if (!session) {
289
+ res.writeHead(404, { 'Content-Type': 'application/json' });
290
+ res.end(JSON.stringify({ error: 'Session not found' }));
291
+ return;
292
+ }
293
+ try {
294
+ const ticketResp = await fetch(`http://127.0.0.1:${targetPort}/api/auth/ticket`, {
295
+ method: 'POST', headers: { 'Authorization': `Bearer ${session.token}` },
296
+ signal: AbortSignal.timeout(3000),
297
+ });
298
+ if (!ticketResp.ok)
299
+ throw new Error('Ticket request failed');
300
+ const ticketData = await ticketResp.json();
301
+ res.writeHead(200, { 'Content-Type': 'application/json' });
302
+ res.end(JSON.stringify({ ticket: ticketData.ticket, port: targetPort }));
303
+ }
304
+ catch {
305
+ res.writeHead(502, { 'Content-Type': 'application/json' });
306
+ res.end(JSON.stringify({ error: 'Session unreachable' }));
307
+ return;
308
+ }
309
+ return;
310
+ }
184
311
  // Sessions API
185
- if (req.url === '/api/sessions' && req.method === 'GET') {
312
+ if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
186
313
  try {
187
314
  const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
188
315
  const data = JSON.parse(output);
316
+ const localMachine = os.hostname();
317
+ const localSessions = hubMode ? readLocalSessions() : [];
318
+ const tokenMap = new Map(localSessions.map(s => [s.tunnelId, s.token]));
189
319
  const sessions = (data.tunnels || []).map((t) => {
190
320
  const labels = t.labels || [];
191
321
  const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
192
322
  const cluster = t.tunnelId?.split('.').pop() || 'euw';
193
323
  const portLabel = labels.find((l) => l.startsWith('port-'));
194
324
  const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
195
- return {
325
+ const machine = labels[4] || 'unknown';
326
+ const session = {
196
327
  id, tunnelId: t.tunnelId,
197
328
  name: labels[1] || 'unnamed',
198
329
  repo: labels[2] || 'unknown',
199
330
  branch: (labels[3] || 'unknown').replace(/_/g, '/'),
200
- machine: labels[4] || 'unknown',
331
+ machine,
201
332
  online: (t.hostConnections || 0) > 0,
202
333
  port: p,
203
334
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
335
+ isLocal: machine === localMachine,
204
336
  };
337
+ // Attach token from local session files (hub mode only)
338
+ const baseId = t.tunnelId?.split('.')[0] || t.tunnelId;
339
+ const token = tokenMap.get(baseId) || tokenMap.get(t.tunnelId);
340
+ if (token)
341
+ session.token = token;
342
+ return session;
205
343
  });
206
344
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
207
345
  res.end(JSON.stringify({ sessions }));
@@ -236,7 +374,9 @@ const server = http.createServer((req, res) => {
236
374
  // #18: Guard against malformed URI encoding
237
375
  let decodedUrl;
238
376
  try {
239
- decodedUrl = decodeURIComponent(req.url || '/');
377
+ // Strip query string before resolving file path
378
+ const urlPath = (req.url || '/').split('?')[0];
379
+ decodedUrl = decodeURIComponent(urlPath);
240
380
  }
241
381
  catch {
242
382
  res.writeHead(400);
@@ -277,9 +417,10 @@ const server = http.createServer((req, res) => {
277
417
  'Content-Type': mimes[ext] || 'application/octet-stream',
278
418
  'X-Frame-Options': 'DENY',
279
419
  'X-Content-Type-Options': 'nosniff',
280
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* wss://*.devtunnels.ms;",
420
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
281
421
  'Referrer-Policy': 'no-referrer',
282
422
  'Cache-Control': 'no-store',
423
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
283
424
  };
284
425
  res.writeHead(200, securityHeaders);
285
426
  // #8: Handle createReadStream errors
@@ -298,19 +439,7 @@ const wss = new WebSocketServer({
298
439
  // F-18: Session expiry
299
440
  if (Date.now() - sessionCreatedAt > SESSION_TTL)
300
441
  return false;
301
- const url = new URL(info.req.url, `http://${info.req.headers.host}`);
302
- // F-02: Accept one-time ticket
303
- const ticket = url.searchParams.get('ticket');
304
- if (ticket && tickets.has(ticket)) {
305
- const t = tickets.get(ticket);
306
- tickets.delete(ticket); // Single use
307
- return t.expires > Date.now();
308
- }
309
- // Backward compat: accept token
310
- if (url.searchParams.get('token') !== sessionToken)
311
- return false;
312
- // Validate origin if present
313
- // #28: Proper origin validation using URL parsing
442
+ // F-3: Validate origin BEFORE ticket acceptance
314
443
  const origin = info.req.headers.origin;
315
444
  if (origin) {
316
445
  try {
@@ -324,7 +453,15 @@ const wss = new WebSocketServer({
324
453
  return false;
325
454
  }
326
455
  }
327
- return true;
456
+ const url = new URL(info.req.url, `http://${info.req.headers.host}`);
457
+ // F-02: Accept one-time ticket (only auth method for WS)
458
+ const ticket = url.searchParams.get('ticket');
459
+ if (ticket && tickets.has(ticket)) {
460
+ const t = tickets.get(ticket);
461
+ tickets.delete(ticket); // Single use
462
+ return t.expires > Date.now();
463
+ }
464
+ return false;
328
465
  },
329
466
  });
330
467
  // ─── Security: Audit log for remote PTY input ──────────────
@@ -334,14 +471,27 @@ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice
334
471
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
335
472
  auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
336
473
  wss.on('connection', (ws, req) => {
337
- // F-10: Connection cap
474
+ // F-10: Connection cap (global + per-IP)
338
475
  if (connections.size >= 5) {
339
476
  ws.close(1013, 'Max connections reached');
340
477
  return;
341
478
  }
342
- const id = crypto.randomUUID();
343
479
  const remoteAddress = req.socket.remoteAddress || 'unknown';
480
+ let perIpCount = 0;
481
+ for (const [, c] of connections) {
482
+ if (c._remoteAddress === remoteAddress)
483
+ perIpCount++;
484
+ }
485
+ if (perIpCount >= 2) {
486
+ ws.close(1013, 'Max connections per IP reached');
487
+ return;
488
+ }
489
+ const id = crypto.randomUUID();
490
+ ws._remoteAddress = remoteAddress;
344
491
  connections.set(id, ws);
492
+ // F-10: WS ping/pong heartbeat
493
+ ws._isAlive = true;
494
+ ws.on('pong', () => { ws._isAlive = true; });
345
495
  // Replay history with secrets redacted (only if replay is enabled)
346
496
  if (hasReplay) {
347
497
  for (const event of acpEventLog) {
@@ -373,6 +523,18 @@ wss.on('connection', (ws, req) => {
373
523
  });
374
524
  ws.on('close', () => connections.delete(id));
375
525
  });
526
+ // F-10: WS heartbeat — ping every 30s, close unresponsive after 10s
527
+ setInterval(() => {
528
+ for (const [id, ws] of connections) {
529
+ if (ws._isAlive === false) {
530
+ ws.terminate();
531
+ connections.delete(id);
532
+ continue;
533
+ }
534
+ ws._isAlive = false;
535
+ ws.ping();
536
+ }
537
+ }, 30000);
376
538
  function broadcast(data) {
377
539
  const msg = JSON.stringify({ type: 'pty', data });
378
540
  if (hasReplay) {
@@ -401,7 +563,8 @@ async function main() {
401
563
  console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
402
564
  if (hubMode) {
403
565
  console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
404
- console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
566
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
567
+ console.log(` ${DIM}Local URL:${RESET} http://127.0.0.1:${actualPort}?token=${sessionToken}&hub=1\n`);
405
568
  }
406
569
  else {
407
570
  console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
@@ -421,35 +584,83 @@ async function main() {
421
584
  }
422
585
  catch {
423
586
  console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
424
- console.log(` ${BOLD}To enable remote access, install Microsoft Dev Tunnels:${RESET}\n`);
587
+ let installCmd = '';
425
588
  if (process.platform === 'win32') {
426
- console.log(` ${GREEN}winget install Microsoft.devtunnel${RESET}`);
589
+ installCmd = 'winget install Microsoft.devtunnel';
427
590
  }
428
591
  else if (process.platform === 'darwin') {
429
- console.log(` ${GREEN}brew install --cask devtunnel${RESET}`);
592
+ installCmd = 'brew install --cask devtunnel';
430
593
  }
431
594
  else {
432
- console.log(` ${GREEN}curl -sL https://aka.ms/DevTunnelCliInstall | bash${RESET}`);
595
+ installCmd = 'curl -sL https://aka.ms/DevTunnelCliInstall | bash';
596
+ }
597
+ const answer = await askUser(` Would you like to install it now? (${GREEN}${installCmd}${RESET}) [Y/n] `);
598
+ if (answer === '' || answer === 'y' || answer === 'yes') {
599
+ console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
600
+ try {
601
+ const installParts = installCmd.split(' ');
602
+ const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
603
+ await new Promise((resolve, reject) => {
604
+ installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
605
+ installProc.on('error', reject);
606
+ });
607
+ // Refresh PATH — winget updates the registry but current process has stale PATH
608
+ if (process.platform === 'win32') {
609
+ try {
610
+ const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
611
+ const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
612
+ 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() || '';
613
+ process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
614
+ }
615
+ catch { /* keep existing PATH */ }
616
+ }
617
+ // Verify installation
618
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
619
+ console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
620
+ devtunnelInstalled = true;
621
+ }
622
+ catch (err) {
623
+ console.log(`\n ${YELLOW}⚠${RESET} Installation failed: ${err.message}`);
624
+ console.log(` ${DIM}You can install it manually: ${installCmd}${RESET}\n`);
625
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
626
+ }
627
+ }
628
+ else {
629
+ console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
630
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
433
631
  }
434
- console.log(`\n Then authenticate once:\n`);
435
- console.log(` ${GREEN}devtunnel user login${RESET}\n`);
436
- console.log(` ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}\n`);
437
- console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
438
632
  }
439
- // Check if logged in
440
633
  if (devtunnelInstalled) {
634
+ // Check if logged in before attempting tunnel creation
441
635
  try {
442
636
  const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
443
- if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
637
+ if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
444
638
  throw new Error('not logged in');
445
639
  }
446
640
  }
447
641
  catch {
448
- console.log(`\n ${YELLOW}⚠ devtunnel not authenticated!${RESET}\n`);
449
- console.log(` Run this once to log in:\n`);
450
- console.log(` ${GREEN}devtunnel user login${RESET}\n`);
451
- console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
452
- devtunnelInstalled = false;
642
+ console.log(`\n ${YELLOW}⚠ devtunnel not authenticated.${RESET}\n`);
643
+ const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
644
+ if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
645
+ try {
646
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
647
+ await new Promise((resolve, reject) => {
648
+ loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
649
+ loginProc.on('error', reject);
650
+ });
651
+ console.log(`\n ${GREEN}✓${RESET} Logged in successfully!\n`);
652
+ }
653
+ catch {
654
+ console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
655
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
656
+ devtunnelInstalled = false;
657
+ }
658
+ }
659
+ else {
660
+ console.log(`\n ${DIM}Run this once to log in: ${GREEN}devtunnel user login${RESET}`);
661
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
662
+ devtunnelInstalled = false;
663
+ }
453
664
  }
454
665
  }
455
666
  if (devtunnelInstalled) {
@@ -474,25 +685,48 @@ async function main() {
474
685
  });
475
686
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
476
687
  });
477
- const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
688
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
478
689
  console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
690
+ // Write session file for hub discovery
691
+ writeSessionFile(tunnelId, url, actualPort);
479
692
  try {
480
693
  // @ts-ignore
481
694
  const qr = (await import('qrcode-terminal'));
482
695
  qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
483
696
  }
484
697
  catch { }
485
- process.on('SIGINT', () => { hostProc.kill(); try {
698
+ process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
486
699
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
487
700
  }
488
701
  catch { } });
489
- process.on('exit', () => { hostProc.kill(); try {
702
+ process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
490
703
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
491
704
  }
492
705
  catch { } });
493
706
  }
494
707
  catch (err) {
495
- console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
708
+ const errMsg = err.message || '';
709
+ // Detect auth failure at create time (expired token, anonymous, etc.)
710
+ if (errMsg.includes('Anonymous') || errMsg.includes('Unauthorized') || errMsg.includes('not permitted')) {
711
+ console.log(`\n ${YELLOW}⚠ devtunnel session expired or not authenticated.${RESET}\n`);
712
+ const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
713
+ if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
714
+ try {
715
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
716
+ await new Promise((resolve, reject) => {
717
+ loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
718
+ loginProc.on('error', reject);
719
+ });
720
+ console.log(`\n ${GREEN}✓${RESET} Logged in! Please run cli-tunnel again to create the tunnel.\n`);
721
+ }
722
+ catch {
723
+ console.log(`\n ${YELLOW}⚠${RESET} Login failed. Run manually: ${GREEN}devtunnel user login${RESET}\n`);
724
+ }
725
+ }
726
+ }
727
+ else {
728
+ console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${errMsg}\n`);
729
+ }
496
730
  }
497
731
  } // end if (devtunnelInstalled)
498
732
  }
@@ -527,27 +761,16 @@ async function main() {
527
761
  }
528
762
  catch { /* use as-is */ }
529
763
  }
530
- // F-07: Security — allowlist safe environment variables for PTY
531
- const SAFE_ENV_VARS = new Set([
532
- 'PATH', 'HOME', 'USERPROFILE', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
533
- 'USER', 'LOGNAME', 'EDITOR', 'VISUAL', 'COLORTERM', 'TERM_PROGRAM',
534
- 'HOSTNAME', 'COMPUTERNAME', 'PWD', 'OLDPWD', 'SHLVL', 'TMPDIR', 'TMP', 'TEMP',
535
- 'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME',
536
- 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS',
537
- 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'SYSTEMROOT', 'WINDIR', 'COMSPEC',
538
- 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA',
539
- 'NODE_ENV',
540
- 'GOPATH', 'GOROOT', 'CARGO_HOME', 'RUSTUP_HOME',
541
- 'JAVA_HOME', 'MAVEN_HOME', 'GRADLE_HOME',
542
- 'PYTHONPATH', 'VIRTUAL_ENV', 'CONDA_DEFAULT_ENV',
543
- 'KUBECONFIG', 'DOCKER_HOST', 'DOCKER_CONFIG',
544
- 'GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL',
545
- 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
546
- 'SSH_AUTH_SOCK', 'GPG_TTY',
547
- ]);
764
+ // F-07: Security — filter dangerous environment variables for PTY
765
+ // Blocklist approach: pass everything except known dangerous vars and secrets
766
+ const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS',
767
+ 'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION',
768
+ 'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES',
769
+ 'SSH_AUTH_SOCK', 'GPG_TTY']);
770
+ const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
548
771
  const safeEnv = {};
549
772
  for (const [k, v] of Object.entries(process.env)) {
550
- if (SAFE_ENV_VARS.has(k) && v !== undefined) {
773
+ if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
551
774
  safeEnv[k] = v;
552
775
  }
553
776
  }
@@ -556,6 +779,25 @@ async function main() {
556
779
  cols, rows, cwd,
557
780
  env: safeEnv,
558
781
  });
782
+ // Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
783
+ let ptyExitedEarly = false;
784
+ const earlyExitCheck = new Promise((resolve) => {
785
+ ptyProcess.onExit(({ exitCode }) => {
786
+ if (exitCode === 134 || exitCode === 3221226505) {
787
+ ptyExitedEarly = true;
788
+ resolve();
789
+ }
790
+ });
791
+ setTimeout(resolve, 2000);
792
+ });
793
+ await earlyExitCheck;
794
+ if (ptyExitedEarly) {
795
+ const nodeVer = process.version;
796
+ console.log(` ${YELLOW}⚠${RESET} The command crashed (CSPRNG assertion failure).`);
797
+ console.log(` This is a known issue with Node.js ${nodeVer} + PTY on Windows.`);
798
+ console.log(` ${BOLD}Fix:${RESET} Install Node.js 22 LTS: ${GREEN}nvm install 22${RESET} or ${GREEN}winget install OpenJS.NodeJS.LTS${RESET}\n`);
799
+ process.exit(1);
800
+ }
559
801
  ptyProcess.onData((data) => {
560
802
  process.stdout.write(data);
561
803
  broadcast(data);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.2.0-beta.1",
4
- "description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
3
+ "version": "1.2.0-beta.11",
4
+ "description": "Tunnel any CLI app to your phone PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "test": "vitest run"
16
+ "test": "vitest run",
17
+ "test:coverage": "vitest run --coverage"
17
18
  },
18
19
  "keywords": [
19
20
  "cli",
@@ -35,13 +36,14 @@
35
36
  "node": ">=22.0.0"
36
37
  },
37
38
  "dependencies": {
38
- "node-pty": "^1.1.0",
39
- "qrcode-terminal": "^0.12.0",
40
- "ws": "^8.19.0"
39
+ "node-pty": "1.1.0",
40
+ "qrcode-terminal": "0.12.0",
41
+ "ws": "8.19.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/node": "^25.3.2",
44
45
  "@types/ws": "^8.18.1",
46
+ "@vitest/coverage-v8": "^4.0.18",
45
47
  "typescript": "^5.9.3",
46
48
  "vitest": "^4.0.18"
47
49
  }
package/remote-ui/app.js CHANGED
@@ -45,7 +45,9 @@
45
45
  const permOverlay = $('#permission-overlay');
46
46
  const dashboard = $('#dashboard');
47
47
  const termContainer = $('#terminal-container');
48
- let currentView = 'terminal'; // 'dashboard' or 'terminal'
48
+ let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
49
+ let cachedSessions = [];
50
+ let gridTerminals = []; // { xterm, fitAddon, ws, session }
49
51
 
50
52
  // ─── xterm.js Terminal ───────────────────────────────────
51
53
  let xterm = null;
@@ -115,7 +117,9 @@
115
117
 
116
118
  async function loadSessions() {
117
119
  try {
118
- const resp = await fetch('/api/sessions');
120
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
121
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
122
+ const resp = await fetch('/api/sessions', { headers });
119
123
  const data = await resp.json();
120
124
  renderDashboard(data.sessions || []);
121
125
  } catch (err) {
@@ -127,41 +131,52 @@
127
131
  const filtered = showOffline ? sessions : sessions.filter(s => s.online);
128
132
  const offlineCount = sessions.filter(s => !s.online).length;
129
133
  const onlineCount = sessions.filter(s => s.online).length;
134
+ const connectable = filtered.filter(s => s.online && s.token);
130
135
 
131
136
  let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
132
137
  <span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
133
138
  <span style="flex:1"></span>
134
- <button onclick="toggleOffline()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
135
- ${offlineCount > 0 ? '<button onclick="cleanOffline()" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
136
- <button onclick="loadSessions()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
139
+ ${connectable.length > 1 ? '<button data-action="grid-view" style="background:none;border:1px solid var(--blue);color:var(--blue);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">⊞ Grid</button>' : ''}
140
+ <button data-action="toggle-offline" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
141
+ ${offlineCount > 0 ? '<button data-action="clean-offline" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
142
+ <button data-action="refresh" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
137
143
  </div>`;
138
144
 
139
145
  if (filtered.length === 0) {
140
146
  html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
141
- (sessions.length === 0 ? 'No Squad RC sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
147
+ (sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
142
148
  '</div>';
143
149
  } else {
144
- html += filtered.map(s => `
145
- <div class="session-card" ${s.online ? 'data-session-url="' + escapeHtml(s.url) + '"' : ''}>
150
+ html += filtered.map(s => {
151
+ const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
152
+ return `
153
+ <div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
146
154
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
147
155
  <div class="info">
156
+ <div class="session-name">${escapeHtml(s.name)}</div>
148
157
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
149
158
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
150
- <div class="machine">💻 ${escapeHtml(s.machine)}</div>
159
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
151
160
  </div>
152
- ${s.online ? '<span class="arrow">→</span>' :
153
- '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
154
- </div>
155
- `).join('');
161
+ ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
162
+ !s.online ? '<button data-delete-id="' + escapeHtml(s.id) + '" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'
163
+ : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
164
+ </div>`;
165
+ }).join('');
156
166
  }
157
167
  dashboard.innerHTML = html;
158
- // #16: XSS fix — use event delegation instead of inline onclick
168
+ cachedSessions = sessions;
169
+ // Event delegation
159
170
  dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
160
171
  card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
161
172
  });
162
173
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
163
174
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
164
175
  });
176
+ dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
177
+ dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
178
+ dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
179
+ dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
165
180
  }
166
181
 
167
182
  window.openSession = (url) => {
@@ -174,21 +189,181 @@
174
189
  };
175
190
 
176
191
  window.cleanOffline = async () => {
177
- const resp = await fetch('/api/sessions');
192
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
193
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
194
+ const resp = await fetch('/api/sessions', { headers });
178
195
  const data = await resp.json();
179
196
  const offline = (data.sessions || []).filter(s => !s.online);
180
197
  for (const s of offline) {
181
- await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
198
+ await fetch('/api/sessions/' + s.id, { method: 'DELETE', headers });
182
199
  }
183
200
  loadSessions();
184
201
  };
185
202
 
186
203
  window.deleteSession = async (id) => {
187
- await fetch('/api/sessions/' + id, { method: 'DELETE' });
204
+ const tokenParam = new URLSearchParams(window.location.search).get('token');
205
+ const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
206
+ await fetch('/api/sessions/' + id, { method: 'DELETE', headers });
188
207
  loadSessions();
189
208
  };
190
209
 
210
+ // ─── Grid View (tmux-style multi-terminal) ────────────────
211
+ function showGridView(sessions) {
212
+ const connectable = sessions.filter(function(s) { return s.online && s.token; });
213
+ if (connectable.length === 0) return;
214
+
215
+ // Clean up previous grid
216
+ destroyGrid();
217
+
218
+ currentView = 'grid';
219
+ dashboard.classList.add('hidden');
220
+ terminal.classList.add('hidden');
221
+ termContainer.classList.add('hidden');
222
+ $('#input-area').classList.add('hidden');
223
+
224
+ var gridEl = document.getElementById('grid-view');
225
+ if (!gridEl) {
226
+ gridEl = document.createElement('div');
227
+ gridEl.id = 'grid-view';
228
+ document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
229
+ }
230
+ gridEl.classList.remove('hidden');
231
+ gridEl.innerHTML = '';
232
+
233
+ // Calculate grid dimensions
234
+ var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
235
+ gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
236
+
237
+ connectable.forEach(function(s) {
238
+ var panel = document.createElement('div');
239
+ panel.className = 'grid-panel';
240
+
241
+ // Header
242
+ var header = document.createElement('div');
243
+ header.className = 'grid-panel-header';
244
+ header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
245
+ '<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
246
+ '<span class="grid-panel-status">●</span>';
247
+ panel.appendChild(header);
248
+
249
+ // Terminal container
250
+ var termDiv = document.createElement('div');
251
+ termDiv.className = 'grid-panel-terminal';
252
+ panel.appendChild(termDiv);
253
+
254
+ gridEl.appendChild(panel);
255
+
256
+ // Create xterm instance for this panel
257
+ var panelXterm = new Terminal({
258
+ theme: {
259
+ background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
260
+ selectionBackground: '#264f78',
261
+ },
262
+ fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
263
+ fontSize: 11,
264
+ scrollback: 1000,
265
+ cursorBlink: true,
266
+ });
267
+ var panelFit = new FitAddon.FitAddon();
268
+ panelXterm.loadAddon(panelFit);
269
+ panelXterm.open(termDiv);
270
+
271
+ // Delay fit to ensure container has size
272
+ setTimeout(function() { panelFit.fit(); }, 100);
273
+
274
+ // Connect WebSocket to this session
275
+ var statusDot = header.querySelector('.grid-panel-status');
276
+ var panelWs = null;
277
+
278
+ (function connectPanel() {
279
+ // Use hub's proxy endpoint to get a ticket for the session
280
+ var tokenParam = new URLSearchParams(window.location.search).get('token');
281
+ var proxyUrl = '/api/proxy/ticket/' + s.port;
282
+ var wsBase = s.isLocal ? 'ws://127.0.0.1:' + s.port : s.url.replace('https://', 'wss://');
283
+
284
+ fetch(proxyUrl, {
285
+ method: 'POST',
286
+ headers: { 'Authorization': 'Bearer ' + tokenParam }
287
+ }).then(function(resp) {
288
+ if (!resp.ok) throw new Error('Auth failed');
289
+ return resp.json();
290
+ }).then(function(data) {
291
+ panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
292
+
293
+ panelWs.onopen = function() {
294
+ if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
295
+ panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
296
+ };
297
+ panelWs.onclose = function() {
298
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
299
+ };
300
+ panelWs.onerror = function() {
301
+ if (statusDot) { statusDot.style.color = 'var(--red)'; }
302
+ };
303
+ panelWs.onmessage = function(e) {
304
+ try {
305
+ var msg = JSON.parse(e.data);
306
+ if (msg.type === 'pty') {
307
+ panelXterm.write(msg.data);
308
+ }
309
+ } catch (err) {}
310
+ };
311
+
312
+ panelXterm.onData(function(data) {
313
+ if (panelWs && panelWs.readyState === WebSocket.OPEN) {
314
+ panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
315
+ }
316
+ });
317
+
318
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
319
+ }).catch(function() {
320
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
321
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
322
+ });
323
+ })();
324
+
325
+ // Click header to go full-screen on this session
326
+ header.addEventListener('click', function() {
327
+ window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
328
+ });
329
+
330
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
331
+ });
332
+
333
+ // Handle window resize for grid panels
334
+ window.addEventListener('resize', fitGridPanels);
335
+
336
+ // Add back button
337
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
338
+ }
339
+
340
+ function fitGridPanels() {
341
+ gridTerminals.forEach(function(gt) {
342
+ if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
343
+ });
344
+ }
345
+
346
+ function destroyGrid() {
347
+ gridTerminals.forEach(function(gt) {
348
+ if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
349
+ if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
350
+ });
351
+ gridTerminals = [];
352
+ window.removeEventListener('resize', fitGridPanels);
353
+ var gridEl = document.getElementById('grid-view');
354
+ if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
355
+ }
356
+
191
357
  window.toggleView = () => {
358
+ if (currentView === 'grid') {
359
+ // Grid → dashboard (list view)
360
+ destroyGrid();
361
+ currentView = 'dashboard';
362
+ dashboard.classList.remove('hidden');
363
+ $('#btn-sessions').textContent = 'Terminal';
364
+ loadSessions();
365
+ return;
366
+ }
192
367
  if (currentView === 'terminal') {
193
368
  currentView = 'dashboard';
194
369
  terminal.classList.add('hidden');
@@ -198,6 +373,7 @@
198
373
  $('#btn-sessions').textContent = 'Terminal';
199
374
  loadSessions();
200
375
  } else {
376
+ destroyGrid();
201
377
  currentView = 'terminal';
202
378
  dashboard.classList.add('hidden');
203
379
  $('#input-area').classList.remove('hidden');
@@ -367,7 +543,7 @@
367
543
  }
368
544
 
369
545
  // ─── Detect hub mode (no token in URL) ────────────────────
370
- const isHubMode = !new URLSearchParams(window.location.search).get('token');
546
+ const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
371
547
 
372
548
  // ─── WebSocket ───────────────────────────────────────────
373
549
  let reconnectAttempt = 0;
@@ -392,7 +568,7 @@
392
568
 
393
569
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
394
570
 
395
- // F-02: Try ticket-based auth first
571
+ // F-02: Ticket-based auth (required)
396
572
  try {
397
573
  const resp = await fetch('/api/auth/ticket', {
398
574
  method: 'POST',
@@ -402,12 +578,12 @@
402
578
  const { ticket } = await resp.json();
403
579
  ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
404
580
  } else {
405
- // Fallback to token-in-URL (backward compat)
406
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
581
+ setStatus('offline', 'Auth failed');
582
+ return;
407
583
  }
408
584
  } catch {
409
- // Fallback to token-in-URL
410
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
585
+ setStatus('offline', 'Auth failed');
586
+ return;
411
587
  }
412
588
  setStatus('connecting', 'Connecting...');
413
589
 
@@ -562,6 +738,25 @@
562
738
  };
563
739
 
564
740
  // ─── Mobile Key Bar ───────────────────────────────────────
741
+ // F-5: Event delegation for key-bar buttons (no inline onclick)
742
+ const keyBar = document.getElementById('key-bar');
743
+ if (keyBar) {
744
+ var keyMap = {
745
+ '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
746
+ '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
747
+ };
748
+ keyBar.addEventListener('click', function(e) {
749
+ var btn = e.target;
750
+ if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
751
+ var key = keyMap[btn.dataset.key] || btn.dataset.key;
752
+ if (ws && ws.readyState === WebSocket.OPEN) {
753
+ ws.send(JSON.stringify({ type: 'pty_input', data: key }));
754
+ }
755
+ if (xterm) xterm.focus();
756
+ }
757
+ });
758
+ }
759
+
565
760
  window.sendKey = (key) => {
566
761
  if (ws && ws.readyState === WebSocket.OPEN) {
567
762
  ws.send(JSON.stringify({ type: 'pty_input', data: key }));
@@ -32,16 +32,16 @@
32
32
 
33
33
  <footer id="input-area">
34
34
  <div id="key-bar">
35
- <button onclick="sendKey('\x1b[A')">↑</button>
36
- <button onclick="sendKey('\x1b[B')">↓</button>
37
- <button onclick="sendKey('\x1b[C')">→</button>
38
- <button onclick="sendKey('\x1b[D')">←</button>
39
- <button onclick="sendKey('\t')">Tab</button>
40
- <button onclick="sendKey('\r')">Enter</button>
41
- <button onclick="sendKey('\x1b')">Esc</button>
42
- <button onclick="sendKey('\x03')">Ctrl+C</button>
43
- <button onclick="sendKey(' ')">Space</button>
44
- <button onclick="sendKey('\x7f')">⌫</button>
35
+ <button data-key="\x1b[A">↑</button>
36
+ <button data-key="\x1b[B">↓</button>
37
+ <button data-key="\x1b[C">→</button>
38
+ <button data-key="\x1b[D">←</button>
39
+ <button data-key="\t">Tab</button>
40
+ <button data-key="\r">Enter</button>
41
+ <button data-key="\x1b">Esc</button>
42
+ <button data-key="\x03">Ctrl+C</button>
43
+ <button data-key=" ">Space</button>
44
+ <button data-key="\x7f">⌫</button>
45
45
  </div>
46
46
  <form id="input-form">
47
47
  <span class="prompt">&gt;</span>
@@ -242,10 +242,51 @@ header {
242
242
  .session-card .status-dot.offline { background: var(--text-dim); }
243
243
  .session-card .info { flex: 1; min-width: 0; }
244
244
  .session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; }
245
+ .session-card .session-name { color: var(--text-bright); font-weight: bold; font-size: 14px; }
245
246
  .session-card .branch { color: var(--text-dim); font-size: 11px; }
246
247
  .session-card .machine { color: var(--text-dim); font-size: 11px; }
247
248
  .session-card .arrow { color: var(--text-dim); }
248
249
 
250
+ /* Grid View (tmux-style multi-terminal) */
251
+ #grid-view {
252
+ flex: 1;
253
+ display: grid;
254
+ gap: 2px;
255
+ padding: 2px;
256
+ overflow: hidden;
257
+ background: var(--border);
258
+ }
259
+ .grid-panel {
260
+ display: flex;
261
+ flex-direction: column;
262
+ background: var(--bg);
263
+ overflow: hidden;
264
+ min-height: 0;
265
+ }
266
+ .grid-panel-header {
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 6px;
270
+ padding: 3px 8px;
271
+ background: var(--bg-tool);
272
+ border-bottom: 1px solid var(--border);
273
+ flex-shrink: 0;
274
+ cursor: pointer;
275
+ font-size: 11px;
276
+ }
277
+ .grid-panel-header:hover { background: var(--border); }
278
+ .grid-panel-name { color: var(--blue); font-weight: bold; }
279
+ .grid-panel-machine { color: var(--text-dim); flex: 1; }
280
+ .grid-panel-status { font-size: 8px; color: var(--yellow); }
281
+ .grid-panel-terminal {
282
+ flex: 1;
283
+ overflow: hidden;
284
+ }
285
+ .grid-panel-terminal .xterm {
286
+ height: 100%;
287
+ padding: 2px;
288
+ }
289
+
249
290
  /* Scrollbar */
250
291
  ::-webkit-scrollbar { width: 6px; }
251
292
  ::-webkit-scrollbar-track { background: transparent; }