@miraj181/ipingyou 2.1.15 → 2.1.18

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/src/modes/ai.js CHANGED
@@ -3,7 +3,6 @@
3
3
  */
4
4
 
5
5
  import { execa } from 'execa';
6
- import { parse as shellParse } from 'shell-quote';
7
6
  import chalk from 'chalk';
8
7
  import inquirer from 'inquirer';
9
8
  import fs from 'node:fs';
@@ -11,13 +10,13 @@ import os from 'node:os';
11
10
  import path from 'node:path';
12
11
  import { getAlias } from '../lib/config.js';
13
12
  import { resolveUID } from '../lib/broker.js';
14
- import { buildSshArgs, extractHostname } from '../lib/ssh.js';
13
+ import { buildSshArgs, extractHostname, quoteRemoteShell } from '../lib/ssh.js';
15
14
  import { addCleanupHook, cleanupAll } from '../lib/cleanup.js';
16
15
  import { startHostMode } from './host.js';
17
16
  import { startClientMode } from './client.js';
18
17
  import { performSCPNonInteractive } from './client.js';
19
18
  import { DEFAULT_AI_MODEL, createGroqChatCompletion, getGroqApiKey, getRateLimitWarnings, listGroqModels, estimateTokensForMessages } from '../lib/ai/groq.js';
20
- import { classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
19
+ import { assertSafeReadablePath, classifyCommand, redactSensitive, sanitizeUserTask, truncateForModel } from '../lib/ai/safety.js';
21
20
  import { recordEvent } from '../lib/session-log.js';
22
21
 
23
22
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
@@ -139,13 +138,8 @@ async function confirmCommand(scope, command, reason, classification) {
139
138
  return false;
140
139
  }
141
140
 
142
- if (!classification.needsApproval) {
143
- console.log(chalk.dim(` AI tool: ${scope} $ ${command}`));
144
- return true;
145
- }
146
-
147
141
  console.log('');
148
- console.log(chalk.yellow(' AI wants to run a command that needs approval:'));
142
+ console.log(chalk.yellow(' AI wants to run a command:'));
149
143
  console.log(chalk.dim(` Scope: ${scope}`));
150
144
  console.log(chalk.dim(` Reason: ${reason || classification.reason}`));
151
145
  console.log(chalk.cyan(` ${command}`));
@@ -160,13 +154,27 @@ async function confirmCommand(scope, command, reason, classification) {
160
154
  return allow;
161
155
  }
162
156
 
157
+ async function confirmFileRead(scope, filePath) {
158
+ console.log('');
159
+ console.log(chalk.yellow(' AI wants to read a text file and send redacted content to Groq:'));
160
+ console.log(chalk.dim(` Scope: ${scope}`));
161
+ console.log(chalk.cyan(` ${filePath}`));
162
+ const { allow } = await inquirer.prompt([{
163
+ type: 'confirm',
164
+ name: 'allow',
165
+ message: 'Allow this file read?',
166
+ default: false,
167
+ }]);
168
+ return allow;
169
+ }
170
+
163
171
  function matchAppAction(task) {
164
172
  const lowered = String(task || '').toLowerCase();
165
173
  if (/\b(panic|self[- ]?destruct|wipe traces)\b/i.test(lowered)) {
166
174
  return {
167
175
  id: 'panic_blocked',
168
- label: 'Panic mode',
169
- description: 'Panic mode is intentionally not launched from AI mode. Run `ipingyou panic` directly if you mean it.',
176
+ label: 'Emergency shutdown',
177
+ description: 'Emergency shutdown is never launched from AI mode. Run `ipingyou panic` directly and confirm it locally.',
170
178
  blocked: true,
171
179
  };
172
180
  }
@@ -211,9 +219,7 @@ function showRateLimitWarnings(rateLimit) {
211
219
  }
212
220
 
213
221
  async function runLocalCommand(command) {
214
- const parsed = shellParse(command);
215
- // Filter out non-string tokens (shell operators like |, &&, ; etc.) to prevent injection
216
- const args = parsed.filter(token => typeof token === 'string');
222
+ const args = parseLocalCommand(command);
217
223
  if (args.length === 0) {
218
224
  return { exitCode: 1, stdout: '', stderr: 'Empty or unsafe command after parsing' };
219
225
  }
@@ -230,6 +236,57 @@ async function runLocalCommand(command) {
230
236
  };
231
237
  }
232
238
 
239
+ export function parseLocalCommand(command) {
240
+ const input = String(command || '');
241
+ if (!input || input.length > 8192 || /[\u0000\r\n]/.test(input)) {
242
+ throw new Error('Command is empty, too long, or contains control characters');
243
+ }
244
+
245
+ const args = [];
246
+ let current = '';
247
+ let quote = null;
248
+ let tokenStarted = false;
249
+
250
+ for (let index = 0; index < input.length; index += 1) {
251
+ const char = input[index];
252
+ if (quote) {
253
+ if (char === quote) {
254
+ quote = null;
255
+ } else if (char === '\\' && quote === '"' && index + 1 < input.length) {
256
+ current += input[++index];
257
+ } else {
258
+ current += char;
259
+ }
260
+ tokenStarted = true;
261
+ continue;
262
+ }
263
+
264
+ if (char === "'" || char === '"') {
265
+ quote = char;
266
+ tokenStarted = true;
267
+ } else if (/\s/.test(char)) {
268
+ if (tokenStarted) {
269
+ args.push(current);
270
+ current = '';
271
+ tokenStarted = false;
272
+ }
273
+ } else if (char === '\\') {
274
+ if (index + 1 >= input.length) throw new Error('Command ends with an incomplete escape');
275
+ current += input[++index];
276
+ tokenStarted = true;
277
+ } else if (/[;&|`$()<>]/.test(char)) {
278
+ throw new Error('Local AI commands do not support shell operators');
279
+ } else {
280
+ current += char;
281
+ tokenStarted = true;
282
+ }
283
+ }
284
+
285
+ if (quote) throw new Error('Command contains an unterminated quote');
286
+ if (tokenStarted) args.push(current);
287
+ return args;
288
+ }
289
+
233
290
  async function runRemoteCommand(context, command) {
234
291
  const sshArgs = buildSshArgs(context.hostname, context.privateKeyPath);
235
292
  sshArgs.push(`${context.username}@${context.hostname}`, command);
@@ -246,24 +303,34 @@ async function runRemoteCommand(context, command) {
246
303
  };
247
304
  }
248
305
 
249
- function assertReadablePath(filePath) {
250
- const classification = classifyCommand(`cat ${filePath}`);
251
- if (classification.blocked) {
252
- throw new Error(classification.reason);
253
- }
254
- }
255
-
256
306
  async function readLocalTextFile(filePath) {
257
- assertReadablePath(filePath);
258
- const stat = fs.statSync(filePath);
307
+ const requestedPath = assertSafeReadablePath(filePath);
308
+ const expandedPath = requestedPath === '~'
309
+ ? os.homedir()
310
+ : requestedPath.replace(/^~(?=[/\\])/, os.homedir());
311
+ const resolvedPath = fs.realpathSync(path.resolve(expandedPath));
312
+ assertSafeReadablePath(resolvedPath);
313
+ const stat = fs.statSync(resolvedPath);
259
314
  if (!stat.isFile()) throw new Error('Path is not a file');
260
315
  if (stat.size > 256 * 1024) throw new Error('File is too large for AI mode; use a targeted command instead');
261
- return redactSensitive(fs.readFileSync(filePath, 'utf8'));
316
+ return redactSensitive(fs.readFileSync(resolvedPath, 'utf8'));
262
317
  }
263
318
 
264
319
  async function readRemoteTextFile(context, filePath) {
265
- assertReadablePath(filePath);
266
- return runRemoteCommand(context, `python3 - <<'PY'\nfrom pathlib import Path\np=Path(${JSON.stringify(filePath)})\nif not p.is_file(): raise SystemExit('Path is not a file')\nif p.stat().st_size > 262144: raise SystemExit('File is too large for AI mode')\nprint(p.read_text(errors='replace'), end='')\nPY`);
320
+ const safePath = assertSafeReadablePath(filePath);
321
+ const script = [
322
+ 'import pathlib, sys',
323
+ 'p = pathlib.Path(sys.argv[1]).expanduser().resolve()',
324
+ 'parts = {part.lower() for part in p.parts}',
325
+ "blocked = {'.ssh','.gnupg','.aws','.ipingyou','.kube','.docker'}",
326
+ "name = p.name.lower()",
327
+ "if parts & blocked or name in {'.npmrc','.netrc','.pypirc','credentials','credentials.json','known_hosts','authorized_keys','shadow','sudoers'} or name.startswith('.env') or name in {'id_rsa','id_dsa','id_ecdsa','id_ed25519'}: raise SystemExit('Protected path')",
328
+ "if not p.is_file(): raise SystemExit('Path is not a file')",
329
+ "if p.stat().st_size > 262144: raise SystemExit('File is too large for AI mode')",
330
+ "sys.stdout.write(p.read_text(errors='replace'))",
331
+ ].join('\n');
332
+ const command = `python3 -c ${quoteRemoteShell(script)} -- ${quoteRemoteShell(safePath)}`;
333
+ return runRemoteCommand(context, command);
267
334
  }
268
335
 
269
336
  async function setupRemoteContext() {
@@ -391,6 +458,15 @@ async function executeToolCall(context, call) {
391
458
  if (name === 'read_text_file') {
392
459
  const filePath = String(args.filePath || '').trim();
393
460
  if (!filePath) return buildToolResult({ ok: false, error: 'Missing filePath' });
461
+ try {
462
+ assertSafeReadablePath(filePath);
463
+ } catch (err) {
464
+ return buildToolResult({ ok: false, blocked: true, error: err.message });
465
+ }
466
+ if (!await confirmFileRead(context.scope, filePath)) {
467
+ return buildToolResult({ ok: false, blocked: true, error: 'User denied file access' });
468
+ }
469
+ recordEvent('ai_file_read_approved', { scope: context.scope });
394
470
 
395
471
  if (context.scope === 'remote') {
396
472
  const result = await readRemoteTextFile(context, filePath);
@@ -26,7 +26,7 @@ import { calculateChecksum } from '../lib/checksum.js';
26
26
  import { promptLocalPath, promptRemotePath } from '../lib/path-browser.js';
27
27
  import { buildProxyCommandOption, buildSshArgs, extractHostname, formatScpRemotePath, getKnownHostsOptions, getSshControlOptions, quoteRemoteShell } from '../lib/ssh.js';
28
28
  import { buildTmuxSessionName, TMUX_SOCKET_PATH } from '../lib/tmux.js';
29
- import open from 'open';
29
+ import { openUrl } from '../lib/open-url.js';
30
30
  import { secureSensitiveUrl } from '../lib/secure-print.js';
31
31
  import { cleanupSessionLog, initSessionLog, logSessionEvent, recordEvent } from '../lib/session-log.js';
32
32
 
@@ -479,7 +479,7 @@ export async function startClientMode(options = {}) {
479
479
  console.log('');
480
480
  logSessionEvent('client_http_mode', { uid: targetUid, port: payload.port || null });
481
481
  try {
482
- await open(payload.url);
482
+ await openUrl(payload.url);
483
483
  } catch {
484
484
  console.log(chalk.dim(` Could not auto-open. Please visit: ${payload.url}`));
485
485
  }
@@ -711,7 +711,7 @@ async function handleClientChat(uid, password, cachedChatUrl) {
711
711
  spinner.succeed('Chat Room found! Opening browser...');
712
712
  try {
713
713
  const fullUrl = `${chatUrl}#${password}`;
714
- await open(fullUrl);
714
+ await openUrl(fullUrl);
715
715
  } catch {
716
716
  console.log(chalk.cyan(` 👉 Please open: ${secureSensitiveUrl(chatUrl, password)}`));
717
717
  }
package/src/modes/host.js CHANGED
@@ -21,7 +21,9 @@ import { fileURLToPath } from 'node:url';
21
21
  import { createRequire } from 'node:module';
22
22
  import fs from 'node:fs';
23
23
  import os from 'node:os';
24
+ import crypto from 'node:crypto';
24
25
  import { generateUID } from '../lib/uid.js';
26
+ import { openUrl } from '../lib/open-url.js';
25
27
  import { decryptAsync } from '../lib/crypto.js';
26
28
  import { cleanupAll, killProcessTree, trackPID, untrackPID, setRevokeOnExit, addCleanupHook } from '../lib/cleanup.js';
27
29
  import { detectOS } from '../lib/platform.js';
@@ -36,6 +38,16 @@ import { TMUX_SESSION_NAME, TMUX_SESSION_PREFIX, tmuxSocketArgs } from '../lib/t
36
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
37
39
  let BROKER_URL = process.env.BROKER_URL || 'https://ipingyou.onrender.com';
38
40
 
41
+ function escapeDashboardHtml(value) {
42
+ return String(value ?? '').replace(/[&<>"']/g, character => ({
43
+ '&': '&amp;',
44
+ '<': '&lt;',
45
+ '>': '&gt;',
46
+ '"': '&quot;',
47
+ "'": '&#39;',
48
+ })[character]);
49
+ }
50
+
39
51
  async function waitForValue(getValue, timeoutMs, label) {
40
52
  const startedAt = Date.now();
41
53
  while (!getValue()) {
@@ -209,15 +221,26 @@ async function injectPublicKey(pubKey) {
209
221
  }
210
222
 
211
223
  const authKeysPath = path.join(sshDir, 'authorized_keys');
212
- await fs.promises.appendFile(authKeysPath, `\n${pubKey}\n`);
213
- return authKeysPath;
224
+ const existing = await fs.promises.lstat(authKeysPath).catch(() => null);
225
+ if (existing?.isSymbolicLink()) {
226
+ throw new Error('Refusing to modify a symlinked authorized_keys file');
227
+ }
228
+ const authorizedKey = `no-agent-forwarding,no-X11-forwarding ${pubKey}`;
229
+ await fs.promises.appendFile(authKeysPath, `\n${authorizedKey}\n`, { mode: 0o600 });
230
+ await fs.promises.chmod(authKeysPath, 0o600);
231
+ return { authKeysPath, authorizedKey };
214
232
  }
215
233
 
216
- async function removePublicKey(authKeysPath, pubKey) {
234
+ async function removePublicKey(authKeysPath, authorizedKey) {
217
235
  if (fs.existsSync(authKeysPath)) {
236
+ const stat = await fs.promises.lstat(authKeysPath);
237
+ if (stat.isSymbolicLink()) {
238
+ throw new Error('Refusing to modify a symlinked authorized_keys file');
239
+ }
218
240
  let keys = await fs.promises.readFile(authKeysPath, 'utf8');
219
- keys = keys.replace(`\n${pubKey}\n`, '');
241
+ keys = keys.replace(`\n${authorizedKey}\n`, '');
220
242
  await fs.promises.writeFile(authKeysPath, keys);
243
+ await fs.promises.chmod(authKeysPath, 0o600);
221
244
  }
222
245
  }
223
246
 
@@ -275,13 +298,17 @@ async function promptOneTimeSharePath() {
275
298
 
276
299
  async function startLocalHostDashboard(uid, password, serviceConfig, sessionState) {
277
300
  const { default: express } = await import('express');
278
- const { default: open } = await import('open');
279
301
  const app = express();
280
302
  const startedAt = new Date().toISOString();
281
303
  const decryptedClientCache = new Map();
282
304
  const MAX_DECRYPTED_CLIENT_CACHE = 100;
283
305
  const activeEventStreams = new Set();
284
306
  const MAX_EVENT_STREAMS = 5;
307
+ const dashboardUid = escapeDashboardHtml(uid);
308
+ const dashboardService = escapeDashboardHtml(String(serviceConfig.type || '').toUpperCase());
309
+ const dashboardPort = escapeDashboardHtml(serviceConfig.port);
310
+ const dashboardDropPath = escapeDashboardHtml(serviceConfig.sharedDropPath || 'none');
311
+ const dashboardSharePath = escapeDashboardHtml(serviceConfig.oneTimeSharePath || 'none');
285
312
 
286
313
  async function fetchDecryptedClients() {
287
314
  const brokerRes = await fetch(`${BROKER_URL}/clients/${uid}`, {
@@ -464,6 +491,17 @@ async function startLocalHostDashboard(uid, password, serviceConfig, sessionStat
464
491
  });
465
492
 
466
493
  app.get('/', (_req, res) => {
494
+ const scriptNonce = crypto.randomBytes(18).toString('base64');
495
+ res.set('Content-Security-Policy', [
496
+ "default-src 'self'",
497
+ `script-src 'nonce-${scriptNonce}'`,
498
+ "style-src 'self' 'unsafe-inline'",
499
+ "connect-src 'self'",
500
+ "img-src 'none'",
501
+ "object-src 'none'",
502
+ "base-uri 'none'",
503
+ "frame-ancestors 'none'",
504
+ ].join('; '));
467
505
  res.type('html').send(`<!doctype html>
468
506
  <html lang="en"><head><meta charset="utf-8"><title>iPingYou Host Dashboard</title>
469
507
  <style>
@@ -504,22 +542,22 @@ code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
504
542
  </style></head>
505
543
  <body>
506
544
  <div class="header">
507
- <div><h1>🛡️ iPingYou Host Dashboard</h1><span style="color:var(--dim);font-size:0.85rem">UID: <code>${uid}</code></span></div>
545
+ <div><h1>🛡️ iPingYou Host Dashboard</h1><span style="color:var(--dim);font-size:0.85rem">UID: <code>${dashboardUid}</code></span></div>
508
546
  <div style="display:flex;gap:1rem;align-items:center">
509
547
  <span class="badge">● LIVE</span>
510
- <button class="btn btn-revoke" onclick="revokeSession()">🚫 Revoke Session</button>
548
+ <button id="revoke-session" class="btn btn-revoke" type="button">🚫 Revoke Session</button>
511
549
  </div>
512
550
  </div>
513
551
 
514
552
  <div class="grid">
515
553
  <div class="card">
516
554
  <h2>📊 Session Info</h2>
517
- <div class="info-row"><span class="info-label">UID</span><span class="info-value"><code>${uid}</code></span></div>
555
+ <div class="info-row"><span class="info-label">UID</span><span class="info-value"><code>${dashboardUid}</code></span></div>
518
556
  <div class="info-row"><span class="info-label">Password</span><span class="info-value"><code>[Hidden — see terminal]</code></span></div>
519
- <div class="info-row"><span class="info-label">Service</span><span class="info-value">${serviceConfig.type.toUpperCase()} on port ${serviceConfig.port}</span></div>
557
+ <div class="info-row"><span class="info-label">Service</span><span class="info-value">${dashboardService} on port ${dashboardPort}</span></div>
520
558
  <div class="info-row"><span class="info-label">Approval Gate</span><span class="info-value">${serviceConfig.approvalRequired ? '<span style="color:var(--green)">✓ Enabled</span>' : '<span style="color:var(--dim)">Disabled</span>'}</span></div>
521
- <div class="info-row"><span class="info-label">Drop Folder</span><span class="info-value"><code>${serviceConfig.sharedDropPath || 'none'}</code></span></div>
522
- <div class="info-row"><span class="info-label">One-Time Share</span><span class="info-value"><code>${serviceConfig.oneTimeSharePath || 'none'}</code></span></div>
559
+ <div class="info-row"><span class="info-label">Drop Folder</span><span class="info-value"><code>${dashboardDropPath}</code></span></div>
560
+ <div class="info-row"><span class="info-label">One-Time Share</span><span class="info-value"><code>${dashboardSharePath}</code></span></div>
523
561
  <div class="info-row"><span class="info-label">Chat</span><span class="info-value">${serviceConfig.chatUrl ? '<span style="color:var(--green)">Active</span>' : '<span style="color:var(--dim)">Not started</span>'}</span></div>
524
562
  <div class="info-row"><span class="info-label">Uptime</span><span class="info-value" id="uptime">—</span></div>
525
563
  </div>
@@ -537,7 +575,7 @@ code{background:var(--bg);padding:2px 8px;border-radius:4px;font-size:0.85rem}
537
575
 
538
576
  <div id="toast"></div>
539
577
 
540
- <script>
578
+ <script nonce="${scriptNonce}">
541
579
  const startedAt = new Date("${startedAt}");
542
580
 
543
581
  function showToast(msg) {
@@ -547,14 +585,11 @@ function showToast(msg) {
547
585
  setTimeout(() => t.classList.remove('show'), 2500);
548
586
  }
549
587
 
550
- function escapeHtml(value) {
551
- return String(value || '').replace(/[&<>"']/g, (m) => ({
552
- '&': '&amp;',
553
- '<': '&lt;',
554
- '>': '&gt;',
555
- '"': '&quot;',
556
- "'": '&#39;',
557
- })[m]);
588
+ function appendEmptyState(container, text) {
589
+ const message = document.createElement('p');
590
+ message.className = 'empty';
591
+ message.textContent = text;
592
+ container.replaceChildren(message);
558
593
  }
559
594
 
560
595
  function updateUptime() {
@@ -615,39 +650,63 @@ function renderApprovals(approvals) {
615
650
  document.getElementById('approval-count').textContent = pending.length > 0 ? '(' + pending.length + ' pending)' : '';
616
651
 
617
652
  if (approvals.length === 0) {
618
- container.innerHTML = '<p class="empty">No approval requests yet</p>';
653
+ appendEmptyState(container, 'No approval requests yet');
619
654
  return;
620
655
  }
621
656
 
622
- let html = '';
657
+ const fragment = document.createDocumentFragment();
623
658
  for (const req of pending) {
624
- html += '<div class="approval-item">'
625
- + '<div style="display:flex;justify-content:space-between;align-items:center">'
626
- + '<strong>Request ' + req.id + '</strong>'
627
- + '<span class="status-badge status-pending">PENDING</span></div>'
628
- + '<div class="meta">Submitted: ' + new Date(req.createdAt).toLocaleTimeString() + '</div>'
629
- + '<div class="actions">'
630
- + '<button class="btn btn-approve" data-id="' + req.id + '" data-decision="approved">✅ Approve</button>'
631
- + '<button class="btn btn-deny" data-id="' + req.id + '" data-decision="denied">❌ Deny</button>'
632
- + '</div></div>';
659
+ const item = document.createElement('div');
660
+ item.className = 'approval-item';
661
+ const heading = document.createElement('div');
662
+ heading.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
663
+ const title = document.createElement('strong');
664
+ title.textContent = 'Request ' + String(req.id || '');
665
+ const badge = document.createElement('span');
666
+ badge.className = 'status-badge status-pending';
667
+ badge.textContent = 'PENDING';
668
+ heading.append(title, badge);
669
+ const meta = document.createElement('div');
670
+ meta.className = 'meta';
671
+ meta.textContent = 'Submitted: ' + new Date(req.createdAt).toLocaleTimeString();
672
+ const actions = document.createElement('div');
673
+ actions.className = 'actions';
674
+ for (const choice of [
675
+ { decision: 'approved', label: '✅ Approve', className: 'btn btn-approve' },
676
+ { decision: 'denied', label: '❌ Deny', className: 'btn btn-deny' }
677
+ ]) {
678
+ const button = document.createElement('button');
679
+ button.className = choice.className;
680
+ button.type = 'button';
681
+ button.textContent = choice.label;
682
+ button.addEventListener('click', function() {
683
+ decide(String(req.id || ''), choice.decision);
684
+ });
685
+ actions.appendChild(button);
686
+ }
687
+ item.append(heading, meta, actions);
688
+ fragment.appendChild(item);
633
689
  }
634
690
  for (const req of decided) {
635
- const cls = req.status === 'approved' ? 'status-approved' : 'status-denied';
636
- html += '<div class="approval-item" style="opacity:0.6">'
637
- + '<div style="display:flex;justify-content:space-between;align-items:center">'
638
- + '<strong>Request ' + req.id + '</strong>'
639
- + '<span class="status-badge ' + cls + '">' + req.status.toUpperCase() + '</span></div>'
640
- + '<div class="meta">Decided: ' + (req.decidedAt ? new Date(req.decidedAt).toLocaleTimeString() : 'N/A') + '</div>'
641
- + '</div>';
691
+ const status = req.status === 'approved' ? 'approved' : 'denied';
692
+ const item = document.createElement('div');
693
+ item.className = 'approval-item';
694
+ item.style.opacity = '0.6';
695
+ const heading = document.createElement('div');
696
+ heading.style.cssText = 'display:flex;justify-content:space-between;align-items:center';
697
+ const title = document.createElement('strong');
698
+ title.textContent = 'Request ' + String(req.id || '');
699
+ const badge = document.createElement('span');
700
+ badge.className = 'status-badge status-' + status;
701
+ badge.textContent = status.toUpperCase();
702
+ heading.append(title, badge);
703
+ const meta = document.createElement('div');
704
+ meta.className = 'meta';
705
+ meta.textContent = 'Decided: ' + (req.decidedAt ? new Date(req.decidedAt).toLocaleTimeString() : 'N/A');
706
+ item.append(heading, meta);
707
+ fragment.appendChild(item);
642
708
  }
643
- container.innerHTML = html;
644
-
645
- // Attach click handlers via event delegation (avoids inline quote escaping issues)
646
- container.querySelectorAll('[data-decision]').forEach(function(btn) {
647
- btn.addEventListener('click', function() {
648
- decide(btn.getAttribute('data-id'), btn.getAttribute('data-decision'));
649
- });
650
- });
709
+ container.replaceChildren(fragment);
651
710
  }
652
711
 
653
712
  async function decide(requestId, decision) {
@@ -670,34 +729,48 @@ async function revokeSession() {
670
729
  else showToast('Failed to revoke');
671
730
  } catch (err) { showToast('Error: ' + err.message); }
672
731
  }
732
+ document.getElementById('revoke-session').addEventListener('click', revokeSession);
673
733
 
674
734
  function renderClients(clients) {
675
735
  const container = document.getElementById('clients');
676
736
  if (!clients || clients.length === 0) {
677
- container.innerHTML = '<p class="empty">No clients connected yet</p>';
737
+ appendEmptyState(container, 'No clients connected yet');
678
738
  document.getElementById('client-count').textContent = '';
679
739
  return;
680
740
  }
681
741
 
682
742
  document.getElementById('client-count').textContent = '(' + clients.length + ' connected)';
683
- let html = '';
743
+ const fragment = document.createDocumentFragment();
684
744
  clients.forEach((c, idx) => {
745
+ const card = document.createElement('div');
746
+ card.className = 'client-card';
685
747
  if (c.error) {
686
- html += '<div class="client-card"><strong>Client #' + (idx + 1) + '</strong> — payload decryption failed</div>';
748
+ const title = document.createElement('strong');
749
+ title.textContent = 'Client #' + (idx + 1);
750
+ card.append(title, document.createTextNode(' — payload decryption failed'));
751
+ fragment.appendChild(card);
687
752
  return;
688
753
  }
689
754
  const when = c.time || (c.seenAt ? new Date(c.seenAt).toLocaleTimeString() : 'Unknown');
690
- html += '<div class="client-card">'
691
- + '<strong>' + escapeHtml(c.username || 'Unknown') + '</strong>'
692
- + ' — ' + escapeHtml(c.action || 'connected') + '<br>'
693
- + '<span class="meta">IP: ' + escapeHtml(c.ip || 'Unknown') + '</span><br>'
694
- + '<span class="meta">OS: ' + escapeHtml(c.os || 'Unknown') + '</span><br>'
695
- + '<span class="meta">CPU: ' + escapeHtml(c.cpu || 'Unknown') + '</span><br>'
696
- + '<span class="meta">RAM: ' + escapeHtml(c.ram || 'Unknown') + '</span><br>'
697
- + '<span class="meta">Time: ' + escapeHtml(when) + '</span>'
698
- + '</div>';
755
+ const title = document.createElement('strong');
756
+ title.textContent = String(c.username || 'Unknown');
757
+ card.append(title, document.createTextNode(' — ' + String(c.action || 'connected')), document.createElement('br'));
758
+ for (const field of [
759
+ ['IP', c.ip],
760
+ ['OS', c.os],
761
+ ['CPU', c.cpu],
762
+ ['RAM', c.ram],
763
+ ['Time', when]
764
+ ]) {
765
+ const meta = document.createElement('span');
766
+ meta.className = 'meta';
767
+ meta.textContent = field[0] + ': ' + String(field[1] || 'Unknown');
768
+ card.appendChild(meta);
769
+ if (field[0] !== 'Time') card.appendChild(document.createElement('br'));
770
+ }
771
+ fragment.appendChild(card);
699
772
  });
700
- container.innerHTML = html;
773
+ container.replaceChildren(fragment);
701
774
  }
702
775
 
703
776
  </script>
@@ -709,7 +782,7 @@ function renderClients(clients) {
709
782
  const port = server.address().port;
710
783
  const url = `http://127.0.0.1:${port}`;
711
784
  console.log(chalk.green(` ✓ Local dashboard: ${url}`));
712
- try { await open(url); } catch { }
785
+ try { await openUrl(url); } catch { }
713
786
  resolve({ url, close: () => server.close() });
714
787
  });
715
788
  });
@@ -721,7 +794,6 @@ function renderClients(clients) {
721
794
  async function spawnPrivateBroker() {
722
795
  console.log(chalk.yellow('\n ⚠️ Public Broker is unreachable. Spawning Private Broker...'));
723
796
 
724
- const packageRoot = path.resolve(__dirname, '../..');
725
797
  const serverEntrypoint = path.join(__dirname, '../server.js');
726
798
  const requireFromServer = createRequire(serverEntrypoint);
727
799
  const requiredBrokerPackages = ['express', 'express-rate-limit', 'helmet'];
@@ -735,34 +807,10 @@ async function spawnPrivateBroker() {
735
807
  });
736
808
 
737
809
  if (missingPackages.length > 0) {
738
- console.log(chalk.yellow(` ⚠️ Missing broker runtime packages: ${missingPackages.join(', ')}`));
739
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
740
- const installResult = await execa(
741
- npmCmd,
742
- ['install', '--no-save', '--no-audit', '--no-fund', ...missingPackages],
743
- { cwd: packageRoot, reject: false, all: true }
810
+ throw new Error(
811
+ `Private broker dependencies are missing: ${missingPackages.join(', ')}. `
812
+ + 'Reinstall iPingYou from its verified package before starting a private broker.'
744
813
  );
745
-
746
- if (installResult.exitCode !== 0) {
747
- const installOutput = installResult.all?.trim();
748
- throw new Error(
749
- `Private broker dependencies failed to install (${missingPackages.join(', ')})`
750
- + (installOutput ? `: ${installOutput}` : '')
751
- );
752
- }
753
-
754
- // Verify modules are now resolvable for the server entrypoint context.
755
- const unresolved = missingPackages.filter((pkg) => {
756
- try {
757
- requireFromServer.resolve(pkg);
758
- return false;
759
- } catch {
760
- return true;
761
- }
762
- });
763
- if (unresolved.length > 0) {
764
- throw new Error(`Private broker dependencies are still missing after install: ${unresolved.join(', ')}`);
765
- }
766
814
  }
767
815
 
768
816
  // 1. Spawn the broker server process
@@ -1080,12 +1128,11 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
1080
1128
  return waitForAction();
1081
1129
 
1082
1130
  case 'terminate': {
1083
- const spinner = createSpinner('Terminating active SSH sessions...', networkSpinner).start();
1131
+ const spinner = createSpinner('Revoking session and closing its tunnel...', networkSpinner).start();
1084
1132
  try {
1085
- if (process.platform === 'win32') {
1086
- await execa('powershell', ['-NoProfile', '-Command', "Get-CimInstance Win32_Process -Filter \"name = 'sshd.exe'\" | Where-Object { $_.CommandLine -match 'sshd:.*@' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], { reject: false });
1087
- } else {
1088
- await execa('pkill', ['-f', 'sshd:.*@'], { reject: false });
1133
+ await revokeUID(BROKER_URL, uid, sessionState.hostToken);
1134
+ tunnelProcess.kill();
1135
+ if (process.platform !== 'win32') {
1089
1136
  await execa('tmux', [...tmuxSocketArgs(), 'kill-server'], { reject: false });
1090
1137
  const legacySessions = await listTmuxSessions();
1091
1138
  for (const session of legacySessions) {
@@ -1094,7 +1141,7 @@ async function hostDashboard(uid, password, serviceConfig, tunnelProcess, sessio
1094
1141
  }
1095
1142
  }
1096
1143
  }
1097
- spinner.succeed('All client SSH sessions terminated');
1144
+ spinner.succeed('Session revoked and iPingYou-owned connections terminated');
1098
1145
  logSessionEvent('host_sessions_terminated');
1099
1146
  } catch {
1100
1147
  spinner.warn('Could not terminate sessions (none active?)');
@@ -1271,13 +1318,13 @@ export async function startHostMode() {
1271
1318
  console.log(chalk.dim(' 🔑 Generating ephemeral SSH key for passwordless entry...'));
1272
1319
  try {
1273
1320
  const ephemeralKey = await generateEphemeralKey();
1274
- const authKeysPath = await injectPublicKey(ephemeralKey.pubKey);
1321
+ const { authKeysPath, authorizedKey } = await injectPublicKey(ephemeralKey.pubKey);
1275
1322
 
1276
1323
  serviceConfig.privateKey = ephemeralKey.privKey;
1277
1324
 
1278
1325
  addCleanupHook(async () => {
1279
1326
  console.log(chalk.dim(' Removing ephemeral public key...'));
1280
- await removePublicKey(authKeysPath, ephemeralKey.pubKey);
1327
+ await removePublicKey(authKeysPath, authorizedKey);
1281
1328
  try { await fs.promises.unlink(ephemeralKey.keyPath); } catch { }
1282
1329
  try { await fs.promises.unlink(`${ephemeralKey.keyPath}.pub`); } catch { }
1283
1330
  });