cli-tunnel 1.3.1-beta.6 → 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 +5 -5
- package/dist/index.js +128 -39
- package/package.json +1 -1
- package/remote-ui/app.js +109 -83
- package/remote-ui/index.html +0 -2
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,15 +565,78 @@ 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
|
-
// F-10: WS heartbeat — ping every
|
|
638
|
+
// F-10: WS heartbeat — ping every 2 minutes, close unresponsive connections
|
|
639
|
+
// Longer interval prevents killing phone connections that go to background briefly
|
|
568
640
|
setInterval(() => {
|
|
569
641
|
for (const [id, ws] of connections) {
|
|
570
642
|
if (ws._isAlive === false) {
|
|
@@ -575,10 +647,16 @@ setInterval(() => {
|
|
|
575
647
|
ws._isAlive = false;
|
|
576
648
|
ws.ping();
|
|
577
649
|
}
|
|
578
|
-
},
|
|
650
|
+
}, 120000);
|
|
651
|
+
// Rolling replay buffer for late-joining clients (grid panels, reconnects)
|
|
652
|
+
let replayBuffer = '';
|
|
579
653
|
function broadcast(data) {
|
|
580
654
|
const redacted = redactSecrets(data);
|
|
581
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);
|
|
582
660
|
for (const [, ws] of connections) {
|
|
583
661
|
if (ws.readyState === WebSocket.OPEN)
|
|
584
662
|
ws.send(msg);
|
|
@@ -618,7 +696,7 @@ async function main() {
|
|
|
618
696
|
// Check if devtunnel is installed
|
|
619
697
|
let devtunnelInstalled = false;
|
|
620
698
|
try {
|
|
621
|
-
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
699
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe', env: getSubprocessEnv() });
|
|
622
700
|
devtunnelInstalled = true;
|
|
623
701
|
}
|
|
624
702
|
catch {
|
|
@@ -638,7 +716,7 @@ async function main() {
|
|
|
638
716
|
console.log(`\n ${DIM}Installing devtunnel...${RESET}\n`);
|
|
639
717
|
try {
|
|
640
718
|
const installParts = installCmd.split(' ');
|
|
641
|
-
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() });
|
|
642
720
|
await new Promise((resolve, reject) => {
|
|
643
721
|
installProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Install exited with code ${code}`)));
|
|
644
722
|
installProc.on('error', reject);
|
|
@@ -646,15 +724,15 @@ async function main() {
|
|
|
646
724
|
// Refresh PATH — winget updates the registry but current process has stale PATH
|
|
647
725
|
if (process.platform === 'win32') {
|
|
648
726
|
try {
|
|
649
|
-
const userPath = execFileSync('reg', ['query', 'HKCU\\Environment', '/v', 'Path'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
650
|
-
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() });
|
|
651
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() || '';
|
|
652
730
|
process.env.PATH = `${extractPath(userPath)};${extractPath(sysPath)}`;
|
|
653
731
|
}
|
|
654
732
|
catch { /* keep existing PATH */ }
|
|
655
733
|
}
|
|
656
734
|
// Verify installation
|
|
657
|
-
execFileSync('devtunnel', ['--version'], { stdio: 'pipe' });
|
|
735
|
+
execFileSync('devtunnel', ['--version'], { stdio: 'pipe', env: getSubprocessEnv() });
|
|
658
736
|
console.log(`\n ${GREEN}✓${RESET} devtunnel installed successfully!\n`);
|
|
659
737
|
devtunnelInstalled = true;
|
|
660
738
|
}
|
|
@@ -672,7 +750,7 @@ async function main() {
|
|
|
672
750
|
if (devtunnelInstalled) {
|
|
673
751
|
// Check if logged in before attempting tunnel creation
|
|
674
752
|
try {
|
|
675
|
-
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() });
|
|
676
754
|
if (userInfo.includes('not logged in') || userInfo.includes('No user') || userInfo.includes('Anonymous')) {
|
|
677
755
|
throw new Error('not logged in');
|
|
678
756
|
}
|
|
@@ -682,7 +760,7 @@ async function main() {
|
|
|
682
760
|
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
683
761
|
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
684
762
|
try {
|
|
685
|
-
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
763
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit', env: getSubprocessEnv() });
|
|
686
764
|
await new Promise((resolve, reject) => {
|
|
687
765
|
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
688
766
|
loginProc.on('error', reject);
|
|
@@ -706,10 +784,10 @@ async function main() {
|
|
|
706
784
|
try {
|
|
707
785
|
const labelValues = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`];
|
|
708
786
|
const labelArgs = labelValues.flatMap(l => ['--labels', l]);
|
|
709
|
-
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() });
|
|
710
788
|
const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
|
|
711
789
|
const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
|
|
712
|
-
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() });
|
|
713
791
|
const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false, env: getSubprocessEnv() });
|
|
714
792
|
const url = await new Promise((resolve, reject) => {
|
|
715
793
|
const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
|
|
@@ -736,11 +814,11 @@ async function main() {
|
|
|
736
814
|
}
|
|
737
815
|
catch { }
|
|
738
816
|
process.on('SIGINT', () => { removeSessionFile(); hostProc.kill(); try {
|
|
739
|
-
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
817
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe', env: getSubprocessEnv() });
|
|
740
818
|
}
|
|
741
819
|
catch { } });
|
|
742
820
|
process.on('exit', () => { removeSessionFile(); hostProc.kill(); try {
|
|
743
|
-
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe' });
|
|
821
|
+
execFileSync('devtunnel', ['delete', tunnelId, '--force'], { stdio: 'pipe', env: getSubprocessEnv() });
|
|
744
822
|
}
|
|
745
823
|
catch { } });
|
|
746
824
|
}
|
|
@@ -752,7 +830,7 @@ async function main() {
|
|
|
752
830
|
const loginAnswer = await askUser(` Would you like to log in now? [Y/n] `);
|
|
753
831
|
if (loginAnswer === '' || loginAnswer === 'y' || loginAnswer === 'yes') {
|
|
754
832
|
try {
|
|
755
|
-
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit' });
|
|
833
|
+
const loginProc = spawn('devtunnel', ['user', 'login'], { stdio: 'inherit', env: getSubprocessEnv() });
|
|
756
834
|
await new Promise((resolve, reject) => {
|
|
757
835
|
loginProc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`Login exited with code ${code}`)));
|
|
758
836
|
loginProc.on('error', reject);
|
|
@@ -770,6 +848,13 @@ async function main() {
|
|
|
770
848
|
}
|
|
771
849
|
} // end if (devtunnelInstalled)
|
|
772
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
|
+
}
|
|
773
858
|
if (hubMode) {
|
|
774
859
|
// Hub mode — just serve the sessions dashboard, no PTY
|
|
775
860
|
console.log(` ${GREEN}✓${RESET} Hub running — open in browser to see all sessions\n`);
|
|
@@ -800,7 +885,7 @@ async function main() {
|
|
|
800
885
|
let resolvedCmd = command;
|
|
801
886
|
if (process.platform === 'win32') {
|
|
802
887
|
try {
|
|
803
|
-
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');
|
|
804
889
|
// Prefer .exe or .cmd over .ps1 for node-pty compatibility
|
|
805
890
|
const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
|
|
806
891
|
if (exePath) {
|
|
@@ -831,7 +916,7 @@ async function main() {
|
|
|
831
916
|
'AZURE_SUBSCRIPTION_ID', 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET',
|
|
832
917
|
'SENDGRID_API_KEY', 'TWILIO_AUTH_TOKEN', 'STRIPE_SECRET_KEY',
|
|
833
918
|
'AWS_SESSION_TOKEN', 'AWS_SECURITY_TOKEN']);
|
|
834
|
-
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;
|
|
835
920
|
const safeEnv = {};
|
|
836
921
|
for (const [k, v] of Object.entries(process.env)) {
|
|
837
922
|
if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) {
|
|
@@ -843,6 +928,11 @@ async function main() {
|
|
|
843
928
|
cols, rows, cwd,
|
|
844
929
|
env: safeEnv,
|
|
845
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
|
+
});
|
|
846
936
|
// Detect CSPRNG crash (rare Node.js + PTY issue) and show helpful message
|
|
847
937
|
let earlyExitCode = null;
|
|
848
938
|
const earlyExitCheck = new Promise((resolve) => {
|
|
@@ -867,19 +957,18 @@ async function main() {
|
|
|
867
957
|
process.exit(earlyExitCode);
|
|
868
958
|
}
|
|
869
959
|
}
|
|
870
|
-
ptyProcess.onData((data) => {
|
|
871
|
-
process.stdout.write(data);
|
|
872
|
-
broadcast(data);
|
|
873
|
-
});
|
|
874
960
|
ptyProcess.onExit(({ exitCode }) => {
|
|
875
961
|
console.log(`\n${DIM}Process exited (code ${exitCode}).${RESET}`);
|
|
962
|
+
ptyProcess = null;
|
|
876
963
|
server.close();
|
|
877
964
|
process.exit(exitCode);
|
|
878
965
|
});
|
|
879
966
|
if (process.stdin.isTTY)
|
|
880
967
|
process.stdin.setRawMode(true);
|
|
881
968
|
process.stdin.resume();
|
|
882
|
-
process.stdin.on('data', (data) => ptyProcess
|
|
883
|
-
|
|
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); });
|
|
884
973
|
}
|
|
885
974
|
main().catch((err) => { console.error(err); process.exit(1); });
|
package/package.json
CHANGED
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,15 +396,14 @@
|
|
|
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
|
};
|
|
404
403
|
|
|
405
404
|
// ─── Grid View (multi-terminal with layout modes) ───────────
|
|
406
405
|
function showGridView(sessions) {
|
|
407
|
-
var connectable = sessions.filter(function(s) { return s.online && s.
|
|
406
|
+
var connectable = sessions.filter(function(s) { return s.online && s.hasToken; });
|
|
408
407
|
if (connectable.length === 0) return;
|
|
409
408
|
|
|
410
409
|
// Clean up previous grid
|
|
@@ -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,7 +1013,11 @@
|
|
|
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;
|
|
1027
|
-
|
|
1016
|
+
if (reconnectAttempt >= 10) {
|
|
1017
|
+
setStatus('offline', 'Connection lost');
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
setStatus('offline', 'Reconnecting...');
|
|
1028
1021
|
const delay = Math.min(30000, 1000 * Math.pow(2, reconnectAttempt)) + Math.random() * 1000;
|
|
1029
1022
|
reconnectAttempt++;
|
|
1030
1023
|
setTimeout(connect, delay);
|
|
@@ -1038,6 +1031,14 @@
|
|
|
1038
1031
|
};
|
|
1039
1032
|
}
|
|
1040
1033
|
|
|
1034
|
+
// Reconnect immediately when phone comes back from background
|
|
1035
|
+
document.addEventListener('visibilitychange', function() {
|
|
1036
|
+
if (!document.hidden && !connected && !isHubMode) {
|
|
1037
|
+
reconnectAttempt = 0;
|
|
1038
|
+
connect();
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1041
1042
|
// ─── Message Handler ─────────────────────────────────────
|
|
1042
1043
|
function handleMessage(msg) {
|
|
1043
1044
|
// Replay events from bridge recording
|
|
@@ -1052,6 +1053,31 @@
|
|
|
1052
1053
|
return;
|
|
1053
1054
|
}
|
|
1054
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
|
+
|
|
1055
1081
|
// PTY data — raw terminal output → xterm.js
|
|
1056
1082
|
if (msg.type === 'pty') {
|
|
1057
1083
|
if (!ptyMode) {
|
|
@@ -1189,8 +1215,8 @@
|
|
|
1189
1215
|
var key = keyMap[btn.dataset.key] || btn.dataset.key;
|
|
1190
1216
|
if (currentView === 'grid' && gridMode === 'fullscreen' && gridTerminals[focusedIndex]) {
|
|
1191
1217
|
var gt = gridTerminals[focusedIndex];
|
|
1192
|
-
if (
|
|
1193
|
-
|
|
1218
|
+
if (ws && ws.readyState === WebSocket.OPEN && gt.session) {
|
|
1219
|
+
ws.send(JSON.stringify({ type: 'grid_input', port: gt.session.port, data: key }));
|
|
1194
1220
|
}
|
|
1195
1221
|
if (gt.xterm) gt.xterm.focus();
|
|
1196
1222
|
} else {
|
package/remote-ui/index.html
CHANGED
|
@@ -42,8 +42,6 @@
|
|
|
42
42
|
<button data-key="\x03">Ctrl+C</button>
|
|
43
43
|
<button data-key=" ">Space</button>
|
|
44
44
|
<button data-key="\x7f">⌫</button>
|
|
45
|
-
<button id="btn-record" data-action="toggle-record" title="Record terminal" aria-label="Record terminal">⏺</button>
|
|
46
|
-
<button id="btn-screenshot" data-action="take-screenshot" title="Screenshot terminal" aria-label="Screenshot terminal">📷</button>
|
|
47
45
|
</div>
|
|
48
46
|
<form id="input-form">
|
|
49
47
|
<span class="prompt">></span>
|