cli-tunnel 1.2.0-beta.1 → 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 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);
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);
153
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
+ }
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) {
@@ -182,26 +275,37 @@ const server = http.createServer((req, res) => {
182
275
  }
183
276
  }
184
277
  // Sessions API
185
- if (req.url === '/api/sessions' && req.method === 'GET') {
278
+ if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
186
279
  try {
187
280
  const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
188
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]));
189
285
  const sessions = (data.tunnels || []).map((t) => {
190
286
  const labels = t.labels || [];
191
287
  const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
192
288
  const cluster = t.tunnelId?.split('.').pop() || 'euw';
193
289
  const portLabel = labels.find((l) => l.startsWith('port-'));
194
290
  const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
195
- return {
291
+ const machine = labels[4] || 'unknown';
292
+ const session = {
196
293
  id, tunnelId: t.tunnelId,
197
294
  name: labels[1] || 'unnamed',
198
295
  repo: labels[2] || 'unknown',
199
296
  branch: (labels[3] || 'unknown').replace(/_/g, '/'),
200
- machine: labels[4] || 'unknown',
297
+ machine,
201
298
  online: (t.hostConnections || 0) > 0,
202
299
  port: p,
203
300
  url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
301
+ isLocal: machine === localMachine,
204
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;
205
309
  });
206
310
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
207
311
  res.end(JSON.stringify({ sessions }));
@@ -236,7 +340,9 @@ const server = http.createServer((req, res) => {
236
340
  // #18: Guard against malformed URI encoding
237
341
  let decodedUrl;
238
342
  try {
239
- decodedUrl = decodeURIComponent(req.url || '/');
343
+ // Strip query string before resolving file path
344
+ const urlPath = (req.url || '/').split('?')[0];
345
+ decodedUrl = decodeURIComponent(urlPath);
240
346
  }
241
347
  catch {
242
348
  res.writeHead(400);
@@ -277,9 +383,10 @@ const server = http.createServer((req, res) => {
277
383
  'Content-Type': mimes[ext] || 'application/octet-stream',
278
384
  'X-Frame-Options': 'DENY',
279
385
  '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;",
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;",
281
387
  'Referrer-Policy': 'no-referrer',
282
388
  'Cache-Control': 'no-store',
389
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
283
390
  };
284
391
  res.writeHead(200, securityHeaders);
285
392
  // #8: Handle createReadStream errors
@@ -298,19 +405,7 @@ const wss = new WebSocketServer({
298
405
  // F-18: Session expiry
299
406
  if (Date.now() - sessionCreatedAt > SESSION_TTL)
300
407
  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
408
+ // F-3: Validate origin BEFORE ticket acceptance
314
409
  const origin = info.req.headers.origin;
315
410
  if (origin) {
316
411
  try {
@@ -324,7 +419,15 @@ const wss = new WebSocketServer({
324
419
  return false;
325
420
  }
326
421
  }
327
- return true;
422
+ const url = new URL(info.req.url, `http://${info.req.headers.host}`);
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;
328
431
  },
329
432
  });
330
433
  // ─── Security: Audit log for remote PTY input ──────────────
@@ -334,14 +437,27 @@ const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice
334
437
  const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
335
438
  auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
336
439
  wss.on('connection', (ws, req) => {
337
- // F-10: Connection cap
440
+ // F-10: Connection cap (global + per-IP)
338
441
  if (connections.size >= 5) {
339
442
  ws.close(1013, 'Max connections reached');
340
443
  return;
341
444
  }
342
- const id = crypto.randomUUID();
343
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;
344
457
  connections.set(id, ws);
458
+ // F-10: WS ping/pong heartbeat
459
+ ws._isAlive = true;
460
+ ws.on('pong', () => { ws._isAlive = true; });
345
461
  // Replay history with secrets redacted (only if replay is enabled)
346
462
  if (hasReplay) {
347
463
  for (const event of acpEventLog) {
@@ -373,6 +489,18 @@ wss.on('connection', (ws, req) => {
373
489
  });
374
490
  ws.on('close', () => connections.delete(id));
375
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);
376
504
  function broadcast(data) {
377
505
  const msg = JSON.stringify({ type: 'pty', data });
378
506
  if (hasReplay) {
@@ -401,7 +529,8 @@ async function main() {
401
529
  console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.1.0${RESET}\n`);
402
530
  if (hubMode) {
403
531
  console.log(` ${BOLD}📋 Hub Mode${RESET} — sessions dashboard`);
404
- console.log(` ${DIM}Port:${RESET} ${actualPort}\n`);
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`);
405
534
  }
406
535
  else {
407
536
  console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
@@ -421,35 +550,83 @@ async function main() {
421
550
  }
422
551
  catch {
423
552
  console.log(`\n ${YELLOW}⚠ devtunnel CLI not found!${RESET}\n`);
424
- console.log(` ${BOLD}To enable remote access, install Microsoft Dev Tunnels:${RESET}\n`);
553
+ let installCmd = '';
425
554
  if (process.platform === 'win32') {
426
- console.log(` ${GREEN}winget install Microsoft.devtunnel${RESET}`);
555
+ installCmd = 'winget install Microsoft.devtunnel';
427
556
  }
428
557
  else if (process.platform === 'darwin') {
429
- console.log(` ${GREEN}brew install --cask devtunnel${RESET}`);
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
+ }
430
593
  }
431
594
  else {
432
- console.log(` ${GREEN}curl -sL https://aka.ms/DevTunnelCliInstall | bash${RESET}`);
595
+ console.log(`\n ${DIM}More info: https://aka.ms/devtunnels/doc${RESET}`);
596
+ console.log(` ${DIM}Continuing without tunnel (local only)...${RESET}\n`);
433
597
  }
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
598
  }
439
- // Check if logged in
440
599
  if (devtunnelInstalled) {
600
+ // Check if logged in before attempting tunnel creation
441
601
  try {
442
602
  const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
443
- if (userInfo.includes('not logged in') || userInfo.includes('No user')) {
603
+ if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
444
604
  throw new Error('not logged in');
445
605
  }
446
606
  }
447
607
  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;
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
+ }
453
630
  }
454
631
  }
455
632
  if (devtunnelInstalled) {
@@ -474,25 +651,48 @@ async function main() {
474
651
  });
475
652
  hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
476
653
  });
477
- const tunnelUrlWithToken = `${url}?token=${sessionToken}`;
654
+ const tunnelUrlWithToken = `${url}?token=${sessionToken}${hubMode ? '&hub=1' : ''}`;
478
655
  console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${tunnelUrlWithToken}${RESET}\n`);
656
+ // Write session file for hub discovery
657
+ writeSessionFile(tunnelId, url, actualPort);
479
658
  try {
480
659
  // @ts-ignore
481
660
  const qr = (await import('qrcode-terminal'));
482
661
  qr.default.generate(tunnelUrlWithToken, { small: true }, (code) => console.log(code));
483
662
  }
484
663
  catch { }
485
- process.on('SIGINT', () => { hostProc.kill(); try {
664
+ process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
486
665
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
487
666
  }
488
667
  catch { } });
489
- process.on('exit', () => { hostProc.kill(); try {
668
+ process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
490
669
  execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
491
670
  }
492
671
  catch { } });
493
672
  }
494
673
  catch (err) {
495
- console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
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
+ }
496
696
  }
497
697
  } // end if (devtunnelInstalled)
498
698
  }
@@ -527,27 +727,16 @@ async function main() {
527
727
  }
528
728
  catch { /* use as-is */ }
529
729
  }
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
- ]);
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;
548
737
  const safeEnv = {};
549
738
  for (const [k, v] of Object.entries(process.env)) {
550
- if (SAFE_ENV_VARS.has(k) && v !== undefined) {
739
+ if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
551
740
  safeEnv[k] = v;
552
741
  }
553
742
  }
@@ -556,6 +745,25 @@ async function main() {
556
745
  cols, rows, cwd,
557
746
  env: safeEnv,
558
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
+ }
559
767
  ptyProcess.onData((data) => {
560
768
  process.stdout.write(data);
561
769
  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.10",
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;
@@ -127,41 +129,52 @@
127
129
  const filtered = showOffline ? sessions : sessions.filter(s => s.online);
128
130
  const offlineCount = sessions.filter(s => !s.online).length;
129
131
  const onlineCount = sessions.filter(s => s.online).length;
132
+ const connectable = filtered.filter(s => s.online && s.token);
130
133
 
131
134
  let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
132
135
  <span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
133
136
  <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>
137
+ ${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>' : ''}
138
+ <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>
139
+ ${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>' : ''}
140
+ <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
141
  </div>`;
138
142
 
139
143
  if (filtered.length === 0) {
140
144
  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.') +
145
+ (sessions.length === 0 ? 'No cli-tunnel sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
142
146
  '</div>';
143
147
  } else {
144
- html += filtered.map(s => `
145
- <div class="session-card" ${s.online ? 'data-session-url="' + escapeHtml(s.url) + '"' : ''}>
148
+ html += filtered.map(s => {
149
+ const sessionUrl = s.token ? s.url + '?token=' + encodeURIComponent(s.token) : '';
150
+ return `
151
+ <div class="session-card" ${s.online && sessionUrl ? 'data-session-url="' + escapeHtml(sessionUrl) + '"' : ''}>
146
152
  <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
147
153
  <div class="info">
154
+ <div class="session-name">${escapeHtml(s.name)}</div>
148
155
  <div class="repo">📦 ${escapeHtml(s.repo)}</div>
149
156
  <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
150
- <div class="machine">💻 ${escapeHtml(s.machine)}</div>
157
+ <div class="machine">💻 ${escapeHtml(s.machine)}${!s.token && s.online ? ' 🔒' : ''}</div>
151
158
  </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('');
159
+ ${s.online && sessionUrl ? '<span class="arrow">→</span>' :
160
+ !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>'
161
+ : '<span style="color:var(--text-dim);font-size:11px">remote</span>'}
162
+ </div>`;
163
+ }).join('');
156
164
  }
157
165
  dashboard.innerHTML = html;
158
- // #16: XSS fix — use event delegation instead of inline onclick
166
+ cachedSessions = sessions;
167
+ // Event delegation
159
168
  dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) {
160
169
  card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); });
161
170
  });
162
171
  dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) {
163
172
  btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); });
164
173
  });
174
+ dashboard.querySelector('[data-action="toggle-offline"]')?.addEventListener('click', function() { toggleOffline(); });
175
+ dashboard.querySelector('[data-action="clean-offline"]')?.addEventListener('click', function() { cleanOffline(); });
176
+ dashboard.querySelector('[data-action="refresh"]')?.addEventListener('click', function() { loadSessions(); });
177
+ dashboard.querySelector('[data-action="grid-view"]')?.addEventListener('click', function() { showGridView(sessions); });
165
178
  }
166
179
 
167
180
  window.openSession = (url) => {
@@ -188,7 +201,159 @@
188
201
  loadSessions();
189
202
  };
190
203
 
204
+ // ─── Grid View (tmux-style multi-terminal) ────────────────
205
+ function showGridView(sessions) {
206
+ const connectable = sessions.filter(function(s) { return s.online && s.token; });
207
+ if (connectable.length === 0) return;
208
+
209
+ // Clean up previous grid
210
+ destroyGrid();
211
+
212
+ currentView = 'grid';
213
+ dashboard.classList.add('hidden');
214
+ terminal.classList.add('hidden');
215
+ termContainer.classList.add('hidden');
216
+ $('#input-area').classList.add('hidden');
217
+
218
+ var gridEl = document.getElementById('grid-view');
219
+ if (!gridEl) {
220
+ gridEl = document.createElement('div');
221
+ gridEl.id = 'grid-view';
222
+ document.getElementById('app').insertBefore(gridEl, document.getElementById('input-area'));
223
+ }
224
+ gridEl.classList.remove('hidden');
225
+ gridEl.innerHTML = '';
226
+
227
+ // Calculate grid dimensions
228
+ var cols = connectable.length <= 2 ? connectable.length : connectable.length <= 4 ? 2 : 3;
229
+ gridEl.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
230
+
231
+ connectable.forEach(function(s) {
232
+ var panel = document.createElement('div');
233
+ panel.className = 'grid-panel';
234
+
235
+ // Header
236
+ var header = document.createElement('div');
237
+ header.className = 'grid-panel-header';
238
+ header.innerHTML = '<span class="grid-panel-name">' + escapeHtml(s.name) + '</span>' +
239
+ '<span class="grid-panel-machine">' + escapeHtml(s.machine) + '</span>' +
240
+ '<span class="grid-panel-status">●</span>';
241
+ panel.appendChild(header);
242
+
243
+ // Terminal container
244
+ var termDiv = document.createElement('div');
245
+ termDiv.className = 'grid-panel-terminal';
246
+ panel.appendChild(termDiv);
247
+
248
+ gridEl.appendChild(panel);
249
+
250
+ // Create xterm instance for this panel
251
+ var panelXterm = new Terminal({
252
+ theme: {
253
+ background: '#0d1117', foreground: '#c9d1d9', cursor: '#3fb950',
254
+ selectionBackground: '#264f78',
255
+ },
256
+ fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
257
+ fontSize: 11,
258
+ scrollback: 1000,
259
+ cursorBlink: true,
260
+ });
261
+ var panelFit = new FitAddon.FitAddon();
262
+ panelXterm.loadAddon(panelFit);
263
+ panelXterm.open(termDiv);
264
+
265
+ // Delay fit to ensure container has size
266
+ setTimeout(function() { panelFit.fit(); }, 100);
267
+
268
+ // Connect WebSocket to this session via ticket auth
269
+ var statusDot = header.querySelector('.grid-panel-status');
270
+ var panelWs = null;
271
+
272
+ (function connectPanel() {
273
+ var sessionOrigin = new URL(s.url).origin;
274
+ fetch(sessionOrigin + '/api/auth/ticket', {
275
+ method: 'POST',
276
+ headers: { 'Authorization': 'Bearer ' + s.token }
277
+ }).then(function(resp) {
278
+ if (!resp.ok) throw new Error('Auth failed');
279
+ return resp.json();
280
+ }).then(function(data) {
281
+ panelWs = new WebSocket(sessionOrigin.replace('https://', 'wss://') + '?ticket=' + encodeURIComponent(data.ticket));
282
+
283
+ panelWs.onopen = function() {
284
+ if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
285
+ panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
286
+ };
287
+ panelWs.onclose = function() {
288
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
289
+ };
290
+ panelWs.onerror = function() {
291
+ if (statusDot) { statusDot.style.color = 'var(--red)'; }
292
+ };
293
+ panelWs.onmessage = function(e) {
294
+ try {
295
+ var msg = JSON.parse(e.data);
296
+ if (msg.type === 'pty') {
297
+ panelXterm.write(msg.data);
298
+ }
299
+ } catch (err) {}
300
+ };
301
+
302
+ panelXterm.onData(function(data) {
303
+ if (panelWs && panelWs.readyState === WebSocket.OPEN) {
304
+ panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
305
+ }
306
+ });
307
+
308
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
309
+ }).catch(function() {
310
+ if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
311
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: null, session: s });
312
+ });
313
+ })();
314
+
315
+ // Click header to go full-screen on this session
316
+ header.addEventListener('click', function() {
317
+ window.location.href = s.url + '?token=' + encodeURIComponent(s.token);
318
+ });
319
+
320
+ gridTerminals.push({ xterm: panelXterm, fitAddon: panelFit, ws: panelWs, session: s });
321
+ });
322
+
323
+ // Handle window resize for grid panels
324
+ window.addEventListener('resize', fitGridPanels);
325
+
326
+ // Add back button
327
+ if ($('#btn-sessions')) { $('#btn-sessions').textContent = 'List'; }
328
+ }
329
+
330
+ function fitGridPanels() {
331
+ gridTerminals.forEach(function(gt) {
332
+ if (gt.fitAddon) { try { gt.fitAddon.fit(); } catch(e) {} }
333
+ });
334
+ }
335
+
336
+ function destroyGrid() {
337
+ gridTerminals.forEach(function(gt) {
338
+ if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
339
+ if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
340
+ });
341
+ gridTerminals = [];
342
+ window.removeEventListener('resize', fitGridPanels);
343
+ var gridEl = document.getElementById('grid-view');
344
+ if (gridEl) { gridEl.innerHTML = ''; gridEl.classList.add('hidden'); }
345
+ }
346
+
191
347
  window.toggleView = () => {
348
+ if (currentView === 'grid') {
349
+ // Grid → dashboard (list view)
350
+ destroyGrid();
351
+ currentView = 'dashboard';
352
+ dashboard.classList.remove('hidden');
353
+ $('#btn-sessions').textContent = 'Terminal';
354
+ loadSessions();
355
+ return;
356
+ }
192
357
  if (currentView === 'terminal') {
193
358
  currentView = 'dashboard';
194
359
  terminal.classList.add('hidden');
@@ -198,6 +363,7 @@
198
363
  $('#btn-sessions').textContent = 'Terminal';
199
364
  loadSessions();
200
365
  } else {
366
+ destroyGrid();
201
367
  currentView = 'terminal';
202
368
  dashboard.classList.add('hidden');
203
369
  $('#input-area').classList.remove('hidden');
@@ -367,7 +533,7 @@
367
533
  }
368
534
 
369
535
  // ─── Detect hub mode (no token in URL) ────────────────────
370
- const isHubMode = !new URLSearchParams(window.location.search).get('token');
536
+ const isHubMode = new URLSearchParams(window.location.search).get('hub') === '1';
371
537
 
372
538
  // ─── WebSocket ───────────────────────────────────────────
373
539
  let reconnectAttempt = 0;
@@ -392,7 +558,7 @@
392
558
 
393
559
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
394
560
 
395
- // F-02: Try ticket-based auth first
561
+ // F-02: Ticket-based auth (required)
396
562
  try {
397
563
  const resp = await fetch('/api/auth/ticket', {
398
564
  method: 'POST',
@@ -402,12 +568,12 @@
402
568
  const { ticket } = await resp.json();
403
569
  ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
404
570
  } else {
405
- // Fallback to token-in-URL (backward compat)
406
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
571
+ setStatus('offline', 'Auth failed');
572
+ return;
407
573
  }
408
574
  } catch {
409
- // Fallback to token-in-URL
410
- ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`);
575
+ setStatus('offline', 'Auth failed');
576
+ return;
411
577
  }
412
578
  setStatus('connecting', 'Connecting...');
413
579
 
@@ -562,6 +728,25 @@
562
728
  };
563
729
 
564
730
  // ─── Mobile Key Bar ───────────────────────────────────────
731
+ // F-5: Event delegation for key-bar buttons (no inline onclick)
732
+ const keyBar = document.getElementById('key-bar');
733
+ if (keyBar) {
734
+ const keyMap: Record<string, string> = {
735
+ '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D',
736
+ '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f',
737
+ };
738
+ keyBar.addEventListener('click', function(e) {
739
+ var btn = e.target;
740
+ if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) {
741
+ var key = keyMap[btn.dataset.key] || btn.dataset.key;
742
+ if (ws && ws.readyState === WebSocket.OPEN) {
743
+ ws.send(JSON.stringify({ type: 'pty_input', data: key }));
744
+ }
745
+ if (xterm) xterm.focus();
746
+ }
747
+ });
748
+ }
749
+
565
750
  window.sendKey = (key) => {
566
751
  if (ws && ws.readyState === WebSocket.OPEN) {
567
752
  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; }