ai-or-die 0.1.53 → 0.1.59

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/bin/ai-or-die.js CHANGED
@@ -23,7 +23,7 @@ program
23
23
  .option('--no-open', 'do not automatically open browser')
24
24
  .option('--auth <token>', 'authentication token for secure access')
25
25
  .option('--disable-auth', 'disable authentication (not recommended for production)')
26
- .option('--https', 'enable HTTPS (requires cert files)')
26
+ .option('--https', 'enable HTTPS (auto-generates self-signed cert if --cert/--key not provided)')
27
27
  .option('--cert <path>', 'path to SSL certificate file')
28
28
  .option('--key <path>', 'path to SSL private key file')
29
29
  .option('--dev', 'development mode with additional logging')
@@ -125,6 +125,12 @@ async function main() {
125
125
  console.log(` Auth token: \x1b[1m\x1b[33m${authToken}\x1b[0m`);
126
126
  }
127
127
 
128
+ // Warn if STT is enabled without HTTPS or tunnel
129
+ if ((serverOptions.stt || serverOptions.sttEndpoint) && !options.https && !options.tunnel) {
130
+ console.log('\n\x1b[33m⚠ STT enabled over plain HTTP \u2014 microphone only works on localhost.\x1b[0m');
131
+ console.log(' For LAN access, restart with \x1b[1m--https\x1b[0m or \x1b[1m--tunnel\x1b[0m.');
132
+ }
133
+
128
134
  // Dev tunnel or browser open
129
135
  let tunnel = null;
130
136
  if (options.tunnel) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.53",
3
+ "version": "0.1.59",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -39,6 +39,7 @@
39
39
  "cors": "^2.8.5",
40
40
  "express": "^4.19.2",
41
41
  "open": "^10.1.0",
42
+ "selfsigned": "^2.4.1",
42
43
  "sherpa-onnx-node": "^1.12.24",
43
44
  "uuid": "^10.0.0",
44
45
  "ws": "^8.18.0"
package/src/public/app.js CHANGED
@@ -1061,6 +1061,15 @@ class ClaudeCodeWebInterface {
1061
1061
  const btn = document.getElementById('voiceInputBtn');
1062
1062
  if (!btn) return;
1063
1063
 
1064
+ // Microphone APIs require a secure context (HTTPS or localhost)
1065
+ if (typeof window !== 'undefined' && !window.isSecureContext) {
1066
+ btn.style.display = '';
1067
+ btn.disabled = true;
1068
+ btn.title = 'Microphone unavailable \u2014 this page must be served over HTTPS';
1069
+ btn.setAttribute('aria-disabled', 'true');
1070
+ return;
1071
+ }
1072
+
1064
1073
  // Determine mode: prefer local if ready, fall back to cloud
1065
1074
  const localReady = voiceCfg.localStatus === 'ready';
1066
1075
  const cloudAvailable = typeof window !== 'undefined' &&
@@ -1180,7 +1189,9 @@ class ClaudeCodeWebInterface {
1180
1189
  }
1181
1190
  // Show error toast (reuse existing toast pattern)
1182
1191
  var toastMsg = errorMessage;
1183
- if (errorMessage.indexOf('not-allowed') !== -1 || errorMessage.indexOf('Permission') !== -1 || errorMessage.indexOf('permission') !== -1) {
1192
+ if (errorMessage.indexOf('HTTPS') !== -1 || errorMessage.indexOf('secure connection') !== -1 || errorMessage.indexOf('Secure context') !== -1) {
1193
+ toastMsg = 'Microphone requires HTTPS. Restart server with --https or --tunnel.';
1194
+ } else if (errorMessage.indexOf('not-allowed') !== -1 || errorMessage.indexOf('Permission') !== -1 || errorMessage.indexOf('permission') !== -1) {
1184
1195
  toastMsg = errorMessage + '. Check browser permissions';
1185
1196
  }
1186
1197
  if (window.feedback) {
@@ -1371,6 +1382,15 @@ class ClaudeCodeWebInterface {
1371
1382
  if (message.voiceInput) {
1372
1383
  this.voiceInputConfig = message.voiceInput;
1373
1384
  }
1385
+ // On insecure context, keep button disabled regardless of backend status
1386
+ if (typeof window !== 'undefined' && !window.isSecureContext) {
1387
+ if (btn) {
1388
+ btn.style.display = '';
1389
+ btn.disabled = true;
1390
+ btn.title = 'Microphone unavailable \u2014 this page must be served over HTTPS';
1391
+ }
1392
+ break;
1393
+ }
1374
1394
  var localReady = this.voiceInputConfig && this.voiceInputConfig.localStatus === 'ready';
1375
1395
  var cloudAvailable = typeof window !== 'undefined' &&
1376
1396
  !!(window.SpeechRecognition || window.webkitSpeechRecognition);
@@ -22,6 +22,16 @@ var MAX_RECORDING_SECONDS = 120;
22
22
  var MIN_RECORDING_SECONDS = 0.5;
23
23
  var PUSH_TO_TALK_THRESHOLD_MS = 300;
24
24
  var TARGET_SAMPLE_RATE = 16000;
25
+
26
+ /**
27
+ * Whether the page is in a secure context (HTTPS, localhost, file://).
28
+ * Required for getUserMedia, AudioWorklet, and SpeechRecognition.
29
+ * @returns {boolean}
30
+ */
31
+ function isSecureContext() {
32
+ if (typeof window === 'undefined') return false;
33
+ return !!window.isSecureContext;
34
+ }
25
35
  // ---------------------------------------------------------------------------
26
36
  // Utility: Float32 → Int16 conversion
27
37
  // ---------------------------------------------------------------------------
@@ -97,11 +107,13 @@ function SpeechRecognitionRecorder() {
97
107
 
98
108
  /**
99
109
  * Whether the SpeechRecognition API is available in this browser.
110
+ * Requires a secure context (HTTPS or localhost) for microphone access.
100
111
  * @returns {boolean}
101
112
  */
102
113
  SpeechRecognitionRecorder.isSupported = function () {
103
- return !!(typeof window !== 'undefined' &&
104
- (window.SpeechRecognition || window.webkitSpeechRecognition));
114
+ if (typeof window === 'undefined') return false;
115
+ if (!window.isSecureContext) return false;
116
+ return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
105
117
  };
106
118
 
107
119
  /**
@@ -287,6 +299,20 @@ LocalVoiceRecorder.prototype.start = function () {
287
299
  return Promise.reject(new Error('Already recording'));
288
300
  }
289
301
 
302
+ // Secure context required for getUserMedia (HTTPS or localhost)
303
+ if (typeof window !== 'undefined' && !window.isSecureContext) {
304
+ return Promise.reject(new Error(
305
+ 'Microphone requires a secure connection (HTTPS). ' +
306
+ 'Restart the server with --https or --tunnel for LAN access.'
307
+ ));
308
+ }
309
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
310
+ return Promise.reject(new Error(
311
+ 'Microphone access is not available. ' +
312
+ 'This page must be served over HTTPS or accessed via localhost.'
313
+ ));
314
+ }
315
+
290
316
  return navigator.mediaDevices.getUserMedia({
291
317
  audio: {
292
318
  echoCancellation: true,
@@ -842,6 +868,7 @@ var voiceHandlerExports = {
842
868
  // Utilities
843
869
  float32ToInt16: float32ToInt16,
844
870
  resample: resample,
871
+ isSecureContext: isSecureContext,
845
872
 
846
873
  // Recorders
847
874
  SpeechRecognitionRecorder: SpeechRecognitionRecorder,
package/src/server.js CHANGED
@@ -1339,12 +1339,25 @@ class ClaudeCodeWebServer {
1339
1339
  let server;
1340
1340
 
1341
1341
  if (this.useHttps) {
1342
- if (!this.certFile || !this.keyFile) {
1343
- throw new Error('HTTPS requires both --cert and --key options');
1342
+ let cert, key;
1343
+ if (this.certFile && this.keyFile) {
1344
+ // User-provided certs
1345
+ cert = fs.readFileSync(this.certFile);
1346
+ key = fs.readFileSync(this.keyFile);
1347
+ } else {
1348
+ // Auto-generate self-signed cert for LAN use
1349
+ const { ensureCert } = require('./utils/self-signed-cert');
1350
+ const certInfo = ensureCert();
1351
+ cert = certInfo.cert;
1352
+ key = certInfo.key;
1353
+ const action = certInfo.generated ? 'Generated' : 'Using cached';
1354
+ console.log(`\n[HTTPS] ${action} self-signed certificate`);
1355
+ if (certInfo.ips.length > 0) {
1356
+ console.log(` Covers: localhost, ${certInfo.ips.join(', ')}`);
1357
+ }
1358
+ console.log(` Cached at: ${certInfo.certPath}`);
1359
+ console.log(' Browsers will show a security warning on first visit.');
1344
1360
  }
1345
-
1346
- const cert = fs.readFileSync(this.certFile);
1347
- const key = fs.readFileSync(this.keyFile);
1348
1361
  server = https.createServer({ cert, key }, this.app);
1349
1362
  } else {
1350
1363
  server = http.createServer(this.app);
@@ -1400,7 +1413,8 @@ class ClaudeCodeWebServer {
1400
1413
  id: wsId,
1401
1414
  ws,
1402
1415
  claudeSessionId: null,
1403
- created: new Date()
1416
+ created: new Date(),
1417
+ secure: !!req.connection.encrypted
1404
1418
  };
1405
1419
  this.webSocketConnections.set(wsId, wsInfo);
1406
1420
 
@@ -1995,6 +2009,16 @@ class ClaudeCodeWebServer {
1995
2009
  }
1996
2010
  }
1997
2011
 
2012
+ _isLocalhostConnection(ws) {
2013
+ try {
2014
+ const addr = ws._socket && ws._socket.remoteAddress;
2015
+ if (!addr) return false;
2016
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
2017
+ } catch (_) {
2018
+ return false;
2019
+ }
2020
+ }
2021
+
1998
2022
  broadcastToSession(claudeSessionId, data) {
1999
2023
  const session = this.claudeSessions.get(claudeSessionId);
2000
2024
  if (!session) return;
@@ -2472,6 +2496,15 @@ class ClaudeCodeWebServer {
2472
2496
  const wsInfo = this.webSocketConnections.get(wsId);
2473
2497
  if (!wsInfo) return;
2474
2498
 
2499
+ // Reject voice uploads over HTTP from non-localhost origins (defense-in-depth)
2500
+ if (!wsInfo.secure && !this._isLocalhostConnection(wsInfo.ws)) {
2501
+ this.sendToWebSocket(wsInfo.ws, {
2502
+ type: 'voice_transcription_error',
2503
+ message: 'Voice input requires a secure connection (HTTPS). Restart with --https or --tunnel.'
2504
+ });
2505
+ return;
2506
+ }
2507
+
2475
2508
  if (!wsInfo.claudeSessionId) {
2476
2509
  this.sendToWebSocket(wsInfo.ws, {
2477
2510
  type: 'voice_transcription_error',
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CERT_DIR = path.join(os.homedir(), '.ai-or-die', 'certs');
8
+ const CERT_PATH = path.join(CERT_DIR, 'server.cert');
9
+ const KEY_PATH = path.join(CERT_DIR, 'server.key');
10
+ const META_PATH = path.join(CERT_DIR, 'cert-meta.json');
11
+ const CERT_DAYS = 365;
12
+
13
+ /**
14
+ * Get all non-internal IPv4 addresses from network interfaces.
15
+ * @returns {string[]}
16
+ */
17
+ function getLocalIPs() {
18
+ const interfaces = os.networkInterfaces();
19
+ const ips = [];
20
+ for (const name of Object.keys(interfaces)) {
21
+ for (const iface of interfaces[name]) {
22
+ if (!iface.internal && iface.family === 'IPv4') {
23
+ ips.push(iface.address);
24
+ }
25
+ }
26
+ }
27
+ return ips;
28
+ }
29
+
30
+ /**
31
+ * Check if the cached certificate covers the current LAN IPs and is not expired.
32
+ * @returns {{ valid: boolean, reason?: string }}
33
+ */
34
+ function validateCachedCert() {
35
+ if (!fs.existsSync(CERT_PATH) || !fs.existsSync(KEY_PATH) || !fs.existsSync(META_PATH)) {
36
+ return { valid: false, reason: 'missing' };
37
+ }
38
+
39
+ try {
40
+ const meta = JSON.parse(fs.readFileSync(META_PATH, 'utf8'));
41
+
42
+ // Check expiry
43
+ const expiresAt = new Date(meta.expiresAt);
44
+ if (expiresAt <= new Date()) {
45
+ return { valid: false, reason: 'expired' };
46
+ }
47
+
48
+ // Check if current LAN IPs match what was in the cert
49
+ const currentIPs = getLocalIPs().sort();
50
+ const certIPs = (meta.lanIPs || []).sort();
51
+ if (JSON.stringify(currentIPs) !== JSON.stringify(certIPs)) {
52
+ return { valid: false, reason: 'ip-changed' };
53
+ }
54
+
55
+ return { valid: true };
56
+ } catch (_) {
57
+ return { valid: false, reason: 'corrupt' };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Generate a self-signed certificate with SANs for localhost and LAN IPs.
63
+ * Caches the result at ~/.ai-or-die/certs/ for reuse.
64
+ *
65
+ * @returns {{ cert: string, key: string, ips: string[] }}
66
+ */
67
+ function generateCert() {
68
+ const selfsigned = require('selfsigned');
69
+ const ips = getLocalIPs();
70
+
71
+ const altNames = [
72
+ { type: 2, value: 'localhost' },
73
+ { type: 7, ip: '127.0.0.1' },
74
+ { type: 7, ip: '::1' },
75
+ ...ips.map(ip => ({ type: 7, ip })),
76
+ ];
77
+
78
+ const attrs = [{ name: 'commonName', value: 'ai-or-die' }];
79
+ const pems = selfsigned.generate(attrs, {
80
+ keySize: 2048,
81
+ days: CERT_DAYS,
82
+ algorithm: 'sha256',
83
+ extensions: [
84
+ { name: 'subjectAltName', altNames },
85
+ ],
86
+ });
87
+
88
+ // Ensure cert directory with restricted permissions
89
+ fs.mkdirSync(CERT_DIR, { recursive: true, mode: 0o700 });
90
+
91
+ // Write cert and key with restricted permissions
92
+ fs.writeFileSync(CERT_PATH, pems.cert, { mode: 0o644 });
93
+ fs.writeFileSync(KEY_PATH, pems.private, { mode: 0o600 });
94
+
95
+ // Write metadata for cache validation
96
+ const meta = {
97
+ createdAt: new Date().toISOString(),
98
+ expiresAt: new Date(Date.now() + CERT_DAYS * 24 * 60 * 60 * 1000).toISOString(),
99
+ lanIPs: ips,
100
+ };
101
+ fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2), { mode: 0o644 });
102
+
103
+ return { cert: pems.cert, key: pems.private, ips };
104
+ }
105
+
106
+ /**
107
+ * Ensure a valid self-signed certificate exists, generating one if needed.
108
+ * Returns the cert and key as strings.
109
+ *
110
+ * @returns {{ cert: string, key: string, certPath: string, ips: string[], generated: boolean }}
111
+ */
112
+ function ensureCert() {
113
+ const validation = validateCachedCert();
114
+
115
+ if (validation.valid) {
116
+ const cert = fs.readFileSync(CERT_PATH, 'utf8');
117
+ const key = fs.readFileSync(KEY_PATH, 'utf8');
118
+ const meta = JSON.parse(fs.readFileSync(META_PATH, 'utf8'));
119
+ return { cert, key, certPath: CERT_PATH, ips: meta.lanIPs || [], generated: false };
120
+ }
121
+
122
+ const { cert, key, ips } = generateCert();
123
+ return { cert, key, certPath: CERT_PATH, ips, generated: true };
124
+ }
125
+
126
+ module.exports = { ensureCert, getLocalIPs, CERT_DIR };