cli-tunnel 1.3.1-beta.7 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/index.js +125 -37
- package/package.json +1 -3
- package/remote-ui/app.js +99 -81
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`, `--
|
|
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
|

|
|
111
111
|
|
|
112
|
-
All modes share the same WebSocket
|
|
112
|
+
All modes share the same WebSocket connection — the 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,
|
|
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
|
-
|
|
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
|
|
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
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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', () =>
|
|
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
|
|
884
|
-
|
|
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
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Tunnel any CLI app to your phone — PTY + devtunnel + xterm.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,8 +36,6 @@
|
|
|
36
36
|
"node": ">=22.0.0"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@xterm/addon-serialize": "^0.14.0",
|
|
40
|
-
"@xterm/headless": "^6.0.0",
|
|
41
39
|
"node-pty": "1.1.0",
|
|
42
40
|
"qrcode-terminal": "0.12.0",
|
|
43
41
|
"ws": "8.19.0"
|
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,
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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,
|
|
534
|
+
var entry = { xterm: panelXterm, fitAddon: panelFit, session: s, panel: panel };
|
|
536
535
|
gridTerminals.push(entry);
|
|
537
536
|
|
|
538
|
-
// Connect
|
|
539
|
-
(
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
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 —
|
|
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
|
|
994
|
-
|
|
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
|
-
//
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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 (
|
|
1201
|
-
|
|
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 {
|