cli-tunnel 1.3.1-beta.7 → 1.3.1-beta.9

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
@@ -69,7 +69,7 @@ cli-tunnel --port 4000 copilot
69
69
  cli-tunnel --local copilot --yolo
70
70
  ```
71
71
 
72
- **cli-tunnel's own flags** (`--local`, `--port`, `--name`, `--replay`) must come **before** the command.
72
+ **cli-tunnel's own flags** (`--local`, `--port`, `--name`, `--no-wait`) must come **before** the command.
73
73
 
74
74
  ## Hub Mode — Sessions Dashboard
75
75
 
@@ -109,7 +109,7 @@ Single terminal with key bar for mobile input. "← Grid" button to go back.
109
109
 
110
110
  ![Fullscreen view](docs/images/grid-fullscreen.png)
111
111
 
112
- All modes share the same WebSocket connections — switching is instant, no reconnection needed.
112
+ All modes share the same WebSocket connectionthe hub relays PTY data from each session, so switching layouts is instant with no reconnection.
113
113
 
114
114
  ## What You See on Your Phone
115
115
 
@@ -177,7 +177,7 @@ cli-tunnel uses a single PTY shared between your local terminal and all remote v
177
177
  Yes, up to 5 devices simultaneously (2 per IP). All viewers see the same terminal output in real time. Input from any device goes to the same CLI session.
178
178
 
179
179
  **What happens if my phone disconnects?**
180
- 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.
180
+ The CLI session keeps running on your machine. When you reconnect, a rolling replay buffer (up to 256 KB) sends the current terminal state so you see the prompt and recent output immediately — no manual refresh needed.
181
181
 
182
182
  **Does cli-tunnel work with any CLI app?**
183
183
  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.
@@ -195,10 +195,10 @@ Yes. Use `--local` to skip tunnel creation. The terminal is available at `http:/
195
195
  Run `cli-tunnel` with no command to start hub mode — a sessions dashboard that shows all active cli-tunnel sessions. Tap any session to connect, or use Grid view to monitor all sessions simultaneously.
196
196
 
197
197
  **How does recording work?**
198
- The record button captures the xterm.js canvas at 30fps using the browser's MediaRecorder API. The recording is a `.webm` video file that downloads to your device when you stop. It records exactly what you see on screen — perfect for demos and presentations. Max duration is 10 minutes.
198
+ Recording via canvas capture is not currently supported due to xterm.js WebGL renderer limitations. This feature may return in a future release.
199
199
 
200
200
  **How does the Grid view connect to sessions?**
201
- The hub reads session tokens from `~/.cli-tunnel/sessions/` (files with owner-only permissions). It proxies ticket requests to each session's local port no tokens are exposed to the browser client.
201
+ The hub acts as a relay — your phone has a single WebSocket to the hub, and the hub opens local connections to each session on your behalf. Session tokens are read from `~/.cli-tunnel/sessions/` (owner-only permissions) and never exposed to the browser. This means grid panels work even from a phone over devtunnel without needing individual tunnel connections to each session.
202
202
 
203
203
  ## How It's Built
204
204
 
package/dist/index.js CHANGED
@@ -113,20 +113,21 @@ function sanitizeLabel(l) {
113
113
  }
114
114
  // F-07: Minimal env for subprocess calls (git, devtunnel) — only PATH and essentials
115
115
  function getSubprocessEnv() {
116
- const env = {};
117
- const SUBPROCESS_ALLOWLIST = new Set(['PATH', 'PATHEXT', 'SYSTEMROOT', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'LANG', 'LC_ALL', 'TERM', 'COMSPEC', 'SHELL']);
118
- for (const [k, v] of Object.entries(process.env)) {
119
- if (v !== undefined && SUBPROCESS_ALLOWLIST.has(k)) {
120
- env[k] = v;
121
- }
122
- }
123
- return env;
116
+ const safe = {};
117
+ const allow = ['PATH', 'PATHEXT', 'HOME', 'USERPROFILE', 'TEMP', 'TMP', 'TMPDIR', 'SHELL', 'COMSPEC',
118
+ 'SYSTEMROOT', 'WINDIR', 'PROGRAMFILES', 'PROGRAMFILES(X86)', 'APPDATA', 'LOCALAPPDATA',
119
+ 'LANG', 'LC_ALL', 'TERM'];
120
+ for (const k of allow) {
121
+ if (process.env[k])
122
+ safe[k] = process.env[k];
123
+ }
124
+ return safe;
124
125
  }
125
126
  function getGitInfo() {
126
127
  try {
127
- const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
128
+ const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() }).trim();
128
129
  const repo = remote.split('/').pop()?.replace('.git', '') || 'unknown';
129
- const branch = execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || 'unknown';
130
+ const branch = execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() }).trim() || 'unknown';
130
131
  return { repo, branch };
131
132
  }
132
133
  catch {
@@ -189,6 +190,8 @@ setInterval(() => {
189
190
  // ─── Security: Redact secrets from replay events ────────────
190
191
  // ─── Bridge server ──────────────────────────────────────────
191
192
  const connections = new Map();
193
+ // Hub relay: WS connections from hub to local sessions (for grid view)
194
+ const relayConnections = new Map(); // port → ws to session
192
195
  let localResizeAt = 0; // Timestamp of last local terminal resize
193
196
  // #10: Session TTL enforcement — periodically close expired connections
194
197
  setInterval(() => {
@@ -319,7 +322,7 @@ const server = http.createServer(async (req, res) => {
319
322
  // Sessions API
320
323
  if ((req.url === '/api/sessions' || req.url?.startsWith('/api/sessions?')) && req.method === 'GET') {
321
324
  try {
322
- const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
325
+ const output = execFileSync('devtunnel', ['list', '--labels', 'cli-tunnel', '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() });
323
326
  const data = JSON.parse(output);
324
327
  const localMachine = os.hostname();
325
328
  const localSessions = hubMode ? readLocalSessions() : [];
@@ -369,7 +372,7 @@ const server = http.createServer(async (req, res) => {
369
372
  }
370
373
  // Verify the tunnel belongs to this machine before allowing delete
371
374
  try {
372
- const verifyOut = execFileSync('devtunnel', ['show', tunnelId, '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
375
+ const verifyOut = execFileSync('devtunnel', ['show', tunnelId, '--json'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() });
373
376
  const verifyData = JSON.parse(verifyOut);
374
377
  const labels = verifyData.tunnel?.labels || [];
375
378
  const tunnelMachine = labels[4] || '';
@@ -386,7 +389,7 @@ const server = http.createServer(async (req, res) => {
386
389
  return;
387
390
  }
388
391
  try {
389
- execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
392
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() });
390
393
  res.writeHead(200, { 'Content-Type': 'application/json', 'X-Frame-Options': 'DENY', 'X-Content-Type-Options': 'nosniff' });
391
394
  res.end(JSON.stringify({ deleted: true }));
392
395
  }
@@ -444,7 +447,7 @@ const server = http.createServer(async (req, res) => {
444
447
  'Content-Type': mimes[ext] || 'application/octet-stream',
445
448
  'X-Frame-Options': 'DENY',
446
449
  'X-Content-Type-Options': 'nosniff',
447
- '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:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
450
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/ https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/ https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/; connect-src 'self' ws://localhost:* ws://127.0.0.1:* wss://*.devtunnels.ms https://*.devtunnels.ms;",
448
451
  'Referrer-Policy': 'no-referrer',
449
452
  'Cache-Control': 'no-store',
450
453
  'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
@@ -495,7 +498,7 @@ const wss = new WebSocketServer({
495
498
  const auditDir = path.join(os.homedir(), '.cli-tunnel', 'audit');
496
499
  fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 });
497
500
  const auditLogPath = path.join(auditDir, `audit-${new Date().toISOString().slice(0, 10)}.jsonl`);
498
- const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a' });
501
+ const auditLog = fs.createWriteStream(auditLogPath, { flags: 'a', mode: 0o600 });
499
502
  auditLog.on('error', (err) => { console.error('Audit log error:', err.message); });
500
503
  // R-01: WebSocketServer error handler — prevents process crash on WSS-level errors
501
504
  wss.on('error', (err) => {
@@ -520,13 +523,19 @@ wss.on('connection', (ws, req) => {
520
523
  const id = crypto.randomUUID();
521
524
  ws._remoteAddress = remoteAddress;
522
525
  connections.set(id, ws);
526
+ // R-02: Per-connection error handler to prevent unhandled crash
527
+ ws.on('error', (err) => { console.error('[ws] Connection error:', err.message); });
528
+ // Send replay buffer to late-joining clients (catch up on PTY state)
529
+ if (!hubMode && replayBuffer.length > 0) {
530
+ ws.send(JSON.stringify({ type: 'pty', data: replayBuffer }));
531
+ }
523
532
  // F-13: Per-connection WS message rate limiter (100 msg/sec)
524
533
  let wsMessageCount = 0;
525
534
  let wsMessageResetAt = Date.now() + 1000;
526
535
  // F-10: WS ping/pong heartbeat
527
536
  ws._isAlive = true;
528
537
  ws.on('pong', () => { ws._isAlive = true; });
529
- ws.on('message', (data) => {
538
+ ws.on('message', async (data) => {
530
539
  // F-13: Enforce WS message rate limit (100 msg/sec)
531
540
  const now = Date.now();
532
541
  if (now > wsMessageResetAt) {
@@ -556,13 +565,75 @@ wss.on('connection', (ws, req) => {
556
565
  if (msg.type === 'pty_resize') {
557
566
  // Only log, don't resize — prevents breaking local terminal layout
558
567
  }
568
+ // Grid relay: hub proxies PTY data between phone and local sessions
569
+ if (hubMode && msg.type === 'grid_connect') {
570
+ const port = Number(msg.port);
571
+ if (!Number.isFinite(port) || port < 1 || port > 65535)
572
+ return;
573
+ const localSessions = readLocalSessions();
574
+ const session = localSessions.find(s => s.port === port);
575
+ if (!session)
576
+ return;
577
+ try {
578
+ const ticketResp = await fetch(`http://127.0.0.1:${port}/api/auth/ticket`, {
579
+ method: 'POST',
580
+ headers: { 'Authorization': `Bearer ${session.token}` },
581
+ signal: AbortSignal.timeout(3000),
582
+ });
583
+ if (!ticketResp.ok)
584
+ return;
585
+ const { ticket } = await ticketResp.json();
586
+ const sessionWs = new WebSocket(`ws://127.0.0.1:${port}?ticket=${encodeURIComponent(ticket)}`, {
587
+ headers: { origin: `http://127.0.0.1:${port}` },
588
+ });
589
+ sessionWs.on('open', () => {
590
+ relayConnections.set(port, sessionWs);
591
+ if (ws.readyState === WebSocket.OPEN) {
592
+ ws.send(JSON.stringify({ type: 'grid_connected', port }));
593
+ }
594
+ });
595
+ sessionWs.on('message', (sData) => {
596
+ try {
597
+ const parsed = JSON.parse(sData.toString());
598
+ if (parsed.type === 'pty' && ws.readyState === WebSocket.OPEN) {
599
+ ws.send(JSON.stringify({ type: 'grid_pty', port, data: parsed.data }));
600
+ }
601
+ }
602
+ catch { }
603
+ });
604
+ sessionWs.on('close', () => {
605
+ relayConnections.delete(port);
606
+ if (ws.readyState === WebSocket.OPEN) {
607
+ ws.send(JSON.stringify({ type: 'grid_disconnected', port }));
608
+ }
609
+ });
610
+ sessionWs.on('error', () => {
611
+ relayConnections.delete(port);
612
+ });
613
+ }
614
+ catch { }
615
+ }
616
+ if (hubMode && msg.type === 'grid_input') {
617
+ const port = Number(msg.port);
618
+ const relay = relayConnections.get(port);
619
+ if (relay && relay.readyState === WebSocket.OPEN) {
620
+ relay.send(JSON.stringify({ type: 'pty_input', data: msg.data }));
621
+ }
622
+ }
559
623
  }
560
624
  catch {
561
625
  // #3: Log but do NOT write to PTY — only structured pty_input messages allowed
562
626
  auditLog.write(JSON.stringify({ ts: new Date().toISOString(), type: 'rejected', reason: 'non-json', length: raw.length }) + '\n');
563
627
  }
564
628
  });
565
- ws.on('close', () => connections.delete(id));
629
+ ws.on('close', () => {
630
+ connections.delete(id);
631
+ // Close all relay connections when hub client disconnects
632
+ for (const [port, relay] of relayConnections) {
633
+ relay.close();
634
+ }
635
+ relayConnections.clear();
636
+ });
566
637
  });
567
638
  // F-10: WS heartbeat — ping every 2 minutes, close unresponsive connections
568
639
  // Longer interval prevents killing phone connections that go to background briefly
@@ -577,9 +648,15 @@ setInterval(() => {
577
648
  ws.ping();
578
649
  }
579
650
  }, 120000);
651
+ // Rolling replay buffer for late-joining clients (grid panels, reconnects)
652
+ let replayBuffer = '';
580
653
  function broadcast(data) {
581
654
  const redacted = redactSecrets(data);
582
655
  const msg = JSON.stringify({ type: 'pty', data: redacted });
656
+ // Append to replay buffer (rolling, max 256KB)
657
+ replayBuffer += redacted;
658
+ if (replayBuffer.length > 262144)
659
+ replayBuffer = replayBuffer.slice(-262144);
583
660
  for (const [, ws] of connections) {
584
661
  if (ws.readyState === WebSocket.OPEN)
585
662
  ws.send(msg);
@@ -619,7 +696,7 @@ async function main() {
619
696
  // Check if devtunnel is installed
620
697
  let devtunnelInstalled = false;
621
698
  try {
622
- execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
699
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe', env: getSubprocessEnv() });
623
700
  devtunnelInstalled = true;
624
701
  }
625
702
  catch {
@@ -639,7 +716,7 @@ async function main() {
639
716
  console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
640
717
  try {
641
718
  const installParts = installCmd.split(' ');
642
- const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|') });
719
+ const installProc = spawn(installParts[0], installParts.slice(1), { stdio: 'inherit', shell: process.platform !== 'win32' && installCmd.includes('|'), env: getSubprocessEnv() });
643
720
  await new Promise((resolve, reject) => {
644
721
  installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
645
722
  installProc.on('error', reject);
@@ -647,15 +724,15 @@ async function main() {
647
724
  // Refresh PATH — winget updates the registry but current process has stale PATH
648
725
  if (process.platform === 'win32') {
649
726
  try {
650
- const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
651
- const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
727
+ const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() });
728
+ const sysPath = execFileSync('reg', ['query', 'HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() });
652
729
  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() || '';
653
730
  process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
654
731
  }
655
732
  catch { /* keep existing PATH */ }
656
733
  }
657
734
  // Verify installation
658
- execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
735
+ execFileSync('devtunnel', ['--version'], { stdio: 'pipe', env: getSubprocessEnv() });
659
736
  console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
660
737
  devtunnelInstalled = true;
661
738
  }
@@ -673,7 +750,7 @@ async function main() {
673
750
  if (devtunnelInstalled) {
674
751
  // Check if logged in before attempting tunnel creation
675
752
  try {
676
- const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
753
+ const userInfo = execFileSync('devtunnel', ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() });
677
754
  if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
678
755
  throw new Error('not logged in');
679
756
  }
@@ -683,7 +760,7 @@ async function main() {
683
760
  const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
684
761
  if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
685
762
  try {
686
- const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
763
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit', env: getSubprocessEnv() });
687
764
  await new Promise((resolve, reject) => {
688
765
  loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
689
766
  loginProc.on('error', reject);
@@ -707,10 +784,10 @@ async function main() {
707
784
  try {
708
785
  const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
709
786
  const labelArgs = labelValues.flatMap(l => ['--labels', l]);
710
- const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
787
+ const createOut = execFileSync('devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() });
711
788
  const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
712
789
  const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
713
- execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe' });
790
+ execFileSync('devtunnel', ['port', 'create', tunnelId, '-p', String(actualPort), '--protocol', 'http'], { stdio: 'pipe', env: getSubprocessEnv() });
714
791
  const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false, env: getSubprocessEnv() });
715
792
  const url = await new Promise((resolve, reject) => {
716
793
  const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
@@ -737,11 +814,11 @@ async function main() {
737
814
  }
738
815
  catch { }
739
816
  process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
740
- execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
817
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe', env: getSubprocessEnv() });
741
818
  }
742
819
  catch { } });
743
820
  process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
744
- execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
821
+ execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe', env: getSubprocessEnv() });
745
822
  }
746
823
  catch { } });
747
824
  }
@@ -753,7 +830,7 @@ async function main() {
753
830
  const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
754
831
  if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
755
832
  try {
756
- const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
833
+ const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit', env: getSubprocessEnv() });
757
834
  await new Promise((resolve, reject) => {
758
835
  loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
759
836
  loginProc.on('error', reject);
@@ -771,6 +848,13 @@ async function main() {
771
848
  }
772
849
  } // end if (devtunnelInstalled)
773
850
  }
851
+ // Write session file for local-only sessions (no tunnel) so hub can discover them
852
+ if (!hasTunnel && !hubMode && !sessionFilePath) {
853
+ const localId = `local-${actualPort}`;
854
+ writeSessionFile(localId, `http://127.0.0.1:${actualPort}`, actualPort);
855
+ process.on('SIGINT', () => { removeSessionFile(); });
856
+ process.on('exit', () => { removeSessionFile(); });
857
+ }
774
858
  if (hubMode) {
775
859
  // Hub mode — just serve the sessions dashboard, no PTY
776
860
  console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
@@ -801,7 +885,7 @@ async function main() {
801
885
  let resolvedCmd = command;
802
886
  if (process.platform === 'win32') {
803
887
  try {
804
- const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
888
+ const wherePaths = execFileSync('where', [command], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], env: getSubprocessEnv() }).trim().split('\n');
805
889
  // Prefer .exe or .cmd over .ps1 for node-pty compatibility
806
890
  const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
807
891
  if (exePath) {
@@ -832,7 +916,7 @@ async function main() {
832
916
  'AZURE_SUBSCRIPTION_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET',
833
917
  'SENDGRID_API_KEY', 'TWILIO_AUTH_TOKEN', 'STRIPE_SECRET_KEY',
834
918
  'AWS_SESSION_TOKEN', 'AWS_SECURITY_TOKEN']);
835
- const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config/i;
919
+ const sensitivePattern = /token|secret|key|password|credential|api_key|private_key|access_key|connection_string|auth|kubeconfig|docker_host|docker_config|passwd|dsn|webhook/i;
836
920
  const safeEnv = {};
837
921
  for (const [k, v] of Object.entries(process.env)) {
838
922
  if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
@@ -844,6 +928,11 @@ async function main() {
844
928
  cols, rows, cwd,
845
929
  env: safeEnv,
846
930
  });
931
+ // Register data handler immediately so no PTY output is lost
932
+ ptyProcess.onData((data) => {
933
+ process.stdout.write(data);
934
+ broadcast(data);
935
+ });
847
936
  // Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
848
937
  let earlyExitCode = null;
849
938
  const earlyExitCheck = new Promise((resolve) => {
@@ -868,19 +957,18 @@ async function main() {
868
957
  process.exit(earlyExitCode);
869
958
  }
870
959
  }
871
- ptyProcess.onData((data) => {
872
- process.stdout.write(data);
873
- broadcast(data);
874
- });
875
960
  ptyProcess.onExit(({ exitCode }) => {
876
961
  console.log(`\n${DIM}Process exited (code ${exitCode}).${RESET}`);
962
+ ptyProcess = null;
877
963
  server.close();
878
964
  process.exit(exitCode);
879
965
  });
880
966
  if (process.stdin.isTTY)
881
967
  process.stdin.setRawMode(true);
882
968
  process.stdin.resume();
883
- process.stdin.on('data', (data) => ptyProcess.write(data.toString()));
884
- process.stdout.on('resize', () => { localResizeAt = Date.now(); const c = process.stdout.columns || 120; const r = process.stdout.rows || 30; ptyProcess.resize(c, r); });
969
+ process.stdin.on('data', (data) => { if (ptyProcess)
970
+ ptyProcess.write(data.toString()); });
971
+ process.stdout.on('resize', () => { localResizeAt = Date.now(); const c = process.stdout.columns || 120; const r = process.stdout.rows || 30; if (ptyProcess)
972
+ ptyProcess.resize(c, r); });
885
973
  }
886
974
  main().catch((err) => { console.error(err); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-tunnel",
3
- "version": "1.3.1-beta.7",
3
+ "version": "1.3.1-beta.9",
4
4
  "description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/remote-ui/app.js CHANGED
@@ -36,6 +36,9 @@
36
36
  let replaying = false;
37
37
  let toolCalls = {};
38
38
 
39
+ // Save token before it's stripped from URL bar
40
+ var savedToken = new URLSearchParams(window.location.search).get('token') || '';
41
+
39
42
  const $ = (sel) => document.querySelector(sel);
40
43
  const terminal = $('#terminal');
41
44
  const inputEl = $('#input');
@@ -47,7 +50,7 @@
47
50
  const termContainer = $('#terminal-container');
48
51
  let currentView = 'terminal'; // 'dashboard', 'terminal', or 'grid'
49
52
  let cachedSessions = [];
50
- let gridTerminals = []; // { xterm, fitAddon, ws, session, panel }
53
+ let gridTerminals = []; // { xterm, fitAddon, session, panel }
51
54
  var gridMode = 'thumbnails';
52
55
  var focusedIndex = 0;
53
56
  var tmuxPreset = 'equal';
@@ -254,8 +257,7 @@
254
257
 
255
258
  async function loadSessions() {
256
259
  try {
257
- var tokenParam = new URLSearchParams(window.location.search).get('token');
258
- var headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
260
+ var headers = savedToken ? { 'Authorization': 'Bearer ' + savedToken } : {};
259
261
  var resp = await fetch('/api/sessions', { headers: headers });
260
262
  if (!resp.ok) throw new Error('Status ' + resp.status);
261
263
  var data = await resp.json();
@@ -349,11 +351,10 @@
349
351
  if (e.target.closest('[data-delete-id]')) return;
350
352
  var port = card.dataset.sessionPort;
351
353
  var baseUrl = card.dataset.sessionBaseUrl;
352
- var tokenParam = new URLSearchParams(window.location.search).get('token');
353
354
  var proxyUrl = '/api/proxy/ticket/' + port;
354
355
  fetch(proxyUrl, {
355
356
  method: 'POST',
356
- headers: tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {}
357
+ headers: savedToken ? { 'Authorization': 'Bearer ' + savedToken } : {}
357
358
  }).then(function(r) { return r.json(); }).then(function(data) {
358
359
  if (data.ticket) {
359
360
  window.location.href = baseUrl + '?ticket=' + encodeURIComponent(data.ticket);
@@ -384,8 +385,7 @@
384
385
  };
385
386
 
386
387
  window.cleanOffline = async () => {
387
- const tokenParam = new URLSearchParams(window.location.search).get('token');
388
- const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
388
+ const headers = savedToken ? { 'Authorization': 'Bearer ' + savedToken } : {};
389
389
  const resp = await fetch('/api/sessions', { headers });
390
390
  const data = await resp.json();
391
391
  const offline = (data.sessions || []).filter(s => !s.online);
@@ -396,8 +396,7 @@
396
396
  };
397
397
 
398
398
  window.deleteSession = async (id) => {
399
- const tokenParam = new URLSearchParams(window.location.search).get('token');
400
- const headers = tokenParam ? { 'Authorization': 'Bearer ' + tokenParam } : {};
399
+ const headers = savedToken ? { 'Authorization': 'Bearer ' + savedToken } : {};
401
400
  await fetch('/api/sessions/' + id, { method: 'DELETE', headers });
402
401
  loadSessions();
403
402
  };
@@ -532,54 +531,19 @@
532
531
  panelXterm.open(termDiv);
533
532
 
534
533
  // Store entry before async connect so index is stable
535
- var entry = { xterm: panelXterm, fitAddon: panelFit, ws: null, session: s, panel: panel };
534
+ var entry = { xterm: panelXterm, fitAddon: panelFit, session: s, panel: panel };
536
535
  gridTerminals.push(entry);
537
536
 
538
- // Connect WebSocket to this session
539
- (function connectPanel() {
540
- // Use hub's proxy endpoint to get a ticket for the session
541
- var tokenParam = new URLSearchParams(window.location.search).get('token');
542
- var proxyUrl = '/api/proxy/ticket/' + s.port;
543
- var wsBase = s.isLocal ? 'ws://127.0.0.1:' + s.port : s.url.replace('https://', 'wss://');
537
+ // Connect via hub relay — send grid_connect message
538
+ if (ws && ws.readyState === WebSocket.OPEN) {
539
+ ws.send(JSON.stringify({ type: 'grid_connect', port: s.port }));
540
+ }
544
541
 
545
- fetch(proxyUrl, {
546
- method: 'POST',
547
- headers: { 'Authorization': 'Bearer ' + tokenParam }
548
- }).then(function(resp) {
549
- if (!resp.ok) throw new Error('Auth failed');
550
- return resp.json();
551
- }).then(function(data) {
552
- var panelWs = new WebSocket(wsBase + '?ticket=' + encodeURIComponent(data.ticket));
553
- entry.ws = panelWs;
554
-
555
- panelWs.onopen = function() {
556
- if (statusDot) { statusDot.style.color = 'var(--green)'; statusDot.title = 'Connected'; }
557
- panelWs.send(JSON.stringify({ type: 'pty_resize', cols: panelXterm.cols, rows: panelXterm.rows }));
558
- };
559
- panelWs.onclose = function() {
560
- if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Disconnected'; }
561
- };
562
- panelWs.onerror = function() {
563
- if (statusDot) { statusDot.style.color = 'var(--red)'; }
564
- };
565
- panelWs.onmessage = function(e) {
566
- try {
567
- var msg = JSON.parse(e.data);
568
- if (msg.type === 'pty') {
569
- panelXterm.write(msg.data);
570
- }
571
- } catch (err) {}
572
- };
573
-
574
- panelXterm.onData(function(data) {
575
- if (panelWs && panelWs.readyState === WebSocket.OPEN) {
576
- panelWs.send(JSON.stringify({ type: 'pty_input', data: data }));
577
- }
578
- });
579
- }).catch(function() {
580
- if (statusDot) { statusDot.style.color = 'var(--red)'; statusDot.title = 'Auth failed'; }
581
- });
582
- })();
542
+ panelXterm.onData(function(data) {
543
+ if (ws && ws.readyState === WebSocket.OPEN) {
544
+ ws.send(JSON.stringify({ type: 'grid_input', port: s.port, data: data }));
545
+ }
546
+ });
583
547
  });
584
548
 
585
549
  // ── Event delegation for panel clicks ──
@@ -734,9 +698,6 @@
734
698
  if (gt.fitAddon) {
735
699
  try {
736
700
  gt.fitAddon.fit();
737
- if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm) {
738
- gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
739
- }
740
701
  } catch(e) {}
741
702
  }
742
703
  });
@@ -754,10 +715,6 @@
754
715
  var prevCols = gt.xterm ? gt.xterm.cols : 0;
755
716
  var prevRows = gt.xterm ? gt.xterm.rows : 0;
756
717
  gt.fitAddon.fit();
757
- if (gt.ws && gt.ws.readyState === WebSocket.OPEN && gt.xterm &&
758
- (gt.xterm.cols !== prevCols || gt.xterm.rows !== prevRows)) {
759
- gt.ws.send(JSON.stringify({ type: 'pty_resize', cols: gt.xterm.cols, rows: gt.xterm.rows }));
760
- }
761
718
  } catch(e) {}
762
719
  }
763
720
  });
@@ -767,7 +724,6 @@
767
724
  function destroyGrid() {
768
725
  if (isRecording) { stopRecording(); var btn = document.getElementById('btn-record'); if (btn) { btn.classList.remove('recording'); btn.textContent = '⏺'; btn.title = 'Record terminal'; } }
769
726
  gridTerminals.forEach(function(gt) {
770
- if (gt.ws) { try { gt.ws.close(); } catch(e) {} }
771
727
  if (gt.xterm) { try { gt.xterm.dispose(); } catch(e) {} }
772
728
  });
773
729
  gridTerminals = [];
@@ -977,7 +933,7 @@
977
933
 
978
934
  async function connect() {
979
935
  if (isHubMode) {
980
- // Hub mode — hide terminal UI, show sessions only
936
+ // Hub mode — show sessions dashboard
981
937
  setStatus('online', 'Hub');
982
938
  terminal.classList.add('hidden');
983
939
  termContainer.classList.add('hidden');
@@ -985,32 +941,65 @@
985
941
  $('#btn-sessions').classList.add('hidden');
986
942
  dashboard.classList.remove('hidden');
987
943
  loadSessions();
988
- // Auto-refresh every 10s
989
944
  setInterval(loadSessions, 10000);
945
+
946
+ // Hub also needs a WS connection for grid relay
947
+ if (savedToken) {
948
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
949
+ try {
950
+ var resp = await fetch('/api/auth/ticket', {
951
+ method: 'POST',
952
+ headers: { 'Authorization': 'Bearer ' + savedToken }
953
+ });
954
+ if (resp.ok) {
955
+ var data = await resp.json();
956
+ ws = new WebSocket(proto + '//' + location.host + '?ticket=' + encodeURIComponent(data.ticket));
957
+ ws.onopen = function() { connected = true; console.log('[hub] WS connected for grid relay'); };
958
+ ws.onclose = function() { connected = false; ws = null; };
959
+ ws.onerror = function() { ws = null; };
960
+ ws.onmessage = function(e) {
961
+ try { handleMessage(JSON.parse(e.data)); } catch(err) {}
962
+ };
963
+ }
964
+ } catch(err) { console.log('[hub] WS connect failed:', err); }
965
+ }
990
966
  return;
991
967
  }
992
968
 
993
- const tokenParam = new URLSearchParams(window.location.search).get('token');
994
- if (!tokenParam) { setStatus('offline', 'No credentials'); return; }
969
+ const ticketParam = new URLSearchParams(window.location.search).get('ticket');
970
+
971
+ if (!savedToken && !ticketParam) { setStatus('offline', 'No credentials'); return; }
972
+
973
+ // Strip token from URL bar to prevent leaking via Referer/history
974
+ if (savedToken) {
975
+ var cleanUrl = new URL(window.location.href);
976
+ cleanUrl.searchParams.delete('token');
977
+ history.replaceState(null, '', cleanUrl.toString());
978
+ }
995
979
 
996
980
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
997
981
 
998
- // F-02: Ticket-based auth (required)
999
- try {
1000
- const resp = await fetch('/api/auth/ticket', {
1001
- method: 'POST',
1002
- headers: { 'Authorization': 'Bearer ' + tokenParam }
1003
- });
1004
- if (resp.ok) {
1005
- const { ticket } = await resp.json();
1006
- ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
1007
- } else {
982
+ // If we have a ticket (from hub Connect button), use it directly
983
+ if (ticketParam) {
984
+ ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticketParam)}`);
985
+ } else {
986
+ // Exchange token for ticket
987
+ try {
988
+ const resp = await fetch('/api/auth/ticket', {
989
+ method: 'POST',
990
+ headers: { 'Authorization': 'Bearer ' + savedToken }
991
+ });
992
+ if (resp.ok) {
993
+ const { ticket } = await resp.json();
994
+ ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`);
995
+ } else {
996
+ setStatus('offline', 'Auth failed');
997
+ return;
998
+ }
999
+ } catch {
1008
1000
  setStatus('offline', 'Auth failed');
1009
1001
  return;
1010
1002
  }
1011
- } catch {
1012
- setStatus('offline', 'Auth failed');
1013
- return;
1014
1003
  }
1015
1004
  setStatus('connecting', 'Connecting...');
1016
1005
 
@@ -1024,6 +1013,10 @@
1024
1013
  ws.onclose = () => {
1025
1014
  if (isRecording) { stopRecording(); var btn = document.getElementById('btn-record'); if (btn) { btn.classList.remove('recording'); btn.textContent = '⏺'; btn.title = 'Record terminal'; } }
1026
1015
  connected = false; acpReady = false; sessionId = null;
1016
+ if (reconnectAttempt >= 10) {
1017
+ setStatus('offline', 'Connection lost');
1018
+ return;
1019
+ }
1027
1020
  setStatus('offline', 'Reconnecting...');
1028
1021
  const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempt)) + Math.random() * 1000;
1029
1022
  reconnectAttempt++;
@@ -1060,6 +1053,31 @@
1060
1053
  return;
1061
1054
  }
1062
1055
 
1056
+ // Grid relay messages from hub
1057
+ if (msg.type === 'grid_pty') {
1058
+ var gt = gridTerminals.find(function(g) { return g.session && g.session.port === msg.port; });
1059
+ if (gt && gt.xterm) { gt.xterm.write(msg.data); }
1060
+ return;
1061
+ }
1062
+
1063
+ if (msg.type === 'grid_connected') {
1064
+ var gt = gridTerminals.find(function(g) { return g.session && g.session.port === msg.port; });
1065
+ if (gt) {
1066
+ var dot = gt.panel.querySelector('.grid-panel-status');
1067
+ if (dot) { dot.style.color = 'var(--green)'; dot.title = 'Connected'; }
1068
+ }
1069
+ return;
1070
+ }
1071
+
1072
+ if (msg.type === 'grid_disconnected') {
1073
+ var gt = gridTerminals.find(function(g) { return g.session && g.session.port === msg.port; });
1074
+ if (gt) {
1075
+ var dot = gt.panel.querySelector('.grid-panel-status');
1076
+ if (dot) { dot.style.color = 'var(--red)'; dot.title = 'Disconnected'; }
1077
+ }
1078
+ return;
1079
+ }
1080
+
1063
1081
  // PTY data — raw terminal output → xterm.js
1064
1082
  if (msg.type === 'pty') {
1065
1083
  if (!ptyMode) {
@@ -1197,8 +1215,8 @@
1197
1215
  var key = keyMap[btn.dataset.key] || btn.dataset.key;
1198
1216
  if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
1199
1217
  var gt = gridTerminals[focusedIndex];
1200
- if (gt.ws && gt.ws.readyState === WebSocket.OPEN) {
1201
- gt.ws.send(JSON.stringify({ type: 'pty_input', data: key }));
1218
+ if (ws && ws.readyState === WebSocket.OPEN && gt.session) {
1219
+ ws.send(JSON.stringify({ type: 'grid_input', port: gt.session.port, data: key }));
1202
1220
  }
1203
1221
  if (gt.xterm) gt.xterm.focus();
1204
1222
  } else {