ai-or-die 0.1.53 → 0.1.60
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 +7 -1
- package/package.json +2 -1
- package/src/public/app.js +21 -1
- package/src/public/voice-handler.js +29 -2
- package/src/server.js +39 -6
- package/src/utils/self-signed-cert.js +126 -0
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 (
|
|
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.
|
|
3
|
+
"version": "0.1.60",
|
|
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('
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
1343
|
-
|
|
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 };
|