airloom 0.1.27 → 0.1.29
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/dist/index.js +525 -108
- package/dist/viewer/assets/{browser-CyAZgeub.js → browser-CM6Qwnug.js} +1 -1
- package/dist/viewer/assets/{index-BYOU4j-Z.css → index-BuD6bVaj.css} +1 -1
- package/dist/viewer/assets/{index-C64jxT85.js → index-CjHk9MyJ.js} +17 -17
- package/dist/viewer/assets/{index-PCs83gKK.js → index-I9e_v5C5.js} +1 -1
- package/dist/viewer/index.html +9 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ function decodeChannelMessage(data) {
|
|
|
38
38
|
function encodePairingData(data) {
|
|
39
39
|
return JSON.stringify(data);
|
|
40
40
|
}
|
|
41
|
+
var utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
41
42
|
function randomCode(length) {
|
|
42
43
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
43
44
|
const limit = 256 - 256 % chars.length;
|
|
@@ -553,9 +554,15 @@ var AblyAdapter = class {
|
|
|
553
554
|
}
|
|
554
555
|
});
|
|
555
556
|
this.channel.presence.subscribe("leave", (member) => {
|
|
556
|
-
if (member.clientId
|
|
557
|
+
if (member.clientId === this.clientId) return;
|
|
558
|
+
this.channel.presence.get().then((members2) => {
|
|
559
|
+
const hasPeer2 = members2.some((m) => m.clientId !== this.clientId);
|
|
560
|
+
if (!hasPeer2) {
|
|
561
|
+
this.peerLeftHandlers.forEach((h) => h());
|
|
562
|
+
}
|
|
563
|
+
}).catch(() => {
|
|
557
564
|
this.peerLeftHandlers.forEach((h) => h());
|
|
558
|
-
}
|
|
565
|
+
});
|
|
559
566
|
});
|
|
560
567
|
await this.channel.presence.enter({ role });
|
|
561
568
|
const members = await this.channel.presence.get();
|
|
@@ -566,7 +573,10 @@ var AblyAdapter = class {
|
|
|
566
573
|
}
|
|
567
574
|
send(payload) {
|
|
568
575
|
if (!this.channel || !this._connected) return;
|
|
569
|
-
this.channel.publish("forward", payload)
|
|
576
|
+
this.channel.publish("forward", payload).catch((err) => {
|
|
577
|
+
console.error("[ably] publish error:", err.message);
|
|
578
|
+
this.errorHandlers.forEach((h) => h(err));
|
|
579
|
+
});
|
|
570
580
|
}
|
|
571
581
|
onMessage(handler) {
|
|
572
582
|
this.messageHandlers.push(handler);
|
|
@@ -744,6 +754,24 @@ var OpenAIAdapter = class {
|
|
|
744
754
|
import { spawn } from "node:child_process";
|
|
745
755
|
import { existsSync } from "node:fs";
|
|
746
756
|
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
757
|
+
|
|
758
|
+
// src/log.ts
|
|
759
|
+
function ts() {
|
|
760
|
+
const d = /* @__PURE__ */ new Date();
|
|
761
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
762
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
763
|
+
const ss = String(d.getSeconds()).padStart(2, "0");
|
|
764
|
+
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
|
765
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
766
|
+
}
|
|
767
|
+
function log(...args) {
|
|
768
|
+
console.log(ts(), ...args);
|
|
769
|
+
}
|
|
770
|
+
function logError(...args) {
|
|
771
|
+
console.error(ts(), ...args);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// src/adapters/cli.ts
|
|
747
775
|
var CLI_PRESETS = [
|
|
748
776
|
{
|
|
749
777
|
id: "devin",
|
|
@@ -884,7 +912,7 @@ var CLIAdapter = class {
|
|
|
884
912
|
if (this.pty) return this.pty;
|
|
885
913
|
const nodePty = await import("node-pty");
|
|
886
914
|
const executable = resolveExecutable(this.command) ?? this.command;
|
|
887
|
-
|
|
915
|
+
log(`[cli-repl] Spawning PTY: ${executable} ${this.args.join(" ")}`);
|
|
888
916
|
const pty = nodePty.spawn(executable, this.args, {
|
|
889
917
|
name: "xterm-256color",
|
|
890
918
|
cols: 120,
|
|
@@ -894,7 +922,7 @@ var CLIAdapter = class {
|
|
|
894
922
|
});
|
|
895
923
|
pty.onData((data) => this.onData(data));
|
|
896
924
|
pty.onExit(({ exitCode }) => {
|
|
897
|
-
|
|
925
|
+
log(`[cli-repl] PTY exited (code ${exitCode})`);
|
|
898
926
|
this.pty = null;
|
|
899
927
|
this.ptyState = "idle";
|
|
900
928
|
this.finishResponse();
|
|
@@ -1017,7 +1045,7 @@ function saveConfig(config) {
|
|
|
1017
1045
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1018
1046
|
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1019
1047
|
} catch (err) {
|
|
1020
|
-
|
|
1048
|
+
logError("[config] Failed to save:", err.message);
|
|
1021
1049
|
}
|
|
1022
1050
|
}
|
|
1023
1051
|
function getConfigPath() {
|
|
@@ -1038,7 +1066,7 @@ function fixSpawnHelperPermissions() {
|
|
|
1038
1066
|
const mode = statSync(helperPath).mode;
|
|
1039
1067
|
if (!(mode & 73)) {
|
|
1040
1068
|
chmodSync(helperPath, mode | 493);
|
|
1041
|
-
|
|
1069
|
+
log(`[host] Fixed spawn-helper permissions: ${helperPath}`);
|
|
1042
1070
|
}
|
|
1043
1071
|
} catch {
|
|
1044
1072
|
}
|
|
@@ -1169,25 +1197,29 @@ var TerminalSession = class {
|
|
|
1169
1197
|
cols = 120;
|
|
1170
1198
|
rows = 36;
|
|
1171
1199
|
outputBuffer = "";
|
|
1200
|
+
/** True after the first viewer has ever attached. The pre-viewer output
|
|
1201
|
+
* buffer contains PTY startup junk (zsh PROMPT_SP "%", resize-triggered
|
|
1202
|
+
* prompt redraws) that shouldn't be replayed. */
|
|
1203
|
+
hasHadViewer = false;
|
|
1172
1204
|
start() {
|
|
1173
1205
|
const command = getDefaultTerminalCommand(this.getLaunchCommand?.());
|
|
1174
1206
|
const file = resolveExecutable2(command.file) ?? command.file;
|
|
1175
1207
|
const cwd = process.cwd();
|
|
1176
|
-
|
|
1177
|
-
const
|
|
1178
|
-
const spawnOpts = { name: "xterm-256color", cols: this.cols, rows: this.rows, cwd, env };
|
|
1208
|
+
log(`[host] PTY spawn: ${file} ${command.args.join(" ")} (${this.cols}x${this.rows}) node=${process.version}`);
|
|
1209
|
+
const env2 = { ...process.env, TERM: "xterm-256color" };
|
|
1210
|
+
const spawnOpts = { name: "xterm-256color", cols: this.cols, rows: this.rows, cwd, env: env2 };
|
|
1179
1211
|
try {
|
|
1180
1212
|
this.pty = spawn2(file, command.args, spawnOpts);
|
|
1181
1213
|
} catch (err) {
|
|
1182
1214
|
const e = err;
|
|
1183
|
-
|
|
1215
|
+
logError(`[host] PTY spawn failed: ${e.message} (code=${e.code ?? "none"}) file=${file} cwd=${cwd}`);
|
|
1184
1216
|
if (file !== "/bin/sh") {
|
|
1185
|
-
|
|
1217
|
+
logError("[host] Retrying with /bin/sh...");
|
|
1186
1218
|
try {
|
|
1187
1219
|
this.pty = spawn2("/bin/sh", [], spawnOpts);
|
|
1188
|
-
|
|
1220
|
+
log("[host] PTY fallback to /bin/sh succeeded");
|
|
1189
1221
|
} catch (err2) {
|
|
1190
|
-
|
|
1222
|
+
logError("[host] PTY fallback also failed:", err2.message);
|
|
1191
1223
|
return;
|
|
1192
1224
|
}
|
|
1193
1225
|
} else {
|
|
@@ -1195,7 +1227,6 @@ var TerminalSession = class {
|
|
|
1195
1227
|
}
|
|
1196
1228
|
}
|
|
1197
1229
|
this.pty.onData((data) => {
|
|
1198
|
-
process.stdout.write(data);
|
|
1199
1230
|
this.outputBuffer += data;
|
|
1200
1231
|
if (this.outputBuffer.length > MAX_BUFFER_BYTES) {
|
|
1201
1232
|
this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - MAX_BUFFER_BYTES);
|
|
@@ -1231,19 +1262,33 @@ var TerminalSession = class {
|
|
|
1231
1262
|
attach(message) {
|
|
1232
1263
|
this.cols = Math.max(20, Math.floor(message.cols || this.cols));
|
|
1233
1264
|
this.rows = Math.max(5, Math.floor(message.rows || this.rows));
|
|
1234
|
-
this.pty?.resize(this.cols, this.rows);
|
|
1235
1265
|
this.detachStream();
|
|
1236
1266
|
const meta = { kind: "terminal", cols: this.cols, rows: this.rows };
|
|
1237
1267
|
this.stream = this.channel.createStream(meta);
|
|
1238
1268
|
this.batcher = new AdaptiveOutputBatcher((data) => {
|
|
1239
1269
|
this.stream?.write(data);
|
|
1240
1270
|
});
|
|
1241
|
-
if (this.outputBuffer) {
|
|
1242
|
-
this.stream.write(this.outputBuffer);
|
|
1243
|
-
}
|
|
1244
1271
|
if (!this.pty) {
|
|
1245
1272
|
this.start();
|
|
1246
1273
|
}
|
|
1274
|
+
const isFirstViewer = !this.hasHadViewer;
|
|
1275
|
+
if (!isFirstViewer && this.outputBuffer) {
|
|
1276
|
+
const REPLAY_CAP = 60 * 1024;
|
|
1277
|
+
const replay = this.outputBuffer.length <= REPLAY_CAP ? this.outputBuffer : this.outputBuffer.slice(this.outputBuffer.length - REPLAY_CAP);
|
|
1278
|
+
this.stream.write(replay);
|
|
1279
|
+
}
|
|
1280
|
+
this.outputBuffer = "";
|
|
1281
|
+
this.hasHadViewer = true;
|
|
1282
|
+
if (this.pty) {
|
|
1283
|
+
this.pty.resize(this.cols, this.rows);
|
|
1284
|
+
}
|
|
1285
|
+
if (isFirstViewer) {
|
|
1286
|
+
setTimeout(() => {
|
|
1287
|
+
if (this.pty && this.batcher) {
|
|
1288
|
+
this.pty.write("\f");
|
|
1289
|
+
}
|
|
1290
|
+
}, 250);
|
|
1291
|
+
}
|
|
1247
1292
|
}
|
|
1248
1293
|
/** End the current stream without killing the PTY (called on peer disconnect). */
|
|
1249
1294
|
detachStream() {
|
|
@@ -1288,6 +1333,73 @@ function isTerminalMessage(data) {
|
|
|
1288
1333
|
return type === "terminal_open" || type === "terminal_input" || type === "terminal_resize" || type === "terminal_close" || type === "terminal_exit";
|
|
1289
1334
|
}
|
|
1290
1335
|
|
|
1336
|
+
// src/security.ts
|
|
1337
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
1338
|
+
var CONTROL_COOKIE_NAME = "airloom_control";
|
|
1339
|
+
var FixedWindowRateLimiter = class {
|
|
1340
|
+
entries = /* @__PURE__ */ new Map();
|
|
1341
|
+
allow(key, max, windowMs) {
|
|
1342
|
+
const now = Date.now();
|
|
1343
|
+
const entry = this.entries.get(key);
|
|
1344
|
+
if (!entry || entry.resetAt <= now) {
|
|
1345
|
+
this.entries.set(key, { count: 1, resetAt: now + windowMs });
|
|
1346
|
+
return true;
|
|
1347
|
+
}
|
|
1348
|
+
if (entry.count >= max) return false;
|
|
1349
|
+
entry.count += 1;
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
};
|
|
1353
|
+
function createControlToken() {
|
|
1354
|
+
return randomBytes(24).toString("base64url");
|
|
1355
|
+
}
|
|
1356
|
+
function encodeControlUrl(baseUrl, token) {
|
|
1357
|
+
const url = new URL(baseUrl);
|
|
1358
|
+
url.searchParams.set("t", token);
|
|
1359
|
+
return url.toString();
|
|
1360
|
+
}
|
|
1361
|
+
function parseCookies(header) {
|
|
1362
|
+
const result = {};
|
|
1363
|
+
if (!header) return result;
|
|
1364
|
+
for (const part of header.split(";")) {
|
|
1365
|
+
const [rawKey, ...rawValue] = part.trim().split("=");
|
|
1366
|
+
if (!rawKey) continue;
|
|
1367
|
+
result[rawKey] = decodeURIComponent(rawValue.join("="));
|
|
1368
|
+
}
|
|
1369
|
+
return result;
|
|
1370
|
+
}
|
|
1371
|
+
function safeEqual(candidate, expected) {
|
|
1372
|
+
const left = Buffer.from(candidate);
|
|
1373
|
+
const right = Buffer.from(expected);
|
|
1374
|
+
if (left.length !== right.length) return false;
|
|
1375
|
+
return timingSafeEqual(left, right);
|
|
1376
|
+
}
|
|
1377
|
+
function readQueryToken(req) {
|
|
1378
|
+
if (req.query && typeof req.query.t === "string") return req.query.t;
|
|
1379
|
+
if (!req.url || !req.headers.host) return null;
|
|
1380
|
+
try {
|
|
1381
|
+
return new URL(req.url, `http://${req.headers.host}`).searchParams.get("t");
|
|
1382
|
+
} catch {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
function readControlToken(req) {
|
|
1387
|
+
const headerToken = req.headers["x-airloom-control"];
|
|
1388
|
+
if (typeof headerToken === "string") return headerToken;
|
|
1389
|
+
const queryToken = readQueryToken(req);
|
|
1390
|
+
if (queryToken) return queryToken;
|
|
1391
|
+
return parseCookies(req.headers.cookie)[CONTROL_COOKIE_NAME] ?? null;
|
|
1392
|
+
}
|
|
1393
|
+
function hasValidControlToken(req, expected) {
|
|
1394
|
+
const token = readControlToken(req);
|
|
1395
|
+
return typeof token === "string" && safeEqual(token, expected);
|
|
1396
|
+
}
|
|
1397
|
+
function hasAllowedOrigin(headers, host) {
|
|
1398
|
+
const origin = headers.origin;
|
|
1399
|
+
if (!origin) return true;
|
|
1400
|
+
return origin === `http://${host}` || origin === `https://${host}`;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1291
1403
|
// src/server.ts
|
|
1292
1404
|
var MAX_MESSAGES = 200;
|
|
1293
1405
|
function trimMessages(messages) {
|
|
@@ -1295,21 +1407,95 @@ function trimMessages(messages) {
|
|
|
1295
1407
|
}
|
|
1296
1408
|
var aiLock = Promise.resolve();
|
|
1297
1409
|
function enqueueAIResponse(channel, adapter, state, broadcast) {
|
|
1298
|
-
aiLock = aiLock.then(() => handleAIResponse(channel, adapter, state, broadcast)).catch((err) =>
|
|
1410
|
+
aiLock = aiLock.then(() => handleAIResponse(channel, adapter, state, broadcast)).catch((err) => logError("[host] AI response error:", err));
|
|
1299
1411
|
}
|
|
1300
1412
|
function createHostServer(opts) {
|
|
1301
1413
|
const app = express();
|
|
1302
1414
|
const server = createServer(app);
|
|
1303
|
-
const wss = new WebSocketServer({
|
|
1415
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1304
1416
|
const uiClients = /* @__PURE__ */ new Set();
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1417
|
+
const rateLimiter = new FixedWindowRateLimiter();
|
|
1418
|
+
function requestKey(req) {
|
|
1419
|
+
return req.ip || req.socket.remoteAddress || "unknown";
|
|
1420
|
+
}
|
|
1421
|
+
function setControlCookie(req, res) {
|
|
1422
|
+
const parts = [
|
|
1423
|
+
`${CONTROL_COOKIE_NAME}=${encodeURIComponent(opts.controlToken)}`,
|
|
1424
|
+
"HttpOnly",
|
|
1425
|
+
"SameSite=Strict",
|
|
1426
|
+
"Path=/"
|
|
1427
|
+
];
|
|
1428
|
+
if (req.secure) parts.push("Secure");
|
|
1429
|
+
res.setHeader("Set-Cookie", parts.join("; "));
|
|
1430
|
+
}
|
|
1431
|
+
function requireSameOrigin(req, res) {
|
|
1432
|
+
const host = req.get("host");
|
|
1433
|
+
if (!host || !hasAllowedOrigin(req.headers, host)) {
|
|
1434
|
+
res.status(403).json({ error: "Forbidden" });
|
|
1435
|
+
return false;
|
|
1436
|
+
}
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
function requireControlAuth(req, res) {
|
|
1440
|
+
if (!requireSameOrigin(req, res)) return false;
|
|
1441
|
+
if (!hasValidControlToken(req, opts.controlToken)) {
|
|
1442
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
1443
|
+
return false;
|
|
1444
|
+
}
|
|
1445
|
+
if (readControlToken(req) === opts.controlToken) setControlCookie(req, res);
|
|
1446
|
+
return true;
|
|
1447
|
+
}
|
|
1448
|
+
function requireRateLimit(req, res, bucket, max, windowMs) {
|
|
1449
|
+
if (rateLimiter.allow(`${bucket}:${requestKey(req)}`, max, windowMs)) return true;
|
|
1450
|
+
res.status(429).json({ error: "Rate limited" });
|
|
1451
|
+
return false;
|
|
1452
|
+
}
|
|
1453
|
+
function rejectUpgrade(socket, statusCode, message) {
|
|
1454
|
+
socket.write(`HTTP/1.1 ${statusCode} ${message}\r
|
|
1455
|
+
Connection: close\r
|
|
1456
|
+
Content-Type: text/plain; charset=utf-8\r
|
|
1457
|
+
Content-Length: ${Buffer.byteLength(message)}\r
|
|
1458
|
+
\r
|
|
1459
|
+
${message}`);
|
|
1460
|
+
socket.destroy();
|
|
1461
|
+
}
|
|
1462
|
+
app.disable("x-powered-by");
|
|
1463
|
+
app.use(express.json({ limit: "16kb" }));
|
|
1464
|
+
app.use((_req, res, next) => {
|
|
1465
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1466
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1467
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
1468
|
+
next();
|
|
1469
|
+
});
|
|
1470
|
+
app.get("/healthz", (_req, res) => {
|
|
1471
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1472
|
+
res.json({ ok: true, connected: opts.state.connected, transport: opts.state.transport ?? "ws" });
|
|
1308
1473
|
});
|
|
1309
1474
|
if (opts.viewerDir && existsSync3(opts.viewerDir)) {
|
|
1310
1475
|
app.use("/viewer", express.static(opts.viewerDir));
|
|
1311
1476
|
}
|
|
1312
|
-
app.get("/
|
|
1477
|
+
app.get("/", (req, res) => {
|
|
1478
|
+
if (!requireRateLimit(req, res, "root", 20, 6e4)) return;
|
|
1479
|
+
const host = req.get("host");
|
|
1480
|
+
if (!host || !hasAllowedOrigin(req.headers, host)) {
|
|
1481
|
+
res.status(403).type("text/plain").send("Forbidden");
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (!hasValidControlToken(req, opts.controlToken)) {
|
|
1485
|
+
res.status(401).type("text/plain").send("Unauthorized. Open the tokenized Airloom URL printed by the host process.");
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
setControlCookie(req, res);
|
|
1489
|
+
if (typeof req.query.t === "string") {
|
|
1490
|
+
res.redirect("/");
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1494
|
+
res.type("html").send(HOST_HTML);
|
|
1495
|
+
});
|
|
1496
|
+
app.get("/api/status", (req, res) => {
|
|
1497
|
+
if (!requireRateLimit(req, res, "status", 60, 6e4) || !requireControlAuth(req, res)) return;
|
|
1498
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1313
1499
|
res.json({
|
|
1314
1500
|
connected: opts.state.connected,
|
|
1315
1501
|
pairingCode: opts.state.pairingCode,
|
|
@@ -1320,11 +1506,15 @@ function createHostServer(opts) {
|
|
|
1320
1506
|
messages: opts.state.messages
|
|
1321
1507
|
});
|
|
1322
1508
|
});
|
|
1323
|
-
app.get("/api/cli-presets", (
|
|
1509
|
+
app.get("/api/cli-presets", (req, res) => {
|
|
1510
|
+
if (!requireRateLimit(req, res, "cli-presets", 60, 6e4) || !requireControlAuth(req, res)) return;
|
|
1511
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1324
1512
|
res.json(CLI_PRESETS);
|
|
1325
1513
|
});
|
|
1326
|
-
app.get("/api/config", (
|
|
1514
|
+
app.get("/api/config", (req, res) => {
|
|
1515
|
+
if (!requireRateLimit(req, res, "config-get", 60, 6e4) || !requireControlAuth(req, res)) return;
|
|
1327
1516
|
const saved = loadConfig();
|
|
1517
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1328
1518
|
res.json({
|
|
1329
1519
|
saved,
|
|
1330
1520
|
envKeys: {
|
|
@@ -1333,32 +1523,52 @@ function createHostServer(opts) {
|
|
|
1333
1523
|
}
|
|
1334
1524
|
});
|
|
1335
1525
|
});
|
|
1526
|
+
const allowedPresetIds = /* @__PURE__ */ new Set(["shell", ...CLI_PRESETS.map((preset) => preset.id)]);
|
|
1527
|
+
function readString(value, maxLength) {
|
|
1528
|
+
if (typeof value !== "string") return void 0;
|
|
1529
|
+
const trimmed = value.trim();
|
|
1530
|
+
if (!trimmed) return void 0;
|
|
1531
|
+
return trimmed.length <= maxLength ? trimmed : void 0;
|
|
1532
|
+
}
|
|
1336
1533
|
app.post("/api/configure", (req, res) => {
|
|
1534
|
+
if (!requireRateLimit(req, res, "configure", 10, 6e4) || !requireControlAuth(req, res)) return;
|
|
1535
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1337
1536
|
const { type, apiKey, model, command, preset } = req.body;
|
|
1537
|
+
if (type !== "anthropic" && type !== "openai" && type !== "cli") {
|
|
1538
|
+
res.status(400).json({ error: "Unknown adapter type" });
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
const normalizedApiKey = readString(apiKey, 512);
|
|
1542
|
+
const normalizedModel = readString(model, 200);
|
|
1543
|
+
const normalizedCommand = readString(command, 512);
|
|
1544
|
+
const selectedPreset = typeof preset === "string" ? preset : "shell";
|
|
1545
|
+
if (!allowedPresetIds.has(selectedPreset)) {
|
|
1546
|
+
res.status(400).json({ error: "Unknown preset" });
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1338
1549
|
try {
|
|
1339
1550
|
switch (type) {
|
|
1340
1551
|
case "anthropic": {
|
|
1341
|
-
const key =
|
|
1552
|
+
const key = normalizedApiKey || process.env.ANTHROPIC_API_KEY;
|
|
1342
1553
|
if (!key) {
|
|
1343
1554
|
res.status(400).json({ error: "API key required (or set ANTHROPIC_API_KEY env var)" });
|
|
1344
1555
|
return;
|
|
1345
1556
|
}
|
|
1346
|
-
opts.state.adapter = new AnthropicAdapter({ apiKey: key, model });
|
|
1557
|
+
opts.state.adapter = new AnthropicAdapter({ apiKey: key, model: normalizedModel });
|
|
1347
1558
|
break;
|
|
1348
1559
|
}
|
|
1349
1560
|
case "openai": {
|
|
1350
|
-
const key =
|
|
1561
|
+
const key = normalizedApiKey || process.env.OPENAI_API_KEY;
|
|
1351
1562
|
if (!key) {
|
|
1352
1563
|
res.status(400).json({ error: "API key required (or set OPENAI_API_KEY env var)" });
|
|
1353
1564
|
return;
|
|
1354
1565
|
}
|
|
1355
|
-
opts.state.adapter = new OpenAIAdapter({ apiKey: key, model });
|
|
1566
|
+
opts.state.adapter = new OpenAIAdapter({ apiKey: key, model: normalizedModel });
|
|
1356
1567
|
break;
|
|
1357
1568
|
}
|
|
1358
1569
|
case "cli": {
|
|
1359
|
-
const selectedPreset = typeof preset === "string" ? preset : "shell";
|
|
1360
1570
|
const presetInfo = CLI_PRESETS.find((p) => p.id === selectedPreset);
|
|
1361
|
-
const cmd = selectedPreset === "shell" ? void 0 :
|
|
1571
|
+
const cmd = selectedPreset === "shell" ? void 0 : normalizedCommand ?? presetInfo?.command;
|
|
1362
1572
|
opts.state.terminalLaunchCommand = cmd;
|
|
1363
1573
|
opts.state.terminalLaunch = getTerminalLaunchDisplay(cmd);
|
|
1364
1574
|
opts.state.adapter = null;
|
|
@@ -1368,15 +1578,12 @@ function createHostServer(opts) {
|
|
|
1368
1578
|
res.json({ ok: true, terminalLaunch: opts.state.terminalLaunch });
|
|
1369
1579
|
return;
|
|
1370
1580
|
}
|
|
1371
|
-
default:
|
|
1372
|
-
res.status(400).json({ error: "Unknown adapter type" });
|
|
1373
|
-
return;
|
|
1374
1581
|
}
|
|
1375
1582
|
const cfg = { type };
|
|
1376
|
-
if (
|
|
1583
|
+
if (normalizedModel) cfg.model = normalizedModel;
|
|
1377
1584
|
if (type === "cli") {
|
|
1378
|
-
cfg.command =
|
|
1379
|
-
cfg.preset =
|
|
1585
|
+
cfg.command = normalizedCommand;
|
|
1586
|
+
cfg.preset = selectedPreset;
|
|
1380
1587
|
}
|
|
1381
1588
|
saveConfig(cfg);
|
|
1382
1589
|
broadcast({ type: "configured", adapter: { name: opts.state.adapter?.name ?? "none", model: opts.state.adapter?.model ?? "" } });
|
|
@@ -1387,7 +1594,9 @@ function createHostServer(opts) {
|
|
|
1387
1594
|
}
|
|
1388
1595
|
});
|
|
1389
1596
|
app.post("/api/send", async (req, res) => {
|
|
1390
|
-
|
|
1597
|
+
if (!requireRateLimit(req, res, "send", 30, 6e4) || !requireControlAuth(req, res)) return;
|
|
1598
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1599
|
+
const content = readString(req.body?.content, 4e3);
|
|
1391
1600
|
if (!content) {
|
|
1392
1601
|
res.status(400).json({ error: "No content" });
|
|
1393
1602
|
return;
|
|
@@ -1401,30 +1610,73 @@ function createHostServer(opts) {
|
|
|
1401
1610
|
res.json({ ok: true });
|
|
1402
1611
|
});
|
|
1403
1612
|
app.get("/api/pair", (req, res) => {
|
|
1613
|
+
if (!requireRateLimit(req, res, "pair", 12, 6e4)) return;
|
|
1614
|
+
const host = req.get("host");
|
|
1615
|
+
if (!host || !hasAllowedOrigin(req.headers, host)) {
|
|
1616
|
+
res.status(403).json({ error: "Forbidden" });
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1404
1619
|
const { session } = req.query;
|
|
1405
|
-
if (!session || session !== opts.state.
|
|
1620
|
+
if (!session || !/^[0-9a-f]{32}$/i.test(session) || session !== opts.state.pairingSessionToken) {
|
|
1406
1621
|
res.status(401).json({ error: "Invalid session" });
|
|
1407
1622
|
return;
|
|
1408
1623
|
}
|
|
1624
|
+
if (!opts.state.relaySessionToken) {
|
|
1625
|
+
res.status(503).json({ error: "Pairing unavailable" });
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1409
1629
|
res.json({
|
|
1630
|
+
session: opts.state.relaySessionToken,
|
|
1410
1631
|
token: opts.state.ablyToken,
|
|
1632
|
+
tokenExpiresAt: opts.state.ablyTokenExpiresAt,
|
|
1411
1633
|
transport: opts.state.transport ?? "ws",
|
|
1412
1634
|
relay: opts.state.relayUrl
|
|
1413
1635
|
});
|
|
1414
1636
|
});
|
|
1637
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1638
|
+
const host = req.headers.host;
|
|
1639
|
+
if (!host) {
|
|
1640
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
let url;
|
|
1644
|
+
try {
|
|
1645
|
+
url = new URL(req.url ?? "/", `http://${host}`);
|
|
1646
|
+
} catch {
|
|
1647
|
+
rejectUpgrade(socket, 400, "Bad Request");
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (url.pathname !== "/ws") {
|
|
1651
|
+
socket.destroy();
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if (!hasAllowedOrigin(req.headers, host)) {
|
|
1655
|
+
rejectUpgrade(socket, 403, "Forbidden");
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (!hasValidControlToken(req, opts.controlToken)) {
|
|
1659
|
+
rejectUpgrade(socket, 401, "Unauthorized");
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1663
|
+
wss.emit("connection", ws, req);
|
|
1664
|
+
});
|
|
1665
|
+
});
|
|
1415
1666
|
wss.on("connection", (ws) => {
|
|
1416
1667
|
uiClients.add(ws);
|
|
1417
1668
|
ws.on("close", () => uiClients.delete(ws));
|
|
1418
1669
|
ws.on("message", (data) => {
|
|
1419
1670
|
try {
|
|
1671
|
+
if (data.toString().length > 16384) return;
|
|
1420
1672
|
const message = JSON.parse(data.toString());
|
|
1421
|
-
if (message.type === "terminal_input" && typeof message.data === "string") {
|
|
1673
|
+
if (message.type === "terminal_input" && typeof message.data === "string" && message.data.length <= 8192) {
|
|
1422
1674
|
opts.state.terminal?.writeRawInput(message.data);
|
|
1423
|
-
} else if (message.type === "terminal_resize") {
|
|
1675
|
+
} else if (message.type === "terminal_resize" && Number.isInteger(message.cols) && Number.isInteger(message.rows) && message.cols > 0 && message.rows > 0) {
|
|
1424
1676
|
opts.state.terminal?.handleMessage(message);
|
|
1425
1677
|
}
|
|
1426
1678
|
} catch (err) {
|
|
1427
|
-
|
|
1679
|
+
logError("[host] Invalid WebSocket message:", err);
|
|
1428
1680
|
}
|
|
1429
1681
|
});
|
|
1430
1682
|
});
|
|
@@ -1434,16 +1686,35 @@ function createHostServer(opts) {
|
|
|
1434
1686
|
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
|
|
1435
1687
|
}
|
|
1436
1688
|
}
|
|
1437
|
-
return new Promise((resolve4) => {
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1689
|
+
return new Promise((resolve4, reject) => {
|
|
1690
|
+
const MAX_PORT_ATTEMPTS = 20;
|
|
1691
|
+
let attempt = 0;
|
|
1692
|
+
let port = opts.port;
|
|
1693
|
+
function tryListen() {
|
|
1694
|
+
server.once("error", onError);
|
|
1695
|
+
server.listen(port, opts.bind, () => {
|
|
1696
|
+
server.removeListener("error", onError);
|
|
1697
|
+
const addr = server.address();
|
|
1698
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1699
|
+
resolve4({ server, broadcast, port: actualPort });
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
function onError(err) {
|
|
1703
|
+
server.removeListener("error", onError);
|
|
1704
|
+
if (err.code === "EADDRINUSE" && attempt < MAX_PORT_ATTEMPTS) {
|
|
1705
|
+
attempt++;
|
|
1706
|
+
port++;
|
|
1707
|
+
if (port > 65535) {
|
|
1708
|
+
reject(err);
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
log(`[host] Port ${port - 1} in use, trying ${port}...`);
|
|
1712
|
+
tryListen();
|
|
1713
|
+
} else {
|
|
1714
|
+
reject(err);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
tryListen();
|
|
1447
1718
|
});
|
|
1448
1719
|
}
|
|
1449
1720
|
async function handleAIResponse(channel, adapter, state, broadcast) {
|
|
@@ -1494,7 +1765,7 @@ var HOST_HTML = `<!DOCTYPE html>
|
|
|
1494
1765
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.0.0/css/xterm.css">
|
|
1495
1766
|
<style>
|
|
1496
1767
|
:root{color-scheme:light dark;--bg:#0a0a0a;--surface:#1a1a1a;--border:#2a2a2a;--text:#e0e0e0;--text-muted:#888;--accent:#7c8aff;--accent-hover:#6b79ee;--input-bg:#111;--input-border:#333;--term-bg:#05070c;--tool-bg:#333;--tool-hover:#444;--msg-user:#2a3a6a;--msg-asst:#1e1e1e}
|
|
1497
|
-
@media(prefers-color-scheme:light){:root{--bg:#f5f5f7;--surface:#fff;--border:#d1d1d6;--text:#1c1c1e;--text-muted:#6e6e73;--accent:#5856d6;--accent-hover:#4a48c4;--input-bg:#fff;--input-border:#d1d1d6;--term-bg:#
|
|
1768
|
+
@media(prefers-color-scheme:light){:root{--bg:#f5f5f7;--surface:#fff;--border:#d1d1d6;--text:#1c1c1e;--text-muted:#6e6e73;--accent:#5856d6;--accent-hover:#4a48c4;--input-bg:#fff;--input-border:#d1d1d6;--term-bg:#fff;--tool-bg:#e5e5ea;--tool-hover:#d1d1d6;--msg-user:#d6d5f7;--msg-asst:#f2f2f7}}
|
|
1498
1769
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
1499
1770
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
1500
1771
|
.container{max-width:800px;margin:0 auto;padding:20px}
|
|
@@ -1603,12 +1874,12 @@ const darkTheme = {
|
|
|
1603
1874
|
brightBlue: '#a5b4ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
|
1604
1875
|
};
|
|
1605
1876
|
const lightTheme = {
|
|
1606
|
-
background: '#
|
|
1607
|
-
selectionBackground: 'rgba(88,86,214,0.
|
|
1608
|
-
black: '#1c1c1e', red: '#
|
|
1609
|
-
blue: '#
|
|
1610
|
-
brightBlack: '#6e6e73', brightRed: '#
|
|
1611
|
-
brightBlue: '#
|
|
1877
|
+
background: '#ffffff', foreground: '#1c1c1e', cursor: '#5856d6', cursorAccent: '#ffffff',
|
|
1878
|
+
selectionBackground: 'rgba(88,86,214,0.20)',
|
|
1879
|
+
black: '#1c1c1e', red: '#c41a16', green: '#007400', yellow: '#826b28',
|
|
1880
|
+
blue: '#0000ff', magenta: '#a90d91', cyan: '#3e8a8a', white: '#e5e5ea',
|
|
1881
|
+
brightBlack: '#6e6e73', brightRed: '#eb4d3d', brightGreen: '#36b738', brightYellow: '#b79a14',
|
|
1882
|
+
brightBlue: '#0451a5', brightMagenta: '#c42275', brightCyan: '#318495', brightWhite: '#f2f2f7',
|
|
1612
1883
|
};
|
|
1613
1884
|
function getTheme() { return matchMedia('(prefers-color-scheme:light)').matches ? lightTheme : darkTheme; }
|
|
1614
1885
|
matchMedia('(prefers-color-scheme:light)').addEventListener('change', () => {
|
|
@@ -1642,6 +1913,10 @@ function initTerminal() {
|
|
|
1642
1913
|
ws.send(JSON.stringify({ type: 'terminal_resize', cols: term.cols, rows: term.rows }));
|
|
1643
1914
|
}
|
|
1644
1915
|
}).observe(document.getElementById('terminalContainer'));
|
|
1916
|
+
// Prevent wheel events from leaking to the page when the terminal is at its
|
|
1917
|
+
// scroll bounds. xterm.js v6 uses a SmoothScrollableElement that doesn't
|
|
1918
|
+
// always consume wheel events at the extents.
|
|
1919
|
+
document.getElementById('terminalContainer').addEventListener('wheel', (e) => { e.preventDefault(); }, { passive: false });
|
|
1645
1920
|
if (ws.readyState === WebSocket.OPEN) sendTerminalOpen();
|
|
1646
1921
|
}
|
|
1647
1922
|
|
|
@@ -1794,13 +2069,114 @@ function sendMessage() {
|
|
|
1794
2069
|
</body>
|
|
1795
2070
|
</html>`;
|
|
1796
2071
|
|
|
2072
|
+
// src/env.ts
|
|
2073
|
+
import { isIP } from "node:net";
|
|
2074
|
+
var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
|
|
2075
|
+
var DEFAULT_VIEWER_URL = "https://bobstrogg.github.io/Airloom/";
|
|
2076
|
+
var DEFAULT_ABLY_TOKEN_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2077
|
+
var DEFAULT_HOST_BIND = "127.0.0.1";
|
|
2078
|
+
var DEFAULT_HOST_PORT = 4e3;
|
|
2079
|
+
function parseInteger(name, value, fallback, min, max) {
|
|
2080
|
+
if (value === void 0 || value === "") return fallback;
|
|
2081
|
+
const parsed = Number.parseInt(value, 10);
|
|
2082
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
2083
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
2084
|
+
}
|
|
2085
|
+
return parsed;
|
|
2086
|
+
}
|
|
2087
|
+
function parseViewerUrl(value) {
|
|
2088
|
+
const candidate = value?.trim() || DEFAULT_VIEWER_URL;
|
|
2089
|
+
let parsed;
|
|
2090
|
+
try {
|
|
2091
|
+
parsed = new URL(candidate);
|
|
2092
|
+
} catch {
|
|
2093
|
+
throw new Error("VIEWER_URL must be a valid http:// or https:// URL");
|
|
2094
|
+
}
|
|
2095
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2096
|
+
throw new Error("VIEWER_URL must use http:// or https://");
|
|
2097
|
+
}
|
|
2098
|
+
return parsed.toString();
|
|
2099
|
+
}
|
|
2100
|
+
function parseRelayUrl(value) {
|
|
2101
|
+
if (!value?.trim()) return void 0;
|
|
2102
|
+
let parsed;
|
|
2103
|
+
try {
|
|
2104
|
+
parsed = new URL(value.trim());
|
|
2105
|
+
} catch {
|
|
2106
|
+
throw new Error("RELAY_URL must be a valid ws:// or wss:// URL");
|
|
2107
|
+
}
|
|
2108
|
+
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
|
2109
|
+
throw new Error("RELAY_URL must use ws:// or wss://");
|
|
2110
|
+
}
|
|
2111
|
+
return parsed.toString();
|
|
2112
|
+
}
|
|
2113
|
+
function parseHostBind(value, isDev) {
|
|
2114
|
+
const bind = value?.trim() || (isDev ? "0.0.0.0" : DEFAULT_HOST_BIND);
|
|
2115
|
+
if (bind === "localhost" || isIP(bind) !== 0) return bind;
|
|
2116
|
+
if (/^[A-Za-z0-9.-]+$/.test(bind)) return bind;
|
|
2117
|
+
throw new Error("HOST_BIND must be localhost, an IP address, or a hostname");
|
|
2118
|
+
}
|
|
2119
|
+
function isLoopbackBind(bind) {
|
|
2120
|
+
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
|
|
2121
|
+
}
|
|
2122
|
+
function parseHostEnv(cliPort, isDev = false) {
|
|
2123
|
+
const relayUrl = parseRelayUrl(process.env.RELAY_URL);
|
|
2124
|
+
const ablyApiKey = process.env.ABLY_API_KEY ?? (relayUrl ? void 0 : DEFAULT_ABLY_KEY);
|
|
2125
|
+
const ablyTokenTtlMs = parseInteger("ABLY_TOKEN_TTL", process.env.ABLY_TOKEN_TTL, DEFAULT_ABLY_TOKEN_TTL_MS, 6e4, 31 * 24 * 60 * 60 * 1e3);
|
|
2126
|
+
const hostPort = cliPort ?? parseInteger("HOST_PORT", process.env.HOST_PORT, DEFAULT_HOST_PORT, 0, 65535);
|
|
2127
|
+
const hostBind = parseHostBind(process.env.HOST_BIND, isDev);
|
|
2128
|
+
const viewerUrl = parseViewerUrl(process.env.VIEWER_URL);
|
|
2129
|
+
return {
|
|
2130
|
+
viewerUrl,
|
|
2131
|
+
relayUrl,
|
|
2132
|
+
ablyApiKey,
|
|
2133
|
+
ablyTokenTtlMs,
|
|
2134
|
+
hostPort,
|
|
2135
|
+
hostBind,
|
|
2136
|
+
useAbly: !!ablyApiKey,
|
|
2137
|
+
isDefaultAblyKey: !!ablyApiKey && ablyApiKey === DEFAULT_ABLY_KEY
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// src/state.ts
|
|
2142
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2143
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2144
|
+
import { homedir as homedir2 } from "node:os";
|
|
2145
|
+
import { join as join4 } from "node:path";
|
|
2146
|
+
var STATE_DIR = join4(homedir2(), ".config", "airloom");
|
|
2147
|
+
var STATE_PATH = join4(STATE_DIR, "state.json");
|
|
2148
|
+
function readState() {
|
|
2149
|
+
try {
|
|
2150
|
+
const raw = readFileSync2(STATE_PATH, "utf-8");
|
|
2151
|
+
const data = JSON.parse(raw);
|
|
2152
|
+
return data && typeof data === "object" ? data : {};
|
|
2153
|
+
} catch {
|
|
2154
|
+
return {};
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
function writeState(state) {
|
|
2158
|
+
mkdirSync2(STATE_DIR, { recursive: true });
|
|
2159
|
+
writeFileSync2(STATE_PATH, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
2160
|
+
}
|
|
2161
|
+
function isSessionToken(value) {
|
|
2162
|
+
return typeof value === "string" && /^[0-9a-f]{32}$/i.test(value);
|
|
2163
|
+
}
|
|
2164
|
+
function loadOrCreateAblySessionToken() {
|
|
2165
|
+
const state = readState();
|
|
2166
|
+
if (isSessionToken(state.ablySessionToken)) return state.ablySessionToken;
|
|
2167
|
+
const ablySessionToken = randomBytes2(16).toString("hex");
|
|
2168
|
+
state.ablySessionToken = ablySessionToken;
|
|
2169
|
+
writeState(state);
|
|
2170
|
+
return ablySessionToken;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
1797
2173
|
// src/index.ts
|
|
1798
2174
|
var QRCode = null;
|
|
1799
2175
|
async function getQRCode() {
|
|
1800
2176
|
if (!QRCode) QRCode = await import("qrcode");
|
|
1801
2177
|
return QRCode;
|
|
1802
2178
|
}
|
|
1803
|
-
|
|
2179
|
+
log("[host] Module loaded");
|
|
1804
2180
|
function parseArgs(argv) {
|
|
1805
2181
|
const args = {};
|
|
1806
2182
|
const rest = argv.slice(2);
|
|
@@ -1851,7 +2227,8 @@ Environment variables:
|
|
|
1851
2227
|
ABLY_API_KEY Your own Ably key (overrides default community relay).
|
|
1852
2228
|
RELAY_URL Self-hosted WebSocket relay URL (disables Ably).
|
|
1853
2229
|
VIEWER_URL Public viewer URL (default: GitHub Pages).
|
|
1854
|
-
HOST_PORT Same as --port (
|
|
2230
|
+
HOST_PORT Same as --port (default: 4000, auto-increments if in use).
|
|
2231
|
+
HOST_BIND Host bind address (default: 127.0.0.1).
|
|
1855
2232
|
`.trimStart());
|
|
1856
2233
|
}
|
|
1857
2234
|
var cliArgs = parseArgs(process.argv);
|
|
@@ -1859,16 +2236,16 @@ if (cliArgs.help) {
|
|
|
1859
2236
|
printHelp();
|
|
1860
2237
|
process.exit(0);
|
|
1861
2238
|
}
|
|
1862
|
-
var DEFAULT_ABLY_KEY = "SfHSAQ.IRTOQQ:FBbi9a7ZV6jIu0Gdo_UeYhIN4rzpMrud5-LldURNh9s";
|
|
1863
|
-
var DEFAULT_VIEWER_URL = "https://bobstrogg.github.io/Airloom/";
|
|
1864
|
-
var VIEWER_URL = process.env.VIEWER_URL ?? DEFAULT_VIEWER_URL;
|
|
1865
2239
|
var IS_DEV = !process.env.VIEWER_URL && !new URL(import.meta.url).pathname.includes("node_modules");
|
|
1866
|
-
var
|
|
1867
|
-
var
|
|
1868
|
-
var
|
|
1869
|
-
var
|
|
1870
|
-
var
|
|
1871
|
-
var
|
|
2240
|
+
var env = parseHostEnv(cliArgs.port, IS_DEV);
|
|
2241
|
+
var VIEWER_URL = env.viewerUrl;
|
|
2242
|
+
var RELAY_URL = env.relayUrl;
|
|
2243
|
+
var ABLY_API_KEY = env.ablyApiKey;
|
|
2244
|
+
var ABLY_TOKEN_TTL = env.ablyTokenTtlMs;
|
|
2245
|
+
var HOST_PORT = env.hostPort;
|
|
2246
|
+
var HOST_BIND = env.hostBind;
|
|
2247
|
+
var useAbly = env.useAbly;
|
|
2248
|
+
var isDefaultKey = env.isDefaultAblyKey;
|
|
1872
2249
|
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
1873
2250
|
function getLanIP() {
|
|
1874
2251
|
for (const ifaces of Object.values(networkInterfaces())) {
|
|
@@ -1890,41 +2267,58 @@ async function main() {
|
|
|
1890
2267
|
console.log("==============\n");
|
|
1891
2268
|
if (useAbly) {
|
|
1892
2269
|
if (isDefaultKey) {
|
|
1893
|
-
|
|
1894
|
-
|
|
2270
|
+
log("Transport: Ably (community relay \u2014 shared quota)");
|
|
2271
|
+
log(" Set ABLY_API_KEY for your own quota, or RELAY_URL for self-hosted.\n");
|
|
1895
2272
|
} else {
|
|
1896
|
-
|
|
2273
|
+
log("Transport: Ably (your key)");
|
|
1897
2274
|
}
|
|
1898
2275
|
} else {
|
|
1899
|
-
|
|
2276
|
+
log(`Transport: WebSocket (self-hosted relay at ${RELAY_URL})`);
|
|
1900
2277
|
}
|
|
1901
|
-
|
|
1902
|
-
|
|
2278
|
+
let pairingCode;
|
|
2279
|
+
let pairingSessionToken;
|
|
2280
|
+
let relaySessionToken;
|
|
1903
2281
|
let pairingData;
|
|
2282
|
+
let ablyToken;
|
|
2283
|
+
let ablyTokenExpiresAt;
|
|
1904
2284
|
if (useAbly) {
|
|
2285
|
+
pairingCode = randomCode(8);
|
|
2286
|
+
pairingSessionToken = deriveSessionToken(pairingCode);
|
|
2287
|
+
relaySessionToken = loadOrCreateAblySessionToken();
|
|
2288
|
+
const keyPair = generateKeyPair();
|
|
1905
2289
|
const { Rest } = await import("ably");
|
|
1906
2290
|
const rest = new Rest({
|
|
1907
2291
|
key: ABLY_API_KEY,
|
|
1908
2292
|
queryTime: true
|
|
1909
2293
|
});
|
|
1910
|
-
const channelName = `airloom:${
|
|
2294
|
+
const channelName = `airloom:${relaySessionToken}`;
|
|
1911
2295
|
const tokenDetails = await rest.auth.requestToken({
|
|
1912
2296
|
clientId: "*",
|
|
1913
|
-
// viewer picks its own clientId
|
|
1914
2297
|
capability: { [channelName]: ["publish", "subscribe", "presence"] },
|
|
1915
2298
|
ttl: ABLY_TOKEN_TTL
|
|
1916
2299
|
});
|
|
1917
|
-
|
|
2300
|
+
log(`[ably] Scoped token issued (TTL: ${Math.round(ABLY_TOKEN_TTL / 6e4)}min, channel: ${channelName})`);
|
|
2301
|
+
ablyToken = tokenDetails.token;
|
|
2302
|
+
ablyTokenExpiresAt = tokenDetails.expires;
|
|
1918
2303
|
pairingData = {
|
|
1919
|
-
|
|
2304
|
+
relay: "ably",
|
|
2305
|
+
session: relaySessionToken,
|
|
2306
|
+
pub: toBase64(keyPair.publicKey),
|
|
2307
|
+
v: 1,
|
|
1920
2308
|
transport: "ably",
|
|
1921
|
-
token:
|
|
2309
|
+
token: ablyToken,
|
|
2310
|
+
tokenExpiresAt: ablyTokenExpiresAt
|
|
1922
2311
|
};
|
|
1923
2312
|
} else {
|
|
2313
|
+
const session = createSession(RELAY_URL);
|
|
2314
|
+
pairingCode = session.pairingCode;
|
|
2315
|
+
pairingSessionToken = session.sessionToken;
|
|
2316
|
+
relaySessionToken = session.sessionToken;
|
|
1924
2317
|
pairingData = { ...session.pairingData };
|
|
1925
2318
|
}
|
|
2319
|
+
const displayCode = formatPairingCode(pairingCode);
|
|
1926
2320
|
const pairingJSON = encodePairingData(pairingData);
|
|
1927
|
-
const keyMaterial = sha2562(new TextEncoder().encode("airloom-key:" +
|
|
2321
|
+
const keyMaterial = sha2562(new TextEncoder().encode("airloom-key:" + relaySessionToken));
|
|
1928
2322
|
const encryptionKey = deriveEncryptionKey(keyMaterial);
|
|
1929
2323
|
let adapter;
|
|
1930
2324
|
if (useAbly) {
|
|
@@ -1937,12 +2331,12 @@ async function main() {
|
|
|
1937
2331
|
role: "host",
|
|
1938
2332
|
encryptionKey
|
|
1939
2333
|
});
|
|
1940
|
-
await channel.connect(
|
|
1941
|
-
|
|
2334
|
+
await channel.connect(relaySessionToken);
|
|
2335
|
+
log("[host] Connected to relay, waiting for phone...");
|
|
1942
2336
|
const savedConfig = loadConfig();
|
|
1943
2337
|
const launchPreset = cliArgs.preset ? CLI_PRESETS.find((p) => p.id === cliArgs.preset) : void 0;
|
|
1944
2338
|
if (cliArgs.preset && !launchPreset) {
|
|
1945
|
-
|
|
2339
|
+
logError(`[host] Unknown preset "${cliArgs.preset}". Available: ${CLI_PRESETS.map((p) => p.id).join(", ")}`);
|
|
1946
2340
|
process.exit(1);
|
|
1947
2341
|
}
|
|
1948
2342
|
const savedTerminalCommand = !cliArgs.cli && !cliArgs.preset && savedConfig?.type === "terminal" ? savedConfig.command ?? (savedConfig.preset && savedConfig.preset !== "shell" ? CLI_PRESETS.find((p) => p.id === savedConfig.preset)?.command : void 0) : void 0;
|
|
@@ -1959,11 +2353,13 @@ async function main() {
|
|
|
1959
2353
|
terminalLaunch,
|
|
1960
2354
|
terminalLaunchCommand: launchCommand,
|
|
1961
2355
|
messages: [],
|
|
1962
|
-
|
|
1963
|
-
|
|
2356
|
+
pairingSessionToken,
|
|
2357
|
+
relaySessionToken,
|
|
2358
|
+
ablyToken,
|
|
2359
|
+
ablyTokenExpiresAt,
|
|
1964
2360
|
transport: useAbly ? "ably" : "ws"
|
|
1965
2361
|
};
|
|
1966
|
-
|
|
2362
|
+
log(`[host] Terminal launch: ${terminalLaunch}`);
|
|
1967
2363
|
if (cliArgs.cli || cliArgs.preset) {
|
|
1968
2364
|
let command = cliArgs.cli;
|
|
1969
2365
|
const presetInfo = launchPreset;
|
|
@@ -1974,7 +2370,7 @@ async function main() {
|
|
|
1974
2370
|
mode: presetInfo?.mode,
|
|
1975
2371
|
silenceTimeout: presetInfo?.silenceTimeout
|
|
1976
2372
|
});
|
|
1977
|
-
|
|
2373
|
+
log(`[host] CLI adapter: ${command} (${presetInfo?.mode ?? "oneshot"})`);
|
|
1978
2374
|
}
|
|
1979
2375
|
} else {
|
|
1980
2376
|
const saved = loadConfig();
|
|
@@ -2010,28 +2406,35 @@ async function main() {
|
|
|
2010
2406
|
}
|
|
2011
2407
|
}
|
|
2012
2408
|
if (state.adapter) {
|
|
2013
|
-
|
|
2014
|
-
|
|
2409
|
+
log(`[host] Auto-configured: ${state.adapter.name} (${state.adapter.model})`);
|
|
2410
|
+
log(` Loaded from ${getConfigPath()}`);
|
|
2015
2411
|
}
|
|
2016
2412
|
} catch (err) {
|
|
2017
|
-
|
|
2413
|
+
logError("[host] Auto-configure failed:", err.message);
|
|
2018
2414
|
}
|
|
2019
2415
|
}
|
|
2020
2416
|
}
|
|
2021
2417
|
const viewerDir = resolveViewerDir();
|
|
2022
2418
|
if (viewerDir) {
|
|
2023
|
-
|
|
2419
|
+
log(`[host] Viewer files: ${viewerDir}`);
|
|
2024
2420
|
} else {
|
|
2025
|
-
|
|
2026
|
-
}
|
|
2027
|
-
const
|
|
2421
|
+
log("[host] Viewer dist not found \u2014 QR will open raw JSON fallback");
|
|
2422
|
+
}
|
|
2423
|
+
const controlToken = createControlToken();
|
|
2424
|
+
const { server, broadcast, port } = await createHostServer({
|
|
2425
|
+
port: HOST_PORT,
|
|
2426
|
+
bind: HOST_BIND,
|
|
2427
|
+
controlToken,
|
|
2428
|
+
state,
|
|
2429
|
+
viewerDir
|
|
2430
|
+
});
|
|
2028
2431
|
const pairingBase64 = Buffer.from(pairingJSON).toString("base64url");
|
|
2029
2432
|
const viewerBase = VIEWER_URL.replace(/\/+$/, "");
|
|
2030
2433
|
const pagesUrl = `${viewerBase}/#${pairingBase64}`;
|
|
2031
2434
|
const lanIP = getLanIP();
|
|
2032
2435
|
const lanHost = lanIP ?? "localhost";
|
|
2033
2436
|
const lanBaseUrl = `http://${lanHost}:${port}`;
|
|
2034
|
-
const lanViewerUrl = viewerDir ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
|
|
2437
|
+
const lanViewerUrl = viewerDir && !isLoopbackBind(HOST_BIND) ? `${lanBaseUrl}/viewer/#${pairingBase64}` : null;
|
|
2035
2438
|
const qrTarget = IS_DEV && lanViewerUrl ? lanViewerUrl : pagesUrl;
|
|
2036
2439
|
const qrcode = await getQRCode();
|
|
2037
2440
|
const qrDataUrl = await qrcode.toDataURL(qrTarget, { width: 300, margin: 2 });
|
|
@@ -2049,36 +2452,50 @@ async function main() {
|
|
|
2049
2452
|
}
|
|
2050
2453
|
if (!useAbly) console.log(`Relay: ${RELAY_URL}`);
|
|
2051
2454
|
const localUrl = `http://localhost:${port}`;
|
|
2052
|
-
|
|
2455
|
+
const controlUrl = encodeControlUrl(localUrl, controlToken);
|
|
2456
|
+
log(`[host] Web UI at ${controlUrl}
|
|
2053
2457
|
`);
|
|
2054
2458
|
import("node:child_process").then(({ exec }) => {
|
|
2055
2459
|
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2056
|
-
exec(`${cmd} ${
|
|
2460
|
+
exec(`${cmd} ${controlUrl}`);
|
|
2057
2461
|
}).catch(() => {
|
|
2058
2462
|
});
|
|
2059
2463
|
const terminal = new TerminalSession(channel, () => state.terminalLaunchCommand, broadcast);
|
|
2060
2464
|
state.terminal = terminal;
|
|
2465
|
+
const pushViewerSession = () => {
|
|
2466
|
+
if (!state.relaySessionToken || !state.transport) return;
|
|
2467
|
+
const refresh = {
|
|
2468
|
+
type: "session_refresh",
|
|
2469
|
+
relay: state.relayUrl,
|
|
2470
|
+
session: state.relaySessionToken,
|
|
2471
|
+
transport: state.transport,
|
|
2472
|
+
token: state.ablyToken,
|
|
2473
|
+
tokenExpiresAt: state.ablyTokenExpiresAt
|
|
2474
|
+
};
|
|
2475
|
+
channel.send(refresh);
|
|
2476
|
+
};
|
|
2061
2477
|
channel.on("ready", () => {
|
|
2062
|
-
|
|
2478
|
+
log("[host] Phone connected! Channel ready.");
|
|
2063
2479
|
state.connected = true;
|
|
2064
2480
|
broadcast({ type: "peer_connected" });
|
|
2481
|
+
pushViewerSession();
|
|
2065
2482
|
});
|
|
2066
2483
|
channel.on("peer_left", () => {
|
|
2067
|
-
|
|
2484
|
+
log("[host] Phone disconnected.");
|
|
2068
2485
|
state.connected = false;
|
|
2069
2486
|
terminal.detachStream();
|
|
2070
2487
|
broadcast({ type: "peer_disconnected" });
|
|
2071
2488
|
});
|
|
2072
2489
|
channel.on("message", (data) => {
|
|
2073
2490
|
if (isTerminalMessage(data)) {
|
|
2074
|
-
|
|
2491
|
+
log("[host] Terminal message from phone:", data.type);
|
|
2075
2492
|
terminal.handleMessage(data);
|
|
2076
2493
|
return;
|
|
2077
2494
|
}
|
|
2078
2495
|
if (typeof data === "object" && data !== null && "type" in data && "content" in data) {
|
|
2079
2496
|
const msg = data;
|
|
2080
2497
|
if (msg.type === "chat" && typeof msg.content === "string") {
|
|
2081
|
-
|
|
2498
|
+
log(`[phone] ${msg.content}`);
|
|
2082
2499
|
state.messages.push({ role: "user", content: msg.content, timestamp: Date.now() });
|
|
2083
2500
|
broadcast({ type: "message", role: "user", content: msg.content });
|
|
2084
2501
|
if (state.adapter) {
|
|
@@ -2087,12 +2504,12 @@ async function main() {
|
|
|
2087
2504
|
}
|
|
2088
2505
|
}
|
|
2089
2506
|
});
|
|
2090
|
-
channel.on("error", (err) =>
|
|
2507
|
+
channel.on("error", (err) => logError("[host] Channel error:", err.message));
|
|
2091
2508
|
let shuttingDown = false;
|
|
2092
2509
|
const shutdown = () => {
|
|
2093
2510
|
if (shuttingDown) return;
|
|
2094
2511
|
shuttingDown = true;
|
|
2095
|
-
|
|
2512
|
+
log("\n[host] Shutting down...");
|
|
2096
2513
|
terminal.destroy();
|
|
2097
2514
|
state.adapter?.destroy?.();
|
|
2098
2515
|
try {
|
|
@@ -2106,6 +2523,6 @@ async function main() {
|
|
|
2106
2523
|
process.on("SIGTERM", shutdown);
|
|
2107
2524
|
}
|
|
2108
2525
|
main().catch((err) => {
|
|
2109
|
-
|
|
2526
|
+
logError("Fatal error:", err);
|
|
2110
2527
|
process.exit(1);
|
|
2111
2528
|
});
|